isaacscript-common
Version:
Helper functions and features for IsaacScript mods.
687 lines (609 loc) • 21.3 kB
text/typescript
import { ReadonlySet } from "../types/ReadonlySet";
import type { WidenLiteral } from "../types/WidenLiteral";
import { getRandomInt } from "./random";
import { isRNG, newRNG } from "./rng";
import { sortNormal } from "./sort";
import { isNumber, isTable } from "./types";
import { assertDefined, eRange } from "./utils";
/**
* Helper function for determining if two arrays contain the exact same elements. Note that this
* only performs a shallow comparison.
*/
export function arrayEquals<T>(
array1: readonly T[],
array2: readonly T[],
): boolean {
if (array1.length !== array2.length) {
return false;
}
return array1.every((array1Element, i) => {
const array2Element = array2[i];
return array1Element === array2Element;
});
}
/**
* Builds a new array based on the original array without the specified element(s). Returns the new
* array. If the specified element(s) are not found in the array, it will simply return a shallow
* copy of the array.
*
* If there is more than one matching element in the array, this function will remove all of them.
*
* This function is variadic, meaning that you can specify N arguments to remove N elements.
*/
export function arrayRemove<T>(
originalArray: readonly T[],
...elementsToRemove: readonly T[]
// eslint-disable-next-line complete/no-mutable-return
): T[] {
const elementsToRemoveSet = new ReadonlySet(elementsToRemove);
const array: T[] = [];
for (const element of originalArray) {
if (!elementsToRemoveSet.has(element)) {
array.push(element);
}
}
return array;
}
/**
* Removes all of the specified element(s) from the array. If the specified element(s) are not found
* in the array, this function will do nothing.
*
* This function is variadic, meaning that you can specify N arguments to remove N elements.
*
* If there is more than one matching element in the array, this function will remove every matching
* element. If you want to only remove the first matching element, use the `arrayRemoveInPlace`
* function instead.
*
* @returns True if one or more elements were removed, false otherwise.
*/
export function arrayRemoveAllInPlace<T>(
// eslint-disable-next-line complete/prefer-readonly-parameter-types
array: T[],
...elementsToRemove: readonly T[]
): boolean {
let removedOneOrMoreElements = false;
for (const element of elementsToRemove) {
let index: number;
do {
index = array.indexOf(element);
if (index > -1) {
removedOneOrMoreElements = true;
array.splice(index, 1);
}
} while (index > -1);
}
return removedOneOrMoreElements;
}
/**
* Removes the specified element(s) from the array. If the specified element(s) are not found in the
* array, this function will do nothing.
*
* This function is variadic, meaning that you can specify N arguments to remove N elements.
*
* If there is more than one matching element in the array, this function will only remove the first
* matching element. If you want to remove all of the elements, use the `arrayRemoveAllInPlace`
* function instead.
*
* @returns The removed elements. This will be an empty array if no elements were removed.
*/
export function arrayRemoveInPlace<T>(
// eslint-disable-next-line complete/prefer-readonly-parameter-types
array: T[],
...elementsToRemove: readonly T[]
// eslint-disable-next-line complete/no-mutable-return
): T[] {
const removedElements: T[] = [];
for (const element of elementsToRemove) {
const index = array.indexOf(element);
if (index !== -1) {
const removedElement = array.splice(index, 1);
removedElements.push(...removedElement);
}
}
return removedElements;
}
/**
* Shallow copies and removes the elements at the specified indexes from the array. Returns the
* copied array. If the specified indexes are not found in the array, it will simply return a
* shallow copy of the array.
*
* This function is variadic, meaning that you can specify N arguments to remove N elements.
*/
export function arrayRemoveIndex<T>(
originalArray: readonly T[],
...indexesToRemove: readonly int[]
// eslint-disable-next-line complete/no-mutable-return
): T[] {
const indexesToRemoveSet = new ReadonlySet(indexesToRemove);
const array: T[] = [];
for (const [i, element] of originalArray.entries()) {
if (!indexesToRemoveSet.has(i)) {
array.push(element);
}
}
return array;
}
/**
* Removes the elements at the specified indexes from the array. If the specified indexes are not
* found in the array, this function will do nothing.
*
* This function is variadic, meaning that you can specify N arguments to remove N elements.
*
* @returns The removed elements. This will be an empty array if no elements were removed.
*/
export function arrayRemoveIndexInPlace<T>(
// eslint-disable-next-line complete/prefer-readonly-parameter-types
array: T[],
...indexesToRemove: readonly int[]
// eslint-disable-next-line complete/no-mutable-return
): T[] {
const legalIndexes = indexesToRemove.filter(
(i) => i >= 0 && i < array.length,
);
if (legalIndexes.length === 0) {
return [];
}
const legalIndexesSet = new ReadonlySet(legalIndexes);
const removedElements: T[] = [];
for (let i = array.length - 1; i >= 0; i--) {
if (legalIndexesSet.has(i)) {
const removedElement = array.splice(i, 1);
removedElements.push(...removedElement);
}
}
return removedElements;
}
export function arrayToString(array: readonly unknown[]): string {
if (array.length === 0) {
return "[]";
}
const strings = array.map((element) => tostring(element));
const commaSeparatedStrings = strings.join(", ");
return `[${commaSeparatedStrings}]`;
}
/**
* Helper function to combine two or more arrays. Returns a new array that is the composition of all
* of the specified arrays.
*
* This function is variadic, meaning that you can specify N arguments to combine N arrays. Note
* that this will only perform a shallow copy of the array elements.
*/
// eslint-disable-next-line complete/no-mutable-return
export function combineArrays<T>(...arrays: ReadonlyArray<readonly T[]>): T[] {
const elements: T[] = [];
for (const array of arrays) {
for (const element of array) {
elements.push(element);
}
}
return elements;
}
/**
* Helper function to perform a shallow copy.
*
* @param oldArray The array to copy.
* @param numElements Optional. If specified, will only copy the first N elements. By default, the
* entire array will be copied.
*/
// eslint-disable-next-line complete/no-mutable-return
export function copyArray<T>(oldArray: readonly T[], numElements?: int): T[] {
// Using the spread operator was benchmarked to be faster than manually creating an array using
// the below algorithm.
if (numElements === undefined) {
return [...oldArray];
}
const newArrayWithFirstNElements: T[] = [];
for (let i = 0; i < numElements; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
newArrayWithFirstNElements.push(oldArray[i]!);
}
return newArrayWithFirstNElements;
}
/** Helper function to remove all of the elements in an array in-place. */
// eslint-disable-next-line complete/prefer-readonly-parameter-types
export function emptyArray(array: unknown[]): void {
array.splice(0);
}
/**
* Helper function to perform a filter and a map at the same time. Similar to `Array.map`, provide a
* function that transforms a value, but return `undefined` if the value should be skipped. (Thus,
* this function cannot be used in situations where `undefined` can be a valid array element.)
*
* This function is useful because the `Array.map` method will always produce an array with the same
* amount of elements as the original array.
*
* This is named `filterMap` after the Rust function:
* https://doc.rust-lang.org/std/iter/struct.FilterMap.html
*/
export function filterMap<OldT, NewT>(
array: readonly OldT[],
func: (element: OldT) => NewT | undefined,
): readonly NewT[] {
const filteredArray: NewT[] = [];
for (const element of array) {
const newElement = func(element);
if (newElement !== undefined) {
filteredArray.push(newElement);
}
}
return filteredArray;
}
/**
* Helper function to get all possible combinations of the given array. This includes the
* combination of an empty array.
*
* For example, if this function is provided an array containing 1, 2, and 3, then it will return an
* array containing the following arrays:
*
* - [] (if `includeEmptyArray` is set to true)
* - [1]
* - [2]
* - [3]
* - [1, 2]
* - [1, 3]
* - [2, 3]
* - [1, 2, 3]
*
* From: https://github.com/firstandthird/combinations/blob/master/index.js
*
* @param array The array to get the combinations of.
* @param includeEmptyArray Whether to include an empty array in the combinations.
* @param min Optional. The minimum number of elements to include in each combination. Default is 1.
* @param max Optional. The maximum number of elements to include in each combination. Default is
* the length of the array.
*/
export function getArrayCombinations<T>(
array: readonly T[],
includeEmptyArray: boolean,
min?: int,
max?: int,
): ReadonlyArray<readonly T[]> {
if (min === undefined || min <= 0) {
min = 1;
}
if (max === undefined || max <= 0) {
max = array.length;
}
const all: Array<readonly T[]> = [];
for (let i = min; i < array.length; i++) {
addCombinations(i, array, [], all);
}
if (array.length === max) {
all.push(array);
}
// Finally, account for the empty array combination.
if (includeEmptyArray) {
all.unshift([]);
}
return all;
}
/** Mutates the `all` array in-place. */
function addCombinations<T>(
n: number,
src: readonly T[],
got: readonly T[],
// eslint-disable-next-line complete/prefer-readonly-parameter-types
all: Array<readonly T[]>,
) {
if (n === 0) {
if (got.length > 0) {
all[all.length] = got;
}
return;
}
for (const [i, element] of src.entries()) {
addCombinations(n - 1, src.slice(i + 1), [...got, element], all);
}
}
/**
* Helper function to get the duplicate elements in an array. Only one element for each value will
* be returned. The elements will be sorted before they are returned.
*/
export function getArrayDuplicateElements<T extends number | string>(
array: readonly T[],
): readonly T[] {
const duplicateElements = new Set<T>();
const set = new Set<T>();
for (const element of array) {
if (set.has(element)) {
duplicateElements.add(element);
}
set.add(element);
}
const values = [...duplicateElements];
return values.sort(sortNormal);
}
/**
* Helper function to get an array containing the indexes of an array.
*
* For example, an array of `["Apple", "Banana"]` would return an array of `[0, 1]`.
*
* Note that normally, you would use the `Object.keys` method to get the indexes of an array, but
* due to implementation details of TypeScriptToLua, this results in an array of 1 through N
* (instead of an array of 0 through N -1).
*/
export function getArrayIndexes(array: readonly unknown[]): readonly int[] {
return eRange(array.length);
}
/**
* Helper function to get the highest value in an array. Returns undefined if there were no elements
* in the array.
*/
export function getHighestArrayElement(
array: readonly number[],
): number | undefined {
if (array.length === 0) {
return undefined;
}
let highestValue: number | undefined;
for (const element of array) {
if (highestValue === undefined || element > highestValue) {
highestValue = element;
}
}
return highestValue;
}
/**
* Helper function to get the lowest value in an array. Returns undefined if there were no elements
* in the array.
*/
export function getLowestArrayElement(
array: readonly number[],
): number | undefined {
if (array.length === 0) {
return undefined;
}
let lowestValue: number | undefined;
for (const element of array) {
if (lowestValue === undefined || element < lowestValue) {
lowestValue = element;
}
}
return lowestValue;
}
/**
* Helper function to get a random element from the provided array.
*
* If you want to get an unseeded element, you must explicitly pass `undefined` to the `seedOrRNG`
* parameter.
*
* @param array The array to get an element from.
* @param seedOrRNG The `Seed` or `RNG` object to use. If an `RNG` object is provided, the
* `RNG.Next` method will be called. If `undefined` is provided, it will default to
* a random seed.
* @param exceptions Optional. An array of elements to skip over if selected.
*/
export function getRandomArrayElement<T>(
array: readonly T[],
seedOrRNG: Seed | RNG | undefined,
exceptions: readonly T[] = [],
): T {
if (array.length === 0) {
error(
"Failed to get a random array element since the provided array is empty.",
);
}
const arrayToUse =
exceptions.length > 0 ? arrayRemove(array, ...exceptions) : array;
const randomIndex = getRandomArrayIndex(arrayToUse, seedOrRNG);
const randomElement = arrayToUse[randomIndex];
assertDefined(
randomElement,
`Failed to get a random array element since the random index of ${randomIndex} was not valid.`,
);
return randomElement;
}
/**
* Helper function to get a random element from the provided array. Once the random element is
* decided, it is then removed from the array (in-place).
*
* If you want to get an unseeded element, you must explicitly pass `undefined` to the `seedOrRNG`
* parameter.
*
* @param array The array to get an element from.
* @param seedOrRNG The `Seed` or `RNG` object to use. If an `RNG` object is provided, the
* `RNG.Next` method will be called. If `undefined` is provided, it will default to
* a random seed.
* @param exceptions Optional. An array of elements to skip over if selected.
*/
export function getRandomArrayElementAndRemove<T>(
// eslint-disable-next-line complete/prefer-readonly-parameter-types
array: T[],
seedOrRNG: Seed | RNG | undefined,
exceptions: readonly T[] = [],
): T {
const randomArrayElement = getRandomArrayElement(
array,
seedOrRNG,
exceptions,
);
arrayRemoveInPlace(array, randomArrayElement);
return randomArrayElement;
}
/**
* Helper function to get a random index from the provided array.
*
* If you want to get an unseeded index, you must explicitly pass `undefined` to the `seedOrRNG`
* parameter.
*
* @param array The array to get the index from.
* @param seedOrRNG The `Seed` or `RNG` object to use. If an `RNG` object is provided, the
* `RNG.Next` method will be called. If `undefined` is provided, it will default to
* a random seed.
* @param exceptions Optional. An array of indexes that will be skipped over when getting the random
* index. Default is an empty array.
*/
export function getRandomArrayIndex(
array: readonly unknown[],
seedOrRNG: Seed | RNG | undefined,
exceptions: readonly int[] = [],
): int {
if (array.length === 0) {
error(
"Failed to get a random array index since the provided array is empty.",
);
}
return getRandomInt(0, array.length - 1, seedOrRNG, exceptions);
}
/**
* Similar to the `Array.includes` method, but works on a widened version of the array.
*
* This is useful when the normal `Array.includes` produces a type error from an array that uses an
* `as const` assertion.
*/
export function includes<T, TupleElement extends WidenLiteral<T>>(
array: readonly TupleElement[],
searchElement: WidenLiteral<T>,
): searchElement is TupleElement {
const widenedArray: ReadonlyArray<WidenLiteral<T>> = array;
return widenedArray.includes(searchElement);
}
/**
* Since Lua uses tables for every non-primitive data structure, it is non-trivial to determine if a
* particular table is being used as an array. `isArray` returns true if:
*
* - the table contains all numerical indexes that are contiguous, starting at 1
* - the table has no keys (i.e. an "empty" table)
*
* @param object The object to analyze.
* @param ensureContiguousValues Optional. Whether the Lua table has to have all contiguous keys in
* order to be considered an array. Default is true.
*/
export function isArray(
object: unknown,
ensureContiguousValues = true,
): object is unknown[] {
if (!isTable(object)) {
return false;
}
// First, if there is a metatable, this cannot be a simple array and must be a more complex
// object.
const metatable = getmetatable(object);
if (metatable !== undefined) {
return false;
}
// Second, handle the case of an "empty" table.
const keys = Object.keys(object);
if (keys.length === 0) {
return true;
}
// Third, handle the case of non-numerical keys.
const hasAllNumberKeys = keys.every((key) => isNumber(key));
if (!hasAllNumberKeys) {
return false;
}
// Fourth, check for non-contiguous elements. (Lua tables start at an index of 1.)
if (ensureContiguousValues) {
for (let i = 1; i <= keys.length; i++) {
const element = object.get(i);
if (element === undefined) {
return false;
}
}
}
return true;
}
/**
* Helper function to see if every element in the array is N + 1.
*
* For example, `[2, 3, 4]` would return true, and `[2, 3, 5]` would return false.
*/
export function isArrayContiguous(array: readonly int[]): boolean {
let lastValue: int | undefined;
for (const element of array) {
lastValue ??= element - 1;
if (element !== lastValue - 1) {
return false;
}
}
return true;
}
/**
* Helper function to check if all the elements of an array are unique within that array.
*
* Under the hood, this is performed by converting the array to a set.
*/
export function isArrayElementsUnique(array: readonly unknown[]): boolean {
const set = new Set(array);
return set.size === array.length;
}
/** Checks if an array is in the provided 2-dimensional array. */
export function isArrayInArray<T>(
arrayToMatch: readonly T[],
parentArray: ReadonlyArray<readonly T[]>,
): boolean {
return parentArray.some((element) => arrayEquals(element, arrayToMatch));
}
/** Helper function to set every element in an array to a specific value. */
// eslint-disable-next-line complete/prefer-readonly-parameter-types
export function setAllArrayElements<T>(array: T[], value: T): void {
for (let i = 0; i < array.length; i++) {
array[i] = value;
}
}
/**
* Shallow copies and shuffles the array using the Fisher-Yates algorithm. Returns the copied array.
*
* If you want an unseeded shuffle, you must explicitly pass `undefined` to the `seedOrRNG`
* parameter.
*
* From: https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
*
* @param originalArray The array to shuffle.
* @param seedOrRNG The `Seed` or `RNG` object to use. If an `RNG` object is provided, the
* `RNG.Next` method will be called. If `undefined` is provided, it will default to
* a random seed.
*/
export function shuffleArray<T>(
originalArray: readonly T[],
seedOrRNG: Seed | RNG | undefined,
// eslint-disable-next-line complete/no-mutable-return
): T[] {
const array = copyArray(originalArray);
shuffleArrayInPlace(array, seedOrRNG);
return array;
}
/**
* Shuffles the provided array in-place using the Fisher-Yates algorithm.
*
* If you want an unseeded shuffle, you must explicitly pass `undefined` to the `seedOrRNG`
* parameter.
*
* From: https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
*
* @param array The array to shuffle.
* @param seedOrRNG The `Seed` or `RNG` object to use. If an `RNG` object is provided, the
* `RNG.Next` method will be called. If `undefined` is provided, it will default to
* a random seed.
*/
export function shuffleArrayInPlace(
// eslint-disable-next-line complete/prefer-readonly-parameter-types
array: unknown[],
seedOrRNG: Seed | RNG | undefined,
): void {
let currentIndex = array.length;
const rng = isRNG(seedOrRNG) ? seedOrRNG : newRNG(seedOrRNG);
while (currentIndex > 0) {
currentIndex--;
const randomIndex = getRandomArrayIndex(array, rng);
swapArrayElements(array, currentIndex, randomIndex);
}
}
/** Helper function to sum every value in an array together. */
export function sumArray(array: readonly number[]): number {
return array.reduce((accumulator, element) => accumulator + element, 0);
}
/**
* Helper function to swap two different array elements. (The elements will be swapped in-place.)
*/
export function swapArrayElements(
// eslint-disable-next-line complete/prefer-readonly-parameter-types
array: unknown[],
i: number,
j: number,
): void {
const value1 = array[i]!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
const value2 = array[j]!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
array[i] = value2;
array[j] = value1;
}