From 8a4eb2550729994a6f43eb8c9ef1827e2b91298f Mon Sep 17 00:00:00 2001 From: Kim Ravn Hansen Date: Sun, 7 Sep 2025 23:24:50 +0200 Subject: [PATCH] things + stff --- server/.vscode/launch.json | 20 ++ server/config.js | 5 + server/find-element-width-in-chars.html | 50 +++ server/models/character.js | 26 +- server/models/game.js | 2 + server/models/item.js | 20 +- server/models/player.js | 101 ++---- server/models/session.js | 102 ++++++ server/models/states/auth.js | 158 +++++++++ server/models/states/awaitCommands.js | 50 +++ server/models/states/characterCreation.js | 105 ++++++ server/models/states/createPlayer.js | 167 ++++++++++ server/models/states/interface.js | 13 + server/models/states/justLoggedIn.js | 36 ++ server/package-lock.json | 13 + server/package.json | 5 +- server/public/client.js | 330 +++++++++++++++++++ server/public/index.html | 191 +---------- server/public/style.css | 57 +++- server/seed.js | 37 +++ server/server.js | 384 +++++++++------------- server/tui.md | 169 ++++++++++ server/utils/config.js | 4 + server/utils/messages.js | 169 +++++++--- server/utils/password.js | 42 --- server/utils/security.js | 59 ++++ server/utils/tui.js | 306 +++++++++++++++++ 27 files changed, 1991 insertions(+), 630 deletions(-) create mode 100755 server/.vscode/launch.json create mode 100755 server/config.js create mode 100644 server/find-element-width-in-chars.html create mode 100755 server/models/session.js create mode 100755 server/models/states/auth.js create mode 100644 server/models/states/awaitCommands.js create mode 100755 server/models/states/characterCreation.js create mode 100755 server/models/states/createPlayer.js create mode 100755 server/models/states/interface.js create mode 100755 server/models/states/justLoggedIn.js create mode 100755 server/public/client.js mode change 100644 => 100755 server/public/style.css create mode 100755 server/seed.js create mode 100644 server/tui.md create mode 100644 server/utils/config.js delete mode 100644 server/utils/password.js create mode 100755 server/utils/security.js create mode 100755 server/utils/tui.js diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json new file mode 100755 index 0000000..d23e5e9 --- /dev/null +++ b/server/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "env": { + "NODE_ENV": "dev", + }, + "program": "${workspaceFolder}/server.js" + } + ] +} \ No newline at end of file diff --git a/server/config.js b/server/config.js new file mode 100755 index 0000000..d581287 --- /dev/null +++ b/server/config.js @@ -0,0 +1,5 @@ +export const ENV = process.env.NODE_ENV || "prod"; +export const DEV = ENV === "dev"; +export const PROD =!DEV; +export const PORT = process.env.PORT || 3000; +export const PARTY_MAX_SIZE = 4; diff --git a/server/find-element-width-in-chars.html b/server/find-element-width-in-chars.html new file mode 100644 index 0000000..fd00ae3 --- /dev/null +++ b/server/find-element-width-in-chars.html @@ -0,0 +1,50 @@ + + + + + + + + + + + Measure Div Width in Characters + + + +
+ This is a div with monospaced text. +
+ + + + diff --git a/server/models/character.js b/server/models/character.js index b5a639f..a86c9d5 100755 --- a/server/models/character.js +++ b/server/models/character.js @@ -10,7 +10,10 @@ export class Character { /** @type {string} character's name */ name; - /** @protected @type {number} The number of XP the character has. */ + /** + * @protected + * @type {number} The number of XP the character has. + */ _xp = 0; get xp() { return this._xp; @@ -61,15 +64,15 @@ export class Character { /** * @param {string} username The name of player who owns this character. Note that the game can own a character - somehow. * @param {string} name The name of the character - * @param {boolean} initialize Should we initialize the character + * @param {boolean=false} initialize Should we initialize the character */ - constructor(playerUname, name, initialize) { + constructor(username, name, initialize) { this.name = name; // Initialize the unique name if this character. // // things to to hell if two characters with the same name are created at exactly the same time with the same random seed. - this._id = id.fromName(playerUname, name); + this._id = id.fromName(username, name); // should we skip initialization of this object if (initialize !== true) { @@ -144,9 +147,7 @@ export class Character { this.meleeCombat = Math.max(this.skulduggery, 10); break; default: - throw new Error( - "Logic error, ancestry d8() roll was out of scope", - ); + throw new Error("Logic error, ancestry d8() roll was out of scope"); } // @@ -172,11 +173,8 @@ export class Character { case 2: this.foundation = "druid"; this.proficiencies.add("armor/natural"); - this.equipment - .set("sickle", 1) - .set("poisoner's kit", 1) - .set("healer's kit", 1); - default: + this.equipment.set("sickle", 1).set("poisoner's kit", 1).set("healer's kit", 1); + default: // case 2: this.foundation = "debug"; this.proficiencies.add("heavy_armor"); this.proficiencies.add("heavy_weapons"); @@ -193,7 +191,3 @@ export class Character { } } } - -const c = new Character("username", "test", true); - -console.log(c); diff --git a/server/models/game.js b/server/models/game.js index e5921c1..1469725 100755 --- a/server/models/game.js +++ b/server/models/game.js @@ -10,8 +10,10 @@ import WebSocket from "ws"; import { Character } from "./character.js"; import { ItemTemplate } from "./item.js"; +import { Player } from "./player.js"; export class Game { + /** @type {Map} List of all item templates in the game */ _itemTemplates = new Map(); diff --git a/server/models/item.js b/server/models/item.js index fde5f2b..2f9360d 100755 --- a/server/models/item.js +++ b/server/models/item.js @@ -42,17 +42,13 @@ export class ItemTemplate { */ constructor(name, itemSlots, description, id) { if (typeof name !== "string") { - throw new Error( - "Name must be a string, but " + typeof name + " given.", - ); + throw new Error("Name must be a string, but " + typeof name + " given."); } if (typeof description === "undefined") { description = ""; } if (typeof description !== "string") { - throw new Error( - "Name must be a string, but " + typeof name + " given.", - ); + throw new Error("Name must be a string, but " + typeof name + " given."); } if (!Number.isFinite(itemSlots)) { throw new Error("itemSlots must be a finite number!"); @@ -71,12 +67,7 @@ export class ItemTemplate { } createItem() { - return new ChracterItem( - this._id, - this._name, - this._description, - this._itemSlots, - ); + return new ChracterItem(this._id, this._name, this._description, this._itemSlots); } } @@ -119,8 +110,3 @@ export class CharacterItem { this.itemSlots = itemSlots; } } - -const i = new ItemTemplate("knife", 10000); - -const ci = new CharacterItem(); -console.log(ci); diff --git a/server/models/player.js b/server/models/player.js index 44328c2..07fca57 100755 --- a/server/models/player.js +++ b/server/models/player.js @@ -1,103 +1,48 @@ import WebSocket from "ws"; +import { Character } from "./character.js"; /** * Player Account. * - * 1. Contain persistent player account info. - * 2. Contain the connection to the client machine if the player is currently playing the game. - * 3. Contain session information. - * - * We can do this because we only allow a single websocket per player account. - * You are not allowed to log in if a connection/socket is already open. - * - * We regularly ping and pong to ensure that stale connections are closed. - * + * Contain persistent player account info. */ export class Player { - /** @protected @type {string} unique username */ + /** + * @protected + * @type {string} unique username + */ _username; get username() { return this._username; } - /** @protected @type {string} */ + /** + * @protected + * @type {string} + */ _passwordHash; get passwordHash() { return this._passwordHash; } - /** @protected @type {WebSocket} Player's current and only websocket. If undefined, the player is not logged in. */ - _websocket; - get websocket() { - return this._websocket; + /** @protected @type {Set} */ + _characters = new Set(); + get characters() { + return this._characters; } - /** @protected @type {Date} */ - _latestSocketReceived; - + /** + * @param {string} username + * @param {string} passwordHash + */ constructor(username, passwordHash) { this._username = username; + this._passwordHash = passwordHash; + + this.createdAt = new Date(); } - /** @param {WebSocket} websocket */ - clientConnected(websocket) { - this._websocket = websocket; - } - - /*** - * Send a message back to the client via the WebSocket. - * - * @param {string} message - * @return {boolean} success - */ - _send(data) { - if (!this._websocket) { - console.error( - "Trying to send a message to an uninitialized websocket", - this, - data, - ); - return false; - } - if (this._websocket.readyState === WebSocket.OPEN) { - this._websocket.send(JSON.stringify(data)); - return true; - } - if (this._websocket.readyState === WebSocket.CLOSED) { - console.error( - "Trying to send a message through a CLOSED websocket", - this, - data, - ); - return false; - } - if (this._websocket.readyState === WebSocket.CLOSING) { - console.error( - "Trying to send a message through a CLOSING websocket", - this, - data, - ); - return false; - } - if (this._websocket.readyState === WebSocket.CONNECTING) { - console.error( - "Trying to send a message through a CONNECTING (not yet open) websocket", - this, - data, - ); - return false; - } - - console.error( - "Trying to send a message through a websocket with an UNKNOWN readyState (%d)", - this.websocket.readyState, - this, - data, - ); - return false; - } - - sendPrompt() { - this.sendMessage(`\n[${this.currentRoom}] > `); + setPasswordHash(hashedPassword) { + this._passwordHash = hashedPassword; } } diff --git a/server/models/session.js b/server/models/session.js new file mode 100755 index 0000000..cc2d6de --- /dev/null +++ b/server/models/session.js @@ -0,0 +1,102 @@ +import WebSocket from 'ws'; +import { Game } from './game.js'; +import { Player } from './player.js'; +import { StateInterface } from './states/interface.js'; +import * as msg from '../utils/messages.js'; +import figlet from 'figlet'; + +export class Session { + + /** @protected @type {StateInterface} */ + _state; + get state() { + return this._state; + } + + /** @protected @type {Game} */ + _game; + get game() { + return this._game; + } + + /** @type Date */ + latestPing; + + /** @type {Player} */ + player; + + /** @type {WebSocket} */ + websocket; + + /** + * @param {WebSocket} websocket + * @param {Game} game + */ + constructor(websocket, game) { + this.websocket = websocket; + this._game = game; + } + + /** + * Send a message via our websocket. + * + * @param {string|number} messageType + * @param {...any} args + */ + send(messageType, ...args) { + this.websocket.send(JSON.stringify([messageType, ...args])); + } + + sendFigletMessage(message) { + console.debug("sendFigletMessage('%s')", message); + this.sendMessage(figlet.textSync(message), { preformatted: true }); + } + + /** @param {string} message Message to display to player */ + sendMessage(message, ...args) { + if (message.length === 0) { + console.debug("sending a zero-length message, weird"); + } + if (Array.isArray(message)) { + message = message.join("\n"); + } + this.send(msg.MESSAGE, message, ...args); + } + + /** + * @param {string} type prompt type (username, password, character name, etc.) + * @param {string} message The prompting message (please enter your character's name) + */ + sendPrompt(type, message,...args) { + if (Array.isArray(message)) { + message = message.join("\n"); + } + this.send(msg.PROMPT, type, message,...args); + } + + /** @param {string} message The error message to display to player */ + sendError(message,...args) { + this.send(msg.ERROR, message, ...args); + } + + /** @param {string} message The calamitous error to display to player */ + sendCalamity(message,...args) { + this.send(msg.CALAMITY, message, ...args); + } + + sendSystemMessage(arg0,...rest) { + this.send(msg.SYSTEM, arg0, ...rest); + } + + /** + * @param {StateInterface} state + */ + setState(state) { + this._state = state; + console.debug("changing state", state.constructor.name); + if (typeof state.onAttach === "function") { + state.onAttach(); + } + } + +} diff --git a/server/models/states/auth.js b/server/models/states/auth.js new file mode 100755 index 0000000..ed15d3e --- /dev/null +++ b/server/models/states/auth.js @@ -0,0 +1,158 @@ +import { Session } from "../session.js"; +import * as msg from "../../utils/messages.js"; +import * as security from "../../utils/security.js"; +import { JustLoggedInState } from "./justLoggedIn.js"; +import { CreatePlayerState } from "./createPlayer.js"; + +const STATE_EXPECT_USERNAME = "promptUsername"; +const STATE_EXPECT_PASSWORD = "promptPassword"; +const USERNAME_PROMPT = [ + "Please enter your username", + "((type *:help* for help))", + "((type *:create* if you want to create a new user))", +]; +const PASSWORD_PROMPT = "Please enter your password"; +const ERROR_INSANE_PASSWORD = "Invalid password."; +const ERROR_INSANE_USERNAME = "Username invalid, must be at 4-20 characters, and may only contain [a-z], [A-Z], [0-9] and underscore" +const ERROR_INCORRECT_PASSWOD = "Incorrect password."; + +/** @property {Session} session */ +export class AuthState { + + subState = STATE_EXPECT_USERNAME; + + /** + * @param {Session} session + */ + constructor(session) { + /** @type {Session} */ + this.session = session; + } + + onAttach() { + this.session.sendFigletMessage("M U U H D"); + + this.session.sendPrompt("username", USERNAME_PROMPT); + } + + /** @param {msg.ClientMessage} message */ + onMessage(message) { + if (this.subState === STATE_EXPECT_USERNAME) { + this.receiveUsername(message); + return; + } + + if (this.subState === STATE_EXPECT_PASSWORD) { + this.receivePassword(message) + return; + } + + console.error("Logic error, we received a message after we should have been logged in"); + this.session.sendError("I received a message didn't know what to do with!"); + } + + /** @param {msg.ClientMessage} message */ + receiveUsername(message) { + + // + // handle invalid message types + if (!message.isUsernameResponse()) { + this.session.sendError("Incorrect message type!"); + this.session.sendPrompt("username", USERNAME_PROMPT); + return; + } + + // + // Handle the creation of new players + if (message.username === ":create") { + // TODO: + // Set gamestate = CreateNewPlayer + // + // Also check if player creation is allowed in cfg/env + this.session.setState(new CreatePlayerState(this.session)); + return; + } + + // + // do basic syntax checks on usernames + if (!security.isUsernameSane(message.username)) { + this.session.sendError(ERROR_INSANE_USERNAME); + this.session.sendPrompt("username", USERNAME_PROMPT); + return; + } + + if (this.session.game.players.size === 0) { + console.error("there are no players registered"); + } + + const player = this.session.game.players.get(message.username); + + // + // handle invalid username + if (!player) { + + // + // This is a security risk. In the perfect world we would allow the player to enter both + // username and password before kicking them out, but since the player's username is not + // an email address, and we discourage from using “important” usernames, then we tell the + // player that they entered an invalid username right away. + // + // NOTE FOR ACCOUNT CREATION + // Do adult-word checks, so we dont have Fucky_McFuckFace + // https://www.npmjs.com/package/glin-profanity + this.session.sendError("Incorrect username, try again"); + this.session.sendPrompt("username", USERNAME_PROMPT); + return; + } + + + // + // username was correct, proceed to next step + this.session.player = player; + this.subState = STATE_EXPECT_PASSWORD; + this.session.sendPrompt("password", PASSWORD_PROMPT); + } + + /** @param {msg.ClientMessage} message */ + receivePassword(message) { + + // + // handle invalid message types + if (!message.isPasswordResponse()) { + this.session.sendError("Incorrect message type!"); + this.session.sendPrompt("password", PASSWORD_PROMPT); + return; + } + + // + // Check of the password is sane. This is both bad from a security point + // of view, and technically not necessary as insane passwords couldn't + // reside in the player lists. However, let's save some CPU cycles on + // not hashing an insane password 1000+ times. + // This is technically bad practice, but since this is just a game, + // do it anyway. + if (!security.isPasswordSane(message.password)) { + this.session.sendError(ERROR_INSANE_PASSWORD); + this.session.sendPrompt("password", PASSWORD_PROMPT); + return; + } + + // + // Verify the password against the hash we've stored. + if (!security.verifyPassword(message.password, this.session.player.passwordHash)) { + this.session.sendError("Incorrect password!"); + this.session.sendPrompt("password", PASSWORD_PROMPT); + return; + } + + // + // Password correct, check if player is an admin + if (this.session.player.isAdmin) { + // set state AdminJustLoggedIn + } + + // + // Password was correct, go to main game + this.session.setState(new JustLoggedInState(this.session)); + } +} diff --git a/server/models/states/awaitCommands.js b/server/models/states/awaitCommands.js new file mode 100644 index 0000000..896a6d1 --- /dev/null +++ b/server/models/states/awaitCommands.js @@ -0,0 +1,50 @@ +import * as msg from "../../utils/messages.js"; +import { Session } from "../session.js"; + +/** + * Main game state + * + * It's here we listen for player commands. + */ +export class AwaitCommandsState { + /** + * @param {Session} session + */ + constructor(session) { + /** @type {Session} */ + this.session = session; + } + + onAttach() { + console.log("Session is entering the “main” state"); + this.session.sendMessage("Welcome to the game!"); + } + + /** @param {msg.ClientMessage} message */ + onMessage(message) { + if (message.hasCommand()) { + this.handleCommand(message); + } + } + + /** @param {msg.ClientMessage} message */ + handleCommand(message) { + switch (message.command) { + case "help": + this.session.sendFigletMessage("HELP"); + this.session.sendMessage([ + "---------------------------------------", + " *:help* this help screen", + " *:quit* quit the game", + "---------------------------------------", + ]); + break; + case "quit": + this.session.sendMessage("The quitting quitter quits, typical... Cya"); + this.session.websocket.close(); + break; + default: + this.session.sendMessage(`Unknown command: ${message.command}`); + } + } +} diff --git a/server/models/states/characterCreation.js b/server/models/states/characterCreation.js new file mode 100755 index 0000000..a5f8354 --- /dev/null +++ b/server/models/states/characterCreation.js @@ -0,0 +1,105 @@ +import figlet from "figlet"; +import { Session } from "../session.js"; +import WebSocket from "ws"; +import { AuthState } from "./auth.js"; +import { ClientMessage } from "../../utils/messages.js"; +import { PARTY_MAX_SIZE } from "../../config.js"; +import { frameText } from "../../utils/tui.js"; + +export class CharacterCreationState { + + /** + * @proteted + * @type {(msg: ClientMessage) => } + * + * NOTE: Should this be a stack? + */ + _dynamicMessageHandler; + + /** + * @param {Session} session + */ + constructor(session) { + /** @type {Session} */ + this.session = session; + } + + /** + * We attach (and execute) the next state + */ + onAttach() { + const charCount = this.session.player.characters.size; + + const createPartyLogo = frameText( + figlet.textSync("Create Your Party"), + { hPadding: 2, vPadding: 1, hMargin: 2, vMargin: 1 }, + ); + + this.session.sendMessage(createPartyLogo, {preformatted:true}); + + this.session.sendMessage([ + "", + `Current party size: ${charCount}`, + `Max party size: ${PARTY_MAX_SIZE}`, + ]); + + const min = 1; + const max = PARTY_MAX_SIZE - charCount; + const prompt = `Please enter an integer between ${min} - ${max} (or type :help to get more info about party size)`; + + this.session.sendMessage(`You can create a party with ${min} - ${max} characters, how big should your party be?`); + this.session.sendPrompt("integer", prompt); + + /** @param {ClientMessage} message */ + this._dynamicMessageHandler = (message) => { + const n = PARTY_MAX_SIZE; + if (message.isHelpCommand()) { + this.session.sendMessage([ + `Your party can consist of 1 to ${n} characters.`, + "", + "* Large parties tend live longer", + `* If you have fewer than ${n} characters, you can`, + " hire extra characters in your local inn.", + "* large parties level slower because there are more", + " characters to share the Experience Points", + "* The individual members of small parties get better", + " loot because they don't have to share, but it", + " a lot of skill to accumulate loot as fast a larger", + " party can" + ]); + return; + } + if (!message.isIntegerResponse()) { + this.session.sendError("You didn't enter a number"); + this.session.sendPrompt("integer", prompt); + return; + } + + const numCharactersToCreate = message.integer; + + if (numCharactersToCreate > max) { + this.session.sendError("Number too high"); + this.session.sendPrompt("integer", prompt); + return; + } + + if (numCharactersToCreate < min) { + this.session.sendError("Number too low"); + this.session.sendPrompt("integer", prompt); + return; + } + + this.session.sendMessage(`Let's create ${numCharactersToCreate} character(s) for you :)`); + this._dynamicMessageHandler = undefined; + }; + } + + /** @param {ClientMessage} message */ + onMessage(message) { + if (this._dynamicMessageHandler) { + this._dynamicMessageHandler(message); + return; + } + this.session.sendMessage("pong", message.type); + } +} diff --git a/server/models/states/createPlayer.js b/server/models/states/createPlayer.js new file mode 100755 index 0000000..3c70aa2 --- /dev/null +++ b/server/models/states/createPlayer.js @@ -0,0 +1,167 @@ +import WebSocket from "ws"; +import { Session } from "../session.js"; +import * as msg from "../../utils/messages.js"; +import * as security from "../../utils/security.js"; +import { AuthState } from "./auth.js"; +import { Player } from "../player.js"; + +const USERNAME_PROMPT = "Enter a valid username (4-20 characters, [a-z], [A-Z], [0-9], and underscore)"; +const PASSWORD_PROMPT = "Enter a valid password"; +const PASSWORD_PROMPT2 = "Enter your password again"; +const ERROR_INSANE_PASSWORD = "Invalid password."; +const ERROR_INSANE_USERNAME = "Invalid username. It must be 4-20 characters, and may only contain [a-z], [A-Z], [0-9] and underscore" +const ERROR_INCORRECT_PASSWOD = "Incorrect password."; + +/** @property {Session} session */ +export class CreatePlayerState { + + /** + * @proteted + * @type {(msg: ClientMessage) => } + * + * Allows us to dynamically set which + * method handles incoming messages. + */ + _dynamicMessageHandler; + + /** @protected @type {Player} */ + _player; + /** @protected @type {string} */ + _password; + + /** + * @param {Session} session + */ + constructor(session) { + /** @type {Session} */ + this.session = session; + } + + onAttach() { + this.session.sendFigletMessage("New Player"); + this.session.sendPrompt("username", USERNAME_PROMPT); + + // our initial substate is to receive a username + this.setMessageHandler(this.receiveUsername); + } + + /** @param {msg.ClientMessage} message */ + onMessage(message) { + this._dynamicMessageHandler(message); + } + + /* @param {(msg: ClientMessage) => } handler */ + setMessageHandler(handler) { + this._dynamicMessageHandler = handler; + } + + /** @param {msg.ClientMessage} message */ + receiveUsername(message) { + + // + // NOTE FOR ACCOUNT CREATION + // Do adult-word checks, so we dont have Fucky_McFuckFace + // https://www.npmjs.com/package/glin-profanity + + // + // handle invalid message types + if (!message.isUsernameResponse()) { + this.session.sendError("Incorrect message type!"); + this.session.sendPrompt("username", USERNAME_PROMPT); + return; + } + + // + // do basic syntax checks on usernames + if (!security.isUsernameSane(message.username)) { + this.session.sendError(ERROR_INSANE_USERNAME); + this.session.sendPrompt("username", USERNAME_PROMPT); + return; + } + + const taken = this.session.game.players.has(message.username); + + // + // handle taken/occupied username + if (taken) { + // Telling the user right away that the username is taken can + // lead to data leeching. But fukkit. + + this.session.sendError(`Username _${message.username}_ was taken by another player.`); + this.session.sendPrompt("username", USERNAME_PROMPT); + return; + } + + this._player = new Player(message.username, undefined); + + // _____ _ + // | ___(_)_ ___ __ ___ ___ + // | |_ | \ \/ / '_ ` _ \ / _ \ + // | _| | |> <| | | | | | __/ + // |_| |_/_/\_\_| |_| |_|\___| + // + // 1. Add mutex on the players table to avoid race conditions + // 2. Prune "dead" players (players with 0 logins) after a short while + this.session.game.players.set(message.username, this._player); + this.session.sendMessage("Username available"); + this.session.sendMessage(`Username _*${message.username}*_ is available, and I've reserved it for you :)`); + this.session.sendPrompt("password", PASSWORD_PROMPT); + this.setMessageHandler(this.receivePassword); + } + + /** @param {msg.ClientMessage} message */ + receivePassword(message) { + + // + // handle invalid message types + if (!message.isPasswordResponse()) { + console.log("Invalid message type, expected password reply", message); + this.session.sendError("Incorrect message type!"); + this.session.sendPrompt("password", PASSWORD_PROMPT); + return; + } + + // + // Check that it's been hashed thoroughly before being sent here. + if (!security.isPasswordSane(message.password)) { + this.session.sendError(ERROR_INSANE_PASSWORD); + this.session.sendPrompt("password", PASSWORD_PROMPT); + return; + } + + this._password = message.password; // it's relatively safe to store the PW here temporarily. The client already hashed the hell out of it. + this.session.sendPrompt("password", PASSWORD_PROMPT2); + + this.setMessageHandler(this.receivePasswordConfirmation); + } + + /** @param {msg.ClientMessage} memssage */ + receivePasswordConfirmation(message) { + + // + // handle invalid message types + if (!message.isPasswordResponse()) { + console.log("Invalid message type, expected password reply", message); + this.session.sendError("Incorrect message type!"); + this.session.sendPrompt("password", PASSWORD_PROMPT); + this.setMessageHandler(this.receivePassword); + return; + } + + // + // Handle mismatching passwords + if (message.password !== this._password) { + this.session.sendError("Incorrect, you have to enter your password twice in a row successfully"); + this.session.sendPrompt("password", PASSWORD_PROMPT); + this.setMessageHandler(this.receivePassword); + return; + } + + // + // Success! + // Take the user to the login screen. + this.session.sendMessage("*_Success_* ✅ You will now be asked to log in again, sorry for that ;)"); + this._player.setPasswordHash(security.generateHash(this._password)); + this.session.setState(new AuthState(this.session)); + } +} diff --git a/server/models/states/interface.js b/server/models/states/interface.js new file mode 100755 index 0000000..5f976bc --- /dev/null +++ b/server/models/states/interface.js @@ -0,0 +1,13 @@ +import { ClientMessage } from "../../utils/messages.js"; +import { Session } from "../session.js"; + +/** @interface */ +export class StateInterface { + /** @param {Session} session */ + constructor(session) { } + + onAttach() { } + + /** @param {ClientMessage} message */ + onMessage(message) {} +} diff --git a/server/models/states/justLoggedIn.js b/server/models/states/justLoggedIn.js new file mode 100755 index 0000000..3b69696 --- /dev/null +++ b/server/models/states/justLoggedIn.js @@ -0,0 +1,36 @@ +import { Session } from "../session.js"; +import { ClientMessage } from "../../utils/messages.js"; +import { CharacterCreationState } from "./characterCreation.js"; +import { AwaitCommandsState } from "./awaitCommands.js"; + +/** @interface */ +export class JustLoggedInState { + /** @param {Session} session */ + constructor(session) { + /** @type {Session} */ + this.session = session; + } + + // Show welcome screen + onAttach() { + this.session.sendMessage([ + "", + "Welcome", + "", + "You can type “:quit” at any time to quit the game", + "", + ]); + + + + // + // Check if we need to create characters for the player + if (this.session.player.characters.size === 0) { + this.session.sendMessage("You haven't got any characters, so let's make some\n\n"); + this.session.setState(new CharacterCreationState(this.session)); + return; + } + + this.session.setState(new AwaitCommandsState(this.session)); + } +} diff --git a/server/package-lock.json b/server/package-lock.json index 76db030..af2f548 100755 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "figlet": "^1.8.2", "ws": "^8.14.2" }, "devDependencies": { @@ -127,6 +128,18 @@ } } }, + "node_modules/figlet": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.2.tgz", + "integrity": "sha512-iPCpE9B/rOcjewIzDnagP9F2eySzGeHReX8WlrZQJkqFBk2wvq8gY0c6U6Hd2y9HnX1LQcYSeP7aEHoPt6sVKQ==", + "license": "MIT", + "bin": { + "figlet": "bin/index.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/server/package.json b/server/package.json index 4d6c7d6..c06bbc4 100755 --- a/server/package.json +++ b/server/package.json @@ -5,8 +5,8 @@ "main": "server.js", "type": "module", "scripts": { - "start": "node server.js", - "dev": "nodemon server.js" + "start": "NODE_ENV=prod node server.js", + "dev": "NODE_ENV=dev nodemon server.js" }, "keywords": [ "mud", @@ -17,6 +17,7 @@ "author": "Your Name", "license": "MIT", "dependencies": { + "figlet": "^1.8.2", "ws": "^8.14.2" }, "devDependencies": { diff --git a/server/public/client.js b/server/public/client.js new file mode 100755 index 0000000..02f74bb --- /dev/null +++ b/server/public/client.js @@ -0,0 +1,330 @@ +class MUDClient { + + constructor() { + /** @type {WebSocket} ws */ + this.websocket = null; + + /** @type {boolean} Are we in development mode (decided by the server); + this.dev = false; + + /** + * The last thing we were asked. + * @type {string|null} + */ + this.serverExpects = null; + this.output = document.getElementById("output"); + this.input = document.getElementById("input"); + this.sendButton = document.getElementById("send"); + this.status = document.getElementById("status"); + + // Passwords are crypted and salted before being sent to the server + // This means that if ANY of these three parameters below change, + // The server can no longer accept the passwords. + this.digest = "SHA-256"; + this.salt = "V1_Kims_Krappy_Krypto"; + this.rounds = 1000; + + this.username = ""; // the username also salts the password, so the username must never change. + + this.setupEventListeners(); + this.connect(); + } + async hashPassword(password) { + const encoder = new TextEncoder(); + let data = encoder.encode(password + this.salt + this.username); + + for (let i = 0; i < this.rounds; i++) { + const hashBuffer = await crypto.subtle.digest(this.digest, data); + data = new Uint8Array(hashBuffer); // feed hash back in + } + + // Convert final hash to hex + const rawHash = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''); + + return `${this.salt}:${this.rounds}:${this.digest}:${rawHash}`; + } + + connect() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}`; + + this.updateStatus("Connecting...", "connecting"); + + try { + this.websocket = new WebSocket(wsUrl); + + this.websocket.onopen = () => { + this.updateStatus("Connected", "connected"); + this.input.disabled = false; + this.sendButton.disabled = false; + this.input.focus(); + this.output.innerHTML = ''; + }; + + this.websocket.onmessage = (event) => { + console.log(event); + const data = JSON.parse(event.data); + this.onMessage(data); + this.input.focus(); + }; + + this.websocket.onclose = () => { + this.updateStatus("Disconnected", "disconnected"); + this.input.disabled = true; + this.sendButton.disabled = true; + + // Attempt to reconnect after 3 seconds + setTimeout(() => this.connect(), 3000); + }; + + this.websocket.onerror = (error) => { + this.updateStatus("Connection Error", "error"); + this.appendOutput("Connection error occurred. Retrying...", { class: "error" }); + }; + } catch (error) { + console.error(error); + this.updateStatus("Connection Failed", "error"); + setTimeout(() => this.connect(), 3000); + } + } + + setupEventListeners() { + this.input.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + this.sendMessage(); + } + }); + + this.sendButton.addEventListener("click", () => { + this.sendMessage(); + }); + + // Command history + this.commandHistory = []; + this.historyIndex = -1; + + this.input.addEventListener("keydown", (e) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + if (this.historyIndex < this.commandHistory.length - 1) { + this.historyIndex++; + this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex]; + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (this.historyIndex > 0) { + this.historyIndex--; + this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex]; + } else if (this.historyIndex === 0) { + this.historyIndex = -1; + this.input.value = ""; + } + } + }); + } + + sendMessage() { + const message = this.input.value.trim(); + + // -- This is a sneaky command that should not be in production? + // + // In reality we want to use :clear, nor /clear + // :clear would be sent to the server, and we ask if it's okay + // to clear the screen right now, and only on a positive answer would we + // allow the screen to be cleared. Maybe..... + if (message === "/clear") { + this.output.innerHTML = ""; + this.input.value = ""; + return; + } + + if (message && this.websocket && this.websocket.readyState === WebSocket.OPEN) { + // Add to command history + if (this.commandHistory[this.commandHistory.length - 1] !== message) { + this.commandHistory.push(message); + if (this.commandHistory.length > 50) { + this.commandHistory.shift(); + } + } + this.historyIndex = -1; + this.input.value = ""; + this.input.type = "text"; + + if (this.serverExpects === "password") { + //-------------------------------------------------- + // The server asked us for a password, so we send it. + // But we hash it first, so we don't send our stuff + // in the clear. + //-------------------------------------------------- + this.hashPassword(message).then((pwHash) => { + this.websocket.send(JSON.stringify(["reply", "password", pwHash])) + this.serverExpects = null; + }); + return; + } + + this.appendOutput("> " + message, { class: "input" }); + + if (message === ":quit") { + this.websocket.send(JSON.stringify(["quit"])); + return; + } + if (message === ":help") { + this.websocket.send(JSON.stringify(["help"])); + return; + } + + if (this.serverExpects === "username") { + //-------------------------------------------------- + // The server asked us for a user, so we send it. + // We also store the username for later + //-------------------------------------------------- + this.username = message; + this.websocket.send(JSON.stringify(["reply", "username", message])) + this.serverExpects = null; + return; + + } + + if (this.serverExpects) { + //-------------------------------------------------- + // The server asked the player a question, + // so we send the answer the way the server wants. + //-------------------------------------------------- + this.websocket.send(JSON.stringify(["reply", this.serverExpects, message])) + this.serverExpects = null; + return; + } + + // + //----------------------------------------------------- + // The player sends a text-based command to the server + //----------------------------------------------------- + this.websocket.send(JSON.stringify(["c", message])); + + } + } + + // ___ __ __ + // / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___ + // | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \ + // | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/ + // \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___| + // + /** @param {any[]} data*/ + onMessage(data) { + console.log(data); + switch (data[0]) { + case "prompt": + this.serverExpects = data[1]; + this.appendOutput(data[2], { class: "prompt" }); + if (this.serverExpects === "password") { + this.input.type = "password"; + } + break; + case "e": // error + this.appendOutput(data[1], { class: "error" }); + break; + case "calamity": + this.appendOutput(data[1], { class: "error" }); + break; + case "_": // system messages, not to be displayed + if (data.length === 3 && data[1] === "dev") { + this.dev = data[2]; + } + + if (this.dev) { + this.appendOutput(`system message: ${data[1]} = ${JSON.stringify(data[2])}`, { class: "debug" }); + } + break; + case "m": + // normal text message to be shown to the player + // formatting magic is allowed. + // + // TODO: styling, font size, etc. + const args = typeof (data[2] === "object") ? data[2] : {}; + this.appendOutput(data[1], args); + break; + + this.appendOutput(data[1], {preformatted:true}) + default: + if (this.dev) { + msgType = data.shift(); + this.appendOutput(`unknown message type: ${msgType}: ${JSON.stringify(data)}`, "debug"); + } + console.log("unknown message type", data); + } + } + + /** + * Add output to the text. + * @param {string} text + * @param {object} options + */ + appendOutput(text, options = {}) { + const el = document.createElement("span"); + + if (typeof options.class === "string") { + el.className = options.class; + } + + + // Enter prompt answers on the same line as the prompt? + // if (className !== "prompt") { + // el.textContent = text + "\n"; + // } else { + // el.textContent = text + " "; + // } + + // add end of line character "\n" unless + // options.addEol = false is set explicitly + const eol = options.addEol === false ? "" : "\n"; + + if (options.preformatted) { + el.textContent = text + eol; + } else { + el.innerHTML = parseCrackdown(text) + eol; + } + this.output.appendChild(el); + this.output.scrollTop = this.output.scrollHeight; + } + + updateStatus(message, className) { + this.status.textContent = `Status: ${message}`; + this.status.className = className; + } +} + +// Initialize the MUD client when the page loads +document.addEventListener("DOMContentLoaded", () => { + new MUDClient(); +}); + +function parseCrackdown(text) { + console.log("starting crack parsing"); + console.log(text); + return text.replace(/[&<>"'`]/g, (c) => { + switch (c) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case '\'': return '''; + case '`': return '`'; + default: return c; + } + }) + .replace(/---(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])---/g, '$1') // line-through + .replace(/___(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])___/g, '$1') // underline + .replace(/_(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])_/g, '$1') // italic + .replace(/\*(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\*/g, '$1') // bold + .replace(/\.{3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\.{3}/g, '$1') // undercurl + .replace(/\({3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){3}/g, '($1)') // faint with parentheses + .replace(/\({2}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){2}/g, '$1') // faint with parentheses + ; + + console.log("crack output", text); + + return text; + +} diff --git a/server/public/index.html b/server/public/index.html index 95d738f..4dce047 100755 --- a/server/public/index.html +++ b/server/public/index.html @@ -4,201 +4,20 @@ WebSocket MUD - + + +
Connecting...
- +
- + diff --git a/server/public/style.css b/server/public/style.css old mode 100644 new mode 100755 index 360d464..26c70f9 --- a/server/public/style.css +++ b/server/public/style.css @@ -1,21 +1,29 @@ +@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap'); + body { - font-family: "Courier New", monospace; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; + font-size: 14px; background-color: #1a1a1a; color: #00ff00; margin: 0; padding: 0; height: 100vh; + width: 100vw; + display: flex; flex-direction: column; + overflow: hidden; } #container { display: flex; flex-direction: column; height: 100vh; - max-width: 1200px; + max-width: 99.9vw; margin: 0 auto; padding: 10px; + overflow: hidden; } #output { @@ -25,9 +33,12 @@ body { padding: 15px; overflow-y: auto; white-space: pre-wrap; - font-size: 14px; line-height: 1.4; - margin-bottom: 10px; + margin-bottom: 20px; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; + font-size: 14px; + width: 100ch; } #input-container { @@ -41,7 +52,8 @@ body { border: 2px solid #333; color: #00ff00; padding: 10px; - font-family: "Courier New", monospace; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; font-size: 14px; } @@ -55,7 +67,8 @@ body { border: 2px solid #555; color: #00ff00; padding: 10px 20px; - font-family: "Courier New", monospace; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; cursor: pointer; } @@ -86,10 +99,38 @@ body { color: #ff4444; } -.system { - color: #aaaaaa; +.input { + color: #666; +} + +.debug { + opacity: 0.33; } .prompt { color: #00ccff; } + +.bold { + font-weight: bold; +} + +.italic { + font-style: italic; +} + +.strike { + text-decoration:line-through; +} + +.underline { + text-decoration: underline; +} + +.undercurl { + text-decoration: wavy underline lime; +} + +.faint { + opacity: 0.42; +} diff --git a/server/seed.js b/server/seed.js new file mode 100755 index 0000000..9ac7fe5 --- /dev/null +++ b/server/seed.js @@ -0,0 +1,37 @@ +import { Game } from "./models/game.js"; +import { Player } from "./models/player.js"; + +// ____ _____ _____ ____ _____ ____ +// / ___|| ____| ____| _ \| ____| _ \ +// \___ \| _| | _| | | | | _| | |_) | +// ___) | |___| |___| |_| | |___| _ < +// |____/|_____|_____|____/|_____|_| \_\ +// +/** @param {Game} game */ +export class Seeder { + seed(game) { + /** @protected @type {Game} */ + this.game = game; + + this.createPlayers(); + } + + /** @protected */ + createPlayers() { + // "pass" encrypted by client is: + // "V1_Kims_Krappy_Krypto:1000:SHA-256:8bdff92251f55df078f7a12446748fbeeb308991008096bf2eed3fd8926d0301" + // "pass" encrypted by client and then by server is: + // "1000:833d63b13a187a0d8950c83ad6d955b9:4bdc9981dd245e7c77949e0166094264f98c62ae9f4f5ebbcda50728bbb8b080" + // + // Since the server-side hashes have random salts, the hashes themselves can change for the same password. + // The client side hash must not have a random salt, otherwise, it must change every time. + // + // The hash below is just one has that represents the password "pass" sent via V1 of the "Kims Krappy Krypto" scheme. + // + const passwordHash = "1000:bdaa0d7436caeaa4d278e7591870b68c:151b8f7e73a97a01af190a51b45ee389c2f4590a6449ddae6f25b9eab49cac0d"; + const player = new Player("user", passwordHash); + this.game.players.set("user", player); + + // const char = new Character(player.username, "Sir Debug The Strong", true); + } +} diff --git a/server/server.js b/server/server.js index 78082a0..acbd170 100755 --- a/server/server.js +++ b/server/server.js @@ -2,255 +2,183 @@ import WebSocket, { WebSocketServer } from "ws"; import http from "http"; import path from "path"; import fs from "fs"; -import { Player } from "./models/player.js"; import { Game } from "./models/game.js"; -import { ClientMessage, MSG_ERROR, MSG_MESSAGE, MSG_PROMPT, MSG_CALAMITY, } from "./utils/messages.js"; - -class Session { - /** @type {boolean} */ - usernameProcessed = false; - - /** @type {boolean} */ - passwordProcessed = false; - - /** @type {boolean} */ - ready = false; - - /** @type Date */ - latestPing; - - /** @type {Player} */ - player; -} +import * as msg from "./utils/messages.js"; +import * as cfg from "./utils/config.js"; +import { Session } from "./models/session.js"; +import { Seeder } from "./seed.js"; +import { AuthState } from "./models/states/auth.js"; class MudServer { - /** @type {Map} */ - sessions = new Map(); - /** @type {Game} */ - game = new Game(); - - /** - * Send a message via a websocket. - * - * @param {WebSocket} websocket - * @param {string|number} messageType - * @param {...any} args - */ - send(websocket, messageType, ...args) { - // create array consisting of [messageType, args[0], args[1], ... ]; - websocket.send(JSON.stringify([messageType, ...args])); + constructor() { + /** @type {Game} */ + this.game = new Game(); + if (cfg.DEV) { + (new Seeder()).seed(this.game); + } } - /** - * @param {WebSocket} websocket - */ + // ____ ___ _ _ _ _ _____ ____ _____ _____ ____ + // / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \ + // | | | | | | \| | \| | _|| | | | | _| | | | | + // | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| | + // \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/ + //------------------------------------------------------ + // Handle New Socket Connections + //------------------------------ + /** @param {WebSocket} websocket */ onConnectionEstabished(websocket) { console.log("New connection established"); - this.sessions[websocket] = new Session(); + const session = new Session(websocket, this.game); + session.sendSystemMessage("dev", true) - websocket.on("message", (data) => { - this.onIncomingMessage(websocket, data); - }); + // ____ _ ___ ____ _____ + // / ___| | / _ \/ ___|| ____| + // | | | | | | | \___ \| _| + // | |___| |__| |_| |___) | |___ + // \____|_____\___/|____/|_____| + //------------------------------- + // Handle Socket Closing + //---------------------- websocket.on("close", () => { - this.onConnectionClosed(websocket); + if (!session.player) { + console.info("A player without a session disconnected"); + return; + } + //------------- + // TODO + //------------- + // Handle player logout (move the or hide their characters) + // + // Maybe session.onConnectionClosed() that calls session._state.onConnectionClosed() + // Maybe this.setState(new ConnectionClosedState()); + // Maybe both ?? + console.log(`Player ${session.player.username} disconnected`); + }); - this.send(websocket, MSG_MESSAGE, "Welcome to MUUUHD", "big"); - this.send(websocket, MSG_PROMPT, "Please enter your username"); + // __ __ _____ ____ ____ _ ____ _____ + // | \/ | ____/ ___/ ___| / \ / ___| ____| + // | |\/| | _| \___ \___ \ / _ \| | _| _| + // | | | | |___ ___) |__) / ___ \ |_| | |___ + // |_| |_|_____|____/____/_/ \_\____|_____| + //-------------------------------------------- + // HANDLE INCOMING MESSAGES + //------------------------- + websocket.on("message", (data) => { + try { + console.debug("incoming websocket message %s", data); + + if (!session.state) { + console.error("we received a message, but don't even have a state. Zark!"); + websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?")); + return; + } + + const msgObj = new msg.ClientMessage(data.toString()); + + if (msgObj.isQuitCommand()) { + //--------------------- + // TODO TODO TODO TODO + //--------------------- + // Set state = QuitState + // + websocket.send(msg.prepare(msg.MESSAGE, "The quitting quitter quits... Typical. Cya!")); + websocket.close(); + return; + } + + if (typeof session.state.onMessage !== "function") { + console.error("we received a message, but we're not i a State to receive it"); + websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do with that message.")); + return; + } + session.state.onMessage(msgObj); + } catch (error) { + console.trace("received an invalid message (error: %s)", error, data.toString(), data); + websocket.send(msg.prepare( + msg.CALAMITY, + error + )); + } + }); + + session.setState(new AuthState(session)); } - /** - * @param {WebSocket} websocket - * @param {strings} data - */ - onIncomingMessage(websocket, data) { - const session = this.sessions.get(websocket); - - if (!session) { - console.error( - "Incoming message from a client without a session!", - data, - ); - this.send( - websocket, - MSG_ERROR, - "terminal", - "You do not have an active session. Go away!", - ); - websocket.close(); - return; - } - - let message; - - try { - message = new ClientMessage(data); - } catch (error) { - console.error("Bad websocket message", data, error); - this.send( - websocket, - MSG_ERROR, - "terminal", - "You sent me a bad message! Goodbye...", - ); - websocket.close(); - return; - } - - if (!session.usernameProcessed) { - // - //---------------------------------------------------- - // We haven"t gotten a username yet, so we expect one. - //---------------------------------------------------- - if (!message.hasUsername()) { - console.error("User should have sent a “username” message, but sent something else instead"); - this.send(websocket, MSG_CALAMITY, "I expected you to send me a username, but you sent me something else instead. You bad! Goodbye..."); - - // for now, just close the socket. - websocket.close(); - } - - const player = this.game.players.get(message.username); - - if (!player) { - //---------------------------------------------------- - // Invalid Username. - //---------------------------------------------------- - console.log("Invalid username sent during login: %s", username); - this.send(websocket, MSG_ERROR, "Invalid username"); - this.send( - websocket, - MSG_PROMPT, - "Please enter a valid username", - ); - } - - // correct username, tentatively assign player to session - // even though we have not yet validated the password. - session.player = player; - session.usernameProcessed = true; - this.send(websocket, MSG_MESSAGE, "Username received"); - this.send(websocket, MSG_PROMPT, "Enter your password"); - - return; - } + // ____ _____ _ ____ _____ + // / ___|_ _|/ \ | _ \_ _| + // \___ \ | | / _ \ | |_) || | + // ___) || |/ ___ \| _ < | | + // |____/ |_/_/ \_\_| \_\|_| + //----------------------------- + // Start the server + //----------------- + start() { // - //---------------------------------------------------- - // The player has entered a valid username, now expect - // a password. - //---------------------------------------------------- - if (!session.passwordProcessed) { - if (!message.hasPassword) { - console.error( - "Youser should have sent a “password” message, but sent this instead: %s", - message.type, - ); - } - } + // The file types we allow to be served. + const contentTypes = { + ".js": "application/javascript", + ".css": "text/css", + ".html": "text/html", + }; // - //---------------------------------------------------- - // Process the player's commands - //---------------------------------------------------- - if (message.isCommand()) { - // switch case for commands. - return; - } - - console.error( - "We have received a message we couldn't handle!!!", - message, - ); - } - - /** - * - * @param {WebSocket} websocket - * @param {string} name - */ - createPlayer(websocket, name) { - const player = new Player(name, websocket); - this.players.set(websocket, player); - this.players.set(name, player); - - const startRoom = this.rooms.get("town_square"); - startRoom.addPlayer(player); - - player.sendMessage(`Welcome, ${name}! You have entered the world.`); - this.showRoom(player); - } - - /** - * Called when a websocket connection is closing. - * - * @param {WebSocket} websocket - */ - onConnectionClosed(websocket) { - const session = this.sessions.get(websocket); - - if (session && session.player) { - console.log(`Player ${player.username} disconnected`); + // Create HTTP server for serving the client - Consider moving to own file + const httpServer = http.createServer((req, res) => { + let filePath = path.join("public", req.url === "/" ? "index.html" : req.url); + const ext = path.extname(filePath); + const contentType = contentTypes[ext]; // - // Handle player logout (move the or hide their characters) - // this.game.playerLoggedOut(); - } else { - console.log("A player without a session disconnected"); - } + // Check if the requested file has a legal file type. + if (!contentType) { + // Invalid file, pretend it did not exist! + res.writeHead(404); + res.end(`File not found`); + console.log("Bad http request", req.url); + return; + } - this.sessions.delete(websocket); + + // + // Check if the file exists. + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end(`File not found`); + console.log("Bad http request", req.url); + return; + } + res.writeHead(200, { "Content-Type": contentType }); + res.end(data); + }); + }); + + // + // Create WebSocket server + const websocketServer = new WebSocketServer({ server: httpServer }); + + websocketServer.on("connection", (ws) => { + this.onConnectionEstabished(ws); + }); + + console.info(`running in ${cfg.ENV} mode`); + httpServer.listen(cfg.PORT, () => { + console.log(`NUUHD server running on port ${cfg.PORT}`); + console.log(`WebSocket server ready for connections`); + }); } } -// Create HTTP server for serving the client -const httpServer = http.createServer((req, res) => { - // let filePath = path.join(__dirname, "public", req.url === "/" ? "index.html" : req.url); - let filePath = path.join( - "public", - req.url === "/" ? "index.html" : req.url, - ); - const ext = path.extname(filePath); - - const contentTypes = { - ".js": "application/javascript", - ".css": "text/css", - ".html": "text/html", - }; - - if (!contentTypes[ext]) { - // Invalid file, pretend it did not exist! - res.writeHead(404); - res.end(`File ${filePath} not found (invalid $ext)`); - return; - } - - const contentType = contentTypes[ext]; - - fs.readFile(filePath, (err, data) => { - if (err) { - res.writeHead(404); - res.end(`File ${filePath} . ${ext} not found (${err})`); - return; - } - res.writeHead(200, { "Content-Type": contentType }); - res.end(data); - }); -}); - -// Create WebSocket server -const websocketServer = new WebSocketServer({ server: httpServer }); -const mudServer = new MudServer(); - -websocketServer.on("connection", (ws) => { - mudServer.onConnectionEstabished(ws); -}); - -// websocketServer.on("connection", mudServer.onConnectionEstabished); - -const PORT = process.env.PORT || 3000; -httpServer.listen(PORT, () => { - console.log(`MUD server running on port ${PORT}`); - console.log(`WebSocket server ready for connections`); -}); +// __ __ _ ___ _ _ +// | \/ | / \ |_ _| \ | | +// | |\/| | / _ \ | || \| | +// | | | |/ ___ \ | || |\ | +// |_| |_/_/ \_\___|_| \_| +//--------------------------- +// Code entry point +//----------------- +(new MudServer()).start(); diff --git a/server/tui.md b/server/tui.md new file mode 100644 index 0000000..766bde8 --- /dev/null +++ b/server/tui.md @@ -0,0 +1,169 @@ +Here are some ASCII and UTF-8 characters commonly used for "shading" effects in text art or terminal displays. These characters provide varying levels of density or shading: + +### ASCII Shading Characters +These are basic ASCII characters often used for shading: +``` +Light shade: ░ (U+2591) +Medium shade: ▒ (U+2592) +Dark shade: ▓ (U+2593) +Full block: █ (U+2588) +Half block: ▄ (U+2584), ▀ (U+2580) +``` + +### Additional UTF-8 Block Characters +These Unicode characters offer more granular shading or block patterns: +``` +Light block: ░ (U+2591) +Medium block: ▒ (U+2592) +Dark block: ▓ (U+2593) +Solid block: █ (U+2588) +Upper half block: ▀ (U+2580) +Lower half block: ▄ (U+2584) +Left half block: ▌ (U+258C) +Right half block: ▐ (U+2590) +Quadrant blocks: ▖ (U+2596), ▗ (U+2597), ▘ (U+2598), ▝ (U+259D) +Checkerboard: ▚ (U+259A), ▞ (U+259E) +``` + +### Example Usage +Here’s an example of a simple shading gradient using some of these characters: +``` +Light to Dark: ░ ▒ ▓ █ +Half blocks: ▀ ▄ ▌ ▐ +Quadrant pattern: ▖ ▗ ▘ ▝ +``` + +### Notes +- Not all terminals or text editors display Unicode characters consistently, so ASCII characters like `.:;#+` are sometimes used for basic shading in simpler environments. +- If you want to create specific patterns or need more complex ASCII art, let me know, and I can generate or suggest more detailed designs! +- If you meant something specific by "shading" (e.g., for a particular programming context or art style), please clarify, and I’ll tailor the response further. + + + + Below is a collection of ASCII and UTF-8 characters suitable for creating frames, borders, or "windows" in a text-based user interface (TUI), such as those built with ncurses. These characters can be used to draw boxes, lines, and corners to simulate window-like structures in a terminal. + +### ASCII Characters for Frames +These are basic ASCII characters that work universally in most terminals: +``` +Horizontal line: - (U+002D) +Vertical line: | (U+007C) +Cross/intersection: + (U+002B) +Corners: + Top-left: + (U+002B) or ` + Top-right: + (U+002B) or ' + Bottom-left: + (U+002B) or , + Bottom-right: + (U+002B) or . +``` + +Example simple ASCII window: +``` ++----------+ +| Content | ++----------+ +``` + +### UTF-8 Box-Drawing Characters +Unicode provides a dedicated **Box Drawing** block (U+2500–U+257F) for creating more refined frames. These are widely supported in modern terminals and ncurses: + +#### Single-Line Box Drawing +``` +Horizontal line: ─ (U+2500) +Vertical line: │ (U+2502) +Corners: + Top-left: ┌ (U+250C) + Top-right: ┐ (U+2510) + Bottom-left: └ (U+2514) + Bottom-right: ┘ (U+2518) +Intersections: + T-junction top: ┬ (U+252C) + T-junction bottom: ┴ (U+2534) + T-junction left: ├ (U+251C) + T-junction right: ┤ (U+2524) + Cross: ┼ (U+253C) +``` + +Example single-line window: +``` +┌──────────┐ +│ Content │ +└──────────┘ +``` + +#### Double-Line Box Drawing +``` +Horizontal line: ═ (U+2550) +Vertical line: ║ (U+2551) +Corners: + Top-left: ╔ (U+2554) + Top-right: ╗ (U+2557) + Bottom-left: ╚ (U+255A) + Bottom-right: ╝ (U+255D) +Intersections: + T-junction top: ╦ (U+2566) + T-junction bottom: ╩ (U+2569) + T-junction left: ╠ (U+2560) + T-junction right: ╣ (U+2563) + Cross: ╬ (U+256C) +``` + +Example double-line window: +``` +╔══════════╗ +║ Content ║ +╚══════════╝ +``` + +#### Mixed and Other Box-Drawing Characters +For more complex designs, you can mix single and double lines or use specialized characters: +``` +Single to double transitions: + Horizontal single to double: ╼ (U+257C) + Vertical single to double: ╽ (U+257D) +Rounded corners (less common, not always supported): + Top-left: ╭ (U+256D) + Top-right: ╮ (U+256E) + Bottom-left: ╰ (U+2570) + Bottom-right: ╯ (U+256F) +``` + +Example with rounded corners: +``` +╭──────────╮ +│ Content │ +╰──────────╯ +``` + +### Additional UTF-8 Characters for Decoration +These can enhance the appearance of your TUI: +``` +Block elements for borders or shading: + Full block: █ (U+2588) + Half blocks: ▀ (U+2580), ▄ (U+2584), ▌ (U+258C), ▐ (U+2590) +Light shade for background: ░ (U+2591) +Medium shade: ▒ (U+2592) +Dark shade: ▓ (U+2593) +``` + +### Example TUI Window with Content +Here’s a sample of a more complex window using single-line box-drawing characters: +``` +┌────────────────────┐ +│ My TUI Window │ +├────────────────────┤ +│ Item 1 [ OK ] │ +│ Item 2 [Cancel] │ +└────────────────────┘ +``` + +### Notes for ncurses +- **ncurses Compatibility**: ncurses supports both ASCII and UTF-8 box-drawing characters, but you must ensure the terminal supports Unicode (e.g., `LANG=en_US.UTF-8` environment variable). Use `initscr()` and `start_color()` in ncurses to handle rendering. +- **Terminal Support**: Some older terminals may not render UTF-8 characters correctly. Test your TUI in the target environment (e.g., xterm, gnome-terminal, or Alacritty). +- **Fallback**: If Unicode support is unreliable, stick to ASCII (`-`, `|`, `+`) for maximum compatibility. +- **ncurses Functions**: Use `box()` in ncurses to draw a border around a window automatically, or manually print characters with `mvaddch()` for custom designs. + +### Tips +- Combine single and double lines for visual hierarchy (e.g., double lines for outer windows, single lines for inner sections). +- If you need specific examples (e.g., a multi-window layout or a dialog box), let me know, and I can provide a detailed ASCII/UTF-8 mockup or even pseudocode for ncurses. +- If you want a particular style (e.g., heavy lines, dashed lines, or specific layouts), please clarify, and I’ll tailor the response. + +Let me know if you need help implementing this in ncurses or want more specific frame designs! diff --git a/server/utils/config.js b/server/utils/config.js new file mode 100644 index 0000000..07a492f --- /dev/null +++ b/server/utils/config.js @@ -0,0 +1,4 @@ +export const ENV = process.env.NODE_ENV || "prod"; +export const DEV = ENV === "dev"; +export const PROD =!DEV; +export const PORT = process.env.PORT || 3000; diff --git a/server/utils/messages.js b/server/utils/messages.js index 8ac3dbd..6818a97 100755 --- a/server/utils/messages.js +++ b/server/utils/messages.js @@ -5,45 +5,73 @@ * or * Server-->Client-->Plater */ -export const MSG_CALAMITY = "calamity"; +export const CALAMITY = "calamity"; -/** Tell recipient that an error has occurred */ -export const MSG_ERROR = "e"; +/** + * Tell recipient that an error has occurred + * + * Server-->Client-->Player + */ +export const ERROR = "e"; /** * Message to be displayed. * * Server-->Client-->Player */ -export const MSG_MESSAGE = "m"; +export const MESSAGE = "m"; + /** - * Message contains the player's password (or hash or whatever). + * Player has entered data, and sends it to server. * * Player-->Client-->Server */ -export const MSG_PASSWORD = "pass"; +export const REPLY = "reply"; /** + * Player wants to quit. + * + * Player-->Client-->Server + */ +export const QUIT = "quit"; + +/** + * Player wants help + * + * Player-->Client-->Server + */ +export const HELP = "help"; + +/** + * Server tells the client to prompt the player for some data + * * Server-->Client-->Player - * - * Server tells the client to prompt the player for some info */ -export const MSG_PROMPT = "ask"; - -/** - * Client sends the player's username to the server - * - * Player-->Client-->Server - */ -export const MSG_USERNAME = "user"; +export const PROMPT = "prompt"; /** * Player has entered a command, and wants to do something. * * Player-->Client-->Server */ -export const MSG_COMMAND = "c"; +export const COMMAND = "c"; + +/** + * Server tells the client to prompt the player for some data + * + * Server-->Client-->Player + */ +export const SYSTEM = "_"; + +/** + * Debug message, to be completely ignored in production + * + * Client-->Server + * or + * Server-->Client-->Plater + */ +export const DEBUG = "dbg"; /** * Represents a message sent from client to server. @@ -53,16 +81,16 @@ export class ClientMessage { * @protected * @type {any[]} _arr The array that contains the message data */ - _arr; + _attr; /** The message type. * - * One of the MSG_* constants from this document. + * One of the * constants from this document. * * @returns {string} */ get type() { - return this._arr[0]; + return this._attr[0]; } /** @@ -70,66 +98,101 @@ export class ClientMessage { */ 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, - ); + 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; } try { - this._arr = JSON.parse(msgData); + this._attr = JSON.parse(msgData); } catch (_) { - throw new Error( - `Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`, - ); + throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`); } - if (typeof this._arr !== "array") { - throw new Error( - `Could not create client message. Excpected an array, but got a ${typeof this._arr}`, - ); + if (!Array.isArray(this._attr)) { + throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`); } - if (this._arr.length < 1) { - throw new Error( - "Could not create client message. Excpected an array with at least 1 element, but got an empty one", - ); + if (this._attr.length < 1) { + throw new Error("Could not create client message. Excpected an array with at least 1 element, but got an empty one"); } - - this._arr = arr; } - - /** Does this message contain a message that should be displayed to the user the "normal" way? */ - isMessage() { - return this._arr[0] === "m"; + hasCommand() { + return this._attr.length > 1 && this._attr[0] === COMMAND; } /** Does this message contain a username-response from the client? */ - hasUsername() { - return this._arr[0] === MSG_USERNAME; + isUsernameResponse() { + return this._attr.length === 3 + && this._attr[0] === REPLY + && this._attr[1] === "username" + && typeof this._attr[2] === "string"; } /** Does this message contain a password-response from the client? */ - hasPassword() { - return this._arr[0] === MSG_PASSWORD; + isPasswordResponse() { + return this._attr.length === 3 + && this._attr[0] === REPLY + && this._attr[1] === "password" + && typeof this._attr[2] === "string"; + } + + /** @returns {boolean} does this message indicate the player wants to quit */ + isQuitCommand() { + return this._attr[0] === QUIT + } + + isHelpCommand() { + return this._attr[0] === HELP + } + + /** @returns {boolean} is this a debug message? */ + isDebug() { + return this._attr.length == 2 && this._attr[0] === DEBUG; + } + + isIntegerResponse() { + return this._attr.length === 3 + && this._attr[0] === REPLY + && this._attr[1] === "integer" + && (typeof this._attr[2] === "string" || typeof this._attr[2] === "number") + && Number.isInteger(Number(this._attr[2])); + } + + /** @returns {number} integer */ + get integer() { + if (!this.isIntegerResponse()) { + return undefined; + } + + return Number.parseInt(this._attr[2]); + } + + get debugInfo() { + return this.isDebug() ? this._attr[1] : undefined; } /** @returns {string|false} Get the username stored in this message */ get username() { - return this.hasUsername() ? this._arr[1] : false; + return this.isUsernameResponse() ? this._attr[2] : false; } /** @returns {string|false} Get the password stored in this message */ get password() { - return this.hasPassword() ? this._arr[1] : false; + return this.isPasswordResponse() ? this._attr[2] : false; } + /** @returns {string} */ get command() { - return this.isCommand() ? this._attr[1] : false; - } - - isCommand() { - return this._raw[0] === MSG_COMMAND; + return this.hasCommand() ? this._attr[1] : false; } } + +/** + * 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 prepare(messageType, ...args) { + return JSON.stringify([messageType, ...args]); +} diff --git a/server/utils/password.js b/server/utils/password.js deleted file mode 100644 index 218f7c0..0000000 --- a/server/utils/password.js +++ /dev/null @@ -1,42 +0,0 @@ -import { randomBytes, pbkdf2Sync, randomInt } from "node:crypto"; - -// Settings (tune as needed) -const ITERATIONS = 100_000; // Slow enough to deter brute force -const KEYLEN = 64; // 512-bit hash -const DIGEST = "sha512"; - -/** - * Generate a hash from a plaintext password. - * @param {String} password - * @returns String - */ -export function hash(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 - * @param {String} hashed_password - * @returns Boolean - */ -export function verify(password, hashed_password) { - const [iterations, salt, hash] = hashed_password.split(":"); - const derived = pbkdf2Sync( - password, - salt, - Number(iterations), - KEYLEN, - DIGEST, - ).toString("hex"); - return hash === derived; -} diff --git a/server/utils/security.js b/server/utils/security.js new file mode 100755 index 0000000..0377e12 --- /dev/null +++ b/server/utils/security.js @@ -0,0 +1,59 @@ +import { randomBytes, pbkdf2Sync } from "node:crypto"; +import { DEV } from "./config.js"; + +// Settings (tune as needed) +const ITERATIONS = 1000; +const KEYLEN = 32; // 32-bit hash +const DIGEST = "sha256"; + +/** + * 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 (DEV) { + console.debug( + "Verifying password:\n" + + " Input : %s\n" + + " Stored : %s\n" + + " Given : %s\n" + + " Derived : %s\n" + + " Success : %s", + 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); +} diff --git a/server/utils/tui.js b/server/utils/tui.js new file mode 100755 index 0000000..d985372 --- /dev/null +++ b/server/utils/tui.js @@ -0,0 +1,306 @@ +/** + * @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. + const [ + 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(""); + + let output = ""; + + // + // GENERATE THE MARGIN SPACE ABOVE THE FRAMED 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 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 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