things+stuff

This commit is contained in:
Kim Ravn Hansen
2025-09-10 12:36:42 +02:00
parent 5d0cc61cf9
commit ba293d08b3
39 changed files with 3425 additions and 2713 deletions

View File

@@ -20,12 +20,12 @@
└────────────────────────────────────────────────────────────────────────────────┘│e) 2 uncursed lichen corpses │ └────────────────────────────────────────────────────────────────────────────────┘│e) 2 uncursed lichen corpses │
┌────────────────────────────────────────────────────────────────────────────────┐│Scrolls │ ┌────────────────────────────────────────────────────────────────────────────────┐│Scrolls │
│ ││j) 2 uncursed scrolls labeled DAIYEN FOOELS │ │ ││j) 2 uncursed scrolls labeled DAIYEN FOOELS │
<20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ ││PD) uncursed scroll labeled ELAM EBOW │ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ ││PD) uncursed scroll labeled ELAM EBOW │
<20><><EFBFBD><EFBFBD><EFBFBD>Ŀ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>*[<5B><><EFBFBD>[[<5B><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││TW) 2 cursed scrolls labeled GNIK SISI VLE │ <20><><EFBFBD><EFBFBD><EFBFBD>Ŀ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>*[<5B><><EFBFBD>[[<5B><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││TW) 2 cursed scrolls labeled GNIK SISI VLE │
<20><><EFBFBD><EFBFBD><EFBFBD>><3E><><EFBFBD>[<5B><>)<29><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││ms) 2 uncursed scrolls labeled HACKEM MUCHE │ <20><><EFBFBD><EFBFBD><EFBFBD>><3E><><EFBFBD>[<5B><>)<29><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││ms) 2 uncursed scrolls labeled HACKEM MUCHE │
<20><><EFBFBD><EFBFBD><EFBFBD>[<5B> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││dp) uncursed scroll labeled KIRJE │ <20><><EFBFBD><EFBFBD><EFBFBD>[<5B> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││dp) uncursed scroll labeled KIRJE │
<20> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><<3C><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││Uu) uncursed scroll labeled TEMOV │ <20> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><<3C><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││Uu) uncursed scroll labeled TEMOV │
<20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││GQ) 2 uncursed scrolls labeled VELOX NEB │ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││GQ) 2 uncursed scrolls labeled VELOX NEB │
<20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││X) blessed scroll labeled VERR YED HORRE │ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ││X) blessed scroll labeled VERR YED HORRE │
<20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD><EFBFBD> ││V) blessed scroll called ASHPD enchARM|rmvCUR|enchWEP │ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD><EFBFBD> ││V) blessed scroll called ASHPD enchARM|rmvCUR|enchWEP │
<20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> ││Wands │ <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> ││Wands │

View File

@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"printWidth": 170
}

View File

@@ -1,25 +1,20 @@
{ {
// Use IntelliSense to learn about possible attributes. // Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes. // Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"name": "Launch with Nodemon", "name": "Launch with Nodemon",
"runtimeExecutable": "nodemon", "runtimeExecutable": "nodemon",
"runtimeArgs": [ "runtimeArgs": ["--inspect=9229", "server.js"],
"--inspect=9229", "env": {
"server.js" "NODE_ENV": "dev"
], },
"env": { "restart": true,
"NODE_ENV": "dev", "skipFiles": ["<node_internals>/**"]
}, }
"restart": true, ]
"skipFiles": [ }
"<node_internals>/**"
]
}
]
}

View File

@@ -2,52 +2,50 @@ const dev = process.env.NODE_ENV === "dev";
const env = process.env.PROD || (dev ? "dev" : "prod"); const env = process.env.PROD || (dev ? "dev" : "prod");
export const Config = { export const Config = {
/** @readonly @type {string} the name of the environment we're running in */ /** @readonly @type {string} the name of the environment we're running in */
"env": env, env: env,
/** @readonly @type {boolean} are we running in development-mode? */ /** @readonly @type {boolean} are we running in development-mode? */
"dev": dev, dev: dev,
/** /**
* Port we're running the server on. * Port we're running the server on.
* *
* @readonly * @readonly
* @const {number} * @const {number}
*/ */
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
/** /**
* Maximum number of players allowed on the server. * Maximum number of players allowed on the server.
* *
* @readonly * @readonly
* @const {number} * @const {number}
*/ */
maxPlayers: dev ? 3 : 40, maxPlayers: dev ? 3 : 40,
/** /**
* Max number of characters in a party. * Max number of characters in a party.
* By default, a player can only have a single party. * By default, a player can only have a single party.
* Multiple parties may happen some day. * Multiple parties may happen some day.
*/ */
maxPartySize: 4, maxPartySize: 4,
/** /**
* Number of failed logins allowed before user is locked out. * Number of failed logins allowed before user is locked out.
* Also known as Account lockout threshold * Also known as Account lockout threshold
* *
* @readonly * @readonly
* @const {number} * @const {number}
*/ */
maxFailedLogins: 5, maxFailedLogins: 5,
/** /**
* When a user has entered a wrong password too many times, * When a user has entered a wrong password too many times,
* block them for this long before they can try again. * block them for this long before they can try again.
* *
* @readonly * @readonly
* @const {number} * @const {number}
*/ */
accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes. accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes.
}; };

View File

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

32
server/ideas.md Executable file
View File

@@ -0,0 +1,32 @@
```
___ ____ _____ _ ____
|_ _| _ \| ____| / \ / ___|
| || | | | _| / _ \ \___ \
| || |_| | |___ / ___ \ ___) |
|___|____/|_____/_/ \_\____/
-------------------------------
```
# GARBAGE COLLECTORS
At night, the Garbage Collectors (smelly gnolls) or other Janitor Mobs come out
to remove any loose items or dead characters that may be lying around. They
are quite tough.
These janitor mobs clean up almost everywhere except Instances (that clean up themselves)
and players' homes, prisons, and other VIP locations.
They never trigger quests or events where they go, but:
- they can interact with adventurers (they are quite aggressive, and may attack unprovoked, maybe sneak past)
- they can interact with each other, but mostly do so if there are PCs nearby.
# Attrition
Even when a player is offline, their characters have to pay rent on their homes
or room and board in an inn, or they can chance it in the wilderness.
If they run out of money or rations, there is a small chance each day that the
characters will be garbage collected.
The sum that needs paying while offline not very large though.

View File

@@ -1,5 +1,6 @@
import * as roll from "../utils/dice.js"; import * as roll from "../utils/dice.js";
import * as id from "../utils/id.js"; import * as id from "../utils/id.js";
import { Item } from "./item.js";
/** /**
* A playable character. * A playable character.
@@ -14,28 +15,31 @@ export class Character {
* @protected * @protected
* @type {number} The number of XP the character has. * @type {number} The number of XP the character has.
*/ */
_xp = 0; xp = 0;
get xp() {
return this._xp;
}
/** @protected @type {number} The character's level. */ /** @protected @type {number} The character's level. */
_level = 1; level = 1;
get level() {
return this._level;
}
/** @protected @type {string} unique name used for chats when there's a name clash and also other things that require a unique character id */ /** @type {number} Awareness Skill */
_id; awareness;
get id() {
return this._id;
}
/** @protected @type {string} username of the player that owns this character. */ /** @type {number} Grit Skill */
_username; grit;
get username() {
return this._username; /** @type {number} Knowledge Skill */
} knowledge;
/** @type {number} Magic Skill */
magic;
/** @type {number} Melee Attack Skill */
meleeCombat;
/** @type {number} Ranged Attack Skill */
rangedCombat;
/** @type {number} Skulduggery Skill */
skulduggery;
/** @type {string} Bloodline background */ /** @type {string} Bloodline background */
ancestry; ancestry;
@@ -56,144 +60,40 @@ export class Character {
itemSlots; itemSlots;
/** @type {Set<string>} Things the character is particularly proficient at. */ /** @type {Set<string>} Things the character is particularly proficient at. */
proficiencies = new Set(); skills = new Set();
/** @type {Map<string,number} Things the character is particularly proficient at. */ /** @type {Map<Item,number} Things the character is particularly proficient at. */
equipment = new Map(); items = new Map();
/** /**
* @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 {string} name The name of the character
* @param {boolean=false} initialize Should we initialize the character
*/ */
constructor(username, name, initialize) { constructor(name, initialize) {
this.name = name; 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(username, name);
// should we skip initialization of this object
if (initialize !== true) {
return;
}
//
// Initializing
//
// Rolling skills
/** @type {number} Awareness Skill */
this.awareness = roll.d6() + 2;
/** @type {number} Grit Skill */
this.grit = roll.d6() + 2;
/** @type {number} Knowledge Skill */
this.knowledge = roll.d6() + 2;
/** @type {number} Magic Skill */
this.magic = roll.d6() + 2;
/** @type {number} Melee Attack Skill */
this.meleeCombat = roll.d6() + 2;
/** @type {number} Ranged Attack Skill */
this.rangedCombat = roll.d6() + 2;
/** @type {number} Skulduggery Skill */
this.skulduggery = roll.d6() + 2;
switch (roll.d8()) {
case 1:
this.ancestry = "human";
// Humans get +1 to all skills
this.awareness++;
this.grit++;
this.knowledge++;
this.magic++;
this.meleeCombat++;
this.rangedCombat++;
this.skulduggery++;
break;
case 2:
this.ancestry = "dwarven";
this.meleeCombat = Math.max(this.meleeCombat, 10);
break;
case 3:
this.ancestry = "elven";
this.rangedCombat = Math.max(this.rangedCombat, 10);
break;
case 4:
this.ancestry = "giant";
this.meleeCombat = Math.max(this.grit, 10);
break;
case 5:
this.ancestry = "Gnomish";
this.meleeCombat = Math.max(this.awareness, 10);
break;
case 6:
this.ancestry = "primordial";
this.meleeCombat = Math.max(this.magic, 10);
break;
case 7:
this.ancestry = "draconic";
this.meleeCombat = Math.max(this.knowledge, 10);
break;
case 8:
this.ancestry = "demonic";
this.meleeCombat = Math.max(this.skulduggery, 10);
break;
default:
throw new Error("Logic error, ancestry d8() roll was out of scope");
}
//
// Determine the character's Foundation
//
//
/** @type {string} Foundational background */
this.foundation = "";
const foundationRoll = roll.withSides(15);
switch (foundationRoll) {
case 1:
this.foundation = "brawler";
this.proficiencies.add("light_armor");
this.equipment.set("studded_leather", 1);
this.equipment.set("spiked_gauntlets", 1);
this.silver = 40;
this.maxHitPoints = this.currentHitPoints = 15;
this.itemSlots = 7;
this.meleeCombat = Math.max(this.meleeCombat, 10);
this.knowledge = Math.min(this.knowledge, 10);
break;
case 2:
this.foundation = "druid";
this.proficiencies.add("armor/natural");
this.equipment
.set("sickle", 1)
.set("poisoner's kit", 1) // can apply to weapon, food, drink. Can recharge in the forest
.set("healer's kit", 1); // provide fast out-of-combat healing. Can recharge in forest.
this.silver = 10;
this.maxHitPoints = this.currentHitPoints = 10;
this.itemSlots = 5;
default:
this.foundation = "debug";
this.proficiencies.add("heavy_armor");
this.proficiencies.add("heavy_weapons");
this.equipment.set("debug_armor", 1);
this.equipment.set("longsword", 1);
this.silver = 666;
this.itemSlots = 10;
this.maxHitPoints = 20;
this.currentHitPoints = 20;
// default:
// throw new Error(`Logic error, foundation d15 roll of ${foundationRoll} roll was out of scope`);
}
} }
/** Add an item to the equipment list
* @param {Item} item
* @param {number} count
*
* Maybe return the accumulated ItemSlots used?
*/
addItem(item, count = 1) {
if (!Number.isInteger(count)) {
throw new Error("Number must be an integer");
}
if (!(item instanceof Item)) {
console.debug("bad item", item);
throw new Error("item must be an instance of Item!");
}
if (count <= 0) {
throw new Error("Number must be > 0");
}
const existingItemCount = this.items.get(item) || 0;
this.items.set(item, count + existingItemCount);
}
// todo removeItem(item, count)
} }

View File

