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

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

View File

@@ -9,17 +9,12 @@
"request": "launch", "request": "launch",
"name": "Launch with Nodemon", "name": "Launch with Nodemon",
"runtimeExecutable": "nodemon", "runtimeExecutable": "nodemon",
"runtimeArgs": [ "runtimeArgs": ["--inspect=9229", "server.js"],
"--inspect=9229",
"server.js"
],
"env": { "env": {
"NODE_ENV": "dev", "NODE_ENV": "dev"
}, },
"restart": true, "restart": true,
"skipFiles": [ "skipFiles": ["<node_internals>/**"]
"<node_internals>/**"
]
} }
] ]
} }

View File

@@ -3,10 +3,10 @@ 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.
@@ -49,5 +49,3 @@ export const Config = {
*/ */
accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes. accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes.
}; };

View File

@@ -3,11 +3,11 @@
<!-- 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 {
@@ -19,17 +19,15 @@
</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
@@ -42,9 +40,9 @@
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;
} }
// /** Add an item to the equipment list
// Initializing * @param {Item} item
// * @param {number} count
*
// Rolling skills * Maybe return the accumulated ItemSlots used?
*/
/** @type {number} Awareness Skill */ addItem(item, count = 1) {
this.awareness = roll.d6() + 2; if (!Number.isInteger(count)) {
throw new Error("Number must be an integer");
/** @type {number} Grit Skill */ }
this.grit = roll.d6() + 2; if (!(item instanceof Item)) {
console.debug("bad item", item);
/** @type {number} Knowledge Skill */ throw new Error("item must be an instance of Item!");
this.knowledge = roll.d6() + 2; }
if (count <= 0) {
/** @type {number} Magic Skill */ throw new Error("Number must be > 0");
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");
} }
// const existingItemCount = this.items.get(item) || 0;
// 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.items.set(item, count + existingItemCount);
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; // todo removeItem(item, count)
this.maxHitPoints = 20;
this.currentHitPoints = 20;
// default:
// throw new Error(`Logic error, foundation d15 roll of ${foundationRoll} roll was out of scope`);
}
}
} }

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

