mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-13 01:20:54 +00:00
* Experiment Changed: This release just changes the parameters of the MQTT pubsub experiment, but otherwise has no changes.
479 lines
No EOL
12 KiB
JavaScript
479 lines
No EOL
12 KiB
JavaScript
'use strict';
|
|
|
|
// ============================================================================
|
|
// Experiments
|
|
// ============================================================================
|
|
|
|
import {DEBUG, SERVER} from 'utilities/constants';
|
|
import Module from 'utilities/module';
|
|
import {has, deep_copy} from 'utilities/object';
|
|
import { getBuster } from 'utilities/time';
|
|
|
|
import Cookie from 'js-cookie';
|
|
import SHA1 from 'crypto-js/sha1';
|
|
|
|
const OVERRIDE_COOKIE = 'experiment_overrides',
|
|
COOKIE_OPTIONS = {
|
|
expires: 7,
|
|
domain: '.twitch.tv'
|
|
};
|
|
|
|
|
|
// We want to import this so that the file is included in the output.
|
|
// We don't load using this because we might want a newer file from the
|
|
// server.
|
|
import EXPERIMENTS from './experiments.json'; // eslint-disable-line no-unused-vars
|
|
|
|
|
|
function sortExperimentLog(a,b) {
|
|
if ( a.rarity < b.rarity )
|
|
return -1;
|
|
else if ( a.rarity > b.rarity )
|
|
return 1;
|
|
|
|
if ( a.name < b.name )
|
|
return -1;
|
|
else if ( a.name > b.name )
|
|
return 1;
|
|
|
|
return 0;
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Experiment Manager
|
|
// ============================================================================
|
|
|
|
export default class ExperimentManager extends Module {
|
|
constructor(...args) {
|
|
super(...args);
|
|
|
|
this.get = this.getAssignment;
|
|
|
|
this.inject('settings');
|
|
|
|
this.settings.addUI('experiments', {
|
|
path: 'Debugging > Experiments',
|
|
component: 'experiments',
|
|
no_filter: true,
|
|
|
|
getExtraTerms: () => {
|
|
const values = [];
|
|
|
|
for(const exps of [this.experiments, this.getTwitchExperiments()]) {
|
|
if ( ! exps )
|
|
continue;
|
|
|
|
for(const [key, val] of Object.entries(exps)) {
|
|
values.push(key);
|
|
if ( val.name )
|
|
values.push(val.name);
|
|
if ( val.description )
|
|
values.push(val.description);
|
|
}
|
|
}
|
|
|
|
return values;
|
|
},
|
|
|
|
is_locked: () => this.getControlsLocked(),
|
|
unlock: () => this.unlockControls(),
|
|
|
|
unique_id: () => this.unique_id,
|
|
|
|
ffz_data: () => deep_copy(this.experiments),
|
|
twitch_data: () => deep_copy(this.getTwitchExperiments()),
|
|
|
|
usingTwitchExperiment: key => this.usingTwitchExperiment(key),
|
|
getTwitchAssignment: key => this.getTwitchAssignment(key),
|
|
getTwitchType: type => this.getTwitchType(type),
|
|
hasTwitchOverride: key => this.hasTwitchOverride(key),
|
|
setTwitchOverride: (key, val) => this.setTwitchOverride(key, val),
|
|
deleteTwitchOverride: key => this.deleteTwitchOverride(key),
|
|
|
|
getAssignment: key => this.getAssignment(key),
|
|
hasOverride: key => this.hasOverride(key),
|
|
setOverride: (key, val) => this.setOverride(key, val),
|
|
deleteOverride: key => this.deleteOverride(key),
|
|
|
|
on: (...args) => this.on(...args),
|
|
off: (...args) => this.off(...args)
|
|
});
|
|
|
|
this.unique_id = Cookie.get('unique_id');
|
|
|
|
this.Cookie = Cookie;
|
|
|
|
this.experiments = {};
|
|
this.cache = new Map;
|
|
}
|
|
|
|
getControlsLocked() {
|
|
if ( DEBUG )
|
|
return false;
|
|
|
|
const ts = this.settings.provider.get('exp-lock', 0);
|
|
if ( isNaN(ts) || ! isFinite(ts) )
|
|
return true;
|
|
|
|
return Date.now() - ts >= 86400000;
|
|
}
|
|
|
|
unlockControls() {
|
|
this.settings.provider.set('exp-lock', Date.now());
|
|
}
|
|
|
|
async onLoad() {
|
|
await this.loadExperiments();
|
|
}
|
|
|
|
|
|
async loadExperiments() {
|
|
let data;
|
|
|
|
try {
|
|
data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${getBuster()}`).then(r =>
|
|
r.ok ? r.json() : null);
|
|
|
|
} catch(err) {
|
|
this.log.warn('Unable to load experiment data.', err);
|
|
}
|
|
|
|
if ( ! data )
|
|
return;
|
|
|
|
this.experiments = data;
|
|
|
|
const old_cache = this.cache;
|
|
this.cache = new Map;
|
|
|
|
let changed = 0;
|
|
|
|
for(const [key, old_val] of old_cache.entries()) {
|
|
const new_val = this.getAssignment(key);
|
|
if ( old_val !== new_val ) {
|
|
changed++;
|
|
this.emit(':changed', key, new_val);
|
|
this.emit(`:changed:${key}`, new_val);
|
|
}
|
|
}
|
|
|
|
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
|
|
//this.emit(':loaded');
|
|
}
|
|
|
|
|
|
onEnable() {
|
|
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
|
|
this.on('pubsub:command:update_experiment', this.updateExperiment, this);
|
|
}
|
|
|
|
|
|
updateExperiment(key, data) {
|
|
this.log.info(`Received updated data for experiment "${key}" via WebSocket.`, data);
|
|
|
|
if ( data.groups )
|
|
this.experiments[key] = data;
|
|
else
|
|
this.experiments[key].groups = data;
|
|
|
|
this._rebuildKey(key);
|
|
}
|
|
|
|
|
|
generateLog() {
|
|
const out = [
|
|
`Unique ID: ${this.unique_id}`,
|
|
''
|
|
];
|
|
|
|
const ffz_assignments = [];
|
|
for(const [key, value] of Object.entries(this.experiments)) {
|
|
const assignment = this.getAssignment(key),
|
|
override = this.hasOverride(key);
|
|
|
|
let weight = 0, total = 0;
|
|
for(const group of value.groups) {
|
|
if ( group.value === assignment )
|
|
weight = group.weight;
|
|
total += group.weight;
|
|
}
|
|
|
|
if ( ! override && weight === total )
|
|
continue;
|
|
|
|
ffz_assignments.push({
|
|
key,
|
|
name: value.name,
|
|
value: assignment,
|
|
override,
|
|
rarity: weight / total
|
|
});
|
|
|
|
//out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`);
|
|
}
|
|
|
|
ffz_assignments.sort(sortExperimentLog);
|
|
|
|
for(const entry of ffz_assignments)
|
|
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
|
|
|
|
const twitch_assignments = [],
|
|
channel = this.settings.get('context.channel');
|
|
|
|
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
|
|
if ( ! this.usingTwitchExperiment(key) )
|
|
continue;
|
|
|
|
const assignment = this.getTwitchAssignment(key),
|
|
override = this.hasTwitchOverride(key);
|
|
|
|
let weight = 0, total = 0;
|
|
for(const group of value.groups) {
|
|
if ( group.value === assignment )
|
|
weight = group.weight;
|
|
total += group.weight;
|
|
}
|
|
|
|
if ( ! override && weight === total )
|
|
continue;
|
|
|
|
twitch_assignments.push({
|
|
key,
|
|
name: value.name,
|
|
value: assignment,
|
|
override,
|
|
type: this.getTwitchTypeByKey(key),
|
|
rarity: weight / total
|
|
});
|
|
|
|
//out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`)
|
|
}
|
|
|
|
twitch_assignments.sort(sortExperimentLog);
|
|
|
|
for(const entry of twitch_assignments)
|
|
out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`);
|
|
|
|
return out.join('\n');
|
|
}
|
|
|
|
|
|
// Twitch Experiments
|
|
|
|
getTwitchType(type) {
|
|
const core = this.resolve('site')?.getCore?.();
|
|
if ( core?.experiments?.getExperimentType )
|
|
return core.experiments.getExperimentType(type);
|
|
|
|
if ( type === 1 )
|
|
return 'device_id';
|
|
else if ( type === 2 )
|
|
return 'user_id';
|
|
else if ( type === 3 )
|
|
return 'channel_id';
|
|
return type;
|
|
}
|
|
|
|
getTwitchTypeByKey(key) {
|
|
const core = this.resolve('site')?.getCore?.(),
|
|
exps = core && core.experiments,
|
|
exp = exps?.experiments?.[key];
|
|
|
|
if ( exp?.t )
|
|
return this.getTwitchType(exp.t);
|
|
|
|
return null;
|
|
}
|
|
|
|
getTwitchExperiments() {
|
|
if ( window.__twilightSettings )
|
|
return window.__twilightSettings.experiments;
|
|
|
|
const core = this.resolve('site')?.getCore?.();
|
|
return core && core.experiments.experiments;
|
|
}
|
|
|
|
|
|
usingTwitchExperiment(key) {
|
|
const core = this.resolve('site')?.getCore?.();
|
|
return core && has(core.experiments.assignments, key)
|
|
}
|
|
|
|
|
|
setTwitchOverride(key, value = null) {
|
|
const overrides = Cookie.getJSON(OVERRIDE_COOKIE) || {};
|
|
const experiments = overrides.experiments = overrides.experiments || {};
|
|
const disabled = overrides.disabled = overrides.disabled || [];
|
|
experiments[key] = value;
|
|
const idx = disabled.indexOf(key);
|
|
if (idx != -1)
|
|
disabled.remove(idx);
|
|
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
|
|
|
|
const core = this.resolve('site')?.getCore?.();
|
|
if ( core )
|
|
core.experiments.overrides[key] = value;
|
|
|
|
this._rebuildTwitchKey(key, true, value);
|
|
}
|
|
|
|
deleteTwitchOverride(key) {
|
|
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
|
|
experiments = overrides?.experiments;
|
|
if ( ! experiments || ! has(experiments, key) )
|
|
return;
|
|
|
|
const old_val = experiments[key];
|
|
delete experiments[key];
|
|
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
|
|
|
|
const core = this.resolve('site')?.getCore?.();
|
|
if ( core )
|
|
delete core.experiments.overrides[key];
|
|
|
|
this._rebuildTwitchKey(key, false, old_val);
|
|
}
|
|
|
|
hasTwitchOverride(key) { // eslint-disable-line class-methods-use-this
|
|
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
|
|
experiments = overrides?.experiments;
|
|
return experiments && has(experiments, key);
|
|
}
|
|
|
|
getTwitchAssignment(key, channel = null) {
|
|
const core = this.resolve('site')?.getCore?.(),
|
|
exps = core && core.experiments;
|
|
|
|
if ( ! exps )
|
|
return null;
|
|
|
|
if ( ! exps.hasInitialized && exps.initialize )
|
|
try {
|
|
exps.initialize();
|
|
} catch(err) {
|
|
this.log.warn('Error attempting to initialize Twitch experiments tracker.', err);
|
|
}
|
|
|
|
if ( channel || this.getTwitchType(exps.experiments[key]?.t) === 'channel_id' )
|
|
return exps.getAssignmentById(key, {channel: channel ?? this.settings.get('context.channel')});
|
|
|
|
if ( exps.overrides && exps.overrides[key] )
|
|
return exps.overrides[key];
|
|
|
|
else if ( exps.assignments && exps.assignments[key] )
|
|
return exps.assignments[key];
|
|
|
|
return null;
|
|
}
|
|
|
|
getTwitchKeyFromName(name) {
|
|
const experiments = this.getTwitchExperiments();
|
|
if ( ! experiments )
|
|
return undefined;
|
|
|
|
name = name.toLowerCase();
|
|
for(const key in experiments)
|
|
if ( has(experiments, key) ) {
|
|
const data = experiments[key];
|
|
if ( data && data.name && data.name.toLowerCase() === name )
|
|
return key;
|
|
}
|
|
}
|
|
|
|
getTwitchAssignmentByName(name, channel = null) {
|
|
return this.getTwitchAssignment(this.getTwitchKeyFromName(name), channel);
|
|
}
|
|
|
|
_rebuildTwitchKey(key, is_set, new_val) {
|
|
const core = this.resolve('site')?.getCore?.(),
|
|
exps = core.experiments,
|
|
|
|
old_val = has(exps.assignments, key) ?
|
|
exps.assignments[key] :
|
|
undefined;
|
|
|
|
if ( old_val !== new_val ) {
|
|
const value = is_set ? new_val : old_val;
|
|
this.emit(':twitch-changed', key, value);
|
|
this.emit(`:twitch-changed:${key}`, value);
|
|
}
|
|
}
|
|
|
|
|
|
// FFZ Experiments
|
|
|
|
setOverride(key, value = null) {
|
|
const overrides = this.settings.provider.get('experiment-overrides') || {};
|
|
overrides[key] = value;
|
|
|
|
this.settings.provider.set('experiment-overrides', overrides);
|
|
|
|
this._rebuildKey(key);
|
|
}
|
|
|
|
deleteOverride(key) {
|
|
const overrides = this.settings.provider.get('experiment-overrides');
|
|
if ( ! overrides || ! has(overrides, key) )
|
|
return;
|
|
|
|
delete overrides[key];
|
|
this.settings.provider.set('experiment-overrides', overrides);
|
|
|
|
this._rebuildKey(key);
|
|
}
|
|
|
|
hasOverride(key) {
|
|
const overrides = this.settings.provider.get('experiment-overrides');
|
|
return overrides && has(overrides, key);
|
|
}
|
|
|
|
getAssignment(key) {
|
|
if ( this.cache.has(key) )
|
|
return this.cache.get(key);
|
|
|
|
const experiment = this.experiments[key];
|
|
if ( ! experiment ) {
|
|
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
|
|
return null;
|
|
}
|
|
|
|
const overrides = this.settings.provider.get('experiment-overrides'),
|
|
out = overrides && has(overrides, key) ?
|
|
overrides[key] :
|
|
ExperimentManager.selectGroup(key, experiment, this.unique_id);
|
|
|
|
this.cache.set(key, out);
|
|
return out;
|
|
}
|
|
|
|
_rebuildKey(key) {
|
|
if ( ! this.cache.has(key) )
|
|
return;
|
|
|
|
const old_val = this.cache.get(key);
|
|
this.cache.delete(key);
|
|
const new_val = this.getAssignment(key);
|
|
|
|
if ( new_val !== old_val ) {
|
|
this.emit(':changed', key, new_val);
|
|
this.emit(`:changed:${key}`, new_val);
|
|
}
|
|
}
|
|
|
|
|
|
static selectGroup(key, experiment, unique_id) {
|
|
const seed = key + unique_id + (experiment.seed || ''),
|
|
total = experiment.groups.reduce((a,b) => a + b.weight, 0);
|
|
|
|
let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32);
|
|
|
|
for(const group of experiment.groups) {
|
|
value -= group.weight / total;
|
|
if ( value <= 0 )
|
|
return group.value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
} |