rearrage_stuff

This commit is contained in:
Kim Ravn Hansen
2025-09-16 11:26:40 +02:00
parent 40e8c5e0ab
commit 3f11ebe6dc
4937 changed files with 1146031 additions and 134 deletions

72
utils/crackdown.js Normal file
View File

@@ -0,0 +1,72 @@
// ____ _ ____
// / ___|_ __ __ _ ___| | _| _ \ _____ ___ __
// | | | '__/ _` |/ __| |/ / | | |/ _ \ \ /\ / / '_ \
// | |___| | | (_| | (__| <| |_| | (_) \ V V /| | | |
// \____|_| \__,_|\___|_|\_\____/ \___/ \_/\_/ |_| |_|
//
//
// _ __ __ _ _ __ ___ ___ _ __
// | '_ \ / _` | '__/ __|/ _ \ '__|
// | |_) | (_| | | \__ \ __/ |
// | .__/ \__,_|_| |___/\___|_|
// |_|
const capture = "([a-z0-9:()-](?:.*[a-zA-Z:().!-])?)";
const skipSpace = "\\s*";
const htmlEscapeRegex = /[&<>"'`]/g; // used to escape html characters
/**
* @type {Array.string[]}
*
* The order of the elements of this array matters.
*/
const opcodes = [
["(^|\\n)=", "($|\\n)", "$1<h1>$2</h1>$3"],
["(^|\\n)==", "($|\\n)", "$1<h2>$2</h2>$3"],
["---", "---", "<span class='strike'>$1</span>"],
["___", "___", "<span class='underline'>$1</span>"],
["(?:[,]{3})", "(?:[,]{3})", "<span class='undercurl'>$1</span>"],
["(?:[(]{2})", "(?:[)]{2})", "<span class='faint'>$1</span>"],
["_", "_", "<span class='italic'>$1</span>"],
["\\*", "\\*", "<span class='bold'>$1</span>"],
["\\[\\[([a-zA-Z0-9_ ]+)\\[\\[", "\\]\\]", "<span class='$1'>$2</span>"],
];
/** @type{Array.Array.<Regexp,string>} */
const regexes = [];
//
// Pre-compile all regexes
for (const [left, right, replacement] of opcodes) {
regexes.push([new RegExp(left + skipSpace + capture + skipSpace + right, "gi"), replacement]);
}
/** @param {string} text */
export function crackdown(text) {
text.replace(htmlEscapeRegex, (c) => {
switch (c) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#039;";
case "`":
return "&#096;";
default:
return c;
}
});
for (const k in regexes) {
const [regex, replacement] = regexes[k];
text = text.replace(regex, replacement);
}
console.debug("crack output", text);
return text;
}

12
utils/dice.js Normal file
View File

@@ -0,0 +1,12 @@
export function dice(sides) {
const r = Math.random();
return Math.floor(r * sides) + 1;
}
export function d6() {
return dice(6);
}
export function d8() {
return dice(8);
}

65
utils/id.js Normal file
View File

@@ -0,0 +1,65 @@
import * as regex from "./regex.js";
const MINI_UID_REGEX = regex.pretty(
"\.uid\.", // Mini-uids always begin with ".uid."
"[a-z0-9]{6,}$", // Terminated by 6 or more random numbers and lowercase letters.
);
const ID_SANITY_REGEX = regex.pretty(
"^:", // All ids start with a colon
"([a-z0-9]+\.)*?", // Middle -optional- part :myid.gogle.thing.thang.thong
"[a-z0-9_]+$", // The terminating part of the id is numbers, lowercase letters, and -notably- underscores.
);
/**
* Sanity check a string to see if it is a potential id.
*
* @param {string} id
* @returns {boolean}
*/
export function isIdSane(id) {
if (typeof id !== "string") {
return false;
}
if (id.length < 2) {
return false;
}
if (!ID_SANITY_REGEX.test(id)) {
return false;
}
return true;
}
/**
* @returns {string} crypto-unsafe pseudo random numbe"r.
*
* Generate a random number, convert it to base36, and return it as a string with 7-8 characters.
*/
export function miniUid() {
// we use 12 digits, but we could go all the way to 16
const digits = 12;
return Number(Math.random().toFixed(digits).substring(2)).toString(36);
}
/**
* Generate an id from a string
* @param {string[]} str
*/
export function appendMiniUid(str) {
return str + ".uid." + miniUid();
}
/**
* Does a given string end with ".uid.23khtasdz", etc.
*
* @param {string} str
*/
export function endsWithMiniUid(str) {
return MINI_UID_REGEX.test(str);
}
export function appendOrReplaceMiniUid(str) {
return appendMiniUid(str.replace(MINI_UID_REGEX, ""));
}

