2018-04-10 21:13:34 -04:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
// Experiments
|
|
|
|
// ============================================================================
|
|
|
|
|
2019-06-01 02:11:22 -04:00
|
|
|
import {DEBUG, SERVER} from 'utilities/constants';
|
2023-11-16 18:41:50 -05:00
|
|
|
import Module, { GenericModule } from 'utilities/module';
|
|
|
|
import {has, deep_copy, fetchJSON} from 'utilities/object';
|
2019-06-03 19:47:41 -04:00
|
|
|
import { getBuster } from 'utilities/time';
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
import Cookie from 'js-cookie';
|
|
|
|
import SHA1 from 'crypto-js/sha1';
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
import type SettingsManager from './settings';
|
|
|
|
|
|
|
|
declare module 'utilities/types' {
|
|
|
|
interface ModuleMap {
|
|
|
|
experiments: ExperimentManager;
|
|
|
|
}
|
|
|
|
interface ProviderTypeMap {
|
|
|
|
'experiment-overrides': Record<string, unknown>
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
interface Window {
|
|
|
|
__twilightSettings?: {
|
|
|
|
experiments?: Record<string, TwitchExperimentData>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2018-04-10 21:13:34 -04:00
|
|
|
const OVERRIDE_COOKIE = 'experiment_overrides',
|
|
|
|
COOKIE_OPTIONS = {
|
|
|
|
expires: 7,
|
|
|
|
domain: '.twitch.tv'
|
|
|
|
};
|
|
|
|
|
|
|
|
|
2018-04-11 17:05:31 -04:00
|
|
|
// 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
|
2023-11-16 18:41:50 -05:00
|
|
|
// server. Because of our webpack settings, this is imported as a URL
|
|
|
|
// and not an object.
|
|
|
|
const EXPERIMENTS: string = require('./experiments.json');
|
2018-04-11 17:05:31 -04:00
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
// ============================================================================
|
|
|
|
// Data Types
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
export enum TwitchExperimentType {
|
|
|
|
Unknown = 0,
|
|
|
|
Device = 1,
|
|
|
|
User = 2,
|
|
|
|
Channel = 3
|
|
|
|
};
|
|
|
|
|
|
|
|
export type ExperimentGroup = {
|
|
|
|
value: unknown;
|
|
|
|
weight: number;
|
|
|
|
};
|
|
|
|
|
|
|
|
export type FFZExperimentData = {
|
|
|
|
name: string;
|
|
|
|
seed?: number;
|
|
|
|
description: string;
|
|
|
|
groups: ExperimentGroup[];
|
|
|
|
}
|
|
|
|
|
|
|
|
export type TwitchExperimentData = {
|
|
|
|
name: string;
|
|
|
|
t: TwitchExperimentType;
|
|
|
|
v: number;
|
|
|
|
groups: ExperimentGroup[];
|
|
|
|
};
|
|
|
|
|
|
|
|
export type ExperimentData = FFZExperimentData | TwitchExperimentData;
|
|
|
|
|
|
|
|
|
|
|
|
export type OverrideCookie = {
|
|
|
|
experiments: Record<string, string>;
|
|
|
|
disabled: string[];
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type ExperimentEvents = {
|
|
|
|
':changed': [key: string, new_value: any, old_value: any];
|
|
|
|
':twitch-changed': [key: string, new_value: string | null, old_value: string | null];
|
|
|
|
[key: `:changed:${string}`]: [new_value: any, old_value: any];
|
|
|
|
[key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null];
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
// Helper Methods
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
export function isTwitchExperiment(exp: ExperimentData): exp is TwitchExperimentData {
|
|
|
|
return 't' in exp;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function isFFZExperiment(exp: ExperimentData): exp is FFZExperimentData {
|
|
|
|
return 'description' in exp;
|
|
|
|
}
|
2018-04-11 17:05:31 -04:00
|
|
|
|
2020-08-29 13:06:17 -04:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2018-04-10 21:13:34 -04:00
|
|
|
// ============================================================================
|
|
|
|
// Experiment Manager
|
|
|
|
// ============================================================================
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
export default class ExperimentManager extends Module<'experiments', ExperimentEvents> {
|
|
|
|
|
|
|
|
// Dependencies
|
|
|
|
settings: SettingsManager = null as any;
|
|
|
|
|
|
|
|
// State
|
|
|
|
unique_id?: string;
|
|
|
|
experiments: Record<string, FFZExperimentData>;
|
|
|
|
|
|
|
|
private cache: Map<string, unknown>;
|
|
|
|
|
|
|
|
|
|
|
|
// Helpers
|
|
|
|
Cookie: typeof Cookie;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(name?: string, parent?: GenericModule) {
|
|
|
|
super(name, parent);
|
2018-04-10 21:13:34 -04:00
|
|
|
|
2022-12-18 17:30:34 -05:00
|
|
|
this.get = this.getAssignment;
|
|
|
|
|
2018-04-10 21:13:34 -04:00
|
|
|
this.inject('settings');
|
|
|
|
|
|
|
|
this.settings.addUI('experiments', {
|
|
|
|
path: 'Debugging > Experiments',
|
|
|
|
component: 'experiments',
|
2020-05-27 15:44:37 -04:00
|
|
|
no_filter: true,
|
|
|
|
|
|
|
|
getExtraTerms: () => {
|
|
|
|
const values = [];
|
|
|
|
|
2021-02-22 20:11:35 -05:00
|
|
|
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);
|
|
|
|
}
|
2020-05-27 15:44:37 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
return values;
|
|
|
|
},
|
2018-04-11 17:05:31 -04:00
|
|
|
|
2020-07-01 19:07:17 -04:00
|
|
|
is_locked: () => this.getControlsLocked(),
|
|
|
|
unlock: () => this.unlockControls(),
|
|
|
|
|
2018-04-11 17:05:31 -04:00
|
|
|
unique_id: () => this.unique_id,
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
ffz_data: () => deep_copy(this.experiments) ?? {},
|
2018-04-10 21:13:34 -04:00
|
|
|
twitch_data: () => deep_copy(this.getTwitchExperiments()),
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
usingTwitchExperiment: (key: string) => this.usingTwitchExperiment(key),
|
|
|
|
getTwitchAssignment: (key: string) => this.getTwitchAssignment(key),
|
|
|
|
getTwitchType: (type: TwitchExperimentType) => this.getTwitchType(type),
|
|
|
|
hasTwitchOverride: (key: string) => this.hasTwitchOverride(key),
|
|
|
|
setTwitchOverride: (key: string, val: string) => this.setTwitchOverride(key, val),
|
|
|
|
deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key),
|
2018-04-10 21:13:34 -04:00
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
getAssignment: (key: string) => this.getAssignment(key),
|
|
|
|
hasOverride: (key: string) => this.hasOverride(key),
|
|
|
|
setOverride: (key: string, val: any) => this.setOverride(key, val),
|
|
|
|
deleteOverride: (key: string) => this.deleteOverride(key),
|
2018-04-10 21:13:34 -04:00
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
on: (...args: Parameters<typeof this.on>) => this.on(...args),
|
|
|
|
off: (...args: Parameters<typeof this.off>) => this.off(...args)
|
2018-04-10 21:13:34 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
this.unique_id = Cookie.get('unique_id');
|
|
|
|
|
|
|
|
this.Cookie = Cookie;
|
|
|
|
|
|
|
|
this.experiments = {};
|
|
|
|
this.cache = new Map;
|
|
|
|
}
|
|
|
|
|
2020-07-01 19:07:17 -04:00
|
|
|
getControlsLocked() {
|
|
|
|
if ( DEBUG )
|
|
|
|
return false;
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
const ts = this.settings.provider.get<number>('exp-lock', 0);
|
2020-07-01 19:07:17 -04:00
|
|
|
if ( isNaN(ts) || ! isFinite(ts) )
|
|
|
|
return true;
|
|
|
|
|
|
|
|
return Date.now() - ts >= 86400000;
|
|
|
|
}
|
|
|
|
|
|
|
|
unlockControls() {
|
|
|
|
this.settings.provider.set('exp-lock', Date.now());
|
|
|
|
}
|
|
|
|
|
2018-04-10 21:13:34 -04:00
|
|
|
async onLoad() {
|
|
|
|
await this.loadExperiments();
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadExperiments() {
|
2023-11-16 18:41:50 -05:00
|
|
|
let data: Record<string, FFZExperimentData> | null;
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
try {
|
2023-11-16 18:41:50 -05:00
|
|
|
data = await fetchJSON(DEBUG
|
|
|
|
? EXPERIMENTS
|
|
|
|
: `${SERVER}/script/experiments.json?_=${getBuster()}`
|
|
|
|
);
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
} catch(err) {
|
|
|
|
this.log.warn('Unable to load experiment data.', err);
|
2023-11-16 18:41:50 -05:00
|
|
|
return;
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
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++;
|
2023-11-16 18:41:50 -05:00
|
|
|
this.emit(':changed', key, new_val, old_val);
|
|
|
|
this.emit(`:changed:${key}`, new_val, old_val);
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
|
2020-05-27 15:44:37 -04:00
|
|
|
//this.emit(':loaded');
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
/** @internal */
|
2018-04-10 21:13:34 -04:00
|
|
|
onEnable() {
|
2023-10-30 14:01:28 -04:00
|
|
|
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
|
|
|
|
this.on('pubsub:command:update_experiment', this.updateExperiment, this);
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
updateExperiment(key: string, data: FFZExperimentData | ExperimentGroup[]) {
|
|
|
|
this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data);
|
|
|
|
|
|
|
|
if ( Array.isArray(data) ) {
|
|
|
|
if ( ! this.experiments[key] )
|
|
|
|
return;
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
this.experiments[key].groups = data;
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
} else if ( data?.groups )
|
|
|
|
this.experiments[key] = data;
|
|
|
|
|
2018-04-10 21:13:34 -04:00
|
|
|
this._rebuildKey(key);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2018-12-03 18:08:32 -05:00
|
|
|
generateLog() {
|
|
|
|
const out = [
|
|
|
|
`Unique ID: ${this.unique_id}`,
|
|
|
|
''
|
|
|
|
];
|
|
|
|
|
2020-08-29 13:06:17 -04:00
|
|
|
const ffz_assignments = [];
|
2018-12-03 18:08:32 -05:00
|
|
|
for(const [key, value] of Object.entries(this.experiments)) {
|
2020-08-29 13:06:17 -04:00
|
|
|
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)' : ''}`);
|
2018-12-03 18:08:32 -05:00
|
|
|
}
|
|
|
|
|
2020-08-29 13:06:17 -04:00
|
|
|
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');
|
|
|
|
|
2018-12-03 18:08:32 -05:00
|
|
|
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
|
2020-08-29 13:06:17 -04:00
|
|
|
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)' : ''}`)
|
2018-12-03 18:08:32 -05:00
|
|
|
}
|
|
|
|
|
2020-08-29 13:06:17 -04:00
|
|
|
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}`: ''})`);
|
|
|
|
|
2018-12-03 18:08:32 -05:00
|
|
|
return out.join('\n');
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2018-04-10 21:13:34 -04:00
|
|
|
// Twitch Experiments
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
getTwitchType(type: number) {
|
2021-02-12 15:27:12 -05:00
|
|
|
const core = this.resolve('site')?.getCore?.();
|
2020-08-29 13:06:17 -04:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
getTwitchTypeByKey(key: string) {
|
|
|
|
const exps = this.getTwitchExperiments(),
|
|
|
|
exp = exps?.[key];
|
2020-08-29 13:06:17 -04:00
|
|
|
|
|
|
|
if ( exp?.t )
|
|
|
|
return this.getTwitchType(exp.t);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
getTwitchExperiments(): Record<string, TwitchExperimentData> {
|
2018-04-10 21:13:34 -04:00
|
|
|
if ( window.__twilightSettings )
|
2023-11-16 18:41:50 -05:00
|
|
|
return window.__twilightSettings.experiments ?? {};
|
2018-04-10 21:13:34 -04:00
|
|
|
|
2021-02-12 15:27:12 -05:00
|
|
|
const core = this.resolve('site')?.getCore?.();
|
2023-11-16 18:41:50 -05:00
|
|
|
return core && core.experiments.experiments || {};
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
usingTwitchExperiment(key: string) {
|
2021-02-12 15:27:12 -05:00
|
|
|
const core = this.resolve('site')?.getCore?.();
|
2018-04-10 21:13:34 -04:00
|
|
|
return core && has(core.experiments.assignments, key)
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
private _getOverrideCookie() {
|
|
|
|
const raw = Cookie.get(OVERRIDE_COOKIE);
|
|
|
|
let out: OverrideCookie;
|
|
|
|
|
|
|
|
try {
|
|
|
|
out = raw ? JSON.parse(raw) : {};
|
|
|
|
} catch(err) {
|
|
|
|
out = {} as OverrideCookie;
|
|
|
|
}
|
|
|
|
|
|
|
|
if ( ! out.experiments )
|
|
|
|
out.experiments = {};
|
|
|
|
|
|
|
|
if ( ! out.disabled )
|
|
|
|
out.disabled = [];
|
|
|
|
|
|
|
|
return out;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _saveOverrideCookie(value?: OverrideCookie) {
|
|
|
|
if ( value ) {
|
|
|
|
if ((! value.experiments || ! Object.keys(value.experiments).length) &&
|
|
|
|
(! value.disabled || ! value.disabled.length)
|
|
|
|
)
|
|
|
|
value = undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
if ( value )
|
|
|
|
Cookie.set(OVERRIDE_COOKIE, JSON.stringify(value), COOKIE_OPTIONS);
|
|
|
|
else
|
|
|
|
Cookie.remove(OVERRIDE_COOKIE, COOKIE_OPTIONS);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTwitchOverride(key: string, value: string) {
|
|
|
|
const overrides = this._getOverrideCookie(),
|
|
|
|
experiments = overrides.experiments,
|
|
|
|
disabled = overrides.disabled;
|
|
|
|
|
2022-03-23 14:10:25 -04:00
|
|
|
experiments[key] = value;
|
2023-11-16 18:41:50 -05:00
|
|
|
|
2022-03-23 14:10:25 -04:00
|
|
|
const idx = disabled.indexOf(key);
|
|
|
|
if (idx != -1)
|
2023-11-16 18:41:50 -05:00
|
|
|
disabled.splice(idx, 1);
|
|
|
|
|
|
|
|
this._saveOverrideCookie(overrides);
|
2018-04-10 21:13:34 -04:00
|
|
|
|
2021-02-12 15:27:12 -05:00
|
|
|
const core = this.resolve('site')?.getCore?.();
|
2018-04-10 21:13:34 -04:00
|
|
|
if ( core )
|
|
|
|
core.experiments.overrides[key] = value;
|
|
|
|
|
|
|
|
this._rebuildTwitchKey(key, true, value);
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
deleteTwitchOverride(key: string) {
|
|
|
|
const overrides = this._getOverrideCookie(),
|
|
|
|
experiments = overrides.experiments;
|
|
|
|
|
|
|
|
if ( ! has(experiments, key) )
|
2018-04-10 21:13:34 -04:00
|
|
|
return;
|
|
|
|
|
2022-03-23 14:10:25 -04:00
|
|
|
const old_val = experiments[key];
|
|
|
|
delete experiments[key];
|
2023-11-16 18:41:50 -05:00
|
|
|
|
|
|
|
this._saveOverrideCookie(overrides);
|
2018-04-10 21:13:34 -04:00
|
|
|
|
2021-02-12 15:27:12 -05:00
|
|
|
const core = this.resolve('site')?.getCore?.();
|
2018-04-10 21:13:34 -04:00
|
|
|
if ( core )
|
|
|
|
delete core.experiments.overrides[key];
|
|
|
|
|
|
|
|
this._rebuildTwitchKey(key, false, old_val);
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
hasTwitchOverride(key: string) { // eslint-disable-line class-methods-use-this
|
|
|
|
const overrides = this._getOverrideCookie(),
|
|
|
|
experiments = overrides.experiments;
|
|
|
|
|
|
|
|
return has(experiments, key);
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
getTwitchAssignment(key: string, channel: string | null = null) {
|
2021-02-12 15:27:12 -05:00
|
|
|
const core = this.resolve('site')?.getCore?.(),
|
2018-04-10 21:13:34 -04:00
|
|
|
exps = core && core.experiments;
|
|
|
|
|
2018-07-22 17:04:10 -04:00
|
|
|
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 ( exps.overrides && exps.overrides[key] )
|
2018-04-10 21:13:34 -04:00
|
|
|
return exps.overrides[key];
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
const exp_data = exps.experiments[key],
|
|
|
|
type = this.getTwitchType(exp_data?.t ?? 0);
|
|
|
|
|
|
|
|
// channel_id experiments always use getAssignmentById
|
|
|
|
if ( type === 'channel_id' ) {
|
|
|
|
return exps.getAssignmentById(key, {
|
|
|
|
bucketing: {
|
|
|
|
type: 1,
|
|
|
|
value: channel ?? this.settings.get('context.channelID')
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Otherwise, just use the default assignment?
|
|
|
|
if ( exps.assignments?.[key] )
|
2018-04-10 21:13:34 -04:00
|
|
|
return exps.assignments[key];
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
// If there is no default assignment, we should try to figure out
|
|
|
|
// what assignment they *would* get.
|
|
|
|
|
|
|
|
if ( type === 'device_id' )
|
|
|
|
return exps.selectTreatment(key, exp_data, this.unique_id);
|
|
|
|
|
|
|
|
else if ( type === 'user_id' )
|
|
|
|
// Technically, some experiments are expecting to get the user's
|
|
|
|
// login rather than user ID. But we don't care that much if an
|
|
|
|
// inactive legacy experiment is shown wrong. Meh.
|
|
|
|
return exps.selectTreatment(key, exp_data, this.resolve('site')?.getUser?.()?.id);
|
|
|
|
|
|
|
|
// We don't know what kind of experiment this is.
|
|
|
|
// Give up!
|
2018-04-10 21:13:34 -04:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
getTwitchKeyFromName(name: string) {
|
2018-08-04 15:01:00 -04:00
|
|
|
const experiments = this.getTwitchExperiments();
|
|
|
|
if ( ! experiments )
|
2023-11-16 18:41:50 -05:00
|
|
|
return;
|
2018-08-04 15:01:00 -04:00
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
getTwitchAssignmentByName(name: string, channel: string | null = null) {
|
|
|
|
const key = this.getTwitchKeyFromName(name);
|
|
|
|
if ( ! key )
|
|
|
|
return null;
|
|
|
|
return this.getTwitchAssignment(key, channel);
|
2018-08-04 15:01:00 -04:00
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
_rebuildTwitchKey(
|
|
|
|
key: string,
|
|
|
|
is_set: boolean,
|
|
|
|
new_val: string | null
|
|
|
|
) {
|
2021-02-12 15:27:12 -05:00
|
|
|
const core = this.resolve('site')?.getCore?.(),
|
2018-04-11 17:05:31 -04:00
|
|
|
exps = core.experiments,
|
2018-04-10 21:13:34 -04:00
|
|
|
|
2018-04-11 17:05:31 -04:00
|
|
|
old_val = has(exps.assignments, key) ?
|
2023-11-16 18:41:50 -05:00
|
|
|
exps.assignments[key] as string :
|
|
|
|
null;
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
if ( old_val !== new_val ) {
|
|
|
|
const value = is_set ? new_val : old_val;
|
2023-11-16 18:41:50 -05:00
|
|
|
this.emit(':twitch-changed', key, value, old_val);
|
|
|
|
this.emit(`:twitch-changed:${key}`, value, old_val);
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FFZ Experiments
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
setOverride(key: string, value: unknown = null) {
|
|
|
|
const overrides = this.settings.provider.get('experiment-overrides', {});
|
2018-04-10 21:13:34 -04:00
|
|
|
overrides[key] = value;
|
|
|
|
|
|
|
|
this.settings.provider.set('experiment-overrides', overrides);
|
|
|
|
|
|
|
|
this._rebuildKey(key);
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
deleteOverride(key: string) {
|
2018-04-10 21:13:34 -04:00
|
|
|
const overrides = this.settings.provider.get('experiment-overrides');
|
|
|
|
if ( ! overrides || ! has(overrides, key) )
|
|
|
|
return;
|
|
|
|
|
|
|
|
delete overrides[key];
|
2023-11-16 18:41:50 -05:00
|
|
|
if ( Object.keys(overrides).length )
|
|
|
|
this.settings.provider.set('experiment-overrides', overrides);
|
|
|
|
else
|
|
|
|
this.settings.provider.delete('experiment-overrides');
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
this._rebuildKey(key);
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
hasOverride(key: string) {
|
2018-04-10 21:13:34 -04:00
|
|
|
const overrides = this.settings.provider.get('experiment-overrides');
|
2023-11-16 18:41:50 -05:00
|
|
|
return overrides ? has(overrides, key): false;
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
get: <T>(key: string) => T | null;
|
|
|
|
|
|
|
|
getAssignment<T>(key: string): T | null {
|
2018-04-10 21:13:34 -04:00
|
|
|
if ( this.cache.has(key) )
|
2023-11-16 18:41:50 -05:00
|
|
|
return this.cache.get(key) as T;
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
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) ?
|
2023-11-16 18:41:50 -05:00
|
|
|
overrides[key] as T :
|
|
|
|
ExperimentManager.selectGroup<T>(key, experiment, this.unique_id ?? '');
|
2018-04-10 21:13:34 -04:00
|
|
|
|
|
|
|
this.cache.set(key, out);
|
|
|
|
return out;
|
|
|
|
}
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
_rebuildKey(key: string) {
|
2018-04-10 21:13:34 -04:00
|
|
|
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 ) {
|
2023-11-16 18:41:50 -05:00
|
|
|
this.emit(':changed', key, new_val, old_val);
|
|
|
|
this.emit(`:changed:${key}`, new_val, old_val);
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-11-16 18:41:50 -05:00
|
|
|
static selectGroup<T>(
|
|
|
|
key: string,
|
|
|
|
experiment: FFZExperimentData,
|
|
|
|
unique_id: string
|
|
|
|
): T | null {
|
2018-04-10 21:13:34 -04:00
|
|
|
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 )
|
2023-11-16 18:41:50 -05:00
|
|
|
return group.value as T;
|
2018-04-10 21:13:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
2023-11-16 18:41:50 -05:00
|
|
|
}
|