bondage-college-mirr/BondageClub/Scripts/Common.js
2026-04-24 21:48:24 -04:00

2595 lines
85 KiB
JavaScript

// Main variables
"use strict";
/** @type {PlayerCharacter} */
var Player;
/** @type {ModuleType} */
var CurrentModule;
/** @type {ModuleScreens[CurrentModule]} */
var CurrentScreen;
/** @type {Required<ScreenFunctions>} */
var CurrentScreenFunctions;
/** @type {Character|NPCCharacter|null} */
var CurrentCharacter = null;
var CurrentOnlinePlayers = 0;
/**
* A per-screen ratio of how darkened the background must be
*
* 1 is bright, 0 is pitch black
*/
var CurrentDarkFactor = 1.0;
var CommonIsMobile = false;
/** @type {Record<string, string[][]>} */
var CommonCSVCache = {};
var CutsceneStage = 0;
var CommonPhotoMode = false;
/**
* An enum encapsulating possible chatroom message substitution tags. Character name substitution tags are interpreted
* in chatrooms as follows (assuming the character name is Ben987):
* SOURCE_CHAR: "Ben987"
* DEST_CHAR: "Ben987's" (if character is not self), "her" (if character is self)
* DEST_CHAR_NAME: "Ben987's"
* TARGET_CHAR: "Ben987" (if character is not self), "herself" (if character is self)
* TARGET_CHAR_NAME: "Ben987"
* Additionally, sending the following tags will ensure that asset names in messages are correctly translated by
* recipients:
* ASSET_NAME: (substituted with the localized name of the asset, if available)
* @type {Record<"SOURCE_CHAR"|"DEST_CHAR"|"DEST_CHAR_NAME"|"TARGET_CHAR"|"TARGET_CHAR_NAME"|"ASSET_NAME"|"AUTOMATIC", CommonChatTags>}
*/
const CommonChatTags = {
SOURCE_CHAR: "SourceCharacter",
DEST_CHAR: "DestinationCharacter",
DEST_CHAR_NAME: "DestinationCharacterName",
TARGET_CHAR: "TargetCharacter",
TARGET_CHAR_NAME: "TargetCharacterName",
ASSET_NAME: "AssetName",
AUTOMATIC: "Automatic",
};
/** @type {(this: string, index: number, character: string) => string} */
String.prototype.replaceAt=function(index, character) {
return this.substr(0, index) + character + this.substr(index+character.length);
};
/**
* A map of keys to common font stack definitions. Each stack definition is a
* two-item array whose first item is an ordered list of fonts, and whose
* second item is the generic fallback font family (e.g. sans-serif, serif,
* etc.)
* @constant
* @type {Record<String, [String[], String]>}
*/
const CommonFontStacks = {
Arial: [["Arial"], "sans-serif"],
TimesNewRoman: [["Times New Roman", "Times"], "serif"],
Papyrus: [["Papyrus", "Ink Free", "Segoe Script", "Gabriola"], "fantasy"],
ComicSans: [["Comic Sans MS", "Comic Sans", "Brush Script MT", "Segoe Print"], "cursive"],
Impact: [["Impact", "Arial Black", "Franklin Gothic", "Arial"], "sans-serif"],
HelveticaNeue: [["Helvetica Neue", "Helvetica", "Arial"], "sans-serif"],
Verdana: [["Verdana", "Helvetica Neue", "Arial"], "sans-serif"],
CenturyGothic: [["Century Gothic", "Apple Gothic", "AppleGothic", "Futura"], "sans-serif"],
Georgia: [["Georgia", "Times"], "serif"],
CourierNew: [["Courier New", "Courier"], "monospace"],
Copperplate: [["Copperplate", "Copperplate Gothic Light"], "fantasy"],
};
function CommonPrefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/**
* Checks if a variable is a number
* @param {any} n - Variable to check for
* @returns {n is number} - Returns TRUE if the variable is a finite number
*/
function CommonIsNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
/**
* Gets the current time as a string
* @returns {string} - Returns the current date and time in a yyyy-mm-dd hh:mm:ss format
*/
function CommonGetFormatDate() {
var d = new Date();
var yyyy = d.getFullYear();
var mm = d.getMonth() < 9 ? "0" + (d.getMonth() + 1) : (d.getMonth() + 1); // getMonth() is zero-based
var dd = d.getDate() < 10 ? "0" + d.getDate() : d.getDate();
var hh = d.getHours() < 10 ? "0" + d.getHours() : d.getHours();
var min = d.getMinutes() < 10 ? "0" + d.getMinutes() : d.getMinutes();
var ss = d.getSeconds() < 10 ? "0" + d.getSeconds() : d.getSeconds();
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
}
/**
* Detects if the user is on a mobile browser
* @returns {boolean} - Returns TRUE if the user is on a mobile browser
*/
function CommonDetectMobile() {
return globalThis.matchMedia("(pointer: coarse)").matches;
}
/**
* Gets the current browser name and version
* @returns {{Name: string, Version: string}} - Browser info
*/
function CommonGetBrowser() {
var ua = navigator.userAgent, tem, M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
if (/trident/i.test(M[1])) {
tem = /\brv[ :]+(\d+)/g.exec(ua) || [];
return { Name: "IE", Version: (tem[1] || "N/A") };
}
if (M[1] === 'Chrome') {
tem = ua.match(/\bOPR|Edge\/(\d+)/);
if (tem != null) return { Name: "Opera", Version: tem[1] || "N/A" };
}
M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?'];
if ((tem = ua.match(/version\/(\d+)/i)) != null) M.splice(1, 1, tem[1]);
return { Name: M[0] || "N/A", Version: M[1] || "N/A" };
}
/**
* Parses a string into an integer
* @param {string} val The string to parse into a number
* @param {null | number} radix A value between 2 and 36 that specifies the base of the number in the string. Defaults to 10 if not provided
* @returns {number | null}
*/
function CommonParseInt(val, radix=null) {
const num = parseInt(val, radix ?? 10);
if (isNaN(num))
return null;
return num;
}
/**
* Parse a CSV file content into an array
* @param {string} str - Content of the CSV
* @returns {string[][]} Array representing each line of the parsed content, each line itself is split by commands and stored within an array.
*/
function CommonParseCSV(str) {
/** @type {string[][]} */
var arr = [];
var quote = false; // true means we're inside a quoted field
var c;
var col;
// We remove whitespace on start and end
str = str.replace(/\r\n/g, '\n').trim();
// iterate over each character, keep track of current row and column (of the returned array)
for (let row = col = c = 0; c < str.length; c++) {
var cc = str[c], nc = str[c + 1]; // current character, next character
arr[row] = arr[row] || []; // create a new row if necessary
arr[row][col] = arr[row][col] || ''; // create a new column (start with empty string) if necessary
// If the current character is a quotation mark, and we're inside a
// quoted field, and the next character is also a quotation mark,
// add a quotation mark to the current column and skip the next character
if (cc == '"' && quote && nc == '"') { arr[row][col] += cc; ++c; continue; }
// If it's just one quotation mark, begin/end quoted field
if (cc == '"') { quote = !quote; continue; }
// If it's a comma and we're not in a quoted field, move on to the next column
if (cc == ',' && !quote) { ++col; continue; }
// If it's a newline and we're not in a quoted field, move on to the next
// row and move to column 0 of that new row
if (cc == '\n' && !quote) { ++row; col = 0; continue; }
// Otherwise, append the current character to the current column
arr[row][col] += cc;
}
return arr;
}
/**
* Read a CSV file from the server
*
* If the file has been cached already it'll use that.
*
* @warning Note that due to the way fetching is async, the
* array you'll get back won't be populated until the fetch completes
*
* @param {string} url - URL to load
* @returns {string[][]}
*/
function CommonReadCSV(url) {
if (CommonCSVCache[url]) {
return CommonCSVCache[url];
}
/** @type {string[][]} */
const temp = CommonCSVCache[url] = [];
// Opens the file, parse it and returns the result in an Object
CommonGet(url, function () {
if (this.status == 200) {
const parsed = CommonParseCSV(this.responseText);
temp.push(...parsed);
}
});
// If a translation file is available, we open the txt file and keep it in cache
const TranslationPath = url.replace(".csv", "_" + TranslationLanguage + ".txt");
if (TranslationAvailable(TranslationPath))
CommonGet(TranslationPath, function () {
if (this.status == 200) TranslationCache[TranslationPath] = TranslationParseTXT(this.responseText);
});
return temp;
}
/**
* Sleep for a number of milliseconds
* @param {number} ms
* @returns {Promise<number>}
*/
async function CommonSleep(ms) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
/**
*
* @param {() => boolean} func
* @param {() => boolean} [cancelFunc]
* @returns
*/
async function CommonWaitFor(func, cancelFunc = () => false) {
while (!func()) {
if (cancelFunc()) {
return false;
}
await CommonSleep(10);
}
return true;
}
/**
* How many times do we retry a resource fetch.
*/
const FETCH_MAX_RETRIES = 10;
/**
* How much to wait max between each attempt.
*/
const FETCH_MAX_RETRY_BACKOFF_TIME = 16;
/**
* Fetch a remote resource
*
* @param {RequestInfo | URL} request
* @returns {Promise<Response>}
*/
async function CommonFetch(request) {
let retries = FETCH_MAX_RETRIES;
const method = request instanceof Request ? request.method : "GET";
const url = request instanceof Request ? request.url : request;
let reply = await fetch(request);
while (retries-- > 0) {
if (reply.status >= 400) {
const retrySeconds = Math.min(Math.pow(2, Math.max(0, FETCH_MAX_RETRIES - retries)), FETCH_MAX_RETRY_BACKOFF_TIME);
console.warn(`${method} request to ${url} failed - retrying in ${retrySeconds} second${retrySeconds === 1 ? "" : "s"}...`);
await CommonSleep(retrySeconds * 1000);
reply = await fetch(request);
continue;
}
break;
}
if (retries <= 0) {
console.error(`${method} request to ${url} failed - no more retries`);
}
return reply;
}
/**
* AJAX utility to get a file and return its content. By default will retry requests 10 times
* @param {string} Path - Path of the resource to request
* @param {(this: XMLHttpRequest, xhr: XMLHttpRequest) => void} Callback - Callback to execute once the resource is received
* @param {number} [RetriesLeft] - How many more times to retry if the request fails - after this hits zero, an error will be logged
* @returns {void} - Nothing
*/
function CommonGet(Path, Callback, RetriesLeft) {
var xhr = new XMLHttpRequest();
xhr.open("GET", Path);
xhr.onloadend = function() {
// For non-error responses, call the callback
if (this.status < 400) Callback.bind(this)(xhr);
// Otherwise, retry
else CommonGetRetry(Path, Callback, RetriesLeft);
};
xhr.onerror = function() { CommonGetRetry(Path, Callback, RetriesLeft); };
xhr.send(null);
}
/**
* Retry handler for CommonGet requests. Exponentially backs off retry attempts up to a limit of 1 minute. By default,
* retries up to a maximum of 10 times.
* @param {string} Path - The path of the resource to request
* @param {(this: XMLHttpRequest, xhr: XMLHttpRequest) => void} Callback - Callback to execute once the resource is received
* @param {number} [RetriesLeft] - How many more times to retry - after this hits zero, an error will be logged
* @returns {void} - Nothing
*/
function CommonGetRetry(Path, Callback, RetriesLeft) {
if (typeof RetriesLeft !== "number") RetriesLeft = FETCH_MAX_RETRIES;
if (RetriesLeft <= 0) {
console.error(`GET request to ${Path} failed - no more retries`);
} else {
const retrySeconds = Math.min(Math.pow(2, Math.max(0, FETCH_MAX_RETRIES - RetriesLeft)), FETCH_MAX_RETRY_BACKOFF_TIME);
console.warn(`GET request to ${Path} failed - retrying in ${retrySeconds} second${retrySeconds === 1 ? "" : "s"}...`);
setTimeout(() => CommonGet(Path, Callback, RetriesLeft - 1), retrySeconds * 1000);
}
}
/** @type {MouseEventListener} */
function CommonMouseDown(event) {
if (CurrentCharacter == null) {
if (ScreenIsLoading) return;
CurrentScreenFunctions.MouseDown(event);
} else {
DialogMouseDown(event);
}
}
/** @type {MouseEventListener} */
function CommonMouseUp(event) {
if (ScreenIsLoading) return;
CurrentScreenFunctions.MouseUp(event);
}
/** @type {MouseEventListener} */
function CommonMouseMove(event) {
if (ScreenIsLoading) return;
CurrentScreenFunctions.MouseMove(event);
}
/** @type {MouseWheelEventListener} */
function CommonMouseWheel(event) {
if (ScreenIsLoading) return;
CurrentScreenFunctions.MouseWheel(event);
}
/** @type {ScreenResizeHandler} */
function CommonResize(load) {
CurrentScreenFunctions.Resize(load);
}
/**
* Catches the clicks on the main screen and forwards it to the current screen click function if it exists, otherwise it sends it to the dialog click function
* @type {MouseEventListener}
*/
function CommonClick(event) {
if (CurrentCharacter == null)
CurrentScreenFunctions.Click(event);
else
DialogClick(event);
}
/**
* Returns TRUE if a section of the screen is currently touched on a mobile device
* @param {number} X - The X position
* @param {number} Y - The Y position
* @param {number} W - The width of the square
* @param {number} H - The height of the square
* @param {null | TouchList} [TL] - Can give a specific touch event instead of the default one
* @returns {boolean}
*/
function CommonTouchActive(X, Y, W, H, TL=null) {
if (!CommonIsMobile) return false;
if (TL == null) TL = CommonTouchList;
if (TL == null) return false;
for (let index = 0; index < TL.length; index++) {
const Touch = TL[index];
let TouchX = Math.round((Touch.pageX - MainCanvas.canvas.offsetLeft) * 2000 / MainCanvas.canvas.clientWidth);
let TouchY = Math.round((Touch.pageY - MainCanvas.canvas.offsetTop) * 1000 / MainCanvas.canvas.clientHeight);
if ((TouchX >= X) && (TouchX <= X + W) && (TouchY >= Y) && (TouchY <= Y + H))
return true;
}
return false;
}
/**
* Calls a basic dynamic function if it exists, for complex functions, use: CommonDynamicFunctionParams
* @param {string} FunctionName - Name of the function to call
* @returns {void} - Nothing
*/
function CommonDynamicFunction(FunctionName) {
/** @type {Record<string, any>} */
const namespace = window;
const func = namespace[FunctionName.substr(0, FunctionName.indexOf("("))];
if (typeof func === "function")
func();
else
console.log("Trying to launch invalid function: " + FunctionName);
}
/**
* Calls a dynamic function with parameters (if it exists), also allow ! in front to reverse the result. The dynamic function is the provided function name in the dialog option object and it is prefixed by the current screen.
* @param {string} FunctionName - Function name to call dynamically
* @returns {unknown} - Returns what the dynamic function returns, or throws if it can't be called
*/
function CommonDynamicFunctionParams(FunctionName) {
// Gets the reverse (!) sign
var Reverse = false;
if (FunctionName.substring(0, 1) == "!") Reverse = true;
FunctionName = FunctionName.replace("!", "");
// Gets the real function name and parameters
var openParenthesisIndex = FunctionName.indexOf("(");
var closedParenthesisIndex = FunctionName.indexOf(")", openParenthesisIndex);
var ParamsString = FunctionName.substring(openParenthesisIndex + 1, closedParenthesisIndex);
var Params = ParamsString.length === 0 ? [] : ParamsString.split(",");
for (let P = 0; P < Params.length; P++)
Params[P] = Params[P].trim().replace('"', '').replace('"', '');
FunctionName = FunctionName.substring(0, openParenthesisIndex);
if (["Dialog", "Inventory", CurrentScreen].every(s => !FunctionName.startsWith(s))) {
FunctionName = CurrentScreen + FunctionName;
}
// If it's really a function, we continue
/** @type {Record<string, any>} */
const namespace = window;
const func = namespace[FunctionName];
if (typeof func !== "function") {
throw new Error("CommonDynamicFunctionParams: Invalid function name: " + FunctionName);
}
// Launches the function with the params and returns the result
const res = func(...Params);
return Reverse ? !res : res;
}
/**
* Calls a named global function with the passed in arguments, if the named function exists. Differs from
* CommonDynamicFunctionParams in that arguments are not parsed from the passed in FunctionName string, but
* passed directly into the function call, allowing for more complex JS objects to be passed in. This
* function will not log to console if the provided function name does not exist as a global function.
* @template {keyof WindowFunctions} T
* @param {T} FunctionName - The name of the global function to call
* @param {Parameters<Window[T]>} args - zero or more arguments to be passed to the function (optional)
* @returns {Window[T] extends undefined ? undefined | ReturnType<Window[T]> : ReturnType<Window[T]>} - returns the result of the function call, or undefined if the function name isn't valid
*/
function CommonCallFunctionByName(FunctionName, ...args) {
const func = window[FunctionName];
if (typeof func === "function") {
return func(...args);
} else {
// @ts-ignore: strict TS fails to recognize that one may pass an optional function
return undefined;
}
}
/**
* Behaves exactly like CommonCallFunctionByName, but logs a warning if the function name is invalid.
* @template {keyof WindowFunctions} T
* @param {T} FunctionName - The name of the global function to call
* @param {Parameters<Window[T]>} args - zero or more arguments to be passed to the function (optional)
* @returns {Window[T] extends undefined ? undefined | ReturnType<Window[T]> : ReturnType<Window[T]>} - returns the result of the function call, or undefined if the function name isn't valid
*/
function CommonCallFunctionByNameWarn(FunctionName, ...args) {
const func = window[FunctionName];
if (typeof func === "function") {
return func(...args);
} else {
console.warn(`Attempted to call invalid function "${FunctionName}"`);
// @ts-ignore: strict TS fails to recognize that one may pass an optional function
return undefined;
}
}
/**
* Get the current screen
* @returns {ScreenSpecifier}
*/
function CommonGetScreen() {
return /** @type {ScreenSpecifier} */([CurrentModule, CurrentScreen]);
}
var ScreenIsLoading = false;
/**
* Sets the current screen and calls the loading script if needed
* @param {ScreenSpecifier} spec
* @returns {Promise<void>} - Nothing
*/
async function CommonSetScreen(...spec) {
const { recursive } = spec[2] ?? {};
// `CurrentScreenFunctions` is still undefined the _very_ first time a screen is set as BC launches
CurrentScreenFunctions?.Unload();
if (ControllerIsActive()) {
ControllerClearAreas();
}
const [NewModule, NewScreen] = spec;
const [OldModule, OldScreen] = [CurrentModule, CurrentScreen];
// Check for required functions
if (typeof window[`${NewScreen}Run`] !== "function") {
throw Error(`Screen "${NewScreen}": Missing required Run function`);
}
if (typeof window[`${NewScreen}Click`] !== "function") {
throw Error(`Screen "${NewScreen}": Missing required Click function`);
}
ScreenIsLoading = true;
CurrentModule = NewModule;
CurrentScreen = NewScreen;
CurrentScreenFunctions = {
Run: (time) => window[`${NewScreen}Run`](time),
Click: (e) => window[`${NewScreen}Click`](e),
MouseDown: (e) => window[`${NewScreen}MouseDown`]?.(e),
MouseUp: (e) => window[`${NewScreen}MouseUp`]?.(e),
MouseMove: (e) => window[`${NewScreen}MouseMove`]?.(e),
MouseWheel: (e) => window[`${NewScreen}MouseWheel`]?.(e),
Draw: () => window[`${NewScreen}Draw`]?.(),
Load: async () => window[`${NewScreen}Load`]?.(),
Unload: () => window[`${NewScreen}Unload`]?.(),
Resize: (load) => window[`${NewScreen}Resize`]?.(load),
KeyDown: (e) => window[`${NewScreen}KeyDown`]?.(e) ?? false,
KeyUp: (e) => window[`${NewScreen}KeyUp`]?.(e) ?? false,
Exit: () => window[`${NewScreen}Exit`]?.(),
Paste: (e) => window[`${NewScreen}Paste`]?.(e),
};
CurrentDarkFactor = 1.0;
CommonGetFont.clearCache();
CommonGetFontName.clearCache();
const textCache = TextLoad();
try {
await textCache.loadedPromise;
} catch (_e) {
console.error(`Failed to load text strings for screen!`);
}
ElementToggleGeneratedElements(CurrentScreen, true);
try {
await CurrentScreenFunctions.Load();
} catch (error) {
if (!recursive) {
// Plan A: failed to load the new screen; revert back to the old screen
console.error(`Failed to load "${NewModule}/${NewScreen}" screen:\n`, error);
ToastManager.error(`Failed to load "${NewModule}/${NewScreen}" screen`);
return CommonSetScreen(.../** @type {ScreenSpecifier} */([OldModule, OldScreen, { recursive: true }]));
} else {
// Plan B: can't even reload the old screen; things are FUBAR at this point ¯\_(ツ)_/¯
// @ts-expect-error: `options.cause` requires es2022, though is inert and harmless on older browsers
throw new Error(`Failed to reload previous "${NewModule}/${NewScreen}" screen`, { cause: error });
}
}
const background = DrawGetBackgroundURL(false);
if (background) {
DrawGetImage(background);
}
ScreenIsLoading = false;
CommonResize(true);
}
/**
* Gets the current time in ms
* @returns {number} - Date in ms
*/
function CommonTime() {
return Date.now();
}
/**
* Checks if a given value is a valid HEX color code (optionally with alpha channel)
* @param {string | undefined} Value - Potential HEX color code
* @param {null | { allowAlpha?: boolean }} [options]
* @returns {Value is HexColor} - Returns TRUE if the string is a valid HEX color
*/
function CommonIsColor(Value, options=null) {
options ??= {};
if ((Value == null) || (Value.length < 3)) return false;
//convert short hand hex color to standard format
if (/^#[0-9A-F]{3}$/i.test(Value)) Value = "#" + Value[1] + Value[1] + Value[2] + Value[2] + Value[3] + Value[3];
return options.allowAlpha ? /^#[0-9A-F]{6,8}$/i.test(Value) : /^#[0-9A-F]{6}$/i.test(Value);
}
/**
* Checks whether an item's color has a valid value that can be interpreted by the drawing
* functions. Valid values are null, undefined, strings, and an array containing any of the
* aforementioned types.
* @param {null | string | readonly (null | string)[]} [Color] - The Color value to check
* @returns {boolean} - Returns TRUE if the color is a valid item color
*/
function CommonColorIsValid(Color) {
if (Color == null || typeof Color === "string") return true;
if (Array.isArray(Color)) return Color.every(C => C == null || typeof C === "string");
return false;
}
/**
* Remove the (potential present) alpha component of the passed color hex code, turning the likes of `RRGGBB(AA)` into `RRGGBB`.
* @param {HexColor} color The color hex code
* @returns {HexColor} The color hex code with its (potentially present) alpha component removed
*/
function CommonColorTrimAlpha(color) {
return /** @type {HexColor} */(color.slice(0, 7));
}
/**
* Check that the passed string looks like an acceptable email address.
*
* @param {string} Email
* @returns {boolean}
*/
function CommonEmailIsValid(Email) {
if (Email.length < 5 || Email.length > 100) return false;
const parts = Email.split("@");
if (parts.length !== 2) return false;
if (parts[1].indexOf(".") === -1) return false;
return ServerAccountEmailRegex.test(Email);
}
/**
* Remove item from list on given index and returns it
* @template T
* @param {T[]} list
* @param {number} index
* @returns {undefined|T}
*/
function CommonRemoveItemFromList(list, index) {
if(index > list.length || index < 0) return undefined;
return list.splice(index, 1)[0];
}
/**
* Removes random item from list and returns it
* @template T
* @param {T[]} list
* @returns {undefined | T}
*/
function CommonRemoveRandomItemFromList(list) {
return CommonRemoveItemFromList(list, Math.floor(Math.random() * list.length));
}
/**
* Get a random item from a list while making sure not to pick the previous one.
* Function expects unique values in the list. If there are multiple instances of ItemPrevious, it may still return it.
* @template T
* @param {T} ItemPrevious - Previously selected item from the given list
* @param {readonly T[]} ItemList - List for which to pick a random item from
* @returns {T} - The randomly selected item from the list
*/
function CommonRandomItemFromList(ItemPrevious, ItemList) {
let previousIndex = ItemList.indexOf(ItemPrevious);
let maxRandom = ItemList.length;
if(previousIndex > -1){
maxRandom--;
}
if(maxRandom > 0){
let randomIndex = Math.floor(Math.random() * maxRandom);
if( previousIndex > -1 && randomIndex >= previousIndex) randomIndex++;
return ItemList[randomIndex];
} else return ItemPrevious;
}
/**
* Converts a string of numbers split by commas to an array, sanitizes the array by removing all NaN or undefined elements.
* @param {string} s - String of numbers split by commas
* @returns {number[]} - Array of valid numbers from the given string
*/
function CommonConvertStringToArray(s) {
/** @type {number[]} */
var arr = [];
if (typeof s === "string") {
arr = s.split(',').reduce((list, curr) => {
if (!(!curr || Number.isNaN(Number(curr)))) list.push(Number(curr));
return list;
}, arr);
}
return arr;
}
/**
* Shuffles all characters in a string
* @param {string} string - The string to shuffle
* @returns {string} - The shuffled string
*/
function CommonStringShuffle(string) {
var parts = string.split('');
for (var i = parts.length; i > 0;) {
var random = parseInt(Math.random() * i);
var temp = parts[--i];
parts[i] = parts[random];
parts[random] = temp;
}
return parts.join('');
}
/**
* Generate a unique ID
* @returns {string}
*/
function CommonGenerateUniqueID() {
return Math.random().toString(36).substring(2);
}
/**
* Converts an array to a string separated by commas (equivalent of .join(","))
* @param {readonly any[]} Arr - Array to convert to a joined string
* @returns {string} - String of all the array items joined together
*/
function CommonConvertArrayToString(Arr) {
var S = "";
for (let P = 0; P < Arr.length; P++) {
if (P != 0) S = S + ",";
S = S + Arr[P].toString();
}
return S;
}
/**
* Checks whether two item colors are equal. An item color may either be a string or an array of strings.
* @param {string | readonly string[]} C1 - The first color to check
* @param {string | readonly string[]} C2 - The second color to check
* @returns {boolean} - TRUE if C1 and C2 represent the same item color, FALSE otherwise
*/
function CommonColorsEqual(C1, C2) {
if (Array.isArray(C1) && Array.isArray(C2)) {
return CommonArraysEqual(C1, C2);
}
return C1 === C2;
}
/**
* Checks whether two arrays are equal. The arrays are considered equal if they have the same length and contain the same items in the same
* order, as determined by === comparison
* @template {readonly *[]} T
* @param {T} a1 - The first array to compare
* @param {readonly *[]} a2 - The second array to compare
* @param {boolean} [ignoreOrder] - Whether to ignore item order when considering equality
* @returns {a2 is T} - TRUE if both arrays have the same length and contain the same items in the same order, FALSE otherwise
*/
function CommonArraysEqual(a1, a2, ignoreOrder = false) {
return a1.length === a2.length && a1.every((item, i) => ignoreOrder ? a2.includes(item) : item === a2[i]);
}
/**
* Creates a debounced wrapper for the provided function with the provided wait time. The wrapped function will not be called as long as
* the debounced function continues to be called. If the debounced function is called, and then not called again within the wait time, the
* wrapped function will be called.
* @template {any[]} argsT
* @template retT
* @param {(...args: argsT) => retT} func - The function to debounce
* @returns {(waitInterval: number, ...args: argsT) => retT} - A debounced version of the provided function
*/
function CommonDebounce(func) {
/** @type {null | number} */
let timeout;
/** @type {number} */
let timestamp;
/** @type {retT} */
let result;
/** @type {number} */
let wait;
// Just a `setTimeout()` alias with some proper function-specific argument typing tailored around `later()`
/** @type {(handler: TimerHandler, timeout: number, context: unknown, args: argsT) => number} */
const laterSetTimeout = setTimeout;
/** @type {(context: unknown, args: argsT) => void} */
function later(context, args) {
const last = CommonTime() - timestamp;
if (last >= 0 && last < wait) {
timeout = laterSetTimeout(later, wait - last, context, args);
} else {
timeout = null;
result = func.apply(context, args);
}
}
/** @type {(this: unknown, waitInterval: number, ...args: argsT) => retT} */
return function (waitInterval, ...args) {
wait = waitInterval;
timestamp = CommonTime();
if (!timeout) {
timeout = laterSetTimeout(later, wait, this, args);
}
return result;
};
}
/**
* Creates a throttling wrapper for the provided function with the provided wait time. If the wrapped function has been successfully called
* within the wait time, further call attempts will be delayed until the wait time has passed.
* @template {any[]} argsT
* @template retT
* @param {(...args: argsT) => retT} func - The function to throttle
* @returns {(waitInterval: number, ...args: argsT) => retT} - A throttled version of the provided function
*/
function CommonThrottle(func) {
/** @type {null | number} */
let timeout;
/** @type {argsT} */
let args;
/** @type {unknown} */
let context;
let timestamp = 0;
/** @type {retT} */
let result;
function run() {
timeout = null;
result = func.apply(context, args);
timestamp = CommonTime();
}
/** @type {(this: unknown, wait: number, ...innerArgs: argsT) => retT} */
return function (wait, ...innerArgs) {
context = this;
args = innerArgs;
if (!timeout) {
const last = CommonTime() - timestamp;
if (last >= 0 && last < wait) {
timeout = setTimeout(run, wait - last);
} else {
run();
}
}
return result;
};
}
/**
* Creates a wrapper for a function to limit how often it can be called. The player-defined wait interval setting determines the
* allowed frequency. Below 100 ms the function will be throttled and above will be debounced.
* @template {any[]} argsT
* @template retT
* @param {(...args: argsT) => retT} func - The function to limit calls of
* @param {number} [minWait=0] - A lower bound for how long the wait interval can be, 0 by default
* @param {number} [maxWait=1000] - An upper bound for how long the wait interval can be, 1 second by default
* @returns {(...args: argsT) => retT} - A debounced or throttled version of the function
*/
function CommonLimitFunction(func, minWait = 0, maxWait = 1000) {
const funcDebounced = CommonDebounce(func);
const funcThrottled = CommonThrottle(func);
/** @type {(this: unknown, ...args: argsT) => retT} */
return function (...args) {
const wait = Math.min(
Math.max(
Player.GraphicsSettings ? Player.GraphicsSettings.AnimationQuality : 100, minWait
),
maxWait,
);
return wait < 100 ? funcThrottled.apply(this, [wait, ...args]) : funcDebounced.apply(this, [wait, ...args]);
};
}
/**
* Creates a simple memoizer.
* The memoized function does calculate its result exactly once and from that point on, uses
* the result stored in a local cache to speed up things.
* @template {(...args: any) => any} T
* @param {T} func - The function to memoize
* @param {null | ((arg: any) => string)[]} argConvertors - A list of stringification functions for creating a memo, one for each function argument
* @returns {MemoizedFunction<T>} - The result of the memoized function
*/
function CommonMemoize(func, argConvertors=null) {
/** @type {Record<string, ReturnType<T>>} */
var memo = {};
var memoized = /** @type {MemoizedFunction<T>} */(function (...args) {
let index = "";
if (argConvertors !== null) {
index += argConvertors.map((callback, i) => callback(args[i])).join(",");
} else {
for (const arg of args) {
if (typeof arg === "object") {
index += JSON.stringify(arg);
} else {
index += String(arg);
}
}
}
if (!(index in memo)) {
memo[index] = func(...args);
}
return memo[index];
});
// add a clear cache method
memoized.clearCache = function () {
memo = {};
};
return memoized;
}
/**
* Memoized getter function. Returns a font string specifying the player's
* preferred font and the provided size. This is memoized as it is called on
* every frame in many cases.
* @param {number} size - The font size that should be specified in the
* returned font string
* @returns {string} - A font string specifying the requested font size and
* the player's preferred font stack. For example:
* 12px "Courier New", "Courier", monospace
*/
const CommonGetFont = CommonMemoize((size) => {
return `${size}px ${CommonGetFontName()}`;
});
/**
* Memoized getter function. Returns a font string specifying the player's
* preferred font stack. This is memoized as it is called on every frame in
* many cases.
* @returns {string} - A font string specifying the player's preferred font
* stack. For example:
* "Courier New", "Courier", monospace
*/
const CommonGetFontName = CommonMemoize(() => {
const pref = Player && Player.GraphicsSettings && Player.GraphicsSettings.Font;
const fontStack = CommonFontStacks[pref] || CommonFontStacks.Arial;
const font = fontStack[0].map(fontName => `"${fontName}"`).join(", ");
return `${font}, ${fontStack[1]}`;
});
/**
* Take a screenshot of specified area in "photo mode" and open the image in a new tab
* @param {number} Left - Position of the area to capture from the left of the canvas
* @param {number} Top - Position of the area to capture from the top of the canvas
* @param {number} Width - Width of the area to capture
* @param {number} Height - Height of the area to capture
* @returns {void} - Nothing
*/
function CommonTakePhoto(Left, Top, Width, Height) {
CommonPhotoMode = true;
// Ensure everything is redrawn once in photo-mode
DrawProcess(0);
// Capture screen as image URL
const ImgData = /** @type {HTMLCanvasElement | null} */ (document.getElementById("MainCanvas"))?.getContext('2d')?.getImageData(Left, Top, Width, Height);
if (!ImgData) {
return;
}
let PhotoCanvas = document.createElement('canvas');
PhotoCanvas.width = Width;
PhotoCanvas.height = Height;
PhotoCanvas.getContext('2d')?.putImageData(ImgData, 0, 0);
const PhotoImg = PhotoCanvas.toDataURL("image/png");
// Open the image in a new window
let newWindow = window.open('about:blank', '_blank');
if (newWindow) {
newWindow.document.write("<img src='" + PhotoImg + "' alt='from canvas'/>");
newWindow.document.close();
} else {
console.warn("Popups blocked: Cannot open photo in new tab.");
}
CommonPhotoMode = false;
}
/**
* Compares two version numbers and returns -1/0/1 if Other number is smaller/same/larger than Current one
* @param {string} Current Current version number
* @param {string} Other Other version number
* @returns {-1|0|1} Comparison result
*/
function CommonCompareVersion(Current, Other) {
const CurrentMatch = GameVersionFormat.exec(Current);
const OtherMatch = GameVersionFormat.exec(Other);
if (!CurrentMatch || !OtherMatch) return -1;
const CurrentVer = [
Number.parseInt(CurrentMatch[1]),
CurrentMatch[2] === "Alpha" ? 1 : CurrentMatch[2] === "Beta" ? 2 : 3,
Number.parseInt(CurrentMatch[3]) || 0
];
const OtherVer = [
Number.parseInt(OtherMatch[1]),
OtherMatch[2] === "Alpha" ? 1 : OtherMatch[2] === "Beta" ? 2 : 3,
Number.parseInt(OtherMatch[3]) || 0
];
for (let i = 0; i < 3; i++) {
if (CurrentVer[i] !== OtherVer[i]) {
return /** @type {-1|0|1} */ (Math.sign(OtherVer[i] - CurrentVer[i]));
}
}
return 0;
}
/**
* A simple deep equality check function which checks whether two objects are equal. The function traverses recursively
* into objects and arrays to check for equality. Primitives and simple types are considered equal as defined by `===`.
* @template T
* @param {unknown} obj1 - The first object to compare
* @param {T} obj2 - The second object to compare
* @returns {obj1 is T} - TRUE if both objects are equal, up to arbitrarily deeply nested property values, FALSE
* otherwise.
*/
function CommonDeepEqual(obj1, obj2) {
if (obj1 === obj2) {
return true;
}
if (obj1 && obj2 && typeof obj1 === "object" && typeof obj2 === "object") {
// If the objects do not share a prototype, they are not equal
if (Object.getPrototypeOf(obj1) !== Object.getPrototypeOf(obj2)) {
return false;
}
// Get the keys for the objects
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
// If the objects have different numbers of keys, they are not equal
if (keys1.length !== keys2.length) {
return false;
}
// Sort the keys
keys1.sort();
keys2.sort();
return keys1.every((key, i) => {
// If the keys are different, the objects are not equal
if (key !== keys2[i]) {
return false;
}
// Otherwise, compare the values
/** @type {Record<string, any>} */
const obj1Compare = obj1;
/** @type {Record<string, any>} */
const obj2Compare = obj2;
return CommonDeepEqual(obj1Compare[key], obj2Compare[key]);
});
}
return false;
}
/**
* A simple deep equality check function which checks whether two objects are equal for all properties in `subRec`.
* The function traverses recursively into objects and arrays to check for equality.
* Primitives and simple types are considered equal as defined by `===`.
* @template T
* @param {unknown} subRec - The subset-containg object to compare
* @param {T} superRec - The superset-containg object to compare
* @returns {subRec is Partial<T>} - Whether `subRec` is a subset of `superRec` up to arbitrarily deeply nested property values
*/
function CommonDeepIsSubset(subRec, superRec) {
if (subRec === superRec) {
return true;
}
if (subRec && superRec && typeof subRec === "object" && typeof superRec === "object") {
// If the objects do not share a prototype, they are not equal
if (Object.getPrototypeOf(subRec) !== Object.getPrototypeOf(superRec)) {
return false;
}
if (CommonIsArray(subRec) && CommonIsArray(superRec)) {
return subRec.every(subValue => superRec.some(superValue => CommonDeepIsSubset(subValue, superValue)));
} else {
// Get the keys for the objects
const subKeys = Object.keys(subRec);
const superKeys = new Set(Object.keys(superRec));
// If the objects have different numbers of keys, they are not equal
if (!subKeys.every(k => superKeys.has(k))) {
return false;
} else {
/** @type {Record<string, any>} */
const subRecCompare = subRec;
/** @type {Record<string, any>} */
const superRecCompare = superRec;
return subKeys.every(key => CommonDeepIsSubset(subRecCompare[key], superRecCompare[key]));
}
}
}
return false;
}
/**
* Adds all items from the source array to the destination array if they aren't already included
* @template T
* @param {T[]} dest - The destination array
* @param {readonly T[]} src - The source array
* @returns {T[]} - The destination array
*/
function CommonArrayConcatDedupe(dest, src) {
if (Array.isArray(src) && Array.isArray(dest)) {
for (const item of src) {
if (!dest.includes(item)) dest.push(item);
}
}
return dest;
}
/**
* Common function for removing a padlock from an item and publishing a corresponding chat message (must be called with
* the item's group focused)
* @param {Character} C - The character on whom the item is equipped
* @param {Item} Item - The item to unlock
* @returns {void} - Nothing
*/
function CommonPadlockUnlock(C, Item) {
if (!C.FocusGroup) {
return;
}
for (let A = 0; A < C.Appearance.length; A++) {
if (C.Appearance[A].Asset.Group.Name === C.FocusGroup.Name) {
C.Appearance[A] = Item;
break;
}
}
InventoryUnlock(C, C.FocusGroup.Name);
if (ChatRoomPublishAction(C, "ActionUnlock", Item, null))
DialogLeave();
}
/**
* Common noop function
* @returns {void} - Nothing
*/
function CommonNoop() {
// Noop function
}
/**
* Redirects the address to HTTPS for all production environments, returns the proper heroku server
* @returns {String} - Returns the proper server to use in production or test
*/
function CommonGetServer() {
if ((location.href.indexOf("bondageprojects") < 0) && (location.href.indexOf("bondage-europe") < 0) && (location.href.indexOf("bondage-asia") < 0)) return "https://bondage-club-server-test.herokuapp.com/";
if (location.protocol !== 'https:') location.replace(`https:${location.href.substring(location.protocol.length)}`);
return "https://bondage-club-server.herokuapp.com/";
}
/**
* Performs the required substitutions on the given message
*
* @param {string} msg - The string to perform the substitutions on.
* @param {CommonSubtituteSubstitution[]} substitutions - An array of [string, replacement, replacer?] subtitutions.
*/
function CommonStringSubstitute(msg, substitutions) {
if (typeof msg !== "string" || msg.trim() === "")
return "";
/**
*
* @param {CommonSubstituteReplacer | undefined} replacer
* @param {string} replacement
* @returns {(match: string, offset: number, string: string) => string}
*/
function makeReplacer(replacer, replacement) {
if (typeof replacer === "function")
return (match, offset, string) => replacer(match, offset, replacement, string);
return () => replacement;
}
substitutions = substitutions.sort((a, b) => b[0].length - a[0].length);
for (const [tag, subst, replacer] of substitutions) {
let repl = makeReplacer(replacer, subst);
msg = msg.replace(new RegExp(tag, "g"), repl);
}
return msg;
}
/**
* Returns a nice version of the passed strings
*
* This turns ["this", "this", "that"] into "this, this, and that" using appropriate localization
*
* @param {string[]} strings The strings to join
*/
function CommonArrayJoinPretty(strings) {
const last = strings.pop();
let pretty = strings.join(InterfaceTextGet("PrettyArrayJoin"));
pretty += InterfaceTextGet("PrettyArrayJoinFinal") + last;
return pretty;
}
/**
* Returns a titlecased version of the given string.
* @param {string} str
* @returns {string}
*/
function CommonStringTitlecase(str) {
return str[0].toUpperCase() + str.substring(1);
}
/**
* Censors a string or words in that string based on the player preferences
* @param {string} S - The string to censor
* @returns {String} - The censored string
*/
function CommonCensor(S) {
// Validates that we must apply censoring
if ((Player.ChatSettings == null) || (Player.ChatSettings.CensoredWordsLevel == null) || (Player.ChatSettings.CensoredWordsList == null)) return S;
let WordList = PreferenceCensoredWordsList = Player.ChatSettings.CensoredWordsList.split("|");
if (WordList.length <= 0) return S;
// At level zero, we replace the word with ***
if (Player.ChatSettings.CensoredWordsLevel == 0)
for (let W of WordList)
if ((W != "") && (W != " ") && !W.includes("*") && S.toUpperCase().includes(W.toUpperCase())) {
let searchMask = W;
let regEx = new RegExp(searchMask, "ig");
let replaceMask = "***";
S = S.replace(regEx, replaceMask);
}
// At level one, we replace the full phrase with ***, at level two we return a ¶¶¶ string indicating to filter out
if (Player.ChatSettings.CensoredWordsLevel >= 1)
for (let W of WordList)
if ((W != "") && (W != " ") && !W.includes("*") && S.toUpperCase().includes(W.toUpperCase()))
return (Player.ChatSettings.CensoredWordsLevel >= 2) ? "¶¶¶" : "***";
// Returns the mashed string
return S;
}
/**
* Type guard which checks that a value is a simple object (i.e. a non-null object which is not an array)
* @param {unknown} value - The value to test
* @returns {value is object}
*/
function CommonIsObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* Type guard which checks that a value is a character
* @param {unknown} value - The value to test
* @returns {value is Character | PlayerCharacter}
*/
function CommonIsCharacter(value) {
const char = /** @type {Character} */ (value);
return CommonIsObject(char) && (char.Type === "online" || char.Type === "npc" || char.Type === "simple" || char.Type === "player");
}
/**
* Deep-clones an object
* @todo JSON serialization will break things like functions, Sets and Maps.
* @template T
* @param {T} obj
* @param {null | ((this: any, key: string, value: any) => any)} [replacer]
* @param {null | ((this: any, key: string, value: any) => any)} [reviver]
* @returns {T}
*/
function CommonCloneDeep(obj, reviver=null, replacer=null) {
reviver ??= undefined;
replacer ??= undefined;
return /** @type {T} */ (JSON.parse(JSON.stringify(obj, replacer), reviver));
}
/**
* Type guard which checks that a value is a non-negative (i.e. positive or zero) integer
* @param {unknown} value - The value to test
* @returns {value is number}
* @see {@link CommonIsInteger}
*/
function CommonIsNonNegativeInteger(value) {
return CommonIsInteger(value, 0);
}
/**
* Type guard which checks that a value is an integer that, optionally, falls within the specified range
* @param {unknown} value - The value to test
* @param {number} min - The minimum allowed value
* @param {number} max - The maximum allowed value
* @returns {value is number}
*/
function CommonIsInteger(value, min=-Infinity, max=Infinity) {
return (
typeof value === "number"
&& Number.isInteger(value)
&& value >= min
&& value <= max
);
}
/**
* Type guard which checks that a value is a finite number that, optionally, falls within the specified range
* @param {unknown} value - The value to test
* @param {number} min - The minimum allowed value
* @param {number} max - The maximum allowed value
* @returns {value is number}
*/
function CommonIsFinite(value, min=-Infinity, max=Infinity) {
return (
typeof value === "number"
&& Number.isFinite(value)
&& value >= min
&& value <= max
);
}
/**
* A version of {@link Array.isArray} more friendly towards readonly arrays.
* @template {unknown} T
* @param {T} arg - The to-be validated object
* @returns {arg is (arg extends readonly unknown[] ? readonly unknown[] : unknown[])} Whether the passed object is a (potentially readonly) array
*/
function CommonIsArray(arg) {
return Array.isArray(arg);
}
/**
* A {@link Object.keys} variant annotated to return respect literal key types
* @template {object} T
* @param {T} obj A record with string-based keys
* @returns {(keyof T)[]} The keys in the passed record
*/
function CommonKeys(obj) {
return /** @type {(keyof T)[]} */(Object.keys(obj));
}
/**
* A {@link Object.entries} variant annotated to return respect literal key types
* @template {{}} T
* @param {T} obj A record with string-based keys
* @returns {[keyof T, T[keyof T]][]} The key/value pairs in the passed record
*/
function CommonEntries(obj) {
return /** @type {[keyof T, T[keyof T]][]} */(Object.entries(obj));
}
/**
* A {@link Array.includes} version annotated to return a type guard.
* @template T
* @param {readonly T[]} array The array in question
* @param {unknown} searchElement The value to search for
* @param {number} [fromIndex] Zero-based index at which to start searching
* @returns {searchElement is T} Whether the array contains the passed element
*/
function CommonIncludes(array, searchElement, fromIndex) {
return array.includes(/** @type {T} */(searchElement), fromIndex);
}
/**
* A {@link Object.fromEntries} variant annotated to return respect literal key types.
* @note The returned record is typed as being non-{@link Partial}, an assumption that may not hold in practice
* @template {string} KT
* @template VT
* @param {Iterable<[key: KT, value: VT]>} iterable An iterable object that contains key-value entries for properties and methods
* @returns {Record<KT, VT>} A record created from the passed key/value pairs
*/
function CommonFromEntries(iterable) {
return /** @type {Record<KT, VT>} */(Object.fromEntries(iterable));
}
/**
* Automatically generate grid coordinates based on parameters.
* @param {number} nItems - The upper bound to the number of grid points; fewer can be returned if they do not all fit on the grid
* @param {CommonGenerateGridParameters} grid - The grid parameters
* @returns {RectTuple[]} - A list of grid coordinates with a length of `<= nItems`
* @see {@link CommonGenerateGrid}
*/
function CommonGenerateGridCoords(nItems, grid) {
// Calculate horizontal & vertical margins
const minMarginX = grid.minMarginX ?? 0;
const minMarginY = grid.minMarginY ?? 0;
const itemCountX = Math.floor(grid.width / grid.itemWidth);
const marginX = Math.max(minMarginX, grid.itemMarginX ?? (grid.width - (itemCountX * grid.itemWidth)) / (itemCountX - 1));
const itemCountY = Math.floor(grid.height / grid.itemHeight);
const marginY = Math.max(minMarginY, grid.itemMarginY ?? (grid.height - (itemCountY * grid.itemHeight)) / (itemCountY - 1));
const direction = grid.direction ?? "horizontal";
/** @type {RectTuple[]} */
const gridList = [];
let i = 0;
let x = grid.x;
let y = grid.y;
if (direction === "horizontal") {
for (i; i < nItems && y <= grid.y + grid.height; i++) {
gridList.push([x, y, grid.itemWidth, grid.itemHeight]);
x += grid.itemWidth + marginX;
if (x - grid.x >= grid.width) {
x = grid.x;
y += grid.itemHeight + marginY;
}
}
} else {
for (i; i < nItems && x <= grid.x + grid.width; i++) {
gridList.push([x, y, grid.itemWidth, grid.itemHeight]);
y += grid.itemHeight + marginY;
if (y - grid.y >= grid.height) {
y = grid.y;
x += grid.itemWidth + marginX;
}
}
}
return gridList;
}
/**
* Automatically generate a grid based on parameters and apply a callback to each grid point.
*
* This function takes a list of items, grid parameters, and a callback to manage
* creating a grid of them. It'll find the best value for margins between each cell,
* then will call the callback passing each item with its calculated coordinates in turn.
*
* Returning true from the callback to stop the iteration, useful for click handlers
* so you don't keep checking items after handling one.
*
* @template T
* @param {readonly T[]} items
* @param {number} offset
* @param {CommonGenerateGridParameters} grid
* @param {CommonGenerateGridCallback<T>} callback
* @returns {number} - The (offsetted) index for which the callback evaluated to `true`, or `-1` if no elements satisfy the testing
*/
function CommonGenerateGrid(items, offset, grid, callback) {
const gridCoords = CommonGenerateGridCoords(items.length - offset, grid);
const index = gridCoords.findIndex((coords, i) => callback(items[i + offset], ...coords));
return index === -1 ? -1 : index - offset;
}
/**
* Create a copy of the passed record with all specified keys removed
* @template {keyof RecordType} KeyType
* @template {{}} RecordType
* @param {RecordType} object - The to-be copied record
* @param {Iterable<KeyType>} keys - The to-be removed keys from the record
* @returns {Omit<RecordType, KeyType>}
*/
function CommonOmit(object, keys) {
const ret = { ...object };
for (const k of keys) {
delete ret[k];
}
return ret;
}
/**
* Create a copy of the passed record with all specified keys removed
* @template {keyof RecordType} KeyType
* @template {{}} RecordType
* @param {RecordType} object - The to-be copied record
* @param {Iterable<KeyType>} keys - The to-be removed keys from the record
* @returns {Pick<RecordType, KeyType>}
*/
function CommonPick(object, keys) {
const ret = /** @type {Pick<RecordType, KeyType>} */({});
for (const k of keys) {
ret[k] = object[k];
}
return ret;
}
/**
* Iterate through the passed iterable and yield index/value pairs.
* @template T
* @param {Iterable<T>} iterable - The to-be iterated iterable
* @param {number} start - The starting index
* @param {number} step - The step size in which the index is incremented
* @returns {Generator<[index: number, value: T], void>}
*/
function* CommonEnumerate(iterable, start=0, step=1) {
let i = start;
for (const item of iterable) {
yield [i, item];
i += step;
}
}
/**
* Return a value clamped to a minimum and maximum
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
function CommonClamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/**
* Return value % divisor but properly handling negative values
*
* Useful if you have a set of keys and want to move an index within its bounds with proper wrap-around.
*
* @param {number} value
* @param {number} divisor
*/
function CommonModulo(value, divisor) {
return Math.abs((divisor + value) % divisor);
}
/**
* Returns TRUE if the URL is valid, is from http or https or screens/ or backgrounds/ and has the required extension
* @param {string} TestURL - The URL to test
* @param {readonly string[]} Extension - An array containing the valid extensions
* @returns {boolean}
*/
function CommonURLHasExtension(TestURL, Extension) {
if ((TestURL == null) || (typeof TestURL !== "string") || (Extension == null) || !CommonIsArray(Extension)) return false;
try {
const Url = new URL(TestURL);
if ((Url.protocol !== 'http:') && (Url.protocol !== 'https:')) return false;
let FormatUrl = Url.pathname.trim().toLowerCase();
for (let Ext of Extension)
if (FormatUrl.endsWith(Ext))
return true;
} catch (_err) {
let FormatUrl = TestURL.trim().toLowerCase();
if ((FormatUrl.startsWith("screens/")) || (FormatUrl.startsWith("backgrounds/")))
for (let Ext of Extension)
if (FormatUrl.endsWith(Ext))
return true;
}
return false;
}
/**
* Return whether two records are equivalent for all fields as returned by {@link Object.keys}.
* @note does *not* support the comparison of nested structures.
* @template T
* @param {T} rec1
* @param {unknown} rec2
* @returns {rec2 is T}
*/
function CommonObjectEqual(rec1, rec2) {
if (!CommonIsObject(rec1) || !CommonIsObject(rec2)) {
return false;
}
const entries1 = Object.entries(rec1);
const keys2 = Object.keys(rec2);
if (entries1.length !== keys2.length) {
return false;
}
return entries1.every(([k, v]) => /** @type {Record<string, any>} */ (rec2)[k] === v);
}
/**
* Return if the key/value pairs of `subRec` form a subset of `superRec`
* @template T
* @param {unknown} subRec
* @param {T} superRec
* @returns {subRec is Partial<T>}
*/
function CommonObjectIsSubset(subRec, superRec) {
if (!CommonIsObject(subRec) || !CommonIsObject(superRec)) {
return false;
}
const entries = Object.entries(subRec);
return entries.every(([k, v]) => /** @type {Record<string, any>} */ (superRec)[k] === v);
}
/**
* Returns the object with keys and values reversed
* @param {object} obj
*/
function CommonObjectFlip(obj) {
return Object.keys(obj).reduce((ret, key) => {
ret[/** @type {Record<string, any>} */ (obj)[key]] = key;
return ret;
}, /** @type {Record<string, any>} */ ({}));
}
/**
* Parse the passed stringified JSON data and catch any exceptions.
* Exceptions will cause the function to return `undefined`.
* @param {string} data
* @returns {unknown}
* @see {@link JSON.parse}
*/
function CommonJSONParse(data) {
try {
return JSON.parse(data);
} catch (error) {
console.error(error, data);
return undefined;
}
}
/**
* Translates the current event into movement directions
*
* This returns a layout independent u/d/l/r string
*
* These keybinds get documented in {@link KeybindingDefaults.DefaultKeybindings}
* @param {KeyboardEvent} event
* @returns {"u"|"d"|"l"|"r"|undefined}
*/
function CommonKeyMove(event, allowArrowKeys = true, checkModifiers=true) {
if (checkModifiers && CommonKey.GetModifiers(event)) {
return undefined;
} else if (event.code === "KeyW" || allowArrowKeys && event.code === "ArrowUp") {
return "u";
} else if (event.code === "KeyA" || allowArrowKeys && event.code === "ArrowLeft") {
return "l";
} else if (event.code === "KeyS" || allowArrowKeys && event.code === "ArrowDown") {
return "d";
} else if (event.code === "KeyD" || allowArrowKeys && event.code === "ArrowRight") {
return "r";
}
return undefined;
}
/**
* A {@link Set.has}/{@link Map.has} version annotated to return a type guard.
* @template T
* @param {{ has: (key: T) => boolean }} obj The set or map in question
* @param {unknown} key The key to search for
* @returns {key is T} Whether the object contains the passed key
*/
function CommonHas(obj, key) {
return obj.has(/** @type {T} */(key));
}
/**
* Defines a property with the given name and the passed setter and getter
* @example
* CommonDeprecate(
* "OldFunc",
* function NewFunc (a, b) { return a + b; },
* );
* @template {readonly any[]} T
* @param {string} propertyName - The name for the new property
* @param {() => T} [getter] - The getter function for the property
* @param {(args: T) => void} [setter] - The setter function for the property
*/
function CommonProperty(propertyName, getter=undefined, setter=undefined) {
/** @type {Record<string, any>} */
const windowNamespace = window;
if (windowNamespace[propertyName] !== undefined) {
throw new Error(`The name ${setter} already exists.`);
}
getter ??= function(){ throw Error(`Property "${propertyName}" has no getter defined`); };
setter ??= function(){ throw Error(`Property "${propertyName}" has no setter defined`); };
Object.defineProperty(window, propertyName, {
enumerable: true,
configurable: true,
get: function() {
return getter();
},
set: function(val) { setter(val); },
});
}
/**
* Assign a function to the passed namespace, creating a deprecated and non-deprecated symbol.
* Both symbols will are in fact get/set wrappers around the same object, enforcing that `namespace[oldName] === namespace[callback.name]` *always* holds.
* @example
* CommonDeprecateFunction(
* "OldFunc",
* function NewFunc (a, b) { return a + b; },
* );
* @template {(...args: any[]) => any} T
* @param {string} oldName - The old (deprecated) name of the symbol
* @param {T} callback - The function with its new name
* @param {Record<string, any>} namespace - The namespace wherein the new and old names will be stored
*/
function CommonDeprecateFunction(oldName, callback, namespace=globalThis) {
const newName = callback.name;
if (!newName) {
throw new Error("Passed function lacks a name");
} else if (newName === oldName) {
throw new Error(`Callback name (${newName}) and oldName (${oldName}) must not be equivalent`);
}
const privateName = `_${newName}`;
namespace[privateName] = callback;
/** @type {Record<string, any>} */
const windowNamespace = window;
windowNamespace[newName].get = function() { return namespace[privateName]; };
/** @type {(value: unknown) => void} */
windowNamespace[newName].set = function(val) { namespace[privateName] = val; };
Object.defineProperty(window, oldName, {
enumerable: true,
configurable: true,
get: function() {
console.warn(`"${oldName}" is deprecated, use "${newName}" instead`);
return namespace[privateName];
},
set: function(val) { namespace[privateName] = val; },
});
}
/**
* Capitalize the first character of the passed string.
* @template {string} T
* @param {T} string
* @returns {Capitalize<T>}
*/
function CommonCapitalize(string) {
return /** @type {Capitalize<T>} */(string ? `${string[0].toUpperCase()}${string.slice(1)}` : "");
}
/**
* Construct an object for holding arbitrary (user-specified) values with a mechanism to reset them to a default
* @template T1
* @template [T2={}]
* @param {T1} defaults - Default values that should be restored upon reset
* @param {null | T2} extraVars - Extra values that should *not* affected by resets
* @param {null | { replacer?: (this: any, key: string, value: any) => any, reviver?: (this: any, key: string, value: any) => any }} resetCallbacks - Extra callbacks for affecting the deep cloning process on reset.
* Generally only relevant if one of the fields contains non-JSON-serializible data.
* @returns {VariableContainer<T1, T2>} - The newly created object
*/
function CommonVariableContainer(defaults, extraVars=null, resetCallbacks=null) {
const ret = Object.assign(
/** @type {VariableContainer<T1, T2>} */(extraVars ?? {}),
defaults,
{
Defaults: defaults,
Reset: () => {
Object.assign(ret, CommonCloneDeep(ret.Defaults, resetCallbacks?.reviver, resetCallbacks?.replacer));
},
},
);
ret.Reset();
return ret;
}
/**
* Namespace with helper functions for validating key presses.
* @namespace
*/
var CommonKey = /** @type {const} */({
/** Bitmask component for the {@link KeyboardEvent.altKey} modifier */
ALT: 1,
/** Bitmask component for the {@link KeyboardEvent.ctrlKey} modifier */
CTRL: 2,
/** Bitmask component for the {@link KeyboardEvent.metaKey} modifier */
META: 4,
/** Bitmask component for the {@link KeyboardEvent.shiftKey} modifier */
SHIFT: 8,
/**
* Check whether the expected key and all its modifiers were pressed
* @example
* // `Enter` without modifiers
* CommonKey(event, "Enter");
* // `Enter` with the `Ctrl` and `Shift` modifiers
* CommonKey(event, "Enter", CommonKey.SHIFT | CommonKey.CTRL);
* @param {KeyboardEvent} event - The keyboard event in question
* @param {string} key - The expected key (see {@link KeyboardEvent.prototype.key})
* @param {null | number} modifiers - An optional bit mask of all expected modifiers (see examples)
* @returns {boolean} - Whether the expected key and all its modifiers were pressed
*/
IsPressed: function IsPressed(event, key, modifiers=null) {
modifiers ??= 0;
return event.key === key && modifiers === CommonKey.GetModifiers(event);
},
/**
* Return a bit mask with all modifiers in the passed keyboard event
* @param {KeyboardEvent} event - The keyboard event in question
* @returns {number} - The bitmask of keyboard modifiers
*/
GetModifiers: function GetModifiers(event) {
return [
event.altKey ? CommonKey.ALT : undefined,
event.ctrlKey ? CommonKey.CTRL : undefined,
event.metaKey ? CommonKey.META : undefined,
event.shiftKey ? CommonKey.SHIFT : undefined,
].filter(Boolean).reduce((sum, bit) => sum | bit, 0);
},
/**
* A {@link ScreenFunctions.KeyDown} helper function for implementing the navigation key handling of scrollable elements.
* These keybinds get documented in {@link KeybindingDefaults.DefaultKeybindings}
* @param {Element} scrollableElem - The scrollable element
* @param {KeyboardEvent} event - The `keydown` event
* @param {(scrollableElem: Element) => number} getArrowScrollDistance - A callback for computing the (absolute) scroll distance for up/down arrow key presses
* @returns {boolean} - Whether a navigation key was (successfuly pressed)
*/
NavigationKeyDown: function NavigationKeyDown(scrollableElem, event, getArrowScrollDistance) {
// Abort if an element has been (explicitly)
// Dialog elements are a bit weird, they can (and must) be focusable despite not being interactive
const root = ElementGetRoot(scrollableElem);
const noFocus = (
root.activeElement === null
|| root.activeElement === document.body
|| root.activeElement instanceof HTMLDialogElement
);
if (!scrollableElem || !noFocus || !event || CommonKey.GetModifiers(event)) {
return false;
}
const sign = (event.key === "PageUp" || event.key === "ArrowUp") ? -1 : 1;
switch (event.key) {
case "Home":
scrollableElem.scrollTop = 0;
return true;
case "End":
scrollableElem.scrollTop = scrollableElem.scrollHeight;
return true;
case "PageUp":
case "PageDown":
scrollableElem.scrollBy({ top: sign * scrollableElem.clientHeight, behavior: "instant" });
return true;
case "ArrowUp":
case "ArrowDown":
scrollableElem.scrollBy({ top: sign * getArrowScrollDistance(scrollableElem), behavior: "instant" });
return true;
default:
return false;
}
},
/**
* A {@link ScreenFunctions.KeyDown} helper function for automatically forwarding key presses to the passed input element.
* @param {HTMLInputElement | HTMLTextAreaElement} inputElem - The input or textarea element in question
* @param {KeyboardEvent} ev - The keydown event
* @param {null | { allowCtrlA?: boolean }} options
* @returns {boolean} - Whether the keypress was processed
*/
InputKeyDown: function InputKeyDown(inputElem, ev, options=null) {
options ??= {};
const activeElement = ElementGetRoot(inputElem).activeElement;
const noFocus = (
activeElement === null
|| activeElement === document.body
|| activeElement instanceof HTMLDialogElement
);
if (
!(inputElem instanceof HTMLInputElement || inputElem instanceof HTMLTextAreaElement)
|| inputElem.disabled
|| inputElem.readOnly
|| !noFocus
) {
return false;
}
const ctrl = navigator.userAgent.includes("Mac") ? CommonKey.META : CommonKey.CTRL;
const modifiers = CommonKey.GetModifiers(ev);
if (ev.key.length === 1 && (!modifiers || modifiers === CommonKey.SHIFT)) {
if (inputElem.maxLength === -1 || inputElem.value.length < inputElem.maxLength) {
inputElem.value += ev.key;
}
inputElem.setSelectionRange(inputElem.value.length, inputElem.value.length);
} else if (ev.key === "Backspace" && !modifiers) {
inputElem.value = inputElem.value.slice(0, -1);
inputElem.setSelectionRange(inputElem.value.length, inputElem.value.length);
} else if ((ev.key === "Enter" || ev.key === "Delete" || ev.key === "Insert") && !modifiers) {
inputElem.setSelectionRange(inputElem.value.length, inputElem.value.length);
} else if (options.allowCtrlA && ev.key === "a" && modifiers === ctrl) {
inputElem.setSelectionRange(0, inputElem.value.length);
} else {
return false;
}
inputElem.focus();
inputElem.dispatchEvent(new InputEvent("input"));
return true;
},
/**
* A {@link ScreenFunctions.Paste} helper function for automatically forwarding paste actions to the passed input element.
* @param {HTMLInputElement | HTMLTextAreaElement} inputElem - helper function for automatically forwarding paste actions to the passed input element.
* @param {ClipboardEvent} ev - The `paste` event
* @returns {void}
*/
InputPaste: function InputPaste(inputElem, ev) {
const activeElement = ElementGetRoot(inputElem).activeElement;
const noFocus = (
activeElement === null
|| activeElement === document.body
|| activeElement instanceof HTMLDialogElement
);
if (
!(inputElem instanceof HTMLInputElement || inputElem instanceof HTMLTextAreaElement)
|| inputElem.disabled
|| inputElem.readOnly
|| !noFocus
) {
return;
}
const content = ev.clipboardData?.getData("text") ?? "";
inputElem.value = (inputElem.value + content).slice(0, inputElem.maxLength === -1 ? undefined : inputElem.maxLength);
inputElem.focus();
inputElem.setSelectionRange(inputElem.value.length, inputElem.value.length);
inputElem.dispatchEvent(new InputEvent("input"));
return;
},
});
/**
* Partition the string into separate parts using the given replacer keys, replacing them with replacer values
* @template T
* @param {string} string
* @param {Record<string, T>} replacers - An object with replacements. Note that {@link Node} elements are cloned prior to the replacement
* @returns {(string | T)[]}
*/
function CommonStringPartitionReplace(string, replacers) {
const pattern = new RegExp(`(${Object.keys(replacers).map(key => CommonRegEscape(key)).join("|")})`, "g");
const matches = Array.from(string.matchAll(pattern));
if (matches.length === 0) {
return [string];
}
matches.sort((a, b) => a.index - b.index);
/** @type {(string | T)[]} */
const ret = [];
let i = 0;
for (const match of matches) {
if (match.index < i) {
continue;
}
const prefix = string.slice(i, match.index);
if (prefix) {
ret.push(prefix);
}
const obj = replacers[match[0]];
ret.push(obj instanceof Node ? /** @type {T} */(obj.cloneNode(true)) : obj);
i = match.index + match[0].length;
}
const suffix = string.slice(i);
if (suffix) {
ret.push(suffix);
}
return ret;
}
/**
* Escapes any potential regex syntax characters in a string, and returns a new string that can be safely used as a literal pattern for the {@link RegExp} constructor.
* @license MIT - Copyright (c) 2014-2025 Denis Pushkarev, core-js 3.40.0 - 2025.01.08
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape
* @see https://github.com/zloirock/core-js/blob/v3.40.0/packages/core-js/modules/esnext.regexp.escape.js
* @param S The string to escape.
* @returns A new string that can be safely used as a literal pattern for the {@link RegExp} constructor.
*/
var CommonRegEscape = (() => {
// As of the time of writing a limited number of browsers have native `RegExp.escape` support
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape
if ("escape" in RegExp && typeof RegExp.escape === "function") {
return RegExp.escape;
}
var WHITESPACES = (
'\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680\u2000\u2001\u2002' +
'\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF'
);
var FIRST_DIGIT_OR_ASCII = /^[0-9a-z]/i;
var SYNTAX_SOLIDUS = /^[$()*+./?[\\\]^{|}]/;
var OTHER_PUNCTUATORS_AND_WHITESPACES = RegExp('^[!"#%&\',\\-:;<=>@`~' + WHITESPACES + ']');
var ControlEscape = {
'\u0009': 't',
'\u000A': 'n',
'\u000B': 'v',
'\u000C': 'f',
'\u000D': 'r',
};
/** @type {(chr: string) => string} */
function escapeChar(chr) {
var hex = chr.charCodeAt(0).toString(16);
return hex.length < 3 ? '\\x' + hex.padStart(2, '0') : '\\u' + hex.padStart(4, '0');
}
// `RegExp.escape` method
// https://github.com/tc39/proposal-regex-escaping
/** @type {(S: string) => string} */
return function escape(S) {
if (typeof S !== 'string') {
throw new TypeError('Argument is not a string');
}
var length = S.length;
var result = Array(length);
for (var i = 0; i < length; i++) {
var chr = S.charAt(i);
if (i === 0 && FIRST_DIGIT_OR_ASCII.exec(chr)) {
result[i] = escapeChar(chr);
} else if (chr in ControlEscape) {
// @ts-ignore: `chr` _is_ in `ControlEscape`; stop your whining TS
result[i] = '\\' + ControlEscape[chr];
} else if (SYNTAX_SOLIDUS.exec(chr)) {
result[i] = '\\' + chr;
} else if (OTHER_PUNCTUATORS_AND_WHITESPACES.exec(chr)) {
result[i] = escapeChar(chr);
} else {
var charCode = chr.charCodeAt(0);
if ((charCode & 0xF800) !== 0xD800) { // single UTF-16 code unit
result[i] = chr;
} else if (charCode >= 0xDC00 || i + 1 >= length || (S.charCodeAt(i + 1) & 0xFC00) !== 0xDC00) { // unpaired surrogate
result[i] = escapeChar(chr);
} else { // surrogate pair
result[i] = chr;
result[++i] = S.charAt(i);
}
}
}
return result.join('');
};
})();
/**
*
* @param {RoomName} screen
*/
function CommonScreenName(screen) {
const cache = TextAllScreenCache.get(`Screens/Room/MainHall/Text_MainHall.csv`);
return cache?.get(screen);
}
/**
* Generates the path to a translation CSV file for a screen
*
* @param {string} [module] - The screen's module
* @param {string} [screen] - The screen's name
* @param {string} [group] - The text group
* @returns {string | undefined}
*/
function ScreenFileGetTranslation(module, screen, group) {
if (!module) module = CurrentModule;
if (!screen) screen = CurrentScreen;
if (!group) group = screen;
if (!module || !screen || !group) return undefined;
return ScreenFileGetPath(`Text_${group}.csv`, module, screen);
}
/**
* Generates the path to a CSV Dialog file for a screen
*
* @param {string} npcType - The dialog file name
* @param {string} [module] - The screen's module
* @param {string} [screen] - The screen's name
* @returns {string}
*/
function ScreenFileGetDialog(npcType, module, screen) {
if (!module) module = CurrentModule;
if (!screen) screen = CurrentScreen;
return ScreenFileGetPath(`Dialog_${npcType}.csv`, module, screen);
}
/**
* Generate a path to one of our Screen assets
*
* @param {string} file
* @param {string} [module]
* @param {string} [screen]
* @returns
*/
function ScreenFileGetPath(file, module, screen) {
if (!module) module = CurrentModule;
if (!screen) screen = CurrentScreen;
return `Screens/${module}/${screen}/${file}`;
}
/**
* Gets the common prefix of a list of strings
* @param {string[]} strings
* @returns {string}
*/
function CommonGetCommonPrefix(strings) {
if (strings.length === 0) return "";
let idx = 0;
while (strings.every((str) => str[idx] === strings[0][idx])) {
idx++;
}
return strings[0].substring(0, idx);
}
/**
* Splits a string into tokens by delimiters and spaces
* @param {string} input
* @param {{
* delimiters: [string, string][];
* includeDelimiters?: boolean;
* trimTrailingSpaces?: boolean;
* }} options
* @returns {string[]}
*/
function CommonTokenize(input, options = {
delimiters: [],
includeDelimiters: false,
trimTrailingSpaces: true
}) {
let tokens = [];
let buffer = '';
let inDelimiter = false;
let currentDelimiterPair = null;
let escapeNext = false;
/** @type {(startIndex: number, closeChar: string) => boolean} */
function hasUnescapedClosing(startIndex, closeChar) {
let escaped = false;
for (let i = startIndex; i < input.length; i++) {
const c = input[i];
if (escaped) {
escaped = false;
continue;
}
if (c === '\\') {
escaped = true;
continue;
}
if (c === closeChar) return true;
}
return false;
}
for (let i = 0; i < input.length; i++) {
const char = input[i];
if (escapeNext) {
buffer += char;
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (!inDelimiter) {
const delimiterPair = options.delimiters?.find(([open, close]) => {
if (open !== char) return false;
return hasUnescapedClosing(i + 1, close); // ✅ FIXED
});
if (delimiterPair) {
if (buffer.length) {
tokens.push(buffer);
buffer = '';
}
if (options.includeDelimiters) buffer += char;
inDelimiter = true;
currentDelimiterPair = delimiterPair;
continue;
}
if (char === ' ') {
if (buffer.length) {
tokens.push(buffer);
buffer = '';
}
continue;
}
buffer += char;
continue;
}
// inside delimiter
if (char === currentDelimiterPair?.[1]) {
if (options.includeDelimiters) buffer += char;
tokens.push(buffer);
buffer = '';
inDelimiter = false;
currentDelimiterPair = null;
continue;
}
buffer += char;
}
if (escapeNext) buffer += '\\';
if (buffer.length) tokens.push(buffer);
if (options.trimTrailingSpaces) {
tokens = tokens.map(t => t.trim()).filter(Boolean);
}
return tokens;
}
/**
* Formats a duration in ms to a human readable string of days, months, and years.
* Defaults to showing days.
* @param {number | Date} end
* @param {number | Date} start
* @param {{
* includeYears?: boolean;
* includeMonths?: boolean;
* includeWeeks?: boolean;
* includeDays?: boolean;
* includeHours?: boolean;
* includeMinutes?: boolean;
* includeSeconds?: boolean;
* showFull?: boolean;
* }} options
* @returns {string}
*/
function CommonFormatDurationRange(end, start, options = {}) {
const {
includeYears = false,
includeMonths = false,
includeWeeks = false,
includeDays = true,
includeHours = false,
includeMinutes = false,
includeSeconds = false,
showFull = false
} = options;
// Update options, because we're passing them to other function
options = {
...options,
includeYears,
includeMonths,
includeWeeks,
includeDays,
includeHours,
includeMinutes,
includeSeconds,
};
const {
y: years,
m: months,
d: daysLeft,
h: hours,
min: minutes,
s: seconds,
} = CommonGetDatePartsRange(new Date(end), new Date(start), options);
// Weeks extracted from remaining days
const weeks = includeWeeks ? Math.floor(daysLeft / 7) : 0;
const days = includeWeeks ? daysLeft % 7 : daysLeft;
const full = showFull ? "Full" : "";
/** @type {[string, boolean, number][]} */
const pairs = [
[InterfaceTextGet("DurationFormatYear" + full), includeYears, years],
[InterfaceTextGet("DurationFormatMonth" + full), includeMonths, months],
[InterfaceTextGet("DurationFormatWeek" + full), includeWeeks, weeks],
[InterfaceTextGet("DurationFormatDay" + full), includeDays, days],
[InterfaceTextGet("DurationFormatHour" + full), includeHours, hours],
[InterfaceTextGet("DurationFormatMinute" + full), includeMinutes, minutes],
[InterfaceTextGet("DurationFormatSecond" + full), includeSeconds, seconds],
]
.filter(([, enabled]) => enabled)
.map(([label, , value]) => /** @type {[string, boolean, number]} */ ([label, true, value]));
const result = pairs
.filter(([, , value], idx) => value > 0 || idx === pairs.length - 1)
.map(([label, , v]) =>
CommonStringPartitionReplace(label, { $value$: v }).join('')
);
if (result.length === 0) {
return InterfaceTextGet("DurationFormatError");
}
return result.join(InterfaceTextGet("CommaDelimiter"));
}
/**
* Formats a duration in ms to a human readable string of days, months, and years.
* Defaults to showing days.
* @deprecated Use CommonFormatDurationRange instead
* @param {number} ms
* @param {{
* includeYears?: boolean;
* includeMonths?: boolean;
* includeWeeks?: boolean;
* includeDays?: boolean;
* includeHours?: boolean;
* includeMinutes?: boolean;
* includeSeconds?: boolean;
* showFull?: boolean;
* }} options
* @returns {string}
*/
function CommonFormatDuration(ms, options = {}) {
return CommonFormatDurationRange(new Date(ms), new Date(0), options);
}
/**
* Break duration into real-calendar components (y, m, d, h, min, s),
* optionally rolling excluded components into the next smaller unit.
*
* @param {Date} end
* @param {Date} start
* @param {{
* includeYears?: boolean,
* includeMonths?: boolean,
* includeDays?: boolean,
* includeHours?: boolean,
* includeMinutes?: boolean,
* }} options
*/
function CommonGetDatePartsRange(end, start, options = {}) {
const {
includeYears = true,
includeMonths = true,
includeDays = true,
includeHours = true,
includeMinutes = true,
} = options;
if (start > end) [start, end] = [end, start];
let y = 0, m = 0, d = 0, h = 0, min = 0;
let diff = end.valueOf() - start.valueOf();
// Note: this assumes if includeYears or includeMonths is true then includeDays is also true, and if includeYears is true then includeMonths is also true
if (includeYears || includeMonths) {
const mid = new Date(start);
mid.setFullYear(end.getFullYear(), end.getMonth(), end.getDate());
if (mid > end) mid.setDate(mid.getDate() - 1);
y = mid.getFullYear() - start.getFullYear();
m = mid.getMonth() - start.getMonth();
d = mid.getDate() - start.getDate();
let monthIndex = mid.getMonth();
while (d < 0) {
const prevMonth = new Date(mid);
prevMonth.setMonth(monthIndex);
prevMonth.setDate(0);
d += prevMonth.getDate();
m--;
monthIndex = (monthIndex + 1) % 12;
}
if (m < 0) { m += 12; y--; }
if (!includeYears) { m += 12 * y; y = 0; }
diff = end.valueOf() - mid.valueOf();
} else if (includeDays) {
d = Math.floor(diff / 86400000);
diff %= 86400000;
}
if (includeHours) {
h = Math.floor(diff / 3600000);
diff %= 3600000;
}
if (includeMinutes) {
min = Math.floor(diff / 60000);
diff %= 60000;
}
const s = Math.floor(diff / 1000);
return { y, m, d, h, min, s };
}
/**
* Break duration into real-calendar components (y, m, d, h, min, s),
* optionally rolling excluded components into the next smaller unit.
*
* @deprecated Use CommonGetDatePartsRange instead.
* @param {number} ms
* @param {{
* includeYears?: boolean,
* includeMonths?: boolean,
* includeDays?: boolean,
* includeHours?: boolean,
* includeMinutes?: boolean,
* }} options
*/
function CommonGetDateParts(ms, options = {}) {
return CommonGetDatePartsRange(new Date(ms), new Date(0), options);
}
/**
* Build and returns a range
* @param {number} start If {@link end} is unspecified, then this will be the end of the range, and start will be set to 0
* @param {number} [end] End value for the range, inclusive
* @param {number} [step=1]
* @returns {number[]}
*/
function CommonRange(start, end, step = 1) {
if (end == null) [start, end] = [0, start];
const n = Math.max(Math.ceil((end - start) / step), 0);
return Array.from({ length: n }, (_, i) => start + i * step);
}
/**
* Take an array and find the first element that maps to a user-specified value, returning the mapped value.
*
* Effectively combines {@link array.find} and {@link array.map}.
* @template Tinp
* @template Tout
* @param {readonly Tinp[]} array - The array in question
* @param {(value: Tinp, index: number, obj: readonly Tinp[]) => undefined | null | Tout} predicateMapper - A mapping
* predicate. The first non-nullish return value will be returned by the outer function.
* @param {any} [thisArg] - If provided, it will be used as the this value for each invocation of
* predicate. If it is not provided, undefined is used instead.
* @returns {undefined | Tout} - The mapped value or `undefined` if no non-nullish value was found
*/
function CommonFindMap(array, predicateMapper, thisArg=null) {
if (thisArg != null) {
predicateMapper = predicateMapper.bind(thisArg);
}
for (const [i, elem] of array.entries()) {
const result = predicateMapper(elem, i, array);
if (result != null) {
return result;
}
}
return undefined;
}
/**
* Splice a string into a string at a specific index.
*
* The string will be automatically extended using {@link defaultValue} if the index falls outside its bounds.
*
* @param {string} string
* @param {number} index
* @param {string} value
* @param {string} defaultValue
* @returns
*/
function CommonStringSplice(string, index, value, defaultValue) {
if (string.length < index) {
string = string + defaultValue.repeat(Math.ceil((index - string.length) / defaultValue.length));
}
return string.substring(0, index) + value + string.substring(index + value.length);
}
/**
* Unwraps a thunk into a value
*
* @template T
* @param {Thunk<T>} thunk
* @returns {T}
*/
function CommonUnwrapThunk(thunk) {
return typeof thunk === "function" ? /** @type {() => T} */ (thunk)() : thunk;
}
/**
* A class representing the result of a failable operation
* @template T
* @template {Error} [E=Error]
*/
class Result {
/** @type {T} */
value;
/** @type {E | null} */
error;
/**
* Create a new Result representing the status of a failable operation
* @param {T} value
* @param {E | null} [error]
*/
constructor(value, error = null) {
this.value = value;
this.error = error;
}
/**
* Build a Result for a successful operation
* @template T
* @param {T} value
* @returns
*/
static success(value) {
return new Result(value, null);
}
/**
* Build a Result for a failed operation
* @param {Error} error
* @returns
*/
static failure(error) {
return new Result(null, error);
}
/**
* Returns whether the operation was a success
*/
get ok() {
return !this.error;
}
/**
* Returns whether the operation was a failure
*/
get err() {
return !!this.error;
}
/**
* Unwrap the result
* This either returns the successful return of the operation, or throws the error it reported
*/
unwrap() {
if (this.ok) {
return this.value;
} else {
throw this.error;
}
}
}
/**
* I am a testing function for typescript to evaluate and should not be executed during runtime nor removed.
*
* Checks whether all all `window`-defined backgrounds and screen functions have an appropriate signature.
* @returns {void}
*/
function CommonTestSatisfies() {
// eslint-disable-next-line
const foo = {
/** @type {{ [key in `${RoomName}Background`]?: string }} */
Background: globalThis,
/** @type {{ [key in `${RoomName}Run`]: ScreenRunHandler }} */
Run: globalThis,
/** @type {{ [key in `${RoomName}MouseDown`]?: MouseEventListener }} */
MouseDown: globalThis,
/** @type {{ [key in `${RoomName}MouseUp`]?: MouseEventListener }} */
MouseUp: globalThis,
/** @type {{ [key in `${RoomName}MouseMove`]?: MouseEventListener }} */
MouseMove: globalThis,
/** @type {{ [key in `${RoomName}MouseWheel`]?: MouseWheelEventListener }} */
MouseWheel: globalThis,
/** @type {{ [key in `${RoomName}Click`]: MouseEventListener }} */
Click: globalThis,
/** @type {{ [key in `${RoomName}Load`]?: ScreenLoadHandler }} */
Load: globalThis,
/** @type {{ [key in `${RoomName}Unload`]?: ScreenUnloadHandler }} */
Unload: globalThis,
/** @type {{ [key in `${RoomName}Draw`]?: ScreenDrawHandler }} */
Draw: globalThis,
/** @type {{ [key in `${RoomName}Resize`]?: ScreenResizeHandler }} */
Resize: globalThis,
/** @type {{ [key in `${RoomName}KeyDown`]?: KeyboardEventListener }} */
KeyDown: globalThis,
/** @type {{ [key in `${RoomName}KeyUp`]?: KeyboardEventListener }} */
KeyUp: globalThis,
/** @type {{ [key in `${RoomName}Paste`]?: ClipboardEventListener }} */
Paste: globalThis,
/** @type {{ [key in `${RoomName}Exit`]?: ScreenExitHandler }} */
Exit: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}Init`]?: ExtendedItemCallbacks.Init }} */
ExtendedItemInit: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}Load`]?: ExtendedItemCallbacks.Load }} */
ExtendedItemLoad: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}Draw`]?: ExtendedItemCallbacks.Draw }} */
ExtendedItemDraw: globalThis,
/** @type {{ [key in `Inventor${AssetGroupName}y${string}Click`]?: ExtendedItemCallbacks.Click }} */
ExtendedItemClick: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}Exit`]?: ExtendedItemCallbacks.Exit }} */
ExtendedItemExit: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}Validate`]?: ExtendedItemCallbacks.Validate }} */
ExtendedItemValidate: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}PublishAction`]?: ExtendedItemCallbacks.PublishAction }} */
ExtendedItemPublishAction: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}SetOption`]?: ExtendedItemCallbacks.SetOption }} */
ExtendedItemSetOption: globalThis,
/** @type {{ [key in `Assets${AssetGroupName}${string}BeforeDraw`]?: ExtendedItemCallbacks.BeforeDraw }} */
ExtendedItemBeforeDraw: globalThis,
/** @type {{ [key in `Assets${AssetGroupName}${string}AfterDraw`]?: ExtendedItemCallbacks.AfterDraw }} */
ExtendedItemAfterDraw: globalThis,
/** @type {{ [key in `Assets${AssetGroupName}${string}ScriptDraw`]?: ExtendedItemCallbacks.ScriptDraw }} */
ExtendedItemScriptDraw: globalThis,
/** @type {{ [key in `Inventory${AssetGroupName}${string}NpcDialog`]?: (C: Character, Option: ExtendedItemOption, PreviousOption: ExtendedItemOption) => void }} */
ExtendedItemNpcDialog: globalThis,
};
}