181
utils/messages.js Executable file
View File

@@ -0,0 +1,181 @@
import { mustBe, mustBeString, mustMatch } from "./mustbe.js";
export const MessageType = Object.freeze({
/**
* Very bad logic error. Player must quit game, refresh page, and log in again.
*
* Client-->Server
* or
* Server-->Client-->Plater
*/
CALAMITY: "CALAMITY",
/**
* Tell recipient that an error has occurred
*
* Server-->Client-->Player
*/
ERROR: "E",
/**
* Message to be displayed.
*
* Server-->Client-->Player
*/
TEXT: "T",
/**
* Player has entered data, and sends it to server.
*
* Player-->Client-->Server
*/
REPLY: "R",
/**
* Player wants to quit.
*
* Player-->Client-->Server
*/
QUIT: "QUIT",
/**
* Player wants help
*
* Player-->Client-->Server
*/
HELP: "HELP",
/**
* Server tells the client to prompt the player for some data
*
* Server-->Client-->Player
*/
PROMPT: "P",
/**
* Server tells the client to prompt the player for some data
*
* Server-->Client-->Player
*/
SYSTEM: "_",
/**
* Debug message, to be completely ignored in production
*
* Client-->Server
* or
* Server-->Client-->Plater
*/
DEBUG: "dbg",
/**
* Player sent colon-prefixed, an out-of-order, command
*
* Player-->Client-->Server
*/
COLON: ":",
});
/**
* Represents a message sent to/from client
*
* @property {string?} command
* @property {any[]} args
*/
export class WebsocketMessage {
/** @protected @type {any[]} _arr The array that contains the message data */
_data;
/** @constant @readonly @type {string} _arr The array that contains the message data */
type;
/**
* @param {string} msgData the raw text data in the websocket message.
*/
constructor(msgData) {
if (typeof msgData !== "string") {
throw new Error(
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData,
);
return;
}
let data;
try {
data = JSON.parse(msgData);
} catch (_) {
throw new Error(
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
);
}
if (!Array.isArray(data)) {
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._data}`);
}
if (data.length < 1) {
throw new Error(
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
);
}
this.type = mustBeString(data[0]);
switch (this.type) {
case MessageType.REPLY: // player ==> client ==> server
this.text = mustBeString(data[1]);
break;
case MessageType.HELP: // player ==> client ==> server
this.text = data[1] === undefined ? "" : mustBeString(data[1]).trim();
break;
case MessageType.COLON: // player ==> client ==> server
this.command = mustMatch(data[1], /^[a-z0-9_]+$/);
this.args = mustBe(data[2], "any[]");
break;
case MessageType.DEBUG: // server ==> client
case MessageType.ERROR: // server ==> client ==> player
case MessageType.QUIT: // player ==> client ==> server
case MessageType.SYSTEM: // client <==> server
case MessageType.PROMPT: // server ==> client ==> player
case MessageType.TEXT: // server ==> client ==> player
break;
default:
throw new Error(`Unknown message type: >>${typeof this.type}<<`);
}
}
isQuit() {
return this.type === MessageType.QUIT;
}
isHelp() {
return this.type === MessageType.HELP;
}
isColon() {
return this.type === MessageType.COLON;
}
isReply() {
return this.type === MessageType.REPLY;
}
isSysMessage() {
return this.type === MessageType.SYSTEM;
}
isDebug() {
return this.type === MessageType.DEBUG;
}
}
/**
* Given a message type and some args, create a string that can be sent from the server to the client (or vise versa)
*
* @param {string} messageType
* @param {...any} args
*/
export function formatMessage(messageType, ...args) {
return JSON.stringify([messageType, ...args]);
}

44
utils/mustbe.js Executable file
View File

@@ -0,0 +1,44 @@
export function mustBe(value, ...types) {
//
// empty type enforcement.
// Means we just want value to be define
if (types.length === 0 && typeof value !== "undefined") {
return value;
}
//
// value has a valid type
if (types.includes(typeof value)) {
return value;
}
const isArray = Array.isArray(value);
if (isArray && (types.includes("any[]") || types.includes("array"))) {
return value;
}
// NOTE: only checks first element of array if it's a string.
if (isArray && types.includes("strings[]") && (value.length === 0 || typeof value[0] === "string")) {
return value;
}
throw new Error("Invalid data type. Expected >>" + types.join(" or ") + "<< but got " + typeof value);
}
export function mustBeString(value) {
return mustBe(value, "string");
}
/**
*
* @param {string} str
* @param {RegExp} regex
*/
export function mustMatch(str, regex) {
if (!regex.test(str)) {
throw new Error(`String did not satisfy ${regex}`);
}
return str;
}

82
utils/parseArgs.js Executable file
View File

@@ -0,0 +1,82 @@
/**
* Parse a command string into arguments. For use with colon-commands.
*
* @param {string} cmdString;
* @returns {(string|number)[]} Command arguments
*/
export function parseArgs(cmdString) {
if (typeof cmdString !== "string") {
throw new Error("Expected string. GoT a finger in the eye instead");
}
const args = [];
const quoteChars = ["'", '"', "`"];
const backslash = "\\";
let currentArg = ""; // The arg we are currently constructing
let inQuotes = false; // are we inside quotes of some kind?
let currentQuoteChar = ""; // if were in quotes, which are they?
// helper function
const pushVal = (value) => {
const n = Number(value);
if (Number.isSafeInteger(n)) {
args.push(n);
} else if (Number.isFinite(n)) {
args.push(n);
} else {
args.push(value);
}
};
for (let i = 0; i < cmdString.length; i++) {
const char = cmdString[i];
const nextChar = cmdString[i + 1];
if (!inQuotes) {
// Not in quotes - look for quote start or whitespace
if (quoteChars.includes(char)) {
inQuotes = true;
currentQuoteChar = char;
} else if (char === " " || char === "\t") {
// Whitespace - end current arg if it exists
if (currentArg) {
pushVal(currentArg);
currentArg = "";
}
// Skip multiple whitespace
while (cmdString[i + 1] === " " || cmdString[i + 1] === "\t") i++;
} else {
currentArg += char;
}
} else {
// Inside quotes
if (char === currentQuoteChar) {
// Found matching quote - end quoted section
inQuotes = false;
currentQuoteChar = "";
} else if (char === backslash && (nextChar === currentQuoteChar || nextChar === backslash)) {
// Escape sequence - add the escaped character
currentArg += nextChar;
//
// Todo, maybe add support for \n newlines? Why would I ?
//
i++; // Skip next character
} else {
currentArg += char;
}
}
}
// Add final argument if exists
if (currentArg) {
pushVal(currentArg);
}
if (currentQuoteChar) {
// We allow quotes to not be terminated
// It allows players to do stuff like `:say "wolla my lovely friend` and not have the text modified or misinterpreted in any way
// May be good for chat where you dont want every word split into individual arguments
}
return args;
}

99
utils/random.js Normal file
View File

@@ -0,0 +1,99 @@
/**
* Pseudo random number generator
* using the xorshift32 method.
*/
export class Xorshift32 {
/* @type {number} */
initialSeed;
/**
* State holds a single uint32.
* It's useful for staying within modulo 2**32.
*
* @type {Uint32Array}
*/
state;
/** @param {number} seed */
constructor(seed) {
if (seed === undefined) {
const maxInt32 = 2 ** 32;
seed = Math.floor(Math.random() * (maxInt32 - 1)) + 1;
}
seed = seed | 0;
console.info("RNG Initial Seed %d", seed);
this.state = Uint32Array.of(seed);
}
/** @protected Shuffle the internal state. */
shuffle() {
console.log("RNG Shuffle: Initial State: %d", this.state);
this.state[0] ^= this.state[0] << 13;
this.state[0] ^= this.state[0] >>> 17;
this.state[0] ^= this.state[0] << 5;
// We could also do something like this:
// x ^= x << 13;
// x ^= x >> 17;
// x ^= x << 5;
// return x;
// But we'd have to xor the x with 2^32 after every op,
// we get that "for free" by using the uint32array
console.log("RNG Shuffle: Exit State: %d", this.state);
return this.state[0];
}
/**
* Get a random number and shuffle the internal state.
* @returns {number} a pseudo-random positive integer.
*/
get() {
this.shuffle();
return this.state;
}
/** @param {number} x @returns {number} a positive integer lower than x */
lowerThan(x) {
return this.get() % x;
}
/** @param {number} x @reurns {number} a positive integer lower than or equal to x */
lowerThanOrEqual(x) {
return this.get() % (x + 1);
}
/**
* @param {<T>[]} arr
*
* @return {<T>}
*/
randomElement(arr) {
const idx = this.lowerThan(arr.length);
return arr[idx];
}
/**
* @param {...<T>} args
* @returns {<T>}
*/
oneOf(...args) {
const idx = this.lowerThan(args.length);
return args[idx];
}
/**
* @param {number} lowerThanOrEqual a positive integer
* @param {number} greaterThanOrEqual a positive integer greater than lowerThanOrEqual
* @returns {number} a pseudo-random integer
*/
within(greaterThanOrEqual, lowerThanOrEqual) {
const range = lowerThanOrEqual - greaterThanOrEqual;
const num = this.lowerThanOrEqual(range);
return num + greaterThanOrEqual;
}
}

10
utils/regex.js Normal file
View File

@@ -0,0 +1,10 @@
/**
* Makes it easier to document regexes because you can break them up
*
* @param {...string} args
* @returns {Regexp}
*/
export function pretty(...args) {
const regexprStr = args.join("");
return new RegExp(regexprStr);
}

68
utils/security.js Normal file
View File

@@ -0,0 +1,68 @@
import { randomBytes, pbkdf2Sync } from "node:crypto";
import { Config } from "../config.js";
// Settings (tune as needed)
const ITERATIONS = 1000;
const KEYLEN = 32; // 32-bit hash
const DIGEST = "sha256";
const DEV = process.env.NODE_ENV === "dev";
/**
* Generate a hash from a plaintext password.
* @param {string} password
* @returns {string}
*/
export function generateHash(password) {
const salt = randomBytes(16).toString("hex"); // 128-bit salt
const hash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString(
"hex",
);
return `${ITERATIONS}:${salt}:${hash}`;
}
/**
* Verify that a password is correct against a given hash.
*
* @param {string} password_candidate
* @param {string} stored_password_hash
* @returns {boolean}
*/
export function verifyPassword(password_candidate, stored_password_hash) {
const [iterations, salt, hash] = stored_password_hash.split(":");
const derived = pbkdf2Sync(
password_candidate,
salt,
Number(iterations),
KEYLEN,
DIGEST,
).toString("hex");
const success = hash === derived;
if (Config.dev || true) {
console.debug(
"Verifying password:\n" +
" Input : %s (the password as it was sent to us by the client)\n" +
" Given : %s (the input password hashed by us (not necessary for validation))\n" +
" Stored : %s (the password hash we have on file for the player)\n" +
" Derived : %s (the hashed version of the input password)\n" +
" Verified : %s (was the password valid)",
password_candidate,
generateHash(password_candidate),
stored_password_hash,
derived,
success,
);
}
return success;
}
/** @param {string} candidate */
export function isUsernameSane(candidate) {
return /^[a-zA-Z0-9_]{4,}$/.test(candidate);
}
/** @param {string} candidate */
export function isPasswordSane(candidate) {
// We know the password must adhere to one of our client-side-hashed crypto schemes,
// so we can be fairly strict with the allowed passwords
return /^[a-zA-Z0-9_: -]{8,}$/.test(candidate);
}

337
utils/tui.js Normal file
View File

@@ -0,0 +1,337 @@
/**
* @readonly
*
* @enum {string}
*/
export const FrameType = {
/**
* ╔════════════╗
* ║ Hello, TUI ║
* ╚════════════╝
*
* @type {string} Double-lined frame
*/
Double: "Double",
/**
* ┌────────────┐
* │ Hello, TUI │
* └────────────┘
*
* @type {string} Single-lined frame
*/
Single: "Single",
/**
*
* Hello, TUI
*
*
* @type {string} Double-lined frame
*/
Invisible: "Invisible",
/**
* ( )
* ( Hello, TUI )
* ( )
*
* @type {string} Double-lined frame
*/
Parentheses: "Parentheses",
/**
* +------------+
* | Hello, TUI |
* +------------+
*
* @type {string} Double-lined frame
*/
Basic: "Basic",
/**
* @protected
* Default values for the common frame types.
*
* [north, south, east, west, northwest, northeast, southwest, southeast]
*/
values: {
Basic: "--||++++",
Double: "══║║╔╗╚╝",
Invisible: " ",
Parentheses: " () ",
Single: "──││┌┐└┘",
},
};
export class FramingOptions {
/** @type {number=0} Vertical Padding; number of vertical whitespace (newlines) between the text and the frame. */
vPadding = 0;
/** @type {number=0} Margin ; number of newlines to to insert before and after the framed text */
vMargin = 0;
/** @type {number=0} Horizontal Padding; number of whitespace characters to insert between the text and the sides of the frame. */
hPadding = 0;
/** @type {number=0} Margin ; number of newlines to to insert before and after the text, but inside the frame */
hMargin = 0;
/** @type {FrameType=FrameType.Double} Type of frame to put around the text */
frameType = FrameType.Double;
/** @type {number=0} Pad each line to become at least this long */
minLineWidth = 0;
// Light block: ░ (U+2591)
// Medium block: ▒ (U+2592)
// Dark block: ▓ (U+2593)
// Solid block: █ (U+2588)
/** @type {string} Single character to use as filler inside the frame. */
paddingChar = " "; // character used for padding inside the frame.
/** @type {string} Single character to use as filler outside the frame. */
marginChar = " ";
/** @type {string} The 8 characters that make up the frame elements */
frameChars = FrameType.values.Double;
/**
* @param {object} o
* @returns {FramingOptions}
*/
static fromObject(o) {
const result = new FramingOptions();
result.vPadding = Math.max(0, Number.parseInt(o.vPadding) || 0);
result.hPadding = Math.max(0, Number.parseInt(o.hPadding) || 0);
result.vMargin = Math.max(0, Number.parseInt(o.vMargin) || 0);
result.hMargin = Math.max(0, Number.parseInt(o.hMargin) || 0);
result.minLineWidth = Math.max(0, Number.parseInt(o.hMargin) || 0);
result.paddingChar = String(o.paddingChar || " ")[0] || " ";
result.marginChar = String(o.marginChar || " ")[0] || " ";
//
// Do we have custom and valid frame chars?
if (
typeof o.frameChars === "string" &&
o.frameChars.length === FrameType.values.Double.length
) {
result.frameChars = o.frameChars;
//
// do we have document frame type instead ?
} else if (o.frameType && FrameType.hasOwnProperty(o.frameType)) {
result.frameChars = FrameType.values[o.frameType];
// Fall back to using "Double" frame
} else {
result.frameChars = FrameType.values.Double;
}
return result;
}
}
/**
* @param {string|string[]} text the text to be framed. If array, each element will be treated as one line, and they are joined so the whole is to be framed.
* @param {FramingOptions} options
*/
export function frameText(text, options) {
if (!options) {
options = new FramingOptions();
}
if (!(options instanceof FramingOptions)) {
options = FramingOptions.fromObject(options);
}
// There is a point to this; each element in the array may contain newlines,
// so we have to combine everything into a long text and then split into
// individual lines afterwards.
if (Array.isArray(text)) {
text = text.join("\n");
}
if (typeof text !== "string") {
console.debug(text);
throw new Error(
`text argument was neither an array or a string, it was a ${typeof text}`,
);
}
/** @type {string[]} */
const lines = text.split("\n");
const innerLineLength = Math.max(
lines.reduce((accumulator, currentLine) => {
if (currentLine.length > accumulator) {
return currentLine.length;
}
return accumulator;
}, 0),
options.minLineWidth,
);
const frameThickness = 1; // always 1 for now.
const outerLineLength =
0 +
innerLineLength +
frameThickness * 2 +
options.hPadding * 2 +
options.hMargin * 2;
// get the frame characters from the frameType.
let [
fNorth, // horizontal frame top lines
fSouth, // horizontal frame bottom lines
fWest, // vertical frame lines on the left side
fEast, // vertical frame lines on the right side
fNorthWest, // upper left frame corner
fNorthEast, // upper right frame corner
fSouthWest, // lower left frame corner
fSouthEast, // lower right frame corner
] = options.frameChars.split("");
if (fNorth === "§") {
fNorth = "";
}
if (fSouth === "§") {
fSouth = "";
}
if (fEast === "§") {
fEast = "";
}
if (fWest === "§") {
fWest = "";
}
if (fNorthEast === "§") {
fNorthEast = "";
}
if (fSouthEast === "§") {
fSouthEast = "";
}
if (fNorthWest === "§") {
fNorthWest = "";
}
if (fSouthWest === "§") {
fSouthWest = "";
}
let output = "";
//
// GENERATE THE MARGIN SPACE ABOVE THE FRAMED MsgType.TEXT
//
// ( we insert space characters even though )
// ( they wouldn't normally be visible. But )
// ( Some fonts might allow us to see blank )
// ( space, and what if we want to nest many )
// ( frames inside each other? )
//
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(
options.vMargin,
);
//
// GENERATE THE TOP PART OF THE FRAME
// ╔════════════╗
//
//
output +=
"" + // Make sure JS knows we're adding a string.
options.marginChar.repeat(options.hMargin) + // the margin before the frame starts
fNorthWest + // northwest frame corner
fNorth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar
fNorthEast + // northeast frame corner
options.marginChar.repeat(options.hMargin) + // the margin after the frame ends
"\n";
//
// GENERATE UPPER PADDING
//
// ║ ║
//
// (the blank lines within the frame and above the text)
output += (
options.marginChar.repeat(options.hMargin) +
fWest +
options.paddingChar.repeat(innerLineLength + options.hPadding * 2) +
fEast +
options.marginChar.repeat(options.hMargin) +
"\n"
).repeat(options.vPadding);
//
// GENERATE FRAMED MsgType.TEXT SEGMENT
//
// ║ My pretty ║
// ║ text here ║
//
// ( this could be done with a reduce() )
//
for (const line of lines) {
output +=
"" + // Make sure JS knows we're adding a string.
options.marginChar.repeat(options.hMargin) + // margin before frame
fWest + // vertical frame char
options.paddingChar.repeat(options.hPadding) + // padding before text
line.padEnd(innerLineLength, " ") + // The actual text. Pad it with normal space character, NOT custom space.
options.paddingChar.repeat(options.hPadding) + // padding after text
fEast + // vertical frame bar
options.marginChar.repeat(options.hMargin) + // margin after frame
"\n";
}
//
// GENERATE LOWER PADDING
//
// ║ ║
//
// ( the blank lines within the )
// ( frame and below the text )
//
// ( this code is a direct )
// ( repeat of the code that )
// ( generates top padding )
output += (
options.marginChar.repeat(options.hMargin) +
fWest +
options.paddingChar.repeat(innerLineLength + options.hPadding * 2) +
fEast +
options.marginChar.repeat(options.hMargin) +
"\n"
).repeat(options.vPadding);
//
// GENERATE THE BOTTOM PART OF THE FRAME
//
// ╚════════════╝
//
output +=
"" + // Make sure JS knows we're adding a string.
options.marginChar.repeat(options.hMargin) + // the margin before the frame starts
fSouthWest + // northwest frame corner
fSouth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar
fSouthEast + // northeast frame corner
options.marginChar.repeat(options.hMargin) + // the margin after the frame starts
"\n";
//
// GENERATE THE MARGIN SPACE BELOW THE FRAMED MsgType.TEXT
//
// ( we insert space characters even though )
// ( they wouldn't normally be visible. But )
// ( Some fonts might allow us to see blank )
// ( space, and what if we want to nest many )
// ( frames inside each other? )
//
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(
options.vMargin,
);
return output;
}
// Allow this script to be run directly from node as well as being included!
// https://stackoverflow.com/a/66309132/5622463