Smart filters

This commit is contained in:
Mr. Stallion 2021-12-31 18:06:08 -06:00
parent fdc7bec43d
commit 65ab5ffa32
16 changed files with 760 additions and 29 deletions

View File

@ -1,5 +1,8 @@
# Changelog
## Canary
* Added a way to hide/filter out characters, messages, and ads (Settings > Identity Politics)
## 1.16.2
* Fixed broken auto-ads

View File

@ -270,7 +270,7 @@
core.connection.onMessage('FKS', async (data) => {
const results = data.characters.map((x) => ({ character: core.characters.get(x), profile: null }))
.filter((x) => core.state.hiddenUsers.indexOf(x.character.name) === -1 && !x.character.isIgnored)
.filter((x) => this.isSpeciesMatch(x) && this.isBodyTypeMatch(x))
.filter((x) => this.isSpeciesMatch(x) && this.isBodyTypeMatch(x) && this.isSmartFilterMatch(x))
.sort(sort);
// pre-warm cache
@ -399,6 +399,14 @@
return this.data.bodytypes.indexOf(bodytype!.value) > -1
}
isSmartFilterMatch(result: SearchResult) {
if (!core.state.settings.risingFilter.hideSearchResults) {
return true;
}
return result.profile ? !result.profile?.match.isFiltered : true;
}
getSpeciesOptions(): SearchSpecies[] {
const species = _.map(
speciesMapping,

View File

@ -449,16 +449,26 @@
/* tslint:disable */
getMessageWrapperClasses(): any {
const filter = core.state.settings.risingFilter;
const classes:any = {};
if (this.isPrivate(this.conversation)) {
classes['filter-channel-messages'] = filter.hidePrivateMessages;
return classes;
}
if (!this.isChannel(this.conversation)) {
return {};
}
const conv = <Conversation.ChannelConversation>this.conversation;
const classes:any = {};
classes['messages-' + conv.mode] = true;
classes['hide-non-matching'] = !this.showNonMatchingAds;
classes['filter-ads'] = filter.hideAds;
classes['filter-channel-messages'] = conv.channel.owner !== '' ? filter.hidePrivateChannelMessages : filter.hidePublicChannelMessages;
return classes;
}
@ -839,12 +849,26 @@
}
.messages.hide-non-matching .message.message-score {
.messages.hide-non-matching .message.message-score,
{
&.mismatch {
display: none;
}
}
.messages.filter-ads {
.message.filter-match.message-ad {
display: none;
}
}
.messages.filter-channel-messages {
.message.filter-match.message-message,
.message.filter-match.message-action {
display: none;
}
}
.message {
.message-pre {
font-size: 75%;

View File

@ -1,7 +1,7 @@
<template>
<modal :action="l('settings.action')" @submit="submit" @open="load()" id="settings" dialogClass="w-100">
<tabs style="flex-shrink:0;margin-bottom:10px" v-model="selectedTab"
:tabs="[l('settings.tabs.general'), l('settings.tabs.notifications'), 'F-Chat Rising 🦄', l('settings.tabs.hideAds'), l('settings.tabs.import')]"></tabs>
:tabs="[l('settings.tabs.general'), l('settings.tabs.notifications'), 'F-Chat Rising 🦄', 'Identity Politics 🦄', l('settings.tabs.hideAds'), l('settings.tabs.import')]"></tabs>
<div v-show="selectedTab === '0'">
<div class="form-group">
<label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label>
@ -200,7 +200,83 @@
</div>
</div>
<div v-show="selectedTab === '3'">
<h5>Visibility</h5>
<div class="form-group filters">
<label class="control-label" for="risingFilter.hideAds">
<input type="checkbox" id="risingFilter.hideAds" v-model="risingFilter.hideAds"/>
Hide <b>ads</b> from matching characters
</label>
<label class="control-label" for="risingFilter.hideSearchResults">
<input type="checkbox" id="risingFilter.hideSearchResults" v-model="risingFilter.hideSearchResults"/>
Hide matching characters from <b>search results</b>
</label>
<label class="control-label" for="risingFilter.hideChannelMembers">
<input type="checkbox" id="risingFilter.hideChannelMembers" v-model="risingFilter.hideChannelMembers"/>
Hide matching characters from <b>channel members lists</b>
</label>
<label class="control-label" for="risingFilter.hidePublicChannelMessages">
<input type="checkbox" id="risingFilter.hidePublicChannelMessages" v-model="risingFilter.hidePublicChannelMessages"/>
Hide <b>public channel messages</b> from matching characters
</label>
<label class="control-label" for="risingFilter.hidePrivateChannelMessages">
<input type="checkbox" id="risingFilter.hidePrivateChannelMessages" v-model="risingFilter.hidePrivateChannelMessages"/>
Hide <b>private channel messages</b> from matching characters
</label>
<label class="control-label" for="risingFilter.hidePrivateMessages">
<input type="checkbox" id="risingFilter.hidePrivateMessages" v-model="risingFilter.hidePrivateMessages"/>
Hide <b>private messages</b> (PMs) from matching characters
</label>
<label class="control-label" for="risingFilter.penalizeMatches">
<input type="checkbox" id="risingFilter.penalizeMatches" v-model="risingFilter.penalizeMatches"/>
Penalize <b>match scores</b> for matching characters
</label>
</div>
<div class="form-group filters">
<label class="control-label" for="risingFilter.autoReply">
<input type="checkbox" id="risingFilter.autoReply" v-model="risingFilter.autoReply"/>
Send an automatic 'no thank you' response to matching characters if they message you
</label>
</div>
<h5>Character Age Match</h5>
<div class="form-group">Leave empty for no limit.</div>
<div class="form-group">
<label class="control-label" for="risingFilter.minAge">Characters younger than</label>
<input id="risingFilter.minAge" type="number" class="form-control" v-model="risingFilter.minAge"/>
<label class="control-label" for="risingFilter.maxAge">Characters older than</label>
<input id="risingFilter.maxAge" type="number" class="form-control" v-model="risingFilter.maxAge"/>
</div>
<h5>Type Match</h5>
<div class="form-group filters" >
<label class="control-label" :for="'risingFilter.smartFilters.' + key" v-for="(value, key) in smartFilterTypes">
<input type="checkbox" :id="'risingFilter.smartFilters.' + key" v-bind:checked="getSmartFilter(key)" @change="(v) => setSmartFilter(key, v)"/>
{{value.name}}
</label>
</div>
<h5>Exception List</h5>
<div class="form-group">Filters are not applied to these character names. Separate names with a linefeed.</div>
<div class="form-group">
<textarea class="form-control" :value="getExceptionList()" @change="(v) => setExceptionList(v)"></textarea>
</div>
</div>
<div v-show="selectedTab === '4'">
<template v-if="hidden.length">
<div v-for="(user, i) in hidden">
<span class="fa fa-times" style="cursor:pointer" @click.stop="hidden.splice(i, 1)"></span>
@ -209,7 +285,7 @@
</template>
<template v-else>{{l('settings.hideAds.empty')}}</template>
</div>
<div v-show="selectedTab === '4'" style="display:flex;padding-top:10px">
<div v-show="selectedTab === '5'" style="display:flex;padding-top:10px">
<select id="import" class="form-control" v-model="importCharacter" style="flex:1;margin-right:10px">
<option value="">{{l('settings.import.selectCharacter')}}</option>
<option v-for="character in availableImports" :value="character">{{character}}</option>
@ -227,6 +303,10 @@
import core from './core';
import {Settings as SettingsInterface} from './interfaces';
import l from './localize';
import { SmartFilterSettings, SmartFilterSelection } from '../learn/filter/types';
import { smartFilterTypes as smartFilterTypesOrigin } from '../learn/filter/types';
import _ from 'lodash';
import { matchesSmartFilters } from '../learn/filter/smart-filter';
@Component({
components: {modal: Modal, tabs: Tabs}
@ -269,6 +349,9 @@
risingShowUnreadOfflineCount!: boolean;
risingColorblindMode!: boolean;
risingFilter!: SmartFilterSettings = {} as any;
smartFilterTypes = smartFilterTypesOrigin;
async load(): Promise<void> {
const settings = core.state.settings;
@ -305,6 +388,7 @@
this.risingShowUnreadOfflineCount = settings.risingShowUnreadOfflineCount;
this.risingColorblindMode = settings.risingColorblindMode;
this.risingFilter = settings.risingFilter;
}
async doImport(): Promise<void> {
@ -325,8 +409,14 @@
}
async submit(): Promise<void> {
const oldRisingFilter = JSON.parse(JSON.stringify(core.state.settings.risingFilter));
const idleTimer = parseInt(this.idleTimer, 10);
const fontSize = parseFloat(this.fontSize);
const minAge = this.getAsNumber(this.risingFilter.minAge);
const maxAge = this.getAsNumber(this.risingFilter.maxAge);
core.state.settings = {
playSound: this.playSound,
clickOpensMessage: this.clickOpensMessage,
@ -360,9 +450,63 @@
risingShowUnreadOfflineCount: this.risingShowUnreadOfflineCount,
risingColorblindMode: this.risingColorblindMode,
risingFilter: {
...this.risingFilter,
minAge: (minAge !== null && maxAge !== null) ? Math.min(minAge, maxAge) : minAge,
maxAge: (minAge !== null && maxAge !== null) ? Math.max(minAge, maxAge) : maxAge
}
};
console.log('SETTINGS', minAge, maxAge, core.state.settings);
const newRisingFilter = JSON.parse(JSON.stringify(core.state.settings.risingFilter));
if (!_.isEqual(oldRisingFilter, newRisingFilter)) {
this.rebuildFilters();
}
if(this.notifications) await core.notifications.requestPermission();
}
rebuildFilters() {
core.cache.profileCache.onEachInMemory(
(c) => {
const oldFiltered = c.match.isFiltered;
c.match.isFiltered = matchesSmartFilters(c.character.character, core.state.settings.risingFilter);
if (oldFiltered !== c.match.isFiltered) {
core.cache.populateAllConversationsWithScore(c.character.character.name, c.match.matchScore, c.match.isFiltered);
}
}
);
}
getAsNumber(input: any): number | null {
if (_.isNil(input) || input === '') {
return null;
}
const n = parseInt(input, 10);
return !Number.isNaN(n) && Number.isFinite(n) ? n : null;
}
getExceptionList(): string {
return _.join(this.risingFilter.exceptionNames, '\n');
}
setExceptionList(v: any): void {
this.risingFilter.exceptionNames = _.map(_.split(v.target.value), (v) => _.trim(v));
}
getSmartFilter(key: keyof SmartFilterSelection): boolean {
return !!this.risingFilter.smartFilters?.[key];
}
setSmartFilter(key: keyof SmartFilterSelection , value: any): void {
this.risingFilter.smartFilters[key] = value.target.checked;
}
}
</script>
@ -371,4 +515,11 @@
margin-left: 0;
margin-right: 0;
}
#settings .form-group.filters label {
display: list-item;
margin: 0;
margin-left: 5px;
list-style: none;
}
</style>

View File

@ -61,9 +61,26 @@
}
get filteredMembers(): ReadonlyArray<Channel.Member> {
if(this.filter.length === 0) return this.channel.sortedMembers;
const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
return this.channel.sortedMembers.filter((member) => filter.test(member.character.name));
const members = this.prefilterMembers();
if (!core.state.settings.risingFilter.hideChannelMembers) {
return members;
}
return members.filter((m) => {
const p = core.cache.profileCache.getSync(m.character.name);
return !p || !p.match.isFiltered;
});
}
prefilterMembers(): ReadonlyArray<Channel.Member> {
if(this.filter.length === 0)
return this.channel.sortedMembers;
const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
return this.channel.sortedMembers.filter((member) => filter.test(member.character.name));
}
}
</script>
@ -106,4 +123,4 @@
display: flex;
}
}
</style>
</style>

