things + stff

This commit is contained in:
Kim Ravn Hansen
2025-09-07 23:24:50 +02:00
parent fb915f2681
commit 8a4eb25507
27 changed files with 1991 additions and 630 deletions

20
server/.vscode/launch.json vendored Executable file
View File

@@ -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": [
"<node_internals>/**"
],
"env": {
"NODE_ENV": "dev",
},
"program": "${workspaceFolder}/server.js"
}
]
}

5
server/config.js Executable file
View File

@@ -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;

View File

@@ -0,0 +1,50 @@
<!-- CHATGPT EXAMPLE -->
<!-- 1. Get the width of the container (in pixels). -->
<!-- 2. Get the width of a single character in the monospaced font (this can be done by creating a temporary element with the same font and measuring its width).-->
<!-- 3. Divide the container's width by the character's width to get the number of characters. -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Measure Div Width in Characters</title>
<style>
.monospaced-div {
font-family: "Courier New", Courier, monospace; /* Monospaced font */
width: 360px; /* Example width in pixels */
border: 1px solid #333;
padding: 10px;
}
</style>
</head>
<body>
<div class="monospaced-div">
This is a div with monospaced text.
</div>
<script>
function getMonospacedCharCount(div) {
// Create a temporary span to get the width of one character
const testChar = document.createElement('span');
testChar.textContent = '0'; // Monospaced fonts use "0" for width
testChar.style.fontFamily = window.getComputedStyle(div).fontFamily;
testChar.style.visibility = 'hidden'; // Hide the element
document.body.appendChild(testChar);
const charWidth = testChar.offsetWidth; // Get width of a single character
document.body.removeChild(testChar); // Remove the test element
// Get the width of the div and calculate how many characters fit
const divWidth = div.offsetWidth;
// Return the number of characters that fit in the div width
return Math.floor(divWidth / charWidth);
}
const div = document.querySelector('.monospaced-div');
const charCount = getMonospacedCharCount(div);
console.log('Number of characters the div can hold:', charCount);
</script>
</body>
</html>

View File

@@ -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);

View File

@@ -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<string,ItemTemplate>} List of all item templates in the game */
_itemTemplates = new Map();

View File

@@ -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);

View File

@@ -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<Character>} */
_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;
}
}

102
server/models/session.js Executable file
View File

@@ -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();
}
}
}

158
server/models/states/auth.js Executable file
View File

@@ -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));
}
}

View File