@@ -7,15 +7,14 @@
* Serializing this object effectively saves the game. * Serializing this object effectively saves the game.
*/ */
import { miniUid } from "../utils/id.js"; import { isIdSane, miniUid } from "../utils/id.js";
import { Character } from "./character.js"; import { Character } from "./character.js";
import { ItemTemplate } from "./item.js"; import { ItemAttributes, ItemBlueprint } from "./item.js";
import { Player } from "./player.js"; import { Player } from "./player.js";
export class Game { export class Game {
/** @type {Map<string,ItemBlueprint>} List of all item blueprints in the game */
/** @type {Map<string,ItemTemplate>} List of all item templates in the game */ _itemBlueprints = new Map();
_itemTemplates = new Map();
/** @type {Map<string,Location>} The list of locations in the game */ /** @type {Map<string,Location>} The list of locations in the game */
_locations = new Map(); _locations = new Map();
@@ -40,10 +39,10 @@ export class Game {
/** /**
* Atomic player creation. * Atomic player creation.
* *
* @param {string} username * @param {string} username
* @param {string?} passwordHash * @param {string?} passwordHash
* @param {string?} salt * @param {string?} salt
* *
* @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise. * @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise.
*/ */
@@ -55,7 +54,7 @@ export class Game {
const player = new Player( const player = new Player(
username, username,
typeof passwordHash === "string" ? passwordHash : "", typeof passwordHash === "string" ? passwordHash : "",
typeof salt === "string" && salt.length > 0 ? salt : miniUid() typeof salt === "string" && salt.length > 0 ? salt : miniUid(),
); );
this._players.set(username, player); this._players.set(username, player);
@@ -64,38 +63,45 @@ export class Game {
} }
/** /**
* Create an ItemTemplate with a given ID * Create an ItemBlueprint with a given blueprintId
* *
* @param {string} id * @param {string} blueprintId
* @param {object} attributes * @param {ItemAttributes} attributes
* *
* @returns {ItemTemplate|false} * @returns {ItemBlueprint|false}
*/ */
createItemTemplate(id, attributes) { addItemBlueprint(blueprintId, attributes) {
console.log(attributes);
if (typeof id !== "string" || !id) { if (typeof blueprintId !== "string" || !blueprintId) {
throw new Error("Invalid id!"); throw new Error("Invalid blueprintId!");
} }
if (this._itemTemplates.has(id)) { const existing = this._itemBlueprints.get(blueprintId);
return false;
if (existing) {
console.debug("we tried to create the same item blueprint more than once", blueprintId, attributes);
return existing;
} }
/** @type {ItemTemplate} */ attributes.blueprintId = blueprintId;
const result = new ItemTemplate(id, attributes.name, attributes.itemSlots);
for (const key of Object.keys(result)) { const result = new ItemBlueprint(attributes);
if (key === "id") {
continue;
}
if (key in attributes) {
result[key] = attributes[key];
}
}
this._itemBlueprints.set(blueprintId, result);
this._itemTemplates.set(id, result);
return result; return result;
} }
/**
* @param {string} blueprintId
* @returns {ItemBlueprint?}
*/
getItemBlueprint(blueprintId) {
if (!isIdSane(blueprintId)) {
throw new Error(`blueprintId >>${blueprintId}<< is insane!`);
}
const tpl = this._itemBlueprints.get(blueprintId);
return tpl || undefined;
}
} }

View File

@@ -1,12 +1,10 @@
/** /**
* Item templates are the built-in basic items of the game. * Abstract class for documentation purposes.
* A character cannot directly own one of these items, * @abstract
* they can only own CharacterItems, and ItemTemplates can be used to
* generate these CharacterItems.
*/ */
export class ItemTemplate { export class ItemAttributes {
/** @constant @readonly @type {string} Item's machine-friendly name */ /** @constant @readonly @type {string} Machine-friendly name for the blueprint */
id; blueprintId;
/** @constant @readonly @type {string} Item's human-friendly name */ /** @constant @readonly @type {string} Item's human-friendly name */
name; name;
@@ -18,9 +16,9 @@ export class ItemTemplate {
itemSlots; itemSlots;
/** @constant @readonly @type {number?} How much damage (if any) does this item deal */ /** @constant @readonly @type {number?} How much damage (if any) does this item deal */
damage; baseDamage;
/** @constant @readonly @type {string?} Which special effect is triggered when successfull attacking with this item? */ /** @constant @readonly @type {string?} Which special effect is triggered when successful attacking with this item? */
specialEffect; specialEffect;
/** @constant @readonly @type {boolean?} Can this item be used as a melee weapon? */ /** @constant @readonly @type {boolean?} Can this item be used as a melee weapon? */
@@ -29,84 +27,83 @@ export class ItemTemplate {
/** @constant @readonly @type {boolean?} Can this item be used as a ranged weapon? */ /** @constant @readonly @type {boolean?} Can this item be used as a ranged weapon? */
ranged; ranged;
/** @readonly @type {number} How many extra HP do you have when oyu wear this armor. */
armorHitPoints;
/** @constant @readonly @type {string?} Type of ammo that this item is, or that this item uses */ /** @constant @readonly @type {string?} Type of ammo that this item is, or that this item uses */
ammoType; ammoType;
/** @readonly @type {number} how much is left in this item. (Potions can have many doses and quivers many arrows) */
count;
/** @readonly @type {number} Some items (quivers) can be replenished, so how much can this quiver/potion/ration pack hold */
maxCount;
/** @constant @readonly @type {string[]} Type of ammo that this item is, or that this item uses */
skills = [];
}
/**
* Item blueprints are the built-in basic items of the game.
* A character cannot directly own one of these items,
* they can only own Items, and ItemBlueprints can be used to
* generate these Items.
*/
export class ItemBlueprint extends ItemAttributes {
/** /**
* Constructor * Constructor
* *
* @param {string=null} id Item's machine-friendly name. * @param {object} o Object whose attributes we copy
* @param {string} name. The Item's Name.
* @param {number} itemSlots number of item slots the item takes up in a character's inventory.
*/ */
constructor(id, name, itemSlots) { constructor(o) {
super();
if (typeof id !== "string" || id.length < 1) { if (typeof o.blueprintId !== "string" || o.name.length < 1) {
throw new Error("id must be a string!"); throw new Error("blueprintId must be a string, but " + typeof o.blueprintId + " given.");
} }
if (typeof name !== "string" || name.length < 1) { if (typeof o.name !== "string" || o.name.length < 1) {
throw new Error("Name must be a string, but " + typeof name + " given."); throw new Error("Name must be a string, but " + typeof o.name + " given.");
} }
if (!Number.isFinite(itemSlots)) { if (!Number.isFinite(o.itemSlots)) {
throw new Error("itemSlots must be a finite number!"); throw new Error("itemSlots must be a finite number!");
} }
this.name = name; o.itemSlots = Number(o.itemSlots);
this.id = id;
this.itemSlots = Number(itemSlots); for (const [key, _] of Object.entries(this)) {
if (o[key] !== "undefied") {
this[key] = o[key];
}
}
} }
// //
// Spawn a new item! // Spawn a new non-unique item!
/** @returns {Item} */ /** @returns {Item} */
createItem() { createItem() {
return new ChracterItem( const item = new Item();
this.id,
this.name, for (const [key, value] of Object.entries(this)) {
this.description, item[key] = value;
this.itemSlots, }
);
item.blueprintId = this.blueprintId;
return item;
} }
} }
/** /**
* Characters can only own CharacterItems. * An object of this class represents a single instance
* of a given item in the game. It can be a shortsword, or a potion,
* or another, different shortsword that belongs to another character, etc.
* *
* If two characters have a short sword, each character has a CharacterItem * If a character has two identical potions of healing, they are each represented
* with the name of Shortsword and with the same properties as the orignial Shortsword ItemTemplate. * by an object of this class.
* * The only notable tweak to this rule is collective items like quivers that have
* If a character picks up a Pickaxe in the dungeon, a new CharacterItem is spawned and injected into * arrows that are consumed. In this case, each individual arrow is not tracked
* the character's Equipment Map. If the item is dropped/destroyed/sold, the CharacterItem is removed from * as its own entity, only the quiver is tracked.
* the character's Equipment Map, and then deleted from memory.
*
* If a ChracterItem is traded away to another character, The other character inserts a clone of this item
* into their equipment map, and the item is then deleted from the previous owner's equipment list.
* This is done so we do not have mulltiple characters with pointers to the same item - we would rather risk
* dupes than wonky references.
*
* An added bonus is that the character can alter the name and description of the item.
*
* Another bonus is, that the game can spawn custom items that arent even in the ItemTemplate Set.
*/ */
export class CharacterItem { export class Item extends ItemAttributes {}
/** @type {ItemTemplate|null} The template that created this item. Null if no such template exists [anymore]. */
itemTemplate; // We use the id instead of a pointer, could make garbage collection better.
/** @type {string} The player's name for this item. */
name;
/** @type {string} The player's description for this item. */
description;
/** @type {number} Number of item slots taken up by this item. */
itemSlots;
constructor(templateItemId, name, description, itemSlots) {
this.templateItemId = templateItemId;
this.name = name;
this.description = description;
this.itemSlots = itemSlots;
}
}

View File

@@ -9,37 +9,37 @@ import { Portal } from "./portal";
* or magical portals to distant locations. * or magical portals to distant locations.
*/ */
export class Location { export class Location {
/** @protected @type string */ /** @protected @type string */
_id; _id;
get id() { get id() {
return this._id; return this._id;
} }
/** @protected @type string */ /** @protected @type string */
_name; _name;
get name() { get name() {
return this._name; return this._name;
} }
/** @protected @type string */ /** @protected @type string */
_description; _description;
get description() { get description() {
return this._description; return this._description;
} }
/** @protected @type {Map<string,Portal>} */ /** @protected @type {Map<string,Portal>} */
_portals = new Map(); _portals = new Map();
get portals() { get portals() {
return this._portals; return this._portals;
} }
/** /**
*/ */
constructor(id, name, description) { constructor(id, name, description) {
this._id = id; this._id = id;
this._name = name; this._name = name;
this._description = description; this._description = description;
} }
} }
const l = new Location("foo", "bar", "baz"); const l = new Location("foo", "bar", "baz");

View File

@@ -1,5 +1,6 @@
import WebSocket from "ws"; import WebSocket from "ws";
import { Character } from "./character.js"; import { Character } from "./character.js";
import { Config } from "./../config.js";
/** /**
* Player Account. * Player Account.
@@ -7,66 +8,84 @@ import { Character } from "./character.js";
* Contain persistent player account info. * Contain persistent player account info.
*/ */
export class Player { export class Player {
/** @protected @type {string} unique username */
_username;
get username() {
return this._username;
}
/** @protected @type {string} unique username */ /** @protected @type {string} */
_username; _passwordHash;
get username() { get passwordHash() {
return this._username; return this._passwordHash;
}
/** @protected @type {string} random salt used for hashing */
_salt;
get salt() {
return this._salt;
}
/** @protected @type {Date} */
_createdAt = new Date();
get createdAt() {
return this._createdAt;
}
/** @type {Date} */
blockedUntil;
/** @type {Date|null} Date of the player's last websocket message. */
lastActivityAt = null;
/** @type {Date|null} Date of the player's last login. */
lastSucessfulLoginAt = null;
/** @type {number} Number of successful logins on this character */
successfulLogins = 0;
/** @type {number} Number of failed login attempts since the last good login attempt */
failedPasswordsSinceLastLogin = 0;
/** @protected @type {Set<Character>} */
_characters = new Set(); // should this be a WeakSet? After all if the player is removed, their items might remain in the system, right?
get characters() {
return this._characters;
}
/**
* @param {string} username
* @param {string} passwordHash
* @param {string} salt
*/
constructor(username, passwordHash, salt) {
this._username = username;
this._passwordHash = passwordHash;
this._salt = salt;
this._createdAt = new Date();
}
setPasswordHash(hashedPassword) {
this._passwordHash = hashedPassword;
}
/**
* Add a character to the player's party
*
* @param {Character} character
* @returns {number|false} the new size of the players party if successful, or false if the character could not be added.
*/
addCharacter(character) {
if (this._characters.has(character)) {
return false;
} }
/** @protected @type {string} */ if (this._characters.size >= Config.maxPartySize) {
_passwordHash; return false;
get passwordHash() {
return this._passwordHash;
} }
/** @protected @type {string} random salt used for hashing */ this._characters.add(character);
_salt;
get salt() {
return this._salt;
}
/** @protected @type {Date} */ return this._characters.size;
_createdAt = new Date(); }
get createdAt() {
return this._createdAt;
}
/** @type {Date} */
blockedUntil;
/** @type {Date|null} Date of the player's last websocket message. */
lastActivityAt = null;
/** @type {Date|null} Date of the player's last login. */
lastSucessfulLoginAt = null;
/** @type {number} Number of successful logins on this character */
successfulLogins = 0;
/** @type {number} Number of failed login attempts since the last good login attempt */
failedPasswordsSinceLastLogin = 0;
/** @protected @type {Set<Character>} */
_characters = new Set(); // should this be a WeakSet? After all if the player is removed, their items might remain in the system, right?
get characters() {
return this._characters;
}
/**
* @param {string} username
* @param {string} passwordHash
* @param {string} salt
*/
constructor(username, passwordHash, salt) {
this._username = username;
this._passwordHash = passwordHash;
this._salt = salt;
this._createdAt = new Date();
}
setPasswordHash(hashedPassword) {
this._passwordHash = hashedPassword;
}
} }

View File

@@ -8,18 +8,18 @@
* @todo Add encounters to portals * @todo Add encounters to portals
*/ */
export class Portal { export class Portal {
/** /**
* Target Location. * Target Location.
*/ */
_targetLocationId; _targetLocationId;
/** /**
* Description shown to the player when they inspect the portal from the source location. * Description shown to the player when they inspect the portal from the source location.
*/ */
_description; _description;
/** /**
* Description shown to the player when they traverse the portal. * Description shown to the player when they traverse the portal.
*/ */
_traversalDescription; _traversalDescription;
} }

View File

@@ -1,131 +1,129 @@
import WebSocket from 'ws'; import WebSocket from "ws";
import { Game } from './game.js'; import { Game } from "./game.js";
import { Player } from './player.js'; import { Player } from "./player.js";
import { StateInterface } from '../states/interface.js'; import { StateInterface } from "../states/interface.js";
import * as msg from '../utils/messages.js'; import * as msg from "../utils/messages.js";
import figlet from 'figlet'; import figlet from "figlet";
export class Session { export class Session {
/** @protected @type {StateInterface} */
_state;
get state() {
return this._state;
}
/** @protected @type {StateInterface} */ /** @protected @type {Game} */
_state; _game;
get state() { get game() {
return this._state; return this._game;
}
/** @type {Player} */
_player;
get player() {
return this._player;
}
/** @param {Player} player */
set player(player) {
if (player instanceof Player) {
this._player = player;
return;
} }
/** @protected @type {Game} */ if (player === null) {
_game; this._player = null;
get game() { return;
return this._game;
} }
/** @type {Player} */ throw Error(
_player; `Can only set player to null or instance of Player, but received ${typeof player}`,
get player() { );
return this._player; }
/** @type {WebSocket} */
_websocket;
/**
* @param {WebSocket} websocket
* @param {Game} game
*/
constructor(websocket, game) {
this._websocket = websocket;
this._game = game;
}
/** Close the session and websocket */
close() {
this._websocket.close();
this._player = null;
}
/**
* 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)) {
/** @param {Player} player */ message = message.join("\n");
set player(player) {
if (player instanceof Player) {
this._player = player;
return;
}
if (player === null) {
this._player = null;
return;
}
throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`);
} }
this.send(msg.MESSAGE, message, ...args);
}
/**
/** @type {WebSocket} */ * @param {string} type prompt type (username, password, character name, etc.)
_websocket; * @param {string|string[]} message The prompting message (please enter your character's name)
* @param {string} tag helps with message routing and handling.
/** */
* @param {WebSocket} websocket sendPrompt(type, message, tag = "", ...args) {
* @param {Game} game if (Array.isArray(message)) {
*/ message = message.join("\n");
constructor(websocket, game) {
this._websocket = websocket;
this._game = game;
} }
this.send(msg.PROMPT, type, message, tag, ...args);
}
/** Close the session and websocket */ /** @param {string} message The error message to display to player */
close() { sendError(message, ...args) {
this._websocket.close(); this.send(msg.ERROR, message, ...args);
this._player = null; }
/** @param {string} message The error message to display to player */
sendDebug(message, ...args) {
this.send(msg.DEBUG, 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();
} }
}
/**
* 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|string[]} message The prompting message (please enter your character's name)
* @param {string} tag helps with message routing and handling.
*/
sendPrompt(type, message, tag="default", ...args) {
if (Array.isArray(message)) {
message = message.join("\n");
}
this.send(msg.PROMPT, type, message, tag, ...args);
}
/** @param {string} message The error message to display to player */
sendError(message, ...args) {
this.send(msg.ERROR, message, ...args);
}
/** @param {string} message The error message to display to player */
sendDebug(message, ...args) {
this.send(msg.DEBUG, 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();
}
}
} }

884
server/package-lock.json generated
View File

@@ -1,446 +1,446 @@
{ {
"name": "websocket-mud", "name": "websocket-mud",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "websocket-mud", "name": "websocket-mud",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"figlet": "^1.8.2", "figlet": "^1.8.2",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "3.6.2" "prettier": "3.6.2"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/anymatch": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"picomatch": "^2.0.4" "picomatch": "^2.0.4"
}, },
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"anymatch": "~3.1.2", "anymatch": "~3.1.2",
"braces": "~3.0.2", "braces": "~3.0.2",
"glob-parent": "~5.1.2", "glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0", "is-binary-path": "~2.1.0",
"is-glob": "~4.0.1", "is-glob": "~4.0.1",
"normalize-path": "~3.0.0", "normalize-path": "~3.0.0",
"readdirp": "~3.6.0" "readdirp": "~3.6.0"
}, },
"engines": { "engines": {
"node": ">= 8.10.0" "node": ">= 8.10.0"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"supports-color": { "supports-color": {
"optional": true "optional": true
}
}
},
"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",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
}
},
"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",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
}
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "websocket-mud", "name": "websocket-mud",
"version": "1.0.0", "version": "1.0.0",
"description": "A Multi-User Dungeon game using WebSockets and Node.js", "description": "A Muuhlti-User Dungeon Game. Is there a secret cow level?",
"main": "server.js", "main": "server.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -14,7 +14,7 @@
"game", "game",
"multiplayer" "multiplayer"
], ],
"author": "Your Name", "author": "Kim Ravn Hansen",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"figlet": "^1.8.2", "figlet": "^1.8.2",
@@ -26,5 +26,14 @@
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
},
"prettier": {
"tabWidth": 4,
"printWidth": 120,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 4,
"bracketSpacing": true,
"objectWrap": "preserve"
} }
} }

View File

@@ -1,520 +1,505 @@
import { crackdown } from "./crackdown.js";
class MUDClient { class MUDClient {
//
// Constructor
constructor() {
/** @type {WebSocket} Our WebSocket */
this.websocket = null;
// /** @type {boolean} Are we in development mode (decided by the server);
// Constructor
constructor() {
/** @type {WebSocket} Our WebSocket */
this.websocket = null;
/** @type {boolean} Are we in development mode (decided by the server);
this.dev = false; this.dev = false;
/** @type {string|null} The message type of the last thing we were asked. */ /** @type {string|null} The message type of the last thing we were asked. */
this.replyType = null;
/** @type {string|null} The #tag of the last thing we were asked. */
this.replyTag = null;
/** @type {HTMLElement} The output "monitor" */
this.output = document.getElementById("output");
/** @type {HTMLElement} The input element */
this.input = document.getElementById("input");
/** @type {HTMLElement} The send/submit button */
this.sendButton = document.getElementById("send");
/** @type {HTMLElement} Status indicator */
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.
/** @type {string} Hashing method to use for client-side password hashing */
this.digest = "SHA-256";
/** @type {string} Salt string to use for client-side password hashing */
this.salt = "No salt, no shorts, no service";
/** @type {string} Number of times the hashing should be done */
this.rounds = 1000;
/** @type {string} the username also salts the password, so the username must never change. */
this.username = "";
this.setupEventListeners();
this.connect();
}
/** @param {string} password the password to be hashed */
async hashPassword(password) {
const encoder = new TextEncoder();
let data = encoder.encode(password + this.salt);
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 `KimsKrappyKryptoV1:${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.debug(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.writeToOutput("Connection error occurred. Retrying...", {
class: "error",
});
};
} catch (error) {
console.error(error);
this.updateStatus("Connection Failed", "error");
setTimeout(() => this.connect(), 3000);
}
}
setupEventListeners() {
document.addEventListener("keypress", (e) => {
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
this.input.focus();
}
});
this.input.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.onUserCommand();
}
});
this.sendButton.addEventListener("click", () => {
this.onUserCommand();
});
// 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 = "";
}
}
});
}
/**
* Send a json-encoded message to the server via websocket.
*
* @param {messageType} string
* @param {...any} rest
*/
send(messageType, ...args) {
if (args.length === 0) {
this.websocket.send(JSON.stringify([messageType]));
return;
}
this.websocket.send(JSON.stringify([messageType, ...args]));
}
//
// Add a command to history so we can go back to previous commands with arrow keys.
_addCommandToHistory(command) {
//
// we do not add usernames or passwords to history.
if (this.replyType === "password" || this.replyType === "username") {
return;
}
//
// Adding empty commands makes no sense.
// Why would the user navigate back through their history to
// find and empty command when they can just press enter.
if (command === "") {
return;
}
//
// Add to command our history
// But not if the command was a password.
this.historyIndex = -1;
//
// We do not add the same commands many times in a row.
if (this.commandHistory[this.commandHistory.length - 1] === command) {
return;
}
//
// Add the command to the history stack
this.commandHistory.push(command);
if (this.commandHistory.length > 50) {
this.commandHistory.shift();
}
}
/**
* User has entered a command
*/
onUserCommand() {
//
// Trim user's input.
const command = this.input.value.trim();
this.input.value = "";
this.input.type = "text";
this._addCommandToHistory(command);
// -- 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 (command === "/clear") {
this.output.innerHTML = "";
this.input.value = "";
return;
}
//
// Don't allow sending messages (for now)
// Later on, prompts may give us the option to simply "press enter";
if (!command) {
console.debug("Cannot send empty message - YET");
return;
}
//
// Can't send a message without a websocket
if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) {
return;
}
//
// 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.
if (this.replyType === "password") {
this.hashPassword(command).then((pwHash) => {
this.send("reply", "password", pwHash, this.replyTag);
this.replyType = null; this.replyType = null;
/** @type {string|null} The #tag of the last thing we were asked. */
this.replyTag = null; this.replyTag = null;
});
/** @type {HTMLElement} The output "monitor" */ return;
this.output = document.getElementById("output");
/** @type {HTMLElement} The input element */
this.input = document.getElementById("input");
/** @type {HTMLElement} The send/submit button */
this.sendButton = document.getElementById("send");
/** @type {HTMLElement} Status indicator */
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.
/** @type {string} Hashing method to use for client-side password hashing */
this.digest = "SHA-256";
/** @type {string} Salt string to use for client-side password hashing */
this.salt = "No salt, no shorts, no service";
/** @type {string} Number of times the hashing should be done */
this.rounds = 1000;
/** @type {string} the username also salts the password, so the username must never change. */
this.username = "";
this.setupEventListeners();
this.connect();
}
/** @param {string} password the password to be hashed */
async hashPassword(password) {
const encoder = new TextEncoder();
let data = encoder.encode(password + this.salt);
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 `KimsKrappyKryptoV1:${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.debug(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.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
};
} catch (error) {
console.error(error);
this.updateStatus("Connection Failed", "error");
setTimeout(() => this.connect(), 3000);
}
}
setupEventListeners() {
document.addEventListener("keypress", (e) => {
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
this.input.focus();
}
});
this.input.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.onUserCommand();
}
});
this.sendButton.addEventListener("click", () => {
this.onUserCommand();
});
// 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 = "";
}
}
});
}
/**
* Send a json-encoded message to the server via websocket.
*
* @param {messageType} string
* @param {...any} rest
*/
send(messageType, ...args) {
if (args.length === 0) {
this.websocket.send(JSON.stringify([messageType]));
return;
}
this.websocket.send(JSON.stringify([messageType, ...args]));
} }
// //
// Add a command to history so we can go back to previous commands with arrow keys. // When the player enters their username during the auth-phase,
_addCommandToHistory(command) { // keep the username in the pocket for later.
// if (this.replyType === "username") {
// we do not add usernames or passwords to history. this.username = command;
if (this.replyType === "password" || this.replyType === "username") {
return;
}
//
// Adding empty commands makes no sense.
// Why would the user navigate back through their history to
// find and empty command when they can just press enter.
if (command === "") {
return;
}
//
// Add to command our history
// But not if the command was a password.
this.historyIndex = -1;
//
// We do not add the same commands many times in a row.
if (this.commandHistory[this.commandHistory.length - 1] === command) {
return;
}
//
// Add the command to the history stack
this.commandHistory.push(command);
if (this.commandHistory.length > 50) {
this.commandHistory.shift();
}
}
/**
* User has entered a command
*/
onUserCommand() {
//
// Trim user's input.
const command = this.input.value.trim();
this.input.value = "";
this.input.type = "text";
this._addCommandToHistory(command);
// -- 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 (command === "/clear") {
this.output.innerHTML = "";
this.input.value = "";
return;
}
//
// Don't allow sending messages (for now)
// Later on, prompts may give us the option to simply "press enter";
if (!command) {
console.debug("Cannot send empty message - YET");
return;
}
//
// Can't send a message without a websocket
if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) {
return;
}
//
// 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.
if (this.replyType === "password") {
this.hashPassword(command).then((pwHash) => {
this.send("reply", "password", pwHash, this.replyTag);
this.replyType = null;
this.replyTag = null;
});
return;
}
//
// When the player enters their username during the auth-phase,
// keep the username in the pocket for later.
if (this.replyType === "username") {
this.username = command;
}
//
// We add our own command to the output stream so the
// player can see what they typed.
this.writeToOutput("> " + command, { class: "input" });
//
// Handle certain-commands differently.
const specialCommands = { ":quit": "quit", ":help": "help" };
if (specialCommands[command]) {
this.send(specialCommands[command]);
return;
}
//
// Handle replies
// We want to be in a place where ALL messages are replies.
// The game loop should always ask you for your next command,
// even if it does so silently
if (this.replyType) {
//--------------------------------------------------
// The server asked the player a question,
// so we send the answer the way the server wants.
//--------------------------------------------------
this.send("reply", this.replyType, command, this.replyTag);
this.replyType = null;
this.replyTag = null;
return;
}
//
//-----------------------------------------------------
// The player sends a text-based command to the server
//-----------------------------------------------------
// ___ _ _ _
// |_ _|_ __ ___ _ __ ___ _ __| |_ __ _ _ __ | |_| |
// | || '_ ` _ \| '_ \ / _ \| '__| __/ _` | '_ \| __| |
// | || | | | | | |_) | (_) | | | || (_| | | | | |_|_|
// |___|_| |_| |_| .__/ \___/|_| \__\__,_|_| |_|\__(_)
// |_|
//
// Aside from :help", ":quit", etc. we should not send
// unsolicited messages to the server without being
// prompted to do so.
this.send("c", command);
}
// ___ __ __
// / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___
// | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \
// | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/
// \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___|
//
/** @param {any[]} data*/
onMessage(data) {
if (this.dev) {
console.debug(data);
}
const messageType = data.shift();
if (messageType === "dbg") {
return this.handleDebugMessages(data);
}
if (messageType === "prompt") {
return this.handlePromptMessage(data);
}
if (messageType === "e") {
return this.handleErrorMessage(data);
}
if (messageType === "calamity") {
return this.handleCalamityMessage(data);
}
if (messageType === "_") {
return this.handleSystemMessages(data);
}
if (messageType === "m") {
return this.handleTextMessages(data);
}
if (this.dev) {
this.writeToOutput(`unknown message type: ${messageType}: ${JSON.stringify(data)}`, "debug");
}
console.debug("unknown message type", data);
} }
// //
// "m" => normal/standard message to be displayed to the user // We add our own command to the output stream so the
handleTextMessages(data) { // player can see what they typed.
const options = { ...data[1] }; // coerce options into an object. this.writeToOutput("> " + command, { class: "input" });
//
// normal text message to be shown to the player // Handle certain-commands differently.
this.writeToOutput(data[0], options); const specialCommands = { ":quit": "quit", ":help": "help" };
return; if (specialCommands[command]) {
this.send(specialCommands[command]);
return;
} }
// //
// Debug messages let the server send data to be displayed on the player's screen // Handle replies
// and also logged to the players browser's log. // We want to be in a place where ALL messages are replies.
handleDebugMessages(data) { // The game loop should always ask you for your next command,
if (!this.dev) { // even if it does so silently
return; // debug messages are thrown away if we're not in dev mode. if (this.replyType) {
} //--------------------------------------------------
this.writeToOutput(data, { class: "debug", preformatted: true }); // The server asked the player a question,
console.debug("DBG", data); // so we send the answer the way the server wants.
//--------------------------------------------------
this.send("reply", this.replyType, command, this.replyTag);
this.replyType = null;
this.replyTag = null;
return;
} }
// //
// "_" => system messages, not to be displayed //-----------------------------------------------------
handleSystemMessages(data) { // The player sends a text-based command to the server
//-----------------------------------------------------
if (data.length < 2) { // ___ _ _ _
console.debug("malformed system message", data); // |_ _|_ __ ___ _ __ ___ _ __| |_ __ _ _ __ | |_| |
return; // | || '_ ` _ \| '_ \ / _ \| '__| __/ _` | '_ \| __| |
} // | || | | | | | |_) | (_) | | | || (_| | | | | |_|_|
// |___|_| |_| |_| .__/ \___/|_| \__\__,_|_| |_|\__(_)
console.debug("Incoming system message", data); // |_|
/** @type {string} */
const messageType = data.shift();
switch (messageType) {
case "dev":
// This is a message that tells us that the server is in
// "dev" mode, and that we should do the same.
this.dev = data[0];
this.status.textContent = "[DEV] " + this.status.textContent;
break;
case "salt":
this.salt = data[0];
console.debug("updating crypto salt", data[0]);
break;
default:
console.debug("unknown system message", messageType, data);
}
// If we're in dev mode, we should output all system messages (in a shaded/faint fashion).
if (this.dev) {
this.writeToOutput(`system message: ${messageType} = ${JSON.stringify(data)}`, { class: "debug" });
}
return;
}
// //
// "calamity" => lethal error. Close connection. // Aside from :help", ":quit", etc. we should not send
// Consider hard refresh of page to reset all variables // unsolicited messages to the server without being
handleCalamityMessage(data) { // prompted to do so.
// this.send("c", command);
// We assume that calamity errors are pre-formatted, and we do not allow }
// any of our own formatting-shenanigans to interfere with the error message
const options = { ...{ class: "error", "preformatted": true }, ...data[1] }; // ___ __ __
this.writeToOutput(data[0], options); // / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___
return; // | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \
// | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/
// \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___|
//
/** @param {any[]} data*/
onMessage(data) {
if (this.dev) {
console.debug(data);
}
const messageType = data.shift();
if (messageType === "dbg") {
return this.handleDebugMessages(data);
} }
if (messageType === "prompt") {
return this.handlePromptMessage(data);
}
if (messageType === "e") {
return this.handleErrorMessage(data);
}
if (messageType === "calamity") {
return this.handleCalamityMessage(data);
}
if (messageType === "_") {
return this.handleSystemMessages(data);
}
if (messageType === "m") {
return this.handleTextMessages(data);
}
if (this.dev) {
this.writeToOutput(
`unknown message type: ${messageType}: ${JSON.stringify(data)}`,
"debug",
);
}
console.debug("unknown message type", data);
}
//
// "m" => normal/standard message to be displayed to the user
handleTextMessages(data) {
const options = { ...data[1] }; // coerce options into an object.
// normal text message to be shown to the player
this.writeToOutput(data[0], options);
return;
}
//
// Debug messages let the server send data to be displayed on the player's screen
// and also logged to the players browser's log.
handleDebugMessages(data) {
if (!this.dev) {
return; // debug messages are thrown away if we're not in dev mode.
}
this.writeToOutput(data, { class: "debug", preformatted: true });
console.debug("DBG", data);
}
//
// "_" => system messages, not to be displayed
handleSystemMessages(data) {
if (data.length < 2) {
console.debug("malformed system message", data);
return;
}
console.debug("Incoming system message", data);
/** @type {string} */
const messageType = data.shift();
switch (messageType) {
case "dev":
// This is a message that tells us that the server is in
// "dev" mode, and that we should do the same.
this.dev = data[0];
this.status.textContent = "[DEV] " + this.status.textContent;
break;
case "salt":
this.salt = data[0];
console.debug("updating crypto salt", data[0]);
break;
default:
console.debug("unknown system message", messageType, data);
}
// If we're in dev mode, we should output all system messages (in a shaded/faint fashion).
if (this.dev) {
this.writeToOutput(
`system message: ${messageType} = ${JSON.stringify(data)}`,
{ class: "debug" },
);
}
return;
}
//
// "calamity" => lethal error. Close connection.
// Consider hard refresh of page to reset all variables
handleCalamityMessage(data) {
// //
// "e" => non-lethal errors // We assume that calamity errors are pre-formatted, and we do not allow
handleErrorMessage(data) { // any of our own formatting-shenanigans to interfere with the error message
const options = { ...{ class: "error" }, ...data[1] }; const options = { ...{ class: "error", preformatted: true }, ...data[1] };
this.writeToOutput(data[0], options); this.writeToOutput(data[0], options);
return; return;
}
//
// "e" => non-lethal errors
handleErrorMessage(data) {
const options = { ...{ class: "error" }, ...data[1] };
this.writeToOutput(data[0], options);
return;
}
//
// The prompt is the most important message type,
// it prompts us send a message back. We should not
// send messages back to the server without being
// prompted.
// In fact, we should ALWAYS be in a state of just-having-been-prompted.
handlePromptMessage(data) {
let [replyType, promptText, replyTag, options = {}] = data;
this.replyType = replyType;
this.replyTag = replyTag;
this.writeToOutput(promptText, { ...{ class: "prompt" }, ...options });
// The server has asked for a password, so we set the
// input type to password for safety reasons.
if (replyType === "password") {
this.input.type = "password";
} }
// return;
// The prompt is the most important message type, }
// it prompts us send a message back. We should not
// send messages back to the server without being
// prompted.
// In fact, we should ALWAYS be in a state of just-having-been-prompted.
handlePromptMessage(data) {
let [replyType, promptText, replyTag, options = {}] = data;
this.replyType = replyType; /**
this.replyTag = replyTag; * Add output to the text.
this.writeToOutput(promptText, { ...{ class: "prompt" }, ...options }); * @param {string} text
* @param {object} options
*/
writeToOutput(text, options = {}) {
const el = document.createElement("span");
// The server has asked for a password, so we set the if (typeof options.class === "string") {
// input type to password for safety reasons. el.className = options.class;
if (replyType === "password") {
this.input.type = "password";
}
return;
} }
/** // add end of line character "\n" unless
* Add output to the text. // options.addEol = false is set explicitly
* @param {string} text const eol = options.addEol === false ? "" : "\n";
* @param {object} options
*/
writeToOutput(text, options = {}) {
const el = document.createElement("span");
if (typeof options.class === "string") { if (options.preformatted) {
el.className = options.class; el.textContent = text + eol;
} el.className += " " + "preformatted";
} else {
// add end of line character "\n" unless el.innerHTML = crackdown(text) + eol;
// options.addEol = false is set explicitly
const eol = options.addEol === false ? "" : "\n";
if (options.preformatted) {
el.textContent = text + eol;
el.className += " " + "preformatted";
} else {
el.innerHTML = parseCrackdown(text) + eol;
}
this.output.appendChild(el);
this.output.scrollTop = this.output.scrollHeight;
} }
this.output.appendChild(el);
this.output.scrollTop = this.output.scrollHeight;
}
/** /**
* Update the status banner. * Update the status banner.
* *
* @param {string} message * @param {string} message
* @param {string} className * @param {string} className
*/ */
updateStatus(message, className) { updateStatus(message, className) {
this.status.textContent = this.dev this.status.textContent = this.dev
? `[DEV] Status: ${message}` ? `[DEV] Status: ${message}`
: `Status: ${message}`; : `Status: ${message}`;
this.status.className = className; this.status.className = className;
} }
} }
// Initialize the MUD client when the page loads // Initialize the MUD client when the page loads
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
new MUDClient(); new MUDClient();
}); });
function parseCrackdown(text) {
console.debug("starting crack parsing");
console.debug(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.debug("crack output", text);
return text;
}

View File

@@ -0,0 +1,67 @@
// ____ _ ____
// / ___|_ __ __ _ ___| | _| _ \ _____ ___ __
// | | | '__/ _` |/ __| |/ / | | |/ _ \ \ /\ / / '_ \
// | |___| | | (_| | (__| <| |_| | (_) \ V V /| | | |
// \____|_| \__,_|\___|_|\_\____/ \___/ \_/\_/ |_| |_|
//
//
// _ __ __ _ _ __ ___ ___ _ __
// | '_ \ / _` | '__/ __|/ _ \ '__|
// | |_) | (_| | | \__ \ __/ |
// | .__/ \__,_|_| |___/\___|_|
// |_|
export function crackdown(text) {
console.debug("starting crack parsing");
console.debug(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.debug("crack output", text);
return text;
}

View File

@@ -1,23 +1,31 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket MUD</title> <title>WebSocket MUD</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<div id="container"> <div id="container">
<div id="status" class="connecting">Connecting...</div> <div id="status" class="connecting">Connecting...</div>
<div id="output"></div> <div id="output"></div>
<div id="input-container"> <div id="input-container">
<input type="text" autocomplete="off" id="input" placeholder="Enter command..." disabled /> <input
<button id="send" disabled>Send</button> type="text"
</div> autocomplete="off"
</div> id="input"
placeholder="Enter command..."
disabled
autocorrect="off"
autocomplete="off"
/>
<button id="send" disabled>Send</button>
</div>
</div>
<script src="client.js"></script> <script type="module" src="client.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,136 +1,136 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap'); @import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap");
body { body {
font-family: "Fira Code", monospace; font-family: "Fira Code", monospace;
font-optical-sizing: auto; font-optical-sizing: auto;
font-size: 14px; font-size: 14px;
background-color: #1a1a1a; background-color: #1a1a1a;
color: #00ff00; color: #00ff00;
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
#container { #container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
max-width: 99.9vw; max-width: 99.9vw;
margin: 0 auto; margin: 0 auto;
padding: 10px; padding: 10px;
overflow: hidden; overflow: hidden;
} }
#output { #output {
flex: 1; flex: 1;
background-color: #000; background-color: #000;
border: 2px solid #333; border: 2px solid #333;
padding: 15px; padding: 15px;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.4; line-height: 1.4;
margin-bottom: 20px; margin-bottom: 20px;
font-family: "Fira Code", monospace; font-family: "Fira Code", monospace;
font-optical-sizing: auto; font-optical-sizing: auto;
font-size: 14px; font-size: 14px;
width: 100ch; width: 100ch;
} }
#input-container { #input-container {
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
#input { #input {
flex: 1; flex: 1;
background-color: #222; background-color: #222;
border: 2px solid #333; border: 2px solid #333;
color: #00ff00; color: #00ff00;
padding: 10px; padding: 10px;
font-family: "Fira Code", monospace; font-family: "Fira Code", monospace;
font-optical-sizing: auto; font-optical-sizing: auto;
font-size: 14px; font-size: 14px;
} }
#input:focus { #input:focus {
outline: none; outline: none;
border-color: #00ff00; border-color: #00ff00;
} }
#send { #send {
background-color: #333; background-color: #333;
border: 2px solid #555; border: 2px solid #555;
color: #00ff00; color: #00ff00;
padding: 10px 20px; padding: 10px 20px;
font-family: "Fira Code", monospace; font-family: "Fira Code", monospace;
font-optical-sizing: auto; font-optical-sizing: auto;
cursor: pointer; cursor: pointer;
} }
#send:hover { #send:hover {
background-color: #444; background-color: #444;
} }
#status { #status {
background-color: #333; background-color: #333;
padding: 5px 15px; padding: 5px 15px;
margin-bottom: 10px; margin-bottom: 10px;
border-radius: 3px; border-radius: 3px;
} }
.connected { .connected {
color: #00ff00; color: #00ff00;
} }
.disconnected { .disconnected {
color: #ff4444; color: #ff4444;
} }
.connecting { .connecting {
color: #ffaa00; color: #ffaa00;
} }
.error { .error {
color: #ff4444; color: #ff4444;
} }
.input { .input {
color: #666; color: #666;
} }
.debug { .debug {
opacity: 0.33; opacity: 0.33;
} }
.prompt { .prompt {
color: #00ccff; color: #00ccff;
} }
.bold { .bold {
font-weight: bold; font-weight: bold;
} }
.italic { .italic {
font-style: italic; font-style: italic;
} }
.strike { .strike {
text-decoration:line-through; text-decoration: line-through;
} }
.underline { .underline {
text-decoration: underline; text-decoration: underline;
} }
.undercurl { .undercurl {
text-decoration: wavy underline lime; text-decoration: wavy underline lime;
} }
.faint { .faint {
opacity: 0.42; opacity: 0.42;
} }