View File

@ -54,6 +54,41 @@ export class Settings implements ISettings {
risingShowUnreadOfflineCount = true;
risingColorblindMode = false;
risingFilter = {
hideAds: false,
hideSearchResults: false,
hideChannelMembers: false,
hidePublicChannelMessages: false,
hidePrivateChannelMessages: false,
hidePrivateMessages: false,
penalizeMatches: false,
autoReply: true,
minAge: null,
maxAge: null,
smartFilters: {
ageplay: false,
anthro: false,
feral: false,
human: false,
hyper: false,
incest: false,
microMacro: false,
obesity: false,
pokemon: false,
pregnancy: false,
rape: false,
scat: false,
std: false,
taur: false,
gore: false,
vore: false,
unclean: false,
watersports: false,
zoophilia: false
},
exceptionNames: []
};
}
@ -114,6 +149,7 @@ export class Message implements Conversation.ChatMessage {
isHighlight = false;
score = 0;
filterMatch = false;
constructor(readonly type: Conversation.Message.Type, readonly sender: Character, readonly text: string,
readonly time: Date = new Date()) {
@ -126,6 +162,7 @@ export class EventMessage implements Conversation.EventMessage {
readonly type = Conversation.Message.Type.Event;
readonly score = 0;
filterMatch = false;
constructor(readonly text: string, readonly time: Date = new Date()) {
}

View File

@ -4,14 +4,16 @@ import {decodeHTML} from '../fchat/common';
import { AdManager } from './ads/ad-manager';
import { characterImage, ConversationSettings, EventMessage, Message, messageToString } from './common';
import core from './core';
import {Channel, Character, Conversation as Interfaces} from './interfaces';
import { Channel, Character, Conversation as Interfaces } from './interfaces';
import l from './localize';
import {CommandContext, isAction, isCommand, isWarn, parse as parseCommand} from './slash_commands';
import MessageType = Interfaces.Message.Type;
import {EventBus} from './preview/event-bus';
import throat from 'throat';
import Bluebird from 'bluebird';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
import log from 'electron-log';
import isChannel = Interfaces.isChannel;
import isPrivate = Interfaces.isPrivate; //tslint:disable-line:match-default-export-name
function createMessage(this: any, type: MessageType, sender: Character, text: string, time?: Date): Message {
if(type === MessageType.Message && isAction(text)) {
@ -45,7 +47,7 @@ abstract class Conversation implements Interfaces.Conversation {
// private loadedMore = false;
adManager: AdManager;
protected static readonly conversationThroat = throat(1); // make sure user posting and ad posting won't get in each others' way
public static readonly conversationThroat = throat(1); // make sure user posting and ad posting won't get in each others' way
constructor(readonly key: string, public _isPinned: boolean) {
this.adManager = new AdManager(this);
@ -161,7 +163,7 @@ abstract class Conversation implements Interfaces.Conversation {
protected static readonly POST_DELAY = 1250;
protected static async testPostDelay(): Promise<void> {
public static async testPostDelay(): Promise<void> {
const lastPostDelta = Date.now() - core.cache.getLastPost().getTime();
// console.log('Last Post Delta', lastPostDelta, ((lastPostDelta < Conversation.POST_DELAY) && (lastPostDelta > 0)));
@ -691,9 +693,41 @@ export default function(this: any): Interfaces.State {
const conversation = state.channelMap[data.channel.toLowerCase()];
if(conversation === undefined) return core.channels.leave(data.channel);
if(char.isIgnored) return;
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
EventBus.$emit('channel-message', { message, channel: conversation });
await conversation.addMessage(message);
// message.type === MessageType.Message
if (
(isPrivate(conversation) && core.state.settings.risingFilter.hidePrivateMessages) ||
(isChannel(conversation) && conversation.channel.owner === '' && core.state.settings.risingFilter.hidePublicChannelMessages) ||
(isChannel(conversation) && conversation.channel.owner !== '' && core.state.settings.risingFilter.hidePrivateChannelMessages)
) {
const cachedProfile = core.cache.profileCache.getSync(char.name) || await core.cache.profileCache.get(char.name);
if (cachedProfile && isPrivate(conversation) && core.state.settings.risingFilter.autoReply && !cachedProfile.match.autoResponded) {
cachedProfile.match.autoResponded = true;
log.debug('filter.autoresponse', { name: char.name });
void Conversation.conversationThroat(
async() => {
await Conversation.testPostDelay();
// tslint:disable-next-line:prefer-template
const m = '[Automated message] Sorry, the player of this character has indicated that they are not interested in characters matching your profile. They will not see your message.\n\n' +
'Need a filter for yourself? Try out [url=https://mrstallion.github.io/fchat-rising/]F-Chat Rising[/url]';
core.connection.send('PRI', {recipient: char.name, message: m});
core.cache.markLastPostTime();
}
);
}
if (cachedProfile && cachedProfile.match.isFiltered) {
return;
}
}
const words = conversation.settings.highlightWords.slice();
if(conversation.settings.defaultHighlights) words.push(...core.state.settings.highlightWords);

View File

@ -8,6 +8,7 @@ import {Channel, Character, Connection, Conversation, Logs, Notifications, Setti
import { AdCoordinatorGuest } from './ads/ad-coordinator-guest';
import { GeneralSettings } from '../electron/common';
import { SiteSession } from '../site/site-session';
import _ from 'lodash';
function createBBCodeParser(): BBCodeParser {
const parser = new BBCodeParser();
@ -73,7 +74,14 @@ const data = {
vue.$watch(getter, callback);
},
async reloadSettings(): Promise<void> {
state._settings = Object.assign(new SettingsImpl(), await core.settingsStore.get('settings'));
const s = await core.settingsStore.get('settings');
state._settings = _.mergeWith(new SettingsImpl(), s, (oVal, sVal) => {
if (_.isArray(oVal) && _.isArray(sVal)) {
return sVal;
}
});
const hiddenUsers = await core.settingsStore.get('hiddenUsers');
state.hiddenUsers = hiddenUsers !== undefined ? hiddenUsers : [];
}

View File

@ -3,6 +3,7 @@ import {Connection} from '../fchat';
import {Channel, Character} from '../fchat/interfaces';
import { AdManager } from './ads/ad-manager';
import { SmartFilterSettings } from '../learn/filter/types';
export {Connection, Channel, Character} from '../fchat/interfaces';
export const userStatuses: ReadonlyArray<Character.Status> = ['online', 'looking', 'away', 'busy', 'dnd'];
export const channelModes: ReadonlyArray<Channel.Mode> = ['chat', 'ads', 'both'];
@ -15,6 +16,7 @@ export namespace Conversation {
readonly time: Date
score: number;
filterMatch: boolean;
}
export interface EventMessage extends BaseMessage {
@ -221,6 +223,8 @@ export namespace Settings {
readonly risingShowUnreadOfflineCount: boolean;
readonly risingColorblindMode: boolean;
readonly risingFilter: SmartFilterSettings;
}
}

View File

@ -33,7 +33,8 @@ const userPostfix: {[key: number]: string | undefined} = {
let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` + (separators ? ' message-block' : '') +
(message.type !== Conversation.Message.Type.Event && message.sender.name === core.connection.character ? ' message-own' : '') +
((this.classes !== undefined) ? ` ${this.classes}` : '') +
` ${this.scoreClasses}`;
` ${this.scoreClasses}` +
` ${this.filterClasses}`;
if(message.type !== Conversation.Message.Type.Event) {
children.push(
(message.type === Conversation.Message.Type.Action) ? createElement('i', { class: 'message-pre fas fa-star' }) : '',
@ -71,6 +72,7 @@ export default class MessageView extends Vue {
readonly logs?: true;
scoreClasses = this.getMessageScoreClasses(this.message);
filterClasses = this.getMessageFilterClasses(this.message);
scoreWatcher: (() => void) | null = ((this.message.type === Conversation.Message.Type.Ad) && (this.message.score === 0))
? this.$watch('message.score', () => this.scoreUpdate())
@ -91,11 +93,13 @@ export default class MessageView extends Vue {
// @Watch('message.score')
scoreUpdate(): void {
const oldClasses = this.scoreClasses;
const oldScoreClasses = this.scoreClasses;
const oldFilterClasses = this.filterClasses;
this.scoreClasses = this.getMessageScoreClasses(this.message);
this.filterClasses = this.getMessageFilterClasses(this.message);
if (this.scoreClasses !== oldClasses) {
if (this.scoreClasses !== oldScoreClasses || this.filterClasses !== oldFilterClasses) {
this.$forceUpdate();
}
@ -115,4 +119,11 @@ export default class MessageView extends Vue {
return `message-score ${Score.getClasses(message.score as Scoring)}`;
}
getMessageFilterClasses(message: Conversation.Message): string {
if (!message.filterMatch) {
return '';
}
return 'filter-match';
}
}

View File

@ -78,7 +78,7 @@ export class CacheManager {
const c = await this.profileCache.get(name);
if (c) {
this.updateAdScoringForProfile(c.character, c.match.matchScore);
this.updateAdScoringForProfile(c.character, c.match.matchScore, c.match.isFiltered);
return;
}
}
@ -111,7 +111,7 @@ export class CacheManager {
const c = await methods.characterData(name, -1, true);
const r = await this.profileCache.register(c);
this.updateAdScoringForProfile(c, r.match.matchScore);
this.updateAdScoringForProfile(c, r.match.matchScore, r.match.isFiltered);
return c;
} catch (err) {
@ -122,7 +122,7 @@ export class CacheManager {
}
updateAdScoringForProfile(c: ComplexCharacter, score: number): void {
updateAdScoringForProfile(c: ComplexCharacter, score: number, isFiltered: boolean): void {
EventBus.$emit(
'character-score',
{
@ -131,7 +131,7 @@ export class CacheManager {
}
);
this.populateAllConversationsWithScore(c.character.name, score);
this.populateAllConversationsWithScore(c.character.name, score, isFiltered);
}
@ -446,9 +446,10 @@ export class CacheManager {
// }
msg.score = p.match.matchScore;
msg.filterMatch = p.match.isFiltered;
if (populateAll) {
this.populateAllConversationsWithScore(char.name, p.match.matchScore);
this.populateAllConversationsWithScore(char.name, p.match.matchScore, p.match.isFiltered);
}
}
@ -457,7 +458,7 @@ export class CacheManager {
// tslint:disable-next-line: prefer-function-over-method
protected populateAllConversationsWithScore(characterName: string, score: number): void {
public populateAllConversationsWithScore(characterName: string, score: number, isFiltered: boolean): void {
_.each(
core.conversations.channelConversations,
(ch: ChannelConversation) => {
@ -467,6 +468,7 @@ export class CacheManager {
// console.log('Update score', score, ch.name, m.sender.name, m.text, m.id);
m.score = score;
m.filterMatch = isFiltered;
}
}
);

View File

@ -0,0 +1,235 @@
import _ from 'lodash';
import { Matcher } from '../matcher';
import { BodyType, Build, Kink, Species, TagId } from '../matcher-types';
import { SmartFilterSelection, SmartFilterSettings } from './types';
import { Character } from '../../interfaces';
import log from 'electron-log';
import core from '../../chat/core';
export interface SmartFilterOpts {
name: string;
kinks?: Kink[],
bodyTypes?: BodyType[],
builds?: Build[],
species?: Species[]
isAnthro?: boolean;
isHuman?: boolean;
}
export class SmartFilter {
constructor(private opts: SmartFilterOpts) {}
test(c: Character): boolean {
const builds = this.testBuilds(c);
const bodyTypes = this.testBodyTypes(c);
const species = this.testSpecies(c);
const isAnthro = this.testIsAnthro(c);
const isHuman = this.testIsHuman(c);
const kinks = this.testKinks(c);
const result = builds || bodyTypes || species || isAnthro || isHuman || kinks;
log.debug('smart-filter.test',
{ name: c.name, filterName: this.opts.name, result, builds, bodyTypes, species, isAnthro, isHuman, kinks });
return this.testBuilds(c) || this.testBodyTypes(c) || this.testSpecies(c) || this.testIsAnthro(c) || this.testIsHuman(c) ||
this.testKinks(c);
}
testKinks(c: Character): boolean {
if (!this.opts.kinks) {
return false;
}
const score = _.reduce(this.opts.kinks, (curScore, kinkId) => {
const pref = Matcher.getKinkPreference(c, kinkId);
if (pref) {
curScore.matches += 1;
curScore.score += pref;
}
return curScore;
}, { score: 0, matches: 0 });
return score.matches >= 1 && score.score >= 1.0 + (Math.log((this.opts.kinks?.length || 0) + 1) / 2);
}
testBuilds(c: Character): boolean {
if (!this.opts.builds) {
return false;
}
const build = Matcher.getTagValueList(TagId.Build, c);
return !!build && !!_.find(this.opts.builds || [], build);
}
testBodyTypes(c: Character): boolean {
if (!this.opts.bodyTypes) {
return false;
}
const bodyType = Matcher.getTagValueList(TagId.BodyType, c);
return !!bodyType && !!_.find(this.opts.bodyTypes || [], bodyType);
}
testSpecies(c: Character): boolean {
if (!this.opts.species) {
return false;
}
const species = Matcher.species(c);
return !!species && !!_.find(this.opts.species || [], species);
}
testIsHuman(c: Character): boolean {
return !!this.opts.isHuman && (Matcher.isHuman(c) || false);
}
testIsAnthro(c: Character): boolean {
return !!this.opts.isAnthro && (Matcher.isAnthro(c) || false);
}
}
export type SmartFilterCollection = {
[key in keyof SmartFilterSelection]: SmartFilter;
};
export const smartFilters: SmartFilterCollection = {
ageplay: new SmartFilter({
name: 'ageplay',
kinks: [Kink.Ageplay, Kink.AgeProgression, Kink.AgeRegression, Kink.UnderageCharacters, Kink.Infantilism]
}),
anthro: new SmartFilter({
name: 'anthro',
isAnthro: true
}),
feral: new SmartFilter({
name: 'feral',
bodyTypes: [BodyType.Feral]
}),
gore: new SmartFilter({
name: 'gore',
kinks: [
Kink.Abrasions, Kink.Castration, Kink.Death, Kink.Emasculation, Kink.ExecutionMurder, Kink.Gore, Kink.Impalement, Kink.Mutilation,
Kink.Necrophilia, Kink.NonsexualPain, Kink.NonsexualTorture, Kink.Nullification, Kink.ToothRemoval, Kink.WoundFucking,
Kink.Cannibalism, Kink.GenitalTorture
]
}),
human: new SmartFilter({
name: 'human',
isHuman: true
}),
hyper: new SmartFilter({
name: 'kinks',
kinks: [Kink.HyperAsses, Kink.HyperBalls, Kink.HyperBreasts, Kink.HyperCocks, Kink.HyperFat, Kink.HyperMuscle, Kink.HyperVaginas,
Kink.HyperVoluptous, Kink.HyperMuscleGrowth, Kink.MacroAsses, Kink.MacroBalls, Kink.MacroBreasts, Kink.MacroCocks]
}),
incest: new SmartFilter({
name: 'incest',
kinks: [Kink.Incest, Kink.IncestParental, Kink.IncestSiblings, Kink.ParentChildPlay, Kink.ForcedIncest]
}),
microMacro: new SmartFilter({
name: 'microMacro',
kinks: [Kink.MacroAsses, Kink.MacroBalls, Kink.MacroBreasts, Kink.MacroCocks, Kink.Macrophilia, Kink.MegaMacro, Kink.Microphilia,
Kink.GrowthMacro, Kink.ShrinkingMicro, Kink.SizeDifferencesMicroMacro]
}),
obesity: new SmartFilter({
name: 'obesity',
builds: [Build.Tubby, Build.Obese, Build.Chubby]
}),
pregnancy: new SmartFilter({
name: 'pregnancy',
kinks: [Kink.AlternativePregnancy, Kink.AnalPregnancy, Kink.Birthing, Kink.ExtremePregnancy, Kink.MalePregnancy, Kink.Pregnancy]
}),
pokemon: new SmartFilter({
name: 'pokemon',
species: [Species.Pokemon]
}),
rape: new SmartFilter({
name: 'rape',
kinks: [Kink.PseudoRape, Kink.DubConsensual, Kink.Nonconsensual]
}),
scat: new SmartFilter({
name: 'scat',
kinks: [Kink.HyperScat, Kink.Scat, Kink.ScatTorture, Kink.Soiling, Kink.SwallowingFeces]
}),
std: new SmartFilter({
name: 'std',
kinks: [Kink.STDs]
}),
taur: new SmartFilter({
name: 'taur',
bodyTypes: [BodyType.Taur]
}),
unclean: new SmartFilter({
name: 'unclean',
kinks: [Kink.BelchingBurping, Kink.DirtyFeet, Kink.ExtremeMusk, Kink.Farting, Kink.Filth, Kink.Slob, Kink.Smegma, Kink.SwallowingVomit,
Kink.UnwashedMusk, Kink.Vomiting]
}),
vore: new SmartFilter({
name: 'vore',
kinks: [Kink.Absorption, Kink.AlternativeVore, Kink.AnalVore, Kink.Cannibalism, Kink.CockVore, Kink.CookingVore, Kink.Digestion,
Kink.Disposal, Kink.HardVore, Kink.RealisticVore, Kink.SoftVore, Kink.Unbirthing, Kink.UnrealisticVore, Kink.VoreBeingPredator,
Kink.VoreBeingPrey]
}),
watersports: new SmartFilter({
name: 'watersports',
kinks: [Kink.HyperWatersports, Kink.PissEnemas, Kink.SwallowingUrine, Kink.Watersports, Kink.Wetting]
}),
zoophilia: new SmartFilter({
name: 'zoophilia',
kinks: [Kink.Zoophilia, Kink.AnimalsFerals, Kink.Quadrupeds]
})
};
export function matchesSmartFilters(c: Character, opts: SmartFilterSettings): boolean {
if (c.name === core.characters.ownCharacter.name) {
return false;
}
if (core.characters.get(c.name)?.isChatOp) {
return false;
}
if (opts.exceptionNames.includes(c.name)) {
log.debug('smart-filter.exception', { name: c.name });
return false;
}
if (opts.minAge !== null || opts.maxAge !== null) {
const age = Matcher.age(c);
if (age !== null) {
if ((opts.minAge !== null && age < opts.minAge) || (opts.maxAge !== null && age > opts.maxAge)) {
log.debug('smart-filter.age', { name: c.name, age, minAge: opts.minAge, maxAge: opts.maxAge });
return true;
}
}
}
return !_.every(opts.smartFilters, (fs, filterName) => !fs || !(smartFilters as any)[filterName].test(c));
}

47
learn/filter/types.ts Normal file
View File

@ -0,0 +1,47 @@
// <!-- [Automated message] Sorry, the player of this character has indicated that they are not interested in characters matching your profile.-->
// <!-- Need a filter for yourself? Try out [F-Chat Rising](https://mrstallion.github.io/fchat-rising/)!-->
export const smartFilterTypes = {
ageplay: { name: 'Ageplay' },
anthro: { name: 'Anthros' },
feral: { name: 'Ferals' },
gore: { name: 'Gore/torture/death' },
human: { name: 'Humans' },
hyper: { name: 'Hyper' },
incest: { name: 'Incest' },
microMacro: { name: 'Micro/macro' },
obesity: { name: 'Obesity' },
pokemon: { name: 'Pokemons/Digimons' },
pregnancy: { name: 'Pregnancy' },
rape: { name: 'Rape' },
scat: { name: 'Scat' },
std: { name: 'STDs' },
taur: { name: 'Taurs' },
vore: { name: 'Vore and unbirthing' },
unclean: { name: 'Unclean' },
watersports: { name: 'Watersports' },
zoophilia: { name: 'Zoophilia' }
};
export type SmartFilterSelection = {
[key in keyof typeof smartFilterTypes]: boolean;
};
export interface SmartFilterSettings {
hideAds: boolean;
hideSearchResults: boolean;
hideChannelMembers: boolean;
hidePublicChannelMessages: boolean;
hidePrivateChannelMessages: boolean;
hidePrivateMessages: boolean;
penalizeMatches: boolean;
autoReply: boolean;
minAge: number | null;
maxAge: number | null;
smartFilters: SmartFilterSelection;
exceptionNames: string[];
}

View File

@ -87,6 +87,22 @@ export enum BodyType {
Taur = 145
}
export enum Build {
Lithe = 12,
Thin = 14,
Slim = 15,
Average = 16,
Toned = 17,
Muscular = 18,
Buff = 19,
Herculean = 20,
Tubby = 21,
Obese = 22,
Curvy = 129,
Chubby = 200,
Varies = 201
}
export enum KinkPreference {
Favorite = 1,
Yes = 0.5,
@ -113,7 +129,119 @@ export enum Kink {
AnthroCharacters = 587,
Humans = 609,
Mammals = 224
Mammals = 224,
Abrasions = 1,
Bloodplay = 4,
Branding = 492,
BreastNippleTorture = 36,
Burning = 21,
Castration = 20,
Death = 28,
Emasculation = 508,
ExecutionMurder = 717,
GenitalTorture = 276,
Gore = 689,
Impalement = 270,
Menses = 99,
Mutilation = 96,
Necrophilia = 308,
NonsexualPain = 486,
NonsexualTorture = 103,
Nullification = 334,
Piercing = 479,
SexualTorture = 174,
SwallowingBlood = 202,
ToothRemoval = 690,
WoundFucking = 691,
HyperScat = 415,
Scat= 164,
ScatTorture = 369,
Soiling = 509,
SwallowingFeces = 201,
HyperWatersports = 414,
PissEnemas = 533,
SwallowingUrine = 203,
Watersports = 59,
Wetting = 371,
BelchingBurping = 709,
DirtyFeet = 706,
ExtremeMusk = 335,
Farting = 549,
Filth = 707,
Messy = 89,
Slob = 570,
Smegma = 708,
SwallowingVomit = 560,
UnwashedMusk = 705,
Vomiting = 184,
Absorption = 239,
AlternativeVore = 244,
AnalVore = 209,
Cannibalism = 714,
CockVore = 208,
CookingVore = 716,
Digestion = 238,
Disposal = 241,
HardVore = 66,
RealisticVore = 242,
SoftVore = 73,
Unbirthing = 210,
UnrealisticVore = 243,
VoreBeingPredator = 422,
VoreBeingPrey = 423,
AlternativePregnancy = 702,
AnalPregnancy = 704,
Birthing = 461,
ExtremePregnancy = 272,
MalePregnancy = 198,
Pregnancy = 154,
STDs = 656,
PseudoRape = 522,
DubConsensual = 657,
Nonconsensual = 100,
Incest = 127,
IncestParental = 646,
IncestSiblings = 647,
ParentChildPlay = 304,
ForcedIncest = 53,
AgeProgression = 622,
AgeRegression = 621,
Infantilism = 497,
Zoophilia = 218,
AnimalsFerals = 487,
Quadrupeds = 382,
HyperAsses = 595,
HyperBalls = 233,
HyperBreasts = 594,
HyperCocks = 60,
HyperFat = 377,
HyperMuscle = 376,
HyperVaginas = 593,
HyperVoluptous = 378,
HyperMuscleGrowth = 389,
MacroAsses = 596,
MacroBalls = 550,
MacroBreasts = 91,
MacroCocks = 61,
Macrophilia = 285,
MegaMacro = 374,
Microphilia = 286,
SizeDifferencesMicroMacro = 502,
GrowthMacro = 384,
ShrinkingMicro = 387
}
export enum FurryPreference {

View File

@ -1235,9 +1235,17 @@ export class Matcher {
+ _.values(m.them.scores).length;
}
static age(c: Character): number | null {
const rawAge = Matcher.getTagValue(TagId.Age, c);
const age = ((rawAge) && (rawAge.string)) ? parseInt(rawAge.string, 10) : null;
return age && !Number.isNaN(age) && Number.isFinite(age) ? age : null;
}
static calculateSearchScoreForMatch(
score: Scoring,
match: MatchReport
match: MatchReport,
penalty: number
): number {
const totalScoreDimensions = match ? Matcher.countScoresTotal(match) : 0;
const dimensionsAtScoreLevel = match ? (Matcher.countScoresAtLevel(match, score) || 0) : 0;
@ -1289,10 +1297,11 @@ export class Matcher {
dimensionsAboveScoreLevel,
dimensionsAtScoreLevel,
theirAtLevelDimensions,
theirAboveLevelDimensions
theirAboveLevelDimensions,
penalty
}
);
return (atLevelScore + aboveLevelScore);
return (atLevelScore + aboveLevelScore + penalty);
}
}

View File

@ -7,6 +7,7 @@ import { Matcher, MatchReport } from './matcher';
import { PermanentIndexedStore } from './store/types';
import { CharacterImage, SimpleCharacter } from '../interfaces';
import { Scoring } from './matcher-types';
import { matchesSmartFilters } from './filter/smart-filter';
export interface MetaRecord {
@ -30,6 +31,8 @@ export interface CharacterMatchSummary {
// dimensionsAboveScoreLevel: number;
// totalScoreDimensions: number;
searchScore: number;
isFiltered: boolean;
autoResponded?: boolean;
}
export interface CharacterCacheRecord {
@ -52,6 +55,11 @@ export class ProfileCache extends AsyncCache<CharacterCacheRecord> {
}
onEachInMemory(cb: (c: CharacterCacheRecord, key: string) => void): void {
_.each(this.cache, cb);
}
getSync(name: string): CharacterCacheRecord | null {
const key = AsyncCache.nameKey(name);
@ -153,8 +161,13 @@ export class ProfileCache extends AsyncCache<CharacterCacheRecord> {
// 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;
const searchScore = match ? Matcher.calculateSearchScoreForMatch(score, match) : 0;
const matchDetails = { matchScore: score, searchScore };
const isFiltered = matchesSmartFilters(c.character, core.state.settings.risingFilter);
const searchScore = match
? Matcher.calculateSearchScoreForMatch(score, match, isFiltered && core.state.settings.risingFilter.penalizeMatches ? -2 : 0)
: 0;
const matchDetails = { matchScore: score, searchScore, isFiltered };
if ((this.store) && (!skipStore)) {
await this.store.storeProfile(c);