0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 09:50:43 -05:00
server/project/src/utils/RandomUtil.ts

451 lines
16 KiB
TypeScript
Raw Normal View History

2023-03-03 15:23:46 +00:00
import { inject, injectable } from "tsyringe";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { MathUtil } from "@spt-aki/utils/MathUtil";
2023-03-03 15:23:46 +00:00
/**
* Array of ProbabilityObjectArray which allow to randomly draw of the contained objects
* based on the relative probability of each of its elements.
* The probabilities of the contained element is not required to be normalized.
*
* Example:
* po = new ProbabilityObjectArray(
* new ProbabilityObject("a", 5),
* new ProbabilityObject("b", 1),
* new ProbabilityObject("c", 1)
* );
* res = po.draw(10000);
* // count the elements which should be distributed according to the relative probabilities
* res.filter(x => x==="b").reduce((sum, x) => sum + 1 , 0)
*/
export class ProbabilityObjectArray<K, V=undefined> extends Array<ProbabilityObject<K, V>>
{
constructor(
private mathUtil: MathUtil,
private jsonUtil: JsonUtil,
2023-03-03 15:23:46 +00:00
...items: ProbabilityObject<K, V>[])
{
super();
this.push(...items);
}
filter(callbackfn: (value: ProbabilityObject<K, V>, index: number, array: ProbabilityObject<K, V>[]) => any): ProbabilityObjectArray<K, V>
{
return new ProbabilityObjectArray(this.mathUtil, this.jsonUtil, ...super.filter(callbackfn));
2023-03-03 15:23:46 +00:00
}
/**
* Calculates the normalized cumulative probability of the ProbabilityObjectArray's elements normalized to 1
* @param {array} probValues The relative probability values of which to calculate the normalized cumulative sum
* @returns {array} Cumulative Sum normalized to 1
*/
cumulativeProbability(probValues: number[]): number[]
{
const sum = this.mathUtil.arraySum(probValues);
let probCumsum = this.mathUtil.arrayCumsum(probValues);
probCumsum = this.mathUtil.arrayProd(probCumsum, 1 / sum);
return probCumsum;
}
/**
* Clone this ProbabilitObjectArray
* @returns {ProbabilityObjectArray} Deep Copy of this ProbabilityObjectArray
*/
clone(): ProbabilityObjectArray<K, V>
{
const clone = this.jsonUtil.clone(this);
const probabliltyObjects = new ProbabilityObjectArray<K, V>(this.mathUtil, this.jsonUtil);
2023-03-03 15:23:46 +00:00
for (const ci of clone)
{
probabliltyObjects.push(new ProbabilityObject(ci.key, ci.relativeProbability, ci.data));
}
return probabliltyObjects;
}
/**
* Drop an element from the ProbabilityObjectArray
*
* @param {string} key The key of the element to drop
* @returns {ProbabilityObjectArray} ProbabilityObjectArray without the dropped element
*/
drop(key: K): ProbabilityObjectArray<K, V>
{
return this.filter(r => r.key !== key);
}
/**
* Return the data field of a element of the ProbabilityObjectArray
* @param {string} key The key of the element whose data shall be retrieved
* @returns {object} The data object
*/
data(key: K): V
{
return this.filter(r => r.key === key)[0]?.data;
}
/**
* Get the relative probability of an element by its key
*
* Example:
* po = new ProbabilityObjectArray(new ProbabilityObject("a", 5), new ProbabilityObject("b", 1))
* po.maxProbability() // returns 5
*
* @param {string} key The key of the element whose relative probability shall be retrieved
* @return {number} The relative probability
*/
probability(key: K): number
{
return this.filter(r => r.key === key)[0].relativeProbability;
}
/**
* Get the maximum relative probability out of a ProbabilityObjectArray
*
* Example:
* po = new ProbabilityObjectArray(new ProbabilityObject("a", 5), new ProbabilityObject("b", 1))
* po.maxProbability() // returns 5
*
* @return {number} the maximum value of all relative probabilities in this ProbabilityObjectArray
*/
maxProbability(): number
{
return Math.max(...this.map(x => x.relativeProbability));
}
/**
* Get the minimum relative probability out of a ProbabilityObjectArray
*
* Example:
* po = new ProbabilityObjectArray(new ProbabilityObject("a", 5), new ProbabilityObject("b", 1))
* po.minProbability() // returns 1
*
* @return {number} the minimum value of all relative probabilities in this ProbabilityObjectArray
*/
minProbability(): number
{
return Math.min(...this.map(x => x.relativeProbability));
}
/**
* Draw random element of the ProbabilityObject N times to return an array of N keys.
* Drawing can be with or without replacement
* @param count The number of times we want to draw
* @param replacement Draw with or without replacement from the input dict (true = dont remove after drawing)
* @param locklist list keys which shall be replaced even if drawing without replacement
* @returns Array consisting of N random keys for this ProbabilityObjectArray
2023-03-03 15:23:46 +00:00
*/
public draw(count = 1, replacement = true, locklist: Array<K> = []): K[]
{
const {probArray, keyArray} = this.reduce((acc, x) =>
{
acc.probArray.push(x.relativeProbability);
acc.keyArray.push(x.key);
return acc;
}, {probArray: [], keyArray: []});
2023-03-03 15:23:46 +00:00
let probCumsum = this.cumulativeProbability(probArray);
const drawnKeys = [];
2023-03-03 15:23:46 +00:00
for (let i = 0; i < count; i++)
{
const rand = Math.random();
const randomIndex = probCumsum.findIndex(x => x > rand);
// We cannot put Math.random() directly in the findIndex because then it draws anew for each of its iteration
if (replacement || locklist.includes(keyArray[randomIndex]))
2023-03-03 15:23:46 +00:00
{
// Add random item from possible value into return array
drawnKeys.push(keyArray[randomIndex]);
2023-03-03 15:23:46 +00:00
}
else
{
// We draw without replacement -> remove the key and its probability from array
const key = keyArray.splice(randomIndex, 1)[0];
probArray.splice(randomIndex, 1);
drawnKeys.push(key);
2023-03-03 15:23:46 +00:00
probCumsum = this.cumulativeProbability(probArray);
// If we draw without replacement and the ProbabilityObjectArray is exhausted we need to break
2023-03-03 15:23:46 +00:00
if (keyArray.length < 1)
{
break;
}
}
}
return drawnKeys;
2023-03-03 15:23:46 +00:00
}
}
/**
* A ProbabilityObject which is use as an element to the ProbabilityObjectArray array
* It contains a key, the relative probability as well as optional data.
*/
export class ProbabilityObject<K,V=undefined>
{
key: K;
relativeProbability: number;
data: V;
/**
* Constructor for the ProbabilityObject
* @param {string} key The key of the element
* @param {number} relativeProbability The relative probability of this element
* @param {any} data Optional data attached to the element
*/
constructor(key: K, relativeProbability: number, data: V = null)
{
this.key = key;
this.relativeProbability = relativeProbability;
this.data = data;
}
}
@injectable()
export class RandomUtil
{
constructor (
@inject("JsonUtil") protected jsonUtil: JsonUtil,
@inject("WinstonLogger") protected logger: ILogger
)
{
}
public getInt(min: number, max: number): number
{
min = Math.ceil(min);
max = Math.floor(max);
return (max > min) ? Math.floor(Math.random() * (max - min + 1) + min) : min;
}
public getIntEx(max: number): number
{
return (max > 1) ? Math.floor(Math.random() * (max - 2) + 1) : 1;
}
public getFloat(min: number, max: number): number
{
return Math.random() * (max - min) + min;
}
public getBool(): boolean
{
return Math.random() < 0.5;
}
public getPercentOfValue(percent: number, number: number, toFixed = 2): number
{
return Number.parseFloat(((percent * number) / 100).toFixed(toFixed));
}
/**
* Check if number passes a check out of 100
* @param chancePercent value check needs to be above
* @returns true if value passes check
*/
public getChance100(chancePercent: number): boolean
{
return this.getIntEx(100) <= chancePercent;
}
// Its better to keep this method separated from getArrayValue so we can use generic inferance on getArrayValue
public getStringArrayValue(arr: string[]): string
{
return arr[this.getInt(0, arr.length - 1)];
}
public getArrayValue<T>(arr: T[]): T
{
return arr[this.getInt(0, arr.length - 1)];
}
public getKey(node: any): string
{
return this.getArrayValue(Object.keys(node));
}
public getKeyValue(node: { [x: string]: any; }): any
{
return node[this.getKey(node)];
}
/**
* Draw from normal distribution
* @param {number} mu Mean of the normal distribution
* @param {number} sigma Standard deviation of the normal distribution
* @returns {number} The value drawn
*/
public randn(mu: number, sigma: number): number
{
let u = 0;
let v = 0;
while (u === 0) u = Math.random(); //Converting [0,1) to (0,1)
while (v === 0) v = Math.random();
const w = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
return mu + w * sigma;
}
/**
* Draw Random integer low inclusive, high exclusive
* if high is not set we draw from 0 to low (exclusive)
* @param {integer} low Lower bound inclusive, when high is not set, this is high
* @param {integer} high Higher bound exclusive
* @returns {integer} The random integer in [low, high)
*/
public randInt(low: number, high?: number): number
{
if (high)
{
return low + Math.floor(Math.random() * (high - low));
}
else
{
return Math.floor(Math.random() * low);
}
}
/**
* Draw a random element of the provided list N times to return an array of N random elements
* Drawing can be with or without replacement
* @param {array} list The array we want to draw randomly from
* @param {integer} count The number of times we want to draw
* @param {boolean} replacement Draw with or without replacement from the input array(defult true)
2023-03-03 15:23:46 +00:00
* @return {array} Array consisting of N random elements
*/
public drawRandomFromList<T>(list: Array<T>, count = 1, replacement = true): Array<T>
{
if (!replacement)
{
list = this.jsonUtil.clone(list);
}
const results = [];
for (let i = 0; i < count; i++)
{
const randomIndex = this.randInt(list.length);
if (replacement)
{
results.push(list[randomIndex]);
}
else
{
results.push(list.splice(randomIndex, 1)[0]);
}
}
return results;
}
/**
* Draw a random (top level) element of the provided dictionary N times to return an array of N random dictionary keys
* Drawing can be with or without replacement
* @param {any} dict The dictionary we want to draw randomly from
* @param {integer} count The number of times we want to draw
* @param {boolean} replacement Draw with ot without replacement from the input dict
* @return {array} Array consisting of N random keys of the dictionary
*/
public drawRandomFromDict(dict: any, count = 1, replacement = true): any[]
{
const keys = Object.keys(dict);
const randomKeys = this.drawRandomFromList(keys, count, replacement);
return randomKeys;
}
public getBiasedRandomNumber(min: number, max: number, shift: number, n: number): number
{
/* To whoever tries to make sense of this, please forgive me - I tried my best at explaining what goes on here.
* This function generates a random number based on a gaussian distribution with an option to add a bias via shifting.
*
* Here's an example graph of how the probabilities can be distributed:
* https://www.boost.org/doc/libs/1_49_0/libs/math/doc/sf_and_dist/graphs/normal_pdf.png
* Our parameter 'n' is sort of like σ (sigma) in the example graph.
*
* An 'n' of 1 means all values are equally likely. Increasing 'n' causes numbers near the edge to become less likely.
* By setting 'shift' to whatever 'max' is, we can make values near 'min' very likely, while values near 'max' become extremely unlikely.
*
* Here's a place where you can play around with the 'n' and 'shift' values to see how the distribution changes:
* http://jsfiddle.net/e08cumyx/ */
if (max < min)
{
throw {
"name": "Invalid arguments",
"message": `Bounded random number generation max is smaller than min (${max} < ${min})`
};
}
if (n < 1)
{
throw {
"name": "Invalid argument",
"message": `'n' must be 1 or greater (received ${n})`
};
}
if (min === max)
{
return min;
}
if (shift > (max - min))
{
/* If a rolled number is out of bounds (due to bias being applied), we simply roll it again.
* As the shifting increases, the chance of rolling a number within bounds decreases.
* A shift that is equal to the available range only has a 50% chance of rolling correctly, theoretically halving performance.
* Shifting even further drops the success chance very rapidly - so we want to warn against that */
this.logger.warning("Bias shift for random number generation is greater than the range of available numbers.\nThis can have a very severe performance impact!");
this.logger.info(`min -> ${min}; max -> ${max}; shift -> ${shift}`);
}
const gaussianRandom = (n: number) =>
{
let rand = 0;
for (let i = 0; i < n; i += 1)
{
rand += Math.random();
}
return (rand / n);
};
const boundedGaussian = (start: number, end: number, n: number) =>
{
return Math.round(start + gaussianRandom(n) * (end - start + 1));
};
const biasedMin = shift >= 0 ? min - shift : min;
const biasedMax = shift < 0 ? max + shift : max;
let num: number;
do
{
num = boundedGaussian(biasedMin, biasedMax, n);
}
while (num < min || num > max);
return num;
}
/**
* Fisher-Yates shuffle an array
* @param array Array to shuffle
* @returns Shuffled array
*/
public shuffle<T>(array: Array<T>): Array<T>
{
let currentIndex = array.length;
let randomIndex: number;
// While there remain elements to shuffle.
while (currentIndex !== 0)
{
// Pick a remaining element.
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [
array[randomIndex], array[currentIndex]];
}
return array;
}
}