555
server/seeders/characerSeeder.js Normal file → Executable file
View File

@@ -3,13 +3,560 @@
// | | | '_ \ / _` | '__/ _` |/ __| __/ _ \ '__| // | | | '_ \ / _` | '__/ _` |/ __| __/ _ \ '__|
// | |___| | | | (_| | | | (_| | (__| || __/ | // | |___| | | | (_| | | | (_| | (__| || __/ |
// \____|_| |_|\__,_|_| \__,_|\___|\__\___|_| // \____|_| |_|\__,_|_| \__,_|\___|\__\___|_|
//
// ____ _ // ____ _
// / ___| ___ ___ __| | ___ _ __ // / ___| ___ ___ __| | ___ _ __
// \___ \ / _ \/ _ \/ _` |/ _ \ '__| // \___ \ / _ \/ _ \/ _` |/ _ \ '__|
// ___) | __/ __/ (_| | __/ | // ___) | __/ __/ (_| | __/ |
// |____/ \___|\___|\__,_|\___|_| // |____/ \___|\___|\__,_|\___|_|
// // ------------------------------------------------
export class CharacterSeeder { import { Character } from "../models/character.js";
} import { Game } from "../models/game.js";
import { Player } from "../models/player.js";
import * as roll from "../utils/dice.js";
import { isIdSane } from "../utils/id.js";
export class CharacterSeeder {
/** @type {Game} */
constructor(game) {
/** @type {Game} */
this.game = game;
}
/**
* Create an item, using an item blueprint with the given name
*
* @param {string} itemBlueprintId id of the item blueprint
* @returns {Item|undefined}
*/
item(itemBlueprintId) {}
/**
* @param {Character} character
* @param {...string} itemBlueprintIds
*/
addItemsToCharacter(character, ...itemBlueprintIds) {
for (const id of itemBlueprintIds) {
const blueprint = this.game.getItemBlueprint(id);
if (!blueprint) {
throw new Error(`No blueprint found for id: ${id}`);
}
const item = blueprint.createItem();
character.addItem(item);
}
}
/**
* @param {Character} character
* @param {...string} skills
*/
addSkillsToCharacter(character, ...skills) {
for (const skill of skills) {
if (!isIdSane(skill)) {
throw new Error(`Skill id >>${skill}<< is insane!`);
}
character.skills.add(skill);
}
}
/**
* Foundation function
* @name FoundationFunction
* @function
* @param {Character} The character to which we apply this foundation.
*/
createCharacter() {
const c = new Character();
//
// Initializing
//
// Rolling skills
c.awareness = roll.d6() + 2;
c.grit = roll.d6() + 2;
c.knowledge = roll.d6() + 2;
c.magic = roll.d6() + 2;
c.meleeCombat = roll.d6() + 2;
c.rangedCombat = roll.d6() + 2;
c.skulduggery = roll.d6() + 2;
switch (roll.d8()) {
case 1:
c.ancestry = "human";
// Humans get +1 to all skills
c.awareness++;
c.grit++;
c.knowledge++;
c.magic++;
c.meleeCombat++;
c.rangedCombat++;
c.skulduggery++;
break;
case 2:
c.ancestry = "dwarven";
c.meleeCombat = Math.max(c.meleeCombat, 10);
break;
case 3:
c.ancestry = "elven";
c.rangedCombat = Math.max(c.rangedCombat, 10);
break;
case 4:
c.ancestry = "giant";
c.meleeCombat = Math.max(c.grit, 10);
break;
case 5:
c.ancestry = "Gnomish";
c.meleeCombat = Math.max(c.awareness, 10);
break;
case 6:
c.ancestry = "primordial";
c.meleeCombat = Math.max(c.magic, 10);
break;
case 7:
c.ancestry = "draconic";
c.meleeCombat = Math.max(c.knowledge, 10);
break;
case 8:
c.ancestry = "demonic";
c.meleeCombat = Math.max(c.skulduggery, 10);
break;
default:
throw new Error("Logic error, ancestry d8() roll was out of scope");
}
this.applyFoundation(c);
}
/**
* Create characters for the given player
*
* The characters are automatically added to the player's party
*
* @param {Player} player
* @param {number} partySize
*
* @return {Character[]}
*/
createParty(player, partySize) {
//
for (let i = 0; i < partySize; i++) {
const character = this.createCharacter(player);
}
}
/**
* @param {Character} c
* @param {string|number} Foundation to add to character
*/
applyFoundation(c, foundation = "random") {
switch (foundation) {
case "random":
return this.applyFoundation(c, roll.dice(3));
break;
//
// Brawler
// ------
case 1:
case ":brawler":
c.foundation = "Brawler";
c.skills.add(":armor.light");
c.silver = 40;
c.maxHitPoints = c.currentHitPoints = 15;
c.itemSlots = 7;
c.meleeCombat = Math.max(c.meleeCombat, 10);
c.knowledge = Math.min(c.knowledge, 10);
this.addItemsToCharacter(
c, //
":armor.light.studded_leather",
":weapon.weird.spiked_gauntlets",
);
this.addSkillsToCharacter(c, ":weapon.weird.spiked_gauntlets");
break;
//
// DRUID
// ------
case 2:
case ":druid":
c.foundation = "Druid";
c.silver = 40;
c.maxHitPoints = this.currentHitPoints = 15;
c.itemSlots = 7;
c.meleeCombat = Math.max(this.meleeCombat, 10);
c.knowledge = Math.min(this.knowledge, 10);
this.addItemsToCharacter(
c, //
":armor.light.leather",
":weapon.light.sickle",
":kits.poisoners_kit",
":kits.healers_kit",
);
this.addSkillsToCharacter(
c, //
":armor.light.leather",
":armor.light.hide",
":weapon.light.sickle",
);
break;
case 3:
case ":fencer":
c.foundation = "Fencer";
//
// Stats
c.maxHitPoints = c.currentHitPoints = 15;
c.meleeCombat = Math.max(c.meleeCombat, 10);
c.magic = Math.min(c.magic, 10);
//
// Skills
this.addSkillsToCharacter(
c, //
":perk.two_weapons", // TODO: perks should be their own thing, and not a part of the skill system?
":armor.light",
);
//
// Gear
c.silver = 40;
c.itemSlots = 5;
this.addItemsToCharacter(
c, //
":armor.light.leather",
":weapon.light.rapier",
":weapon.light.dagger",
);
/*
//
//---------------------------------------------------------------------------------------
//HEADLINE: Fencer
//---------------------------------------------------------------------------------------
| {counter:foundation}
|Fencer
|[unstyled]
* Light Armor
|[unstyled]
* Leather
* Rapier
* Dagger
* 40 Silver Pieces
|[unstyled]
//
//---------------------------------------------------------------------------------------
//HEADLINE: GUARD
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Guard
|[unstyled]
* Medium Armor
|[unstyled]
* Halberd
* Bull's Eye Lantern
* Signal Whistle
* Map of Local Area
* 50 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Awareness raised to 10
* Melee Combat raised to 10
* Skulduggery limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: MAGICIAN
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Magician
|[unstyled]
* None
|[unstyled]
* Tier 2 Wand with random spell.
* Tier 1 Wand with random spell.
* 10 Silver Pieces
|[unstyled]
* 10 Hit Points
* 6 Item Slots
* Melee Combat limited to 10
* Ranged Combat limited to 5
* Magic raised to 10
* Grit limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: MEDIC
//---------------------------------------------------------------------------------------
| {counter:foundation}
|Medic
|[unstyled]
* Light Armor
* Medium Armor
|[unstyled]
* Club
* Sling
* 3 Daggers
* Healer's Kit
* 40 Silver Pieces
|[unstyled]
* 10 Hit Points
* 6 Item Slots
//
//---------------------------------------------------------------------------------------
//HEADLINE: RECKLESS
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Reckless
|[unstyled]
|[unstyled]
* Great Axe
* 50 Silver Pieces
|[unstyled]
* 20 Hit Points
* 7 Item Slots
* Melee Combat raised to 10
* Awareness raised to 10
* Grit raised to 10
* Magic limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: ROVER
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Rover
|[unstyled]
* Light Armor
|[unstyled]
* Leather Armor
* Short Sword
* Longbow
* Snare Maker's Kit
* 25 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Magic Reduced to 10
* Awareness raised to 10
* Ranged Combat raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SKIRMISHER
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Skirmisher
|[unstyled]
* Light Armor
* Shields
|[unstyled]
* Spear
* Small Shield
* 50 Silver Pieces
|[unstyled]
* 15 Hit Points
* 6 Item Slots
* Melee Combat raised to 10
* Awareness raised to 10
* Skulduggery raised to 10
* Grit raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SNEAK
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Sneak
|[unstyled]
* Light Armor
|[unstyled]
* 3 daggers
* Small Crossbow
* Poisoner's Kit
* 30 Silver Pieces
|[unstyled]
* 10 Hit Points
* 6 Item Slots
* Melee Combat raised to 10
* Awareness raised to 10
* Skulduggery raised to 10
* Grit raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SPELLSWORD
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Spellsword
|[unstyled]
|[unstyled]
* Tier 1 Wand with random spell.
* Longsword
* 30 Silver Pieces
|[unstyled]
* 12 Hit Points
* 5 Item Slots
* Melee Combat raised to 10
* Ranged Combat limited to 10
* Magic raised to 10
* Skulduggery limited to 10
* Grit raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SPELUNKER
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Spelunker
|[unstyled]
* None
|[unstyled]
* Spear
* Caltrops
* Bull's Eye Lantern
* Map Maker's Kit
* Chalk
* Caltrops
* 5 Silver Pieces
|[unstyled]
* 10 Hit Points
* 4 Item Slots
* Awareness raised to 10
* Melee Combat raised to 10
* Skulduggery raised to 10
* Magic limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SPIT'N'POLISH
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Spit'n' Polish
|[unstyled]
* Heavy Armor
* Shield
|[unstyled]
* Half-Plate
* Large Shield
* Long Sword
* 10 Silver Pieces
|[unstyled]
* 10 Hit Points
* 2 Item Slots
* Melee Combat raised to 10
* Magic Reduced to 6
* Awareness Reduced to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: STILETTO
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Stiletto
|[unstyled]
* Light Armor
|[unstyled]
* Padded Armor
* 3 Daggers
* Small Crossbow
* Poisoner's Kit
* 20 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Melee Combat raised to 10
* Ranged Combat raised to 10
* Awareness raised to 10
* Magic limited to 6
* Knowledge limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: Tinkerer
//---------------------------------------------------------------------------------------
| {counter:foundation}
|Tinkerer
|[unstyled]
* Light Armor
|[unstyled]
* Studded Leather
* Wrench (club)
* Tinkerer's Kit
* 30 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Awareness raised to 10
* Knowledge raised to 10
*/
//
// WTF ?!
// ------
default:
throw new Error(`Invalid foundation id ${foundation}`);
}
}
}