@@ -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}`);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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) {}
}

View File

@@ -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));
}
}

View File

@@ -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",

View File

@@ -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": {

330
server/public/client.js Executable file
View File

@@ -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 '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case '\'': return '&#039;';
case '`': return '&#096;';
default: return c;
}
})
.replace(/---(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])---/g, '<span class="strike">$1</span>') // line-through
.replace(/___(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])___/g, '<span class="underline">$1</span>') // underline
.replace(/_(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])_/g, '<span class="italic">$1</span>') // italic
.replace(/\*(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\*/g, '<span class="bold">$1</span>') // bold
.replace(/\.{3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\.{3}/g, '<span class="undercurl">$1</span>') // undercurl
.replace(/\({3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){3}/g, '<span class="faint">($1)</span>') // faint with parentheses
.replace(/\({2}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){2}/g, '<span class="faint">$1</span>') // faint with parentheses
;
console.log("crack output", text);
return text;
}

View File

@@ -4,201 +4,20 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket MUD</title>
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="container">
<div id="status" class="connecting">Connecting...</div>
<div id="output"></div>
<div id="input-container">
<input
type="text"
id="input"
placeholder="Enter command..."
disabled
/>
<input type="text" autocomplete="off" id="input" placeholder="Enter command..." disabled />
<button id="send" disabled>Send</button>
</div>
</div>
<script>
class MUDClient {
constructor() {
this.ws = null;
this.output = document.getElementById("output");
this.input = document.getElementById("input");
this.sendButton = document.getElementById("send");
this.status = document.getElementById("status");
this.setupEventListeners();
this.connect();
}
connect() {
const protocol =
window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}`;
this.updateStatus("Connecting...", "connecting");
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.updateStatus("Connected", "connected");
this.input.disabled = false;
this.sendButton.disabled = false;
this.input.focus();
};
this.ws.onmessage = (event) => {
console.log(event);
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onclose = () => {
this.updateStatus("Disconnected", "disconnected");
this.input.disabled = true;
this.sendButton.disabled = true;
// Attempt to reconnect after 3 seconds
setTimeout(() => this.connect(), 3000);
};
this.ws.onerror = (error) => {
this.updateStatus("Connection Error", "error");
this.appendOutput(
"Connection error occurred. Retrying...",
"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();
if (
message &&
this.ws &&
this.ws.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.ws.send(
JSON.stringify({
type: "command",
content: message,
}),
);
this.input.value = "";
}
}
handleMessage(data) {
console.log(data);
switch (data[0]) {
case "error":
this.appendOutput(data[1], "error");
break;
case "system":
this.appendOutput(data[1], "system");
break;
default:
this.appendOutput(data[1]);
}
}
appendOutput(text, className = "") {
const div = document.createElement("div");
if (className) {
div.className = className;
}
// Check if this looks like a prompt
if (text.includes("] > ")) {
div.className = "prompt";
}
div.textContent = text;
this.output.appendChild(div);
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();
});
</script>
<script src="client.js"></script>
</body>
</html>

57
server/public/style.css Normal file → Executable file
View File

@@ -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;
}

37
server/seed.js Executable file
View File

@@ -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);
}
}

View File

@@ -2,236 +2,154 @@ 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<WebSocket,Session>} */
sessions = new Map();
constructor() {
/** @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]));
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");
}
/**
* @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;
// __ __ _____ ____ ____ _ ____ _____
// | \/ | ____/ ___/ ___| / \ / ___| ____|
// | |\/| | _| \___ \___ \ / _ \| | _| _|
// | | | | |___ ___) |__) / ___ \ |_| | |___
// |_| |_|_____|____/____/_/ \_\____|_____|
//--------------------------------------------
// HANDLE INCOMING MESSAGES
//-------------------------
websocket.on("message", (data) => {
try {
message = new ClientMessage(data);
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.error("Bad websocket message", data, error);
this.send(
websocket,
MSG_ERROR,
"terminal",
"You sent me a bad message! Goodbye...",
);
websocket.close();
return;
console.trace("received an invalid message (error: %s)", error, data.toString(), data);
websocket.send(msg.prepare(
msg.CALAMITY,
error
));
}
});
session.setState(new AuthState(session));
}
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,
);
}
}
//
//----------------------------------------------------
// 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`);
//
// Handle player logout (move the or hide their characters)
// this.game.playerLoggedOut();
} else {
console.log("A player without a session disconnected");
}
this.sessions.delete(websocket);
}
}
// 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);
// The file types we allow to be served.
const contentTypes = {
".js": "application/javascript",
".css": "text/css",
".html": "text/html",
};
if (!contentTypes[ext]) {
//
// 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];
//
// 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 ${filePath} not found (invalid $ext)`);
res.end(`File not found`);
console.log("Bad http request", req.url);
return;
}
const contentType = contentTypes[ext];
//
// Check if the file exists.
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end(`File ${filePath} . ${ext} not found (${err})`);
res.end(`File not found`);
console.log("Bad http request", req.url);
return;
}
res.writeHead(200, { "Content-Type": contentType });
@@ -239,18 +157,28 @@ const httpServer = http.createServer((req, res) => {
});
});
//
// Create WebSocket server
const websocketServer = new WebSocketServer({ server: httpServer });
const mudServer = new MudServer();
websocketServer.on("connection", (ws) => {
mudServer.onConnectionEstabished(ws);
this.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.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`);
});
}
}
// __ __ _ ___ _ _
// | \/ | / \ |_ _| \ | |
// | |\/| | / _ \ | || \| |
// | | | |/ ___ \ | || |\ |
// |_| |_/_/ \_\___|_| \_|
//---------------------------
// Code entry point
//-----------------
(new MudServer()).start();

169
server/tui.md Normal file
View File

@@ -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
Heres 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 Ill 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+2500U+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
Heres 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 Ill tailor the response.
Let me know if you need help implementing this in ncurses or want more specific frame designs!

4
server/utils/config.js Normal file
View File

@@ -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;

View File

@@ -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;
return this.hasCommand() ? this._attr[1] : false;
}
}
isCommand() {
return this._raw[0] === MSG_COMMAND;
}
/**
* 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]);
}

View File

@@ -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;
}

59
server/utils/security.js Executable file
View File

@@ -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);
}

306
server/utils/tui.js Executable file
View File

@@ -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