1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-28 15:27:43 +00:00
FrankerFaceZ/src/experiments.ts

661 lines
16 KiB
TypeScript
Raw Normal View History

'use strict';
// ============================================================================
// Experiments
// ============================================================================
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';
import { getBuster } from 'utilities/time';
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>;
}
}
}
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
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');
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;
}
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
// ============================================================================
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);
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,
2023-11-16 18:41:50 -05:00
ffz_data: () => deep_copy(this.experiments) ?? {},
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),
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),
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)
});
this.unique_id = Cookie.get('unique_id');
this.Cookie = Cookie;
this.experiments = {};
this.cache = new Map;
}
getControlsLocked() {
if ( DEBUG )
return false;
2023-11-16 18:41:50 -05:00
const ts = this.settings.provider.get<number>('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() {
2023-11-16 18:41:50 -05:00
let data: Record<string, FFZExperimentData> | null;
try {
2023-11-16 18:41:50 -05:00
data = await fetchJSON(DEBUG
? EXPERIMENTS
: `${SERVER}/script/experiments.json?_=${getBuster()}`
);
} catch(err) {
this.log.warn('Unable to load experiment data.', err);
2023-11-16 18:41:50 -05:00
return;
}
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);
}
}
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
//this.emit(':loaded');
}
2023-11-16 18:41:50 -05:00
/** @internal */
onEnable() {
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
this.on('pubsub:command:update_experiment', this.updateExperiment, this);
}
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;
this.experiments[key].groups = data;
2023-11-16 18:41:50 -05:00
} else if ( data?.groups )
this.experiments[key] = 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
2023-11-16 18:41:50 -05:00
getTwitchType(type: number) {
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;
}
2023-11-16 18:41:50 -05:00
getTwitchTypeByKey(key: string) {
const exps = this.getTwitchExperiments(),
exp = exps?.[key];
if ( exp?.t )
return this.getTwitchType(exp.t);
return null;
}
2023-11-16 18:41:50 -05:00
getTwitchExperiments(): Record<string, TwitchExperimentData> {
if ( window.__twilightSettings )
2023-11-16 18:41:50 -05:00
return window.__twilightSettings.experiments ?? {};
const core = this.resolve('site')?.getCore?.();
2023-11-16 18:41:50 -05:00
return core && core.experiments.experiments || {};
}
2023-11-16 18:41:50 -05:00
usingTwitchExperiment(key: string) {
const core = this.resolve('site')?.getCore?.();
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;
experiments[key] = value;
2023-11-16 18:41:50 -05:00
const idx = disabled.indexOf(key);
if (idx != -1)
2023-11-16 18:41:50 -05:00
disabled.splice(idx, 1);
this._saveOverrideCookie(overrides);
const core = this.resolve('site')?.getCore?.();
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) )
return;
const old_val = experiments[key];
delete experiments[key];
2023-11-16 18:41:50 -05:00
this._saveOverrideCookie(overrides);
const core = this.resolve('site')?.getCore?.();
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);
}
2023-11-16 18:41:50 -05:00
getTwitchAssignment(key: string, channel: string | null = 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 ( exps.overrides && exps.overrides[key] )
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] )
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!
return null;
}
2023-11-16 18:41:50 -05:00
getTwitchKeyFromName(name: string) {
const experiments = this.getTwitchExperiments();
if ( ! experiments )
2023-11-16 18:41:50 -05:00
return;
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);
}
2023-11-16 18:41:50 -05:00
_rebuildTwitchKey(
key: string,
is_set: boolean,
new_val: string | null
) {
const core = this.resolve('site')?.getCore?.(),
exps = core.experiments,
old_val = has(exps.assignments, key) ?
2023-11-16 18:41:50 -05:00
exps.assignments[key] as string :
null;
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);
}
}
// FFZ Experiments
2023-11-16 18:41:50 -05:00
setOverride(key: string, value: unknown = null) {
const overrides = this.settings.provider.get('experiment-overrides', {});
overrides[key] = value;
this.settings.provider.set('experiment-overrides', overrides);
this._rebuildKey(key);
}
2023-11-16 18:41:50 -05:00
deleteOverride(key: string) {
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');
this._rebuildKey(key);
}
2023-11-16 18:41:50 -05:00
hasOverride(key: string) {
const overrides = this.settings.provider.get('experiment-overrides');
2023-11-16 18:41:50 -05:00
return overrides ? has(overrides, key): false;
}
2023-11-16 18:41:50 -05:00
get: <T>(key: string) => T | null;
getAssignment<T>(key: string): T | null {
if ( this.cache.has(key) )
2023-11-16 18:41:50 -05:00
return this.cache.get(key) as T;
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 ?? '');
this.cache.set(key, out);
return out;
}
2023-11-16 18:41:50 -05:00
_rebuildKey(key: string) {
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);
}
}
2023-11-16 18:41:50 -05:00
static selectGroup<T>(
key: string,
experiment: FFZExperimentData,
unique_id: string
): T | null {
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;
}
return null;
}
2023-11-16 18:41:50 -05:00
}