@@ -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,7 +8,6 @@ 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 */ /** @protected @type {string} unique username */
_username; _username;
get username() { get username() {
@@ -35,7 +35,6 @@ export class Player {
/** @type {Date} */ /** @type {Date} */
blockedUntil; blockedUntil;
/** @type {Date|null} Date of the player's last websocket message. */ /** @type {Date|null} Date of the player's last websocket message. */
lastActivityAt = null; lastActivityAt = null;
@@ -69,4 +68,24 @@ export class Player {
setPasswordHash(hashedPassword) { setPasswordHash(hashedPassword) {
this._passwordHash = 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;
}
if (this._characters.size >= Config.maxPartySize) {
return false;
}
this._characters.add(character);
return this._characters.size;
}
} }

View File

@@ -1,12 +1,11 @@
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} */ /** @protected @type {StateInterface} */
_state; _state;
get state() { get state() {
@@ -27,7 +26,6 @@ export class Session {
/** @param {Player} player */ /** @param {Player} player */
set player(player) { set player(player) {
if (player instanceof Player) { if (player instanceof Player) {
this._player = player; this._player = player;
return; return;
@@ -38,10 +36,11 @@ export class Session {
return; return;
} }
throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`); throw Error(
`Can only set player to null or instance of Player, but received ${typeof player}`,
);
} }
/** @type {WebSocket} */ /** @type {WebSocket} */
_websocket; _websocket;
@@ -91,7 +90,7 @@ export class Session {
* @param {string|string[]} message The prompting message (please enter your character's name) * @param {string|string[]} message The prompting message (please enter your character's name)
* @param {string} tag helps with message routing and handling. * @param {string} tag helps with message routing and handling.
*/ */
sendPrompt(type, message, tag="default", ...args) { sendPrompt(type, message, tag = "", ...args) {
if (Array.isArray(message)) { if (Array.isArray(message)) {
message = message.join("\n"); message = message.join("\n");
} }
@@ -127,5 +126,4 @@ export class Session {
state.onAttach(); state.onAttach();
} }
} }
} }

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,5 +1,6 @@
class MUDClient { import { crackdown } from "./crackdown.js";
class MUDClient {
// //
// Constructor // Constructor
constructor() { constructor() {
@@ -57,7 +58,9 @@ class MUDClient {
} }
// Convert final hash to hex // Convert final hash to hex
const rawHash = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''); const rawHash = Array.from(data)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`; return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`;
} }
@@ -76,7 +79,7 @@ class MUDClient {
this.input.disabled = false; this.input.disabled = false;
this.sendButton.disabled = false; this.sendButton.disabled = false;
this.input.focus(); this.input.focus();
this.output.innerHTML = ''; this.output.innerHTML = "";
}; };
this.websocket.onmessage = (event) => { this.websocket.onmessage = (event) => {
@@ -97,7 +100,9 @@ class MUDClient {
this.websocket.onerror = (error) => { this.websocket.onerror = (error) => {
this.updateStatus("Connection Error", "error"); this.updateStatus("Connection Error", "error");
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" }); this.writeToOutput("Connection error occurred. Retrying...", {
class: "error",
});
}; };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -131,13 +136,19 @@ class MUDClient {
e.preventDefault(); e.preventDefault();
if (this.historyIndex < this.commandHistory.length - 1) { if (this.historyIndex < this.commandHistory.length - 1) {
this.historyIndex++; this.historyIndex++;
this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex]; this.input.value =
this.commandHistory[
this.commandHistory.length - 1 - this.historyIndex
];
} }
} else if (e.key === "ArrowDown") { } else if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
if (this.historyIndex > 0) { if (this.historyIndex > 0) {
this.historyIndex--; this.historyIndex--;
this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex]; this.input.value =
this.commandHistory[
this.commandHistory.length - 1 - this.historyIndex
];
} else if (this.historyIndex === 0) { } else if (this.historyIndex === 0) {
this.historyIndex = -1; this.historyIndex = -1;
this.input.value = ""; this.input.value = "";
@@ -209,7 +220,6 @@ class MUDClient {
this._addCommandToHistory(command); this._addCommandToHistory(command);
// -- This is a sneaky command that should not be in production? // -- This is a sneaky command that should not be in production?
// //
// In reality we want to use :clear, nor /clear // In reality we want to use :clear, nor /clear
@@ -300,7 +310,6 @@ class MUDClient {
// unsolicited messages to the server without being // unsolicited messages to the server without being
// prompted to do so. // prompted to do so.
this.send("c", command); this.send("c", command);
} }
// ___ __ __ // ___ __ __
@@ -341,7 +350,10 @@ class MUDClient {
} }
if (this.dev) { if (this.dev) {
this.writeToOutput(`unknown message type: ${messageType}: ${JSON.stringify(data)}`, "debug"); this.writeToOutput(
`unknown message type: ${messageType}: ${JSON.stringify(data)}`,
"debug",
);
} }
console.debug("unknown message type", data); console.debug("unknown message type", data);
} }
@@ -351,7 +363,6 @@ class MUDClient {
handleTextMessages(data) { handleTextMessages(data) {
const options = { ...data[1] }; // coerce options into an object. const options = { ...data[1] }; // coerce options into an object.
// normal text message to be shown to the player // normal text message to be shown to the player
this.writeToOutput(data[0], options); this.writeToOutput(data[0], options);
return; return;
@@ -371,7 +382,6 @@ class MUDClient {
// //
// "_" => system messages, not to be displayed // "_" => system messages, not to be displayed
handleSystemMessages(data) { handleSystemMessages(data) {
if (data.length < 2) { if (data.length < 2) {
console.debug("malformed system message", data); console.debug("malformed system message", data);
return; return;
@@ -399,7 +409,10 @@ class MUDClient {
// If we're in dev mode, we should output all system messages (in a shaded/faint fashion). // If we're in dev mode, we should output all system messages (in a shaded/faint fashion).
if (this.dev) { if (this.dev) {
this.writeToOutput(`system message: ${messageType} = ${JSON.stringify(data)}`, { class: "debug" }); this.writeToOutput(
`system message: ${messageType} = ${JSON.stringify(data)}`,
{ class: "debug" },
);
} }
return; return;
} }
@@ -411,7 +424,7 @@ class MUDClient {
// //
// We assume that calamity errors are pre-formatted, and we do not allow // 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 // any of our own formatting-shenanigans to interfere with the error message
const options = { ...{ class: "error", "preformatted": true }, ...data[1] }; const options = { ...{ class: "error", preformatted: true }, ...data[1] };
this.writeToOutput(data[0], options); this.writeToOutput(data[0], options);
return; return;
} }
@@ -466,7 +479,7 @@ class MUDClient {
el.textContent = text + eol; el.textContent = text + eol;
el.className += " " + "preformatted"; el.className += " " + "preformatted";
} else { } else {
el.innerHTML = parseCrackdown(text) + eol; el.innerHTML = crackdown(text) + eol;
} }
this.output.appendChild(el); this.output.appendChild(el);
this.output.scrollTop = this.output.scrollHeight; this.output.scrollTop = this.output.scrollHeight;
@@ -490,31 +503,3 @@ class MUDClient {
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

@@ -4,8 +4,8 @@
<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>
@@ -13,11 +13,19 @@
<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
type="text"
autocomplete="off"
id="input"
placeholder="Enter command..."
disabled
autocorrect="off"
autocomplete="off"
/>
<button id="send" disabled>Send</button> <button id="send" disabled>Send</button>
</div> </div>
</div> </div>
<script src="client.js"></script> <script type="module" src="client.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,4 +1,4 @@
@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;

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

@@ -3,13 +3,560 @@
// | | | '_ \ / _` | '__/ _` |/ __| __/ _ \ '__| // | | | '_ \ / _` | '__/ _` |/ __| __/ _ \ '__|
// | |___| | | | (_| | | | (_| | (__| || __/ | // | |___| | | | (_| | | | (_| | (__| || __/ |
// \____|_| |_|\__,_|_| \__,_|\___|\__\___|_| // \____|_| |_|\__,_|_| \__,_|\___|\__\___|_|
//
// ____ _ // ____ _
// / ___| ___ ___ __| | ___ _ __ // / ___| ___ ___ __| | ___ _ __
// \___ \ / _ \/ _ \/ _` |/ _ \ '__| // \___ \ / _ \/ _ \/ _` |/ _ \ '__|
// ___) | __/ __/ (_| | __/ | // ___) | __/ __/ (_| | __/ |
// |____/ \___|\___|\__,_|\___|_| // |____/ \___|\___|\__,_|\___|_|
// // ------------------------------------------------
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 { 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

@@ -4,7 +4,6 @@ import { Player } from "../models/player.js";
export class PlayerSeeder { export class PlayerSeeder {
/** @param {Game} game */ /** @param {Game} game */
constructor(game) { constructor(game) {
/** @type {Game} */ /** @type {Game} */
this.game = game; this.game = game;
} }

View File

@@ -5,15 +5,14 @@ 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() { constructor() {
/** @type {Game} */ /** @type {Game} */
this.game = (new GameSeeder()).createGame(); this.game = new GameSeeder().createGame();
} }
// ____ ___ _ _ _ _ _____ ____ _____ _____ ____ // ____ ___ _ _ _ _ _____ ____ _____ _____ ____
@@ -28,7 +27,7 @@ class MudServer {
onConnectionEstabished(websocket) { onConnectionEstabished(websocket) {
console.log("New connection established"); console.log("New connection established");
const session = new Session(websocket, this.game); const session = new Session(websocket, this.game);
session.sendSystemMessage("dev", true) session.sendSystemMessage("dev", true);
// ____ _ ___ ____ _____ // ____ _ ___ ____ _____
// / ___| | / _ \/ ___|| ____| // / ___| | / _ \/ ___|| ____|
@@ -52,7 +51,6 @@ class MudServer {
// Maybe this.setState(new ConnectionClosedState()); // Maybe this.setState(new ConnectionClosedState());
// Maybe both ?? // Maybe both ??
console.log(`Player ${session.player.username} disconnected`); console.log(`Player ${session.player.username} disconnected`);
}); });
// __ __ _____ ____ ____ _ ____ _____ // __ __ _____ ____ ____ _ ____ _____
@@ -68,8 +66,12 @@ class MudServer {
console.debug("incoming websocket message %s", data); console.debug("incoming websocket message %s", data);
if (!session.state) { if (!session.state) {
console.error("we received a message, but don't even have a state. Zark!"); console.error(
websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?")); "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; return;
} }
@@ -81,23 +83,37 @@ class MudServer {
//--------------------- //---------------------
// Set state = QuitState // Set state = QuitState
// //
websocket.send(msg.prepare(msg.MESSAGE, "The quitting quitter quits... Typical. Cya!")); websocket.send(
msg.prepare(
msg.MESSAGE,
"The quitting quitter quits... Typical. Cya!",
),
);
websocket.close(); websocket.close();
return; return;
} }
if (typeof session.state.onMessage !== "function") { if (typeof session.state.onMessage !== "function") {
console.error("we received a message, but we're not i a State to receive it"); console.error(
websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do with that message.")); "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; return;
} }
session.state.onMessage(msgObj); session.state.onMessage(msgObj);
} catch (error) { } catch (error) {
console.trace("received an invalid message (error: %s)", error, data.toString(), data); console.trace(
websocket.send(msg.prepare( "received an invalid message (error: %s)",
msg.CALAMITY, error,
error data.toString(),
)); data,
);
websocket.send(msg.prepare(msg.CALAMITY, error));
} }
}); });
@@ -113,7 +129,6 @@ class MudServer {
// Start the server // Start the server
//----------------- //-----------------
start() { start() {
// //
// The file types we allow to be served. // The file types we allow to be served.
const contentTypes = { const contentTypes = {
@@ -125,7 +140,10 @@ class MudServer {
// //
// Create HTTP server for serving the client - Consider moving to own file // Create HTTP server for serving the client - Consider moving to own file
const httpServer = http.createServer((req, res) => { const httpServer = http.createServer((req, res) => {
let filePath = path.join("public", req.url === "/" ? "index.html" : req.url); let filePath = path.join(
"public",
req.url === "/" ? "index.html" : req.url,
);
const ext = path.extname(filePath); const ext = path.extname(filePath);
const contentType = contentTypes[ext]; const contentType = contentTypes[ext];
@@ -139,7 +157,6 @@ class MudServer {
return; return;
} }
// //
// Check if the file exists. // Check if the file exists.
fs.readFile(filePath, (err, data) => { fs.readFile(filePath, (err, data) => {

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

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

@@ -1,5 +1,5 @@
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 */
@@ -23,8 +23,10 @@ export class JustLoggedInState {
// //
// Check if we need to create characters for the player // Check if we need to create characters for the player
if (this.session.player.characters.size === 0) { 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.sendMessage(
this.session.setState(new CharacterCreationState(this.session)); "You haven't got any characters, so let's make some\n\n",
);
this.session.setState(new PartyCreationState(this.session));
return; return;
} }

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);
} }
return res + ":" + miniUid(); /**
* Does a given string end with ".uid.23khtasdz", etc.
*
* @param {string} str
*/
export function endsWithMiniUid(str) {
return MINI_UID_REGEX.test(str);
}
export function appendOrReplaceMiniUid(str) {
return appendMiniUid(str.replace(MINI_UID_REGEX, ""));
} }

View File

@@ -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.
* *
@@ -98,22 +97,31 @@ export class ClientMessage {
*/ */
constructor(msgData) { constructor(msgData) {
if (typeof msgData !== "string") { if (typeof msgData !== "string") {
throw new Error("Could not create client message. Attempting to parse json, but data was not even a string, it was a " + typeof msgData); throw new Error(
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData,
);
return; return;
} }
try { try {
this._attr = JSON.parse(msgData); this._attr = JSON.parse(msgData);
} catch (_) { } catch (_) {
throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`); throw new Error(
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
);
} }
if (!Array.isArray(this._attr)) { if (!Array.isArray(this._attr)) {
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`); throw new Error(
`Could not create client message. Excpected an array, but got a ${typeof this._attr}`,
);
} }
if (this._attr.length < 1) { 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"); throw new Error(
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
);
} }
} }
hasCommand() { hasCommand() {
@@ -122,27 +130,31 @@ export class ClientMessage {
/** Does this message contain a username-response from the client? */ /** Does this message contain a username-response from the client? */
isUsernameResponse() { isUsernameResponse() {
return this._attr.length === 4 return (
&& this._attr[0] === REPLY this._attr.length === 4 &&
&& this._attr[1] === "username" this._attr[0] === REPLY &&
&& typeof this._attr[2] === "string"; this._attr[1] === "username" &&
typeof this._attr[2] === "string"
);
} }
/** Does this message contain a password-response from the client? */ /** Does this message contain a password-response from the client? */
isPasswordResponse() { isPasswordResponse() {
return this._attr.length === 4 return (
&& this._attr[0] === REPLY this._attr.length === 4 &&
&& this._attr[1] === "password" this._attr[0] === REPLY &&
&& typeof this._attr[2] === "string"; this._attr[1] === "password" &&
typeof this._attr[2] === "string"
);
} }
/** @returns {boolean} does this message indicate the player wants to quit */ /** @returns {boolean} does this message indicate the player wants to quit */
isQuitCommand() { isQuitCommand() {
return this._attr[0] === QUIT return this._attr[0] === QUIT;
} }
isHelpCommand() { isHelpCommand() {
return this._attr[0] === HELP return this._attr[0] === HELP;
} }
/** @returns {boolean} is this a debug message? */ /** @returns {boolean} is this a debug message? */
@@ -151,11 +163,14 @@ export class ClientMessage {
} }
isIntegerResponse() { isIntegerResponse() {
return this._attr.length === 4 return (
&& this._attr[0] === REPLY this._attr.length === 4 &&
&& this._attr[1] === "integer" this._attr[0] === REPLY &&
&& (typeof this._attr[2] === "string" || typeof this._attr[2] === "number") this._attr[1] === "integer" &&
&& Number.isInteger(Number(this._attr[2])); (typeof this._attr[2] === "string" ||
typeof this._attr[2] === "number") &&
Number.isInteger(Number(this._attr[2]))
);
} }
/** @returns {number} integer */ /** @returns {number} integer */

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
@@ -15,7 +14,9 @@ const DEV = process.env.NODE_ENV === "dev";
*/ */
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(
"hex",
);
return `${ITERATIONS}:${salt}:${hash}`; return `${ITERATIONS}:${salt}:${hash}`;
} }
@@ -28,7 +29,13 @@ export function generateHash(password) {
*/ */
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(
password_candidate,
salt,
Number(iterations),
KEYLEN,
DIGEST,
).toString("hex");
const success = hash === derived; const success = hash === derived;
if (Config.dev || true) { if (Config.dev || true) {
console.debug( console.debug(

View File

@@ -4,7 +4,6 @@
* @enum {string} * @enum {string}
*/ */
export const FrameType = { export const FrameType = {
/** /**
* ╔════════════╗ * ╔════════════╗
* ║ Hello, TUI ║ * ║ Hello, TUI ║
@@ -23,7 +22,6 @@ export const FrameType = {
*/ */
Single: "Single", Single: "Single",
/** /**
* *
* Hello, TUI * Hello, TUI
@@ -33,7 +31,6 @@ export const FrameType = {
*/ */
Invisible: "Invisible", Invisible: "Invisible",
/** /**
* ( ) * ( )
* ( Hello, TUI ) * ( Hello, TUI )
@@ -52,7 +49,6 @@ export const FrameType = {
*/ */
Basic: "Basic", Basic: "Basic",
/** /**
* @protected * @protected
* Default values for the common frame types. * Default values for the common frame types.
@@ -65,8 +61,8 @@ export const FrameType = {
Invisible: " ", Invisible: " ",
Parentheses: " () ", Parentheses: " () ",
Single: "──││┌┐└┘", 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. */
@@ -118,7 +114,10 @@ export class FramingOptions {
// //
// 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 (
typeof o.frameChars === "string" &&
o.frameChars.length === FrameType.values.Double.length
) {
result.frameChars = o.frameChars; result.frameChars = o.frameChars;
// //
@@ -131,7 +130,6 @@ export class FramingOptions {
result.frameChars = FrameType.values.Double; result.frameChars = FrameType.values.Double;
} }
return result; return result;
} }
} }
@@ -141,7 +139,6 @@ export class FramingOptions {
* @param {FramingOptions} options * @param {FramingOptions} options
*/ */
export function frameText(text, options) { export function frameText(text, options) {
if (!options) { if (!options) {
options = new FramingOptions(); options = new FramingOptions();
} }
@@ -159,7 +156,9 @@ export function frameText(text, options) {
if (typeof text !== "string") { if (typeof text !== "string") {
console.debug(text); console.debug(text);
throw new Error(`text argument was neither an array or a string, it was a ${typeof text}`); throw new Error(
`text argument was neither an array or a string, it was a ${typeof text}`,
);
} }
/** @type {string[]} */ /** @type {string[]} */
@@ -171,15 +170,18 @@ export function frameText(text, options) {
return currentLine.length; return currentLine.length;
} }
return accumulator; return accumulator;
}, 0), options.minLineWidth); }, 0),
options.minLineWidth,
);
const frameThickness = 1; // always 1 for now. const frameThickness = 1; // always 1 for now.
const outerLineLength = 0 const outerLineLength =
+ innerLineLength 0 +
+ frameThickness * 2 innerLineLength +
+ options.hPadding * 2 frameThickness * 2 +
+ options.hMargin * 2; options.hPadding * 2 +
options.hMargin * 2;
// get the frame characters from the frameType. // get the frame characters from the frameType.
let [ let [
@@ -192,14 +194,30 @@ export function frameText(text, options) {
fSouthWest, // lower left frame corner fSouthWest, // lower left frame corner
fSouthEast, // lower right frame corner fSouthEast, // lower right frame corner
] = options.frameChars.split(""); ] = options.frameChars.split("");
if (fNorth === "§") { fNorth = ""; } if (fNorth === "§") {
if (fSouth === "§") { fSouth = ""; } fNorth = "";
if (fEast === "§") { fEast = ""; } }
if (fWest === "§") { fWest = ""; } if (fSouth === "§") {
if (fNorthEast === "§") { fNorthEast = ""; } fSouth = "";
if (fSouthEast === "§") { fSouthEast = ""; } }
if (fNorthWest === "§") { fNorthWest = ""; } if (fEast === "§") {
if (fSouthWest === "§") { fSouthWest = ""; } fEast = "";
}
if (fWest === "§") {
fWest = "";
}
if (fNorthEast === "§") {
fNorthEast = "";
}
if (fSouthEast === "§") {
fSouthEast = "";
}
if (fNorthWest === "§") {
fNorthWest = "";
}
if (fSouthWest === "§") {
fSouthWest = "";
}
let output = ""; let output = "";
@@ -212,21 +230,23 @@ export function frameText(text, options) {
// ( space, and what if we want to nest many ) // ( space, and what if we want to nest many )
// ( frames inside each other? ) // ( frames inside each other? )
// //
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin); output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(
options.vMargin,
);
// //
// GENERATE THE TOP PART OF THE FRAME // GENERATE THE TOP PART OF THE FRAME
// ╔════════════╗ // ╔════════════╗
// //
// //
output += "" // Make sure JS knows we're adding a string. output +=
+ options.marginChar.repeat(options.hMargin) // the margin before the frame starts "" + // Make sure JS knows we're adding a string.
+ fNorthWest // northwest frame corner options.marginChar.repeat(options.hMargin) + // the margin before the frame starts
+ fNorth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar fNorthWest + // northwest frame corner
+ fNorthEast // northeast frame corner fNorth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar
+ options.marginChar.repeat(options.hMargin) // the margin after the frame ends fNorthEast + // northeast frame corner
+ "\n"; options.marginChar.repeat(options.hMargin) + // the margin after the frame ends
"\n";
// //
// GENERATE UPPER PADDING // GENERATE UPPER PADDING
// //
@@ -234,12 +254,12 @@ export function frameText(text, options) {
// //
// (the blank lines within the frame and above the text) // (the blank lines within the frame and above the text)
output += ( output += (
options.marginChar.repeat(options.hMargin) options.marginChar.repeat(options.hMargin) +
+ fWest fWest +
+ options.paddingChar.repeat(innerLineLength + options.hPadding * 2) options.paddingChar.repeat(innerLineLength + options.hPadding * 2) +
+ fEast fEast +
+ options.marginChar.repeat(options.hMargin) options.marginChar.repeat(options.hMargin) +
+ "\n" "\n"
).repeat(options.vPadding); ).repeat(options.vPadding);
// //
@@ -251,15 +271,16 @@ export function frameText(text, options) {
// ( this could be done with a reduce() ) // ( this could be done with a reduce() )
// //
for (const line of lines) { for (const line of lines) {
output += "" // Make sure JS knows we're adding a string. output +=
+ options.marginChar.repeat(options.hMargin) // margin before frame "" + // Make sure JS knows we're adding a string.
+ fWest // vertical frame char options.marginChar.repeat(options.hMargin) + // margin before frame
+ options.paddingChar.repeat(options.hPadding) // padding before text fWest + // vertical frame char
+ line.padEnd(innerLineLength, " ") // The actual text. Pad it with normal space character, NOT custom space. options.paddingChar.repeat(options.hPadding) + // padding before text
+ options.paddingChar.repeat(options.hPadding) // padding after text line.padEnd(innerLineLength, " ") + // The actual text. Pad it with normal space character, NOT custom space.
+ fEast // vertical frame bar options.paddingChar.repeat(options.hPadding) + // padding after text
+ options.marginChar.repeat(options.hMargin) // margin after frame fEast + // vertical frame bar
+ "\n"; options.marginChar.repeat(options.hMargin) + // margin after frame
"\n";
} }
// //
@@ -274,27 +295,27 @@ export function frameText(text, options) {
// ( repeat of the code that ) // ( repeat of the code that )
// ( generates top padding ) // ( generates top padding )
output += ( output += (
options.marginChar.repeat(options.hMargin) options.marginChar.repeat(options.hMargin) +
+ fWest fWest +
+ options.paddingChar.repeat(innerLineLength + options.hPadding * 2) options.paddingChar.repeat(innerLineLength + options.hPadding * 2) +
+ fEast fEast +
+ options.marginChar.repeat(options.hMargin) options.marginChar.repeat(options.hMargin) +
+ "\n" "\n"
).repeat(options.vPadding); ).repeat(options.vPadding);
// //
// GENERATE THE BOTTOM PART OF THE FRAME // GENERATE THE BOTTOM PART OF THE FRAME
// //
// ╚════════════╝ // ╚════════════╝
// //
output += "" // Make sure JS knows we're adding a string. output +=
+ options.marginChar.repeat(options.hMargin) // the margin before the frame starts "" + // Make sure JS knows we're adding a string.
+ fSouthWest // northwest frame corner options.marginChar.repeat(options.hMargin) + // the margin before the frame starts
+ fSouth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar fSouthWest + // northwest frame corner
+ fSouthEast // northeast frame corner fSouth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar
+ options.marginChar.repeat(options.hMargin) // the margin after the frame starts fSouthEast + // northeast frame corner
+ "\n"; options.marginChar.repeat(options.hMargin) + // the margin after the frame starts
"\n";
// //
// GENERATE THE MARGIN SPACE BELOW THE FRAMED TEXT // GENERATE THE MARGIN SPACE BELOW THE FRAMED TEXT
@@ -305,7 +326,9 @@ export function frameText(text, options) {
// ( space, and what if we want to nest many ) // ( space, and what if we want to nest many )
// ( frames inside each other? ) // ( frames inside each other? )
// //
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin); output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(
options.vMargin,
);
return output; return output;
} }