12
server/seeders/gameSeeder.js Normal file → Executable file
View File

@@ -1,4 +1,5 @@
import { Game } from "../models/game.js"; import { Game } from "../models/game.js";
import { CharacterSeeder } from "./characerSeeder.js";
import { ItemSeeder } from "./itemSeeder.js"; import { ItemSeeder } from "./itemSeeder.js";
import { PlayerSeeder } from "./playerSeeder.js"; import { PlayerSeeder } from "./playerSeeder.js";
@@ -10,11 +11,9 @@ import { PlayerSeeder } from "./playerSeeder.js";
* If dev mode, we create some known debug logins. (username = user, password = pass) as well as a few others * If dev mode, we create some known debug logins. (username = user, password = pass) as well as a few others
*/ */
export class GameSeeder { export class GameSeeder {
/** @returns {Game} */ /** @returns {Game} */
createGame() { createGame() {
/** @protected @constant @readonly @type {Game} */
/** @type {Game} */
this.game = new Game(); this.game = new Game();
this.work(); // Seeding may take a bit, so let's defer it so we can return early. this.work(); // Seeding may take a bit, so let's defer it so we can return early.
@@ -23,11 +22,12 @@ export class GameSeeder {
} }
work() { work() {
console.info("seeding..."); console.info("seeding");
// //
(new PlayerSeeder(this.game)).seed(); // Create debug players new PlayerSeeder(this.game).seed(); // Create debug players
(new ItemSeeder(this.game)).seed(); // Create items, etc. new ItemSeeder(this.game).seed(); // Create items, etc.
new CharacterSeeder(this.game).createParty(this.game.getPlayer("user"), 3); // Create debug characters.
// //
// Done // Done

View File

@@ -1,5 +1,5 @@
import { Game } from "../models/game.js"; import { Game } from "../models/game.js";
import { ItemTemplate } from "../models/item.js"; import { ItemBlueprint } from "../models/item.js";
// //
// ___ _ _____ _ _ // ___ _ _____ _ _
@@ -9,16 +9,15 @@ import { ItemTemplate } from "../models/item.js";
// |___|\__\___|_| |_| |_| |_|\___|_| |_| |_| .__/|_|\__,_|\__\___||___/ // |___|\__\___|_| |_| |_| |_|\___|_| |_| |_| .__/|_|\__,_|\__\___||___/
// |_| // |_|
// //
// Seed the Game.itemTemplate store // Seed the Game.ItemBlueprint store
export class ItemSeeder { export class ItemSeeder {
/** @param {Game} game */ /** @param {Game} game */
constructor(game) { constructor(game) {
this.game = game; this.game = game;
} }
seed() { seed() {
//
// __ __ // __ __
// \ \ / /__ __ _ _ __ ___ _ __ ___ // \ \ / /__ __ _ _ __ ___ _ __ ___
// \ \ /\ / / _ \/ _` | '_ \ / _ \| '_ \/ __| // \ \ /\ / / _ \/ _` | '_ \ / _ \| '_ \/ __|
@@ -26,46 +25,88 @@ export class ItemSeeder {
// \_/\_/ \___|\__,_| .__/ \___/|_| |_|___/ // \_/\_/ \___|\__,_| .__/ \___/|_| |_|___/
// |_| // |_|
//------------------------------------------------------- //-------------------------------------------------------
this.game.createItemTemplate("weapons.light.dagger", { this.game.addItemBlueprint(":weapon.light.dagger", {
name: "Dagger", name: "Dagger",
description: "Small shady blady", description: "Small shady blady",
itemSlots: 0.5, itemSlots: 0.5,
damage: 3, damage: 3,
melee: true, melee: true,
skills: [":weapon.light"],
ranged: true, ranged: true,
specialEffect: "effects.weapons.fast", specialEffect: ":effect.weapon.fast",
}); });
this.game.createItemTemplate("weapons.light.sickle", {
this.game.addItemBlueprint(":weapon.light.sickle", {
name: "Sickle", name: "Sickle",
description: "For cutting nuts, and branches", description: "For cutting nuts, and branches",
itemSlots: 1, itemSlots: 1,
damage: 4, damage: 4,
specialEffect: "effects.weapons.sickle", skills: [":weapon.light"],
specialEffect: ":effect.weapon.sickle",
}); });
this.game.createItemTemplate("weapons.light.spiked_gauntlets", {
this.game.addItemBlueprint(":weapon.weird.spiked_gauntlets", {
name: "Spiked Gauntlets", name: "Spiked Gauntlets",
description: "Spikes with gauntlets on them!", description: "Spikes with gauntlets on them!",
itemSlots: 1, itemSlots: 1,
damage: 5, damage: 5,
skills: [
// Spiked gauntlets are :Weird so you must be specially trained to use them.
// This is done by having a skill that exactly matches the weapon's blueprintId
":weapon.weird.spiked_gauntlets",
],
specialEffect: "TBD", specialEffect: "TBD",
}); });
//
// _ // _
// / \ _ __ _ __ ___ ___ _ __ ___ // / \ _ __ _ __ ___ ___ _ __ ___
// / _ \ | '__| '_ ` _ \ / _ \| '__/ __| // / _ \ | '__| '_ ` _ \ / _ \| '__/ __|
// / ___ \| | | | | | | | (_) | | \__ \ // / ___ \| | | | | | | | (_) | | \__ \
// /_/ \_\_| |_| |_| |_|\___/|_| |___/ // /_/ \_\_| |_| |_| |_|\___/|_| |___/
// --------------------------------------- // ---------------------------------------
// this.game.addItemBlueprint(":armor.light.studded_leather", {
this.game.createItemTemplate("armors.light.studded_leather", { name: "Studded Leather Armor",
name: "Studded Leather",
description: "Padded and hardened leather with metal stud reinforcement", description: "Padded and hardened leather with metal stud reinforcement",
itemSlots: 3, itemSlots: 3,
specialEffect: "TBD", specialEffect: "TBD",
skills: [":armor.light"],
armorHitPoints: 10,
});
this.game.addItemBlueprint(":armor.light.leather", {
name: "Leather Armor",
description: "Padded and hardened leather",
itemSlots: 2,
specialEffect: "TBD",
skills: [":armor.light"],
armorHitPoints: 6,
}); });
console.log(this.game._itemTemplates); console.log(this.game._itemBlueprints);
//
// _ ___ _
// | |/ (_) |_ ___
// | ' /| | __/ __|
// | . \| | |_\__ \
// |_|\_\_|\__|___/
// -------------------
this.game.addItemBlueprint(":kit.poisoners_kit", {
name: "Poisoner's Kit",
description: "Allows you to create poisons that can be applied to weapons",
itemSlots: 2,
specialEffect: "TBD",
count: 20,
maxCount: 20,
});
this.game.addItemBlueprint(":kit.healers_kit", {
name: "Healer's Kit",
description: "Allows you to heal your teammates outside of combat",
itemSlots: 2,
specialEffect: "TBD",
count: 20,
maxCount: 20,
});
} }
} }

View File

@@ -2,34 +2,33 @@ import { Game } from "../models/game.js";
import { Player } from "../models/player.js"; import { Player } from "../models/player.js";
export class PlayerSeeder { export class PlayerSeeder {
/** @param {Game} game */ /** @param {Game} game */
constructor(game) { constructor(game) {
/** @type {Game} */
this.game = game;
}
/** @type {Game} */ seed() {
this.game = game; // Examples of the word "pass" hashed by the client and then the server:
} // Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them.
// "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4
// "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef
//
// 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.
seed() { this.game.createPlayer(
// Examples of the word "pass" hashed by the client and then the server: "user",
// Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them. "1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef",
// "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4 "userSalt",
// "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef );
//
// 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.
this.game.createPlayer( this.game.createPlayer(
"user", "admin",
"1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef", "1000:a84760824d28a9b420ee5f175a04d1e3:a6694e5c9fd41d8ee59f0a6e34c822ee2ce337c187e2d5bb5ba8612d6145aa8e",
"userSalt", "adminSalt",
); );
}
this.game.createPlayer(
"admin",
"1000:a84760824d28a9b420ee5f175a04d1e3:a6694e5c9fd41d8ee59f0a6e34c822ee2ce337c187e2d5bb5ba8612d6145aa8e",
"adminSalt",
);
}
} }

View File

@@ -5,169 +5,186 @@ import fs from "fs";
import { Game } from "./models/game.js"; import { Game } from "./models/game.js";
import * as msg from "./utils/messages.js"; import * as msg from "./utils/messages.js";
import { Session } from "./models/session.js"; import { Session } from "./models/session.js";
import { AuthState } from "./states/Auth.js"; import { AuthState } from "./states/authState.js";
import { GameSeeder } from "./seeders/gameSeeder.js"; import { GameSeeder } from "./seeders/gameSeeder.js";
import { Config } from "./config.js"; import { Config } from "./config.js";
class MudServer { class MudServer {
constructor() {
/** @type {Game} */
this.game = new GameSeeder().createGame();
}
constructor() { // ____ ___ _ _ _ _ _____ ____ _____ _____ ____
/** @type {Game} */ // / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \
this.game = (new GameSeeder()).createGame(); // | | | | | | \| | \| | _|| | | | | _| | | | |
} // | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| |
// \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/
//------------------------------------------------------
// Handle New Socket Connections
//------------------------------
/** @param {WebSocket} websocket */
onConnectionEstabished(websocket) {
console.log("New connection established");
const session = new Session(websocket, this.game);
session.sendSystemMessage("dev", true);
// ____ ___ _ _ _ _ _____ ____ _____ _____ ____ // ____ _ ___ ____ _____
// / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \ // / ___| | / _ \/ ___|| ____|
// | | | | | | \| | \| | _|| | | | | _| | | | | // | | | | | | | \___ \| _|
// | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| | // | |___| |__| |_| |___) | |___
// \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/ // \____|_____\___/|____/|_____|
//------------------------------------------------------ //-------------------------------
// Handle New Socket Connections // Handle Socket Closing
//------------------------------ //----------------------
/** @param {WebSocket} websocket */ websocket.on("close", () => {
onConnectionEstabished(websocket) { if (!session.player) {
console.log("New connection established"); console.info("A player without a session disconnected");
const session = new Session(websocket, this.game); return;
session.sendSystemMessage("dev", true) }
//-------------
// 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`);
});
// ____ _ ___ ____ _____ // __ __ _____ ____ ____ _ ____ _____
// / ___| | / _ \/ ___|| ____| // | \/ | ____/ ___/ ___| / \ / ___| ____|
// | | | | | | | \___ \| _| // | |\/| | _| \___ \___ \ / _ \| | _| _|
// | |___| |__| |_| |___) | |___ // | | | | |___ ___) |__) / ___ \ |_| | |___
// \____|_____\___/|____/|_____| // |_| |_|_____|____/____/_/ \_\____|_____|
//------------------------------- //--------------------------------------------
// Handle Socket Closing // HANDLE INCOMING MESSAGES
//---------------------- //-------------------------
websocket.on("close", () => { websocket.on("message", (data) => {
if (!session.player) { try {
console.info("A player without a session disconnected"); console.debug("incoming websocket message %s", data);
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`);
}); 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());
// | \/ | ____/ ___/ ___| / \ / ___| ____|
// | |\/| | _| \___ \___ \ / _ \| | _| _|
// | | | | |___ ___) |__) / ___ \ |_| | |___
// |_| |_|_____|____/____/_/ \_\____|_____|
//--------------------------------------------
// HANDLE INCOMING MESSAGES
//-------------------------
websocket.on("message", (data) => {
try {
console.debug("incoming websocket message %s", data);
if (!session.state) { if (msgObj.isQuitCommand()) {
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!?")); // TODO TODO TODO TODO
return; //---------------------
} // Set state = QuitState
//
websocket.send(
msg.prepare(
msg.MESSAGE,
"The quitting quitter quits... Typical. Cya!",
),
);
websocket.close();
return;
}
const msgObj = new msg.ClientMessage(data.toString()); 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));
}
});
if (msgObj.isQuitCommand()) { session.setState(new AuthState(session));
//--------------------- }
// 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) { // Start the server
console.trace("received an invalid message (error: %s)", error, data.toString(), data); //-----------------
websocket.send(msg.prepare( start() {
msg.CALAMITY, //
error // The file types we allow to be served.
)); const contentTypes = {
} ".js": "application/javascript",
}); ".css": "text/css",
".html": "text/html",
};
session.setState(new AuthState(session)); //
} // 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 not found`);
// Start the server console.log("Bad http request", req.url);
//----------------- return;
start() { }
// //
// The file types we allow to be served. // Check if the file exists.
const contentTypes = { fs.readFile(filePath, (err, data) => {
".js": "application/javascript", if (err) {
".css": "text/css", res.writeHead(404);
".html": "text/html", res.end(`File not found`);
}; console.log("Bad http request", req.url);
return;
}
res.writeHead(200, { "Content-Type": contentType });
res.end(data);
});
});
// //
// Create HTTP server for serving the client - Consider moving to own file // Create WebSocket server
const httpServer = http.createServer((req, res) => { const websocketServer = new WebSocketServer({ server: httpServer });
let filePath = path.join("public", req.url === "/" ? "index.html" : req.url);
const ext = path.extname(filePath);
const contentType = contentTypes[ext];
// websocketServer.on("connection", (ws) => {
// Check if the requested file has a legal file type. this.onConnectionEstabished(ws);
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;
}
console.info(`running environment: ${Config.env}`);
// httpServer.listen(Config.port, () => {
// Check if the file exists. console.log(`NUUHD server running on port ${Config.port}`);
fs.readFile(filePath, (err, data) => { console.log(`WebSocket server ready for connections`);
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 environment: ${Config.env}`);
httpServer.listen(Config.port, () => {
console.log(`NUUHD server running on port ${Config.port}`);
console.log(`WebSocket server ready for connections`);
});
}
} }
// __ __ _ ___ _ _ // __ __ _ ___ _ _

View File

@@ -1,184 +0,0 @@
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { CreatePlayerState } from "./createPlayer.js";
import { JustLoggedInState } from "./justLoggedIn.js";
import { Session } from "../models/session.js";
import { Config } from "../config.js";
const STATE_EXPECT_USERNAME = "promptUsername";
const STATE_EXPECT_PASSWORD = "promptPassword";
const USERNAME_PROMPT = [
"Please enter your _username_:",
"((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()) {
console.debug("what?!", message);
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 config/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;
}
this.player = this.session.game.getPlayer(message.username);
//
// handle invalid username
if (!this.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.subState = STATE_EXPECT_PASSWORD;
this.session.sendSystemMessage("salt", this.player.salt);
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;
}
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = new Date() + Config.maxFailedLogins,
this.session.sendCalamity("You have been locked out for too many failed password attempts, come back later");
this.session.close();
return;
}
//
// Handle blocked users.
// They don't even get to have their password verified.
if (this.player.blockedUntil > (new Date())) {
this.session.sendCalamity("You have been locked out for too many failed password attempts, come back later");
this.session.close();
return;
}
//
// Verify the password against the hash we've stored.
if (!security.verifyPassword(message.password, this.player.passwordHash)) {
this.session.sendError("Incorrect password!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`);
return;
}
this.player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0;
this.session.player = this.player;
//
// Password correct, check if player is an admin
if (this.player.isAdmin) {
// set state AdminJustLoggedIn
}
//
// Password was correct, go to main game
this.session.setState(new JustLoggedInState(this.session));
}
}

185
server/states/authState.js Executable file
View File

@@ -0,0 +1,185 @@
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { PlayerCreationState } from "./playerCreationState.js";
import { JustLoggedInState } from "./justLoggedIn.js";
import { Session } from "../models/session.js";
import { Config } from "../config.js";
const STATE_EXPECT_USERNAME = "promptUsername";
const STATE_EXPECT_PASSWORD = "promptPassword";
const USERNAME_PROMPT = [
"Please enter your _username_:",
"((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()) {
console.debug("what?!", message);
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 config/env
this.session.setState(new PlayerCreationState(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;
}
this.player = this.session.game.getPlayer(message.username);
//
// handle invalid username
if (!this.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.subState = STATE_EXPECT_PASSWORD;
this.session.sendSystemMessage("salt", this.player.salt);
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;
}
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
((this.blockedUntil = new Date() + Config.maxFailedLogins),
this.session.sendCalamity(
"You have been locked out for too many failed password attempts, come back later",
));
this.session.close();
return;
}
//
// Handle blocked users.
// They don't even get to have their password verified.
if (this.player.blockedUntil > new Date()) {
this.session.sendCalamity(
"You have been locked out for too many failed password attempts, come back later",
);
this.session.close();
return;
}
//
// Verify the password against the hash we've stored.
if (!security.verifyPassword(message.password, this.player.passwordHash)) {
this.session.sendError("Incorrect password!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(
`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`,
);
return;
}
this.player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0;
this.session.player = this.player;
//
// Password correct, check if player is an admin
if (this.player.isAdmin) {
// set state AdminJustLoggedIn
}
//
// Password was correct, go to main game
this.session.setState(new JustLoggedInState(this.session));
}
}

View File

@@ -7,44 +7,44 @@ import { Session } from "../models/session.js";
* It's here we listen for player commands. * It's here we listen for player commands.
*/ */
export class AwaitCommandsState { export class AwaitCommandsState {
/** /**
* @param {Session} session * @param {Session} session
*/ */
constructor(session) { constructor(session) {
/** @type {Session} */ /** @type {Session} */
this.session = session; this.session = session;
} }
onAttach() { onAttach() {
console.log("Session is entering the “main” state"); console.log("Session is entering the “main” state");
this.session.sendMessage("Welcome to the game!"); this.session.sendMessage("Welcome to the game!");
} }
/** @param {msg.ClientMessage} message */ /** @param {msg.ClientMessage} message */
onMessage(message) { onMessage(message) {
if (message.hasCommand()) { if (message.hasCommand()) {
this.handleCommand(message); this.handleCommand(message);
}
} }
}
/** @param {msg.ClientMessage} message */ /** @param {msg.ClientMessage} message */
handleCommand(message) { handleCommand(message) {
switch (message.command) { switch (message.command) {
case "help": case "help":
this.session.sendFigletMessage("HELP"); this.session.sendFigletMessage("HELP");
this.session.sendMessage([ this.session.sendMessage([
"---------------------------------------", "---------------------------------------",
" *:help* this help screen", " *:help* this help screen",
" *:quit* quit the game", " *:quit* quit the game",
"---------------------------------------", "---------------------------------------",
]); ]);
break; break;
case "quit": case "quit":
this.session.sendMessage("The quitting quitter quits, typical... Cya"); this.session.sendMessage("The quitting quitter quits, typical... Cya");
this.session._websocket.close(); this.session._websocket.close();
break; break;
default: default:
this.session.sendMessage(`Unknown command: ${message.command}`); this.session.sendMessage(`Unknown command: ${message.command}`);
}
} }
}
} }

View File

@@ -1,109 +0,0 @@
import figlet from "figlet";
import { Session } from "../models/session.js";
import { ClientMessage } from "../utils/messages.js";
import { frameText } from "../utils/tui.js";
import { Config } from "../config.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;
//NOTE: could use async to optimize performance
const createPartyLogo = frameText(
figlet.textSync("Create Your Party"),
{ vPadding: 0, frameChars: "§=§§§§§§" },
);
this.session.sendMessage(createPartyLogo, { preformatted: true });
this.session.sendMessage([
"",
`Current party size: ${charCount}`,
`Max party size: ${Config.maxPartySize}`,
]);
const min = 1;
const max = Config.maxPartySize - charCount;
const prompt = [
`Please enter an integer between ${min} - ${max}`,
"((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) => {
if (message.isHelpCommand()) {
const mps = Config.maxPartySize; // short var name for easy doctype writing.
this.session.sendMessage([
`Your party can consist of 1 to ${mps} characters.`,
"",
"* Large parties tend live longer",
`* If you have fewer than ${mps} 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

@@ -1,165 +0,0 @@
import { Session } from "../models/session.js";
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { Player } from "../models/player.js";
import { AuthState } from "./Auth.js";
import { Config } from "../config.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() {
//
// If there are too many players, stop allowing new players in.
if (this.session.game._players.size >= Config.maxPlayers) {
this.session.sendCalamity("Server is full, no more players can be created");
this.session.close();
}
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 player = this.session.game.createPlayer(message.username);
//
// handle taken/occupied username
if (player === false) {
// 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 = player;
this.session.sendSystemMessage("salt", player.salt);
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

@@ -3,11 +3,11 @@ import { Session } from "../models/session.js";
/** @interface */ /** @interface */
export class StateInterface { export class StateInterface {
/** @param {Session} session */ /** @param {Session} session */
constructor(session) { } constructor(session) {}
onAttach() { } onAttach() {}
/** @param {ClientMessage} message */ /** @param {ClientMessage} message */
onMessage(message) {} onMessage(message) {}
} }

View File

@@ -1,33 +1,35 @@
import { Session } from "../models/session.js"; import { Session } from "../models/session.js";
import { CharacterCreationState } from "./characterCreation.js"; import { PartyCreationState } from "./partyCreationState.js";
import { AwaitCommandsState } from "./awaitCommands.js"; import { AwaitCommandsState } from "./awaitCommands.js";
/** @interface */ /** @interface */
export class JustLoggedInState { export class JustLoggedInState {
/** @param {Session} session */ /** @param {Session} session */
constructor(session) { constructor(session) {
/** @type {Session} */ /** @type {Session} */
this.session = 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 PartyCreationState(this.session));
return;
} }
// Show welcome screen this.session.setState(new AwaitCommandsState(this.session));
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

@@ -0,0 +1,108 @@
import figlet from "figlet";
import { Session } from "../models/session.js";
import { ClientMessage } from "../utils/messages.js";
import { frameText } from "../utils/tui.js";
import { Config } from "../config.js";
export class PartyCreationState {
/**
* @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;
//NOTE: could use async to optimize performance
const createPartyLogo = frameText(figlet.textSync("Create Your Party"), {
vPadding: 0,
frameChars: "§=§§§§§§",
});
this.session.sendMessage(createPartyLogo, { preformatted: true });
this.session.sendMessage([
"",
`Current party size: ${charCount}`,
`Max party size: ${Config.maxPartySize}`,
]);
const min = 1;
const max = Config.maxPartySize - charCount;
const prompt = [
`Please enter an integer between ${min} - ${max}`,
"((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) => {
if (message.isHelpCommand()) {
const mps = Config.maxPartySize; // short var name for easy doctype writing.
this.session.sendMessage([
`Your party can consist of 1 to ${mps} characters.`,
"",
"* Large parties tend live longer",
`* If you have fewer than ${mps} 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,173 @@
import { Session } from "../models/session.js";
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { Player } from "../models/player.js";
import { AuthState } from "./authState.js";
import { Config } from "../config.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 PlayerCreationState {
/**
* @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() {
//
// If there are too many players, stop allowing new players in.
if (this.session.game._players.size >= Config.maxPlayers) {
this.session.sendCalamity(
"Server is full, no more players can be created",
);
this.session.close();
}
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 player = this.session.game.createPlayer(message.username);
//
// handle taken/occupied username
if (player === false) {
// 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 = player;
this.session.sendSystemMessage("salt", player.salt);
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

@@ -1,7 +1,9 @@
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: 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 ### ASCII Shading Characters
These are basic ASCII characters often used for shading: These are basic ASCII characters often used for shading:
``` ```
Light shade: ░ (U+2591) Light shade: ░ (U+2591)
Medium shade: ▒ (U+2592) Medium shade: ▒ (U+2592)
@@ -11,7 +13,9 @@ Half block: ▄ (U+2584), ▀ (U+2580)
``` ```
### Additional UTF-8 Block Characters ### Additional UTF-8 Block Characters
These Unicode characters offer more granular shading or block patterns: These Unicode characters offer more granular shading or block patterns:
``` ```
Light block: ░ (U+2591) Light block: ░ (U+2591)
Medium block: ▒ (U+2592) Medium block: ▒ (U+2592)
@@ -26,7 +30,9 @@ Checkerboard: ▚ (U+259A), ▞ (U+259E)
``` ```
### Example Usage ### Example Usage
Heres an example of a simple shading gradient using some of these characters: Heres an example of a simple shading gradient using some of these characters:
``` ```
Light to Dark: ░ ▒ ▓ █ Light to Dark: ░ ▒ ▓ █
Half blocks: ▀ ▄ ▌ ▐ Half blocks: ▀ ▄ ▌ ▐
@@ -34,16 +40,17 @@ Quadrant pattern: ▖ ▗ ▘ ▝
``` ```
### Notes ### Notes
- Not all terminals or text editors display Unicode characters consistently, so ASCII characters like `.:;#+` are sometimes used for basic shading in simpler environments. - 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 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. - 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.
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 ### ASCII Characters for Frames
These are basic ASCII characters that work universally in most terminals: These are basic ASCII characters that work universally in most terminals:
``` ```
Horizontal line: - (U+002D) Horizontal line: - (U+002D)
Vertical line: | (U+007C) Vertical line: | (U+007C)
@@ -56,6 +63,7 @@ Corners:
``` ```
Example simple ASCII window: Example simple ASCII window:
``` ```
+----------+ +----------+
| Content | | Content |
@@ -63,9 +71,11 @@ Example simple ASCII window:
``` ```
### UTF-8 Box-Drawing Characters ### 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: 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 #### Single-Line Box Drawing
``` ```
Horizontal line: ─ (U+2500) Horizontal line: ─ (U+2500)
Vertical line: │ (U+2502) Vertical line: │ (U+2502)
@@ -83,6 +93,7 @@ Intersections:
``` ```
Example single-line window: Example single-line window:
``` ```
┌──────────┐ ┌──────────┐
│ Content │ │ Content │
@@ -90,6 +101,7 @@ Example single-line window:
``` ```
#### Double-Line Box Drawing #### Double-Line Box Drawing
``` ```
Horizontal line: ═ (U+2550) Horizontal line: ═ (U+2550)
Vertical line: ║ (U+2551) Vertical line: ║ (U+2551)
@@ -107,6 +119,7 @@ Intersections:
``` ```
Example double-line window: Example double-line window:
``` ```
╔══════════╗ ╔══════════╗
║ Content ║ ║ Content ║
@@ -114,7 +127,9 @@ Example double-line window:
``` ```
#### Mixed and Other Box-Drawing Characters #### Mixed and Other Box-Drawing Characters
For more complex designs, you can mix single and double lines or use specialized characters: For more complex designs, you can mix single and double lines or use specialized characters:
``` ```
Single to double transitions: Single to double transitions:
Horizontal single to double: ╼ (U+257C) Horizontal single to double: ╼ (U+257C)
@@ -127,6 +142,7 @@ Rounded corners (less common, not always supported):
``` ```
Example with rounded corners: Example with rounded corners:
``` ```
╭──────────╮ ╭──────────╮
│ Content │ │ Content │
@@ -134,7 +150,9 @@ Example with rounded corners:
``` ```
### Additional UTF-8 Characters for Decoration ### Additional UTF-8 Characters for Decoration
These can enhance the appearance of your TUI: These can enhance the appearance of your TUI:
``` ```
Block elements for borders or shading: Block elements for borders or shading:
Full block: █ (U+2588) Full block: █ (U+2588)
@@ -145,7 +163,9 @@ Dark shade: ▓ (U+2593)
``` ```
### Example TUI Window with Content ### Example TUI Window with Content
Heres a sample of a more complex window using single-line box-drawing characters: Heres a sample of a more complex window using single-line box-drawing characters:
``` ```
┌────────────────────┐ ┌────────────────────┐
│ My TUI Window │ │ My TUI Window │
@@ -156,12 +176,14 @@ Heres a sample of a more complex window using single-line box-drawing charact
``` ```
### Notes for ncurses ### 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. - **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). - **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. - **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. - **ncurses Functions**: Use `box()` in ncurses to draw a border around a window automatically, or manually print characters with `mvaddch()` for custom designs.
### Tips ### Tips
- Combine single and double lines for visual hierarchy (e.g., double lines for outer windows, single lines for inner sections). - 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 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. - If you want a particular style (e.g., heavy lines, dashed lines, or specific layouts), please clarify, and Ill tailor the response.

View File

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

View File

@@ -1,29 +1,52 @@
export function cleanName(s) { const UID_DIGITS = 12;
if (typeof s !== "string") { const MINI_UID_REGEX = /\.uid\.[a-z0-9]{6,}$/;
throw new Error("String expected, but got a ", typeof s); const ID_SANITY_REGEX = /^:([a-z0-9]+\.)*[a-z0-9_]+$/;
/**
* Sanity check a string to see if it is a potential id.
*
* @param {string} id
* @returns {boolean}
*/
export function isIdSane(id) {
if (typeof id !== "string") {
return false;
} }
return s
.toLowerCase() if (id.length < 2) {
.replace(" ", "_") return false;
.replace(/[^a-zA-Z0-9_]/, "_"); }
return ID_SANITY_REGEX.test(id);
} }
/** /**
* @returns {string} crypto-unsafe pseudo random number.
*
* Generate a random number, convert it to base36, and return it as a string with 7-8 characters. * Generate a random number, convert it to base36, and return it as a string with 7-8 characters.
*/ */
export function miniUid() { export function miniUid() {
// we use 12 digits, but we could go up to 16 // we use 12 digits, but we could go up to 16
return Number(Math.random().toFixed(12).substring(2)).toString(36); return Number(Math.random().toFixed(UID_DIGITS).substring(2)).toString(36);
} }
/** /**
* Generate an id from a name * Generate an id from a string
* @param {string[]} str
*/ */
export function fromName(...names) { export function appendMiniUid(str) {
let res = ""; return str + ".uid." + miniUid();
for (const name of names) { }
res += ":" + cleanName(name);
} /**
* Does a given string end with ".uid.23khtasdz", etc.
return res + ":" + miniUid(); *
* @param {string} str
*/
export function endsWithMiniUid(str) {
return MINI_UID_REGEX.test(str);
}
export function appendOrReplaceMiniUid(str) {
return appendMiniUid(str.replace(MINI_UID_REGEX, ""));
} }

View File

@@ -7,8 +7,8 @@
*/ */
export const CALAMITY = "calamity"; export const CALAMITY = "calamity";
/** /**
* Tell recipient that an error has occurred * Tell recipient that an error has occurred
* *
* Server-->Client-->Player * Server-->Client-->Player
*/ */
@@ -21,7 +21,6 @@ export const ERROR = "e";
*/ */
export const MESSAGE = "m"; export const MESSAGE = "m";
/** /**
* Player has entered data, and sends it to server. * Player has entered data, and sends it to server.
* *
@@ -77,110 +76,126 @@ export const DEBUG = "dbg";
* Represents a message sent from client to server. * Represents a message sent from client to server.
*/ */
export class ClientMessage { export class ClientMessage {
/** /**
* @protected * @protected
* @type {any[]} _arr The array that contains the message data * @type {any[]} _arr The array that contains the message data
*/ */
_attr; _attr;
/** The message type. /** The message type.
* *
* One of the * constants from this document. * One of the * constants from this document.
* *
* @returns {string} * @returns {string}
*/ */
get type() { get type() {
return this._attr[0]; return this._attr[0];
}
/**
* @param {string} msgData the raw text data in the websocket message.
*/
constructor(msgData) {
if (typeof msgData !== "string") {
throw new Error(
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData,
);
return;
} }
/** try {
* @param {string} msgData the raw text data in the websocket message. this._attr = JSON.parse(msgData);
*/ } catch (_) {
constructor(msgData) { throw new Error(
if (typeof msgData !== "string") { `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 not even a string, it was a " + typeof msgData); );
return;
}
try {
this._attr = JSON.parse(msgData);
} catch (_) {
throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`);
}
if (!Array.isArray(this._attr)) {
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`);
}
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");
}
}
hasCommand() {
return this._attr.length > 1 && this._attr[0] === COMMAND;
} }
/** Does this message contain a username-response from the client? */ if (!Array.isArray(this._attr)) {
isUsernameResponse() { throw new Error(
return this._attr.length === 4 `Could not create client message. Excpected an array, but got a ${typeof this._attr}`,
&& this._attr[0] === REPLY );
&& this._attr[1] === "username"
&& typeof this._attr[2] === "string";
} }
/** Does this message contain a password-response from the client? */ if (this._attr.length < 1) {
isPasswordResponse() { throw new Error(
return this._attr.length === 4 "Could not create client message. Excpected an array with at least 1 element, but got an empty one",
&& this._attr[0] === REPLY );
&& this._attr[1] === "password" }
&& typeof this._attr[2] === "string"; }
hasCommand() {
return this._attr.length > 1 && this._attr[0] === COMMAND;
}
/** Does this message contain a username-response from the client? */
isUsernameResponse() {
return (
this._attr.length === 4 &&
this._attr[0] === REPLY &&
this._attr[1] === "username" &&
typeof this._attr[2] === "string"
);
}
/** Does this message contain a password-response from the client? */
isPasswordResponse() {
return (
this._attr.length === 4 &&
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 === 4 &&
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;
} }
/** @returns {boolean} does this message indicate the player wants to quit */ return Number.parseInt(this._attr[2]);
isQuitCommand() { }
return this._attr[0] === QUIT
}
isHelpCommand() { /** @returns {string|false} Get the username stored in this message */
return this._attr[0] === HELP get username() {
} return this.isUsernameResponse() ? this._attr[2] : false;
}
/** @returns {boolean} is this a debug message? */ /** @returns {string|false} Get the password stored in this message */
isDebug() { get password() {
return this._attr.length === 2 && this._attr[0] === DEBUG; return this.isPasswordResponse() ? this._attr[2] : false;
} }
isIntegerResponse() { /** @returns {string} */
return this._attr.length === 4 get command() {
&& this._attr[0] === REPLY return this.hasCommand() ? this._attr[1] : false;
&& 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]);
}
/** @returns {string|false} Get the username stored in this message */
get username() {
return this.isUsernameResponse() ? this._attr[2] : false;
}
/** @returns {string|false} Get the password stored in this message */
get password() {
return this.isPasswordResponse() ? this._attr[2] : false;
}
/** @returns {string} */
get command() {
return this.hasCommand() ? this._attr[1] : false;
}
} }
/** /**
@@ -190,5 +205,5 @@ export class ClientMessage {
* @param {...any} args * @param {...any} args
*/ */
export function prepare(messageType, ...args) { export function prepare(messageType, ...args) {
return JSON.stringify([messageType, ...args]); return JSON.stringify([messageType, ...args]);
} }

View File

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

View File

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