diff --git a/bedhack.txt b/bedhack.txt index 9e2997b..0c17837 100755 --- a/bedhack.txt +++ b/bedhack.txt @@ -20,12 +20,12 @@ └────────────────────────────────────────────────────────────────────────────────┘│e) 2 uncursed lichen corpses │ ┌────────────────────────────────────────────────────────────────────────────────┐│Scrolls │ │ ││j) 2 uncursed scrolls labeled DAIYEN FOOELS │ -│ ����������Ŀ ││PD) uncursed scroll labeled ELAM EBOW │ -│ �����Ŀ ��������������������������������*[���[[��������������������� ││TW) 2 cursed scrolls labeled GNIK SISI VLE │ -│ �����>���[��)�� ���������������Ŀ��������������Ŀ ����������� ││ms) 2 uncursed scrolls labeled HACKEM MUCHE │ -│ �����[� ���������� ������������������ �������������� ��������������� ││dp) uncursed scroll labeled KIRJE │ -│ � ����� ���������������������� �������������� � ���<������� ││Uu) uncursed scroll labeled TEMOV │ -│ ��������������������������������������� ����������� ││GQ) 2 uncursed scrolls labeled VELOX NEB │ +│ ����������Ŀ ││PD) uncursed scroll labeled ELAM EBOW │ +│ �����Ŀ ��������������������������������*[���[[��������������������� ││TW) 2 cursed scrolls labeled GNIK SISI VLE │ +│ �����>���[��)�� ���������������Ŀ��������������Ŀ ����������� ││ms) 2 uncursed scrolls labeled HACKEM MUCHE │ +│ �����[� ���������� ������������������ �������������� ��������������� ││dp) uncursed scroll labeled KIRJE │ +│ � ����� ���������������������� �������������� � ���<������� ││Uu) uncursed scroll labeled TEMOV │ +│ ��������������������������������������� ����������� ││GQ) 2 uncursed scrolls labeled VELOX NEB │ │ ���������������������������������� ������������ ││X) blessed scroll labeled VERR YED HORRE │ │ ��������Ŀ������������������Ŀ����� ││V) blessed scroll called ASHPD enchARM|rmvCUR|enchWEP │ │ ���������������������������� ����� ││Wands │ diff --git a/server/.prettierrc b/server/.prettierrc deleted file mode 100644 index 0512eed..0000000 --- a/server/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "tabWidth": 4, - "printWidth": 170 -} diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json index 80298df..185e541 100755 --- a/server/.vscode/launch.json +++ b/server/.vscode/launch.json @@ -1,25 +1,20 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Launch with Nodemon", - "runtimeExecutable": "nodemon", - "runtimeArgs": [ - "--inspect=9229", - "server.js" - ], - "env": { - "NODE_ENV": "dev", - }, - "restart": true, - "skipFiles": [ - "/**" - ] - } - ] -} \ No newline at end of file + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch with Nodemon", + "runtimeExecutable": "nodemon", + "runtimeArgs": ["--inspect=9229", "server.js"], + "env": { + "NODE_ENV": "dev" + }, + "restart": true, + "skipFiles": ["/**"] + } + ] +} diff --git a/server/config.js b/server/config.js index 738b412..2f44607 100755 --- a/server/config.js +++ b/server/config.js @@ -2,52 +2,50 @@ const dev = process.env.NODE_ENV === "dev"; const env = process.env.PROD || (dev ? "dev" : "prod"); export const Config = { - /** @readonly @type {string} the name of the environment we're running in */ - "env": env, + /** @readonly @type {string} the name of the environment we're running in */ + env: env, - /** @readonly @type {boolean} are we running in development-mode? */ - "dev": dev, + /** @readonly @type {boolean} are we running in development-mode? */ + dev: dev, - /** - * Port we're running the server on. - * - * @readonly - * @const {number} - */ - port: process.env.PORT || 3000, + /** + * Port we're running the server on. + * + * @readonly + * @const {number} + */ + port: process.env.PORT || 3000, - /** - * Maximum number of players allowed on the server. - * - * @readonly - * @const {number} - */ - maxPlayers: dev ? 3 : 40, + /** + * Maximum number of players allowed on the server. + * + * @readonly + * @const {number} + */ + maxPlayers: dev ? 3 : 40, - /** - * Max number of characters in a party. - * By default, a player can only have a single party. - * Multiple parties may happen some day. - */ - maxPartySize: 4, + /** + * Max number of characters in a party. + * By default, a player can only have a single party. + * Multiple parties may happen some day. + */ + maxPartySize: 4, - /** - * Number of failed logins allowed before user is locked out. - * Also known as Account lockout threshold - * - * @readonly - * @const {number} - */ - maxFailedLogins: 5, + /** + * Number of failed logins allowed before user is locked out. + * Also known as Account lockout threshold + * + * @readonly + * @const {number} + */ + maxFailedLogins: 5, - /** - * When a user has entered a wrong password too many times, - * block them for this long before they can try again. - * - * @readonly - * @const {number} - */ - accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes. + /** + * When a user has entered a wrong password too many times, + * block them for this long before they can try again. + * + * @readonly + * @const {number} + */ + accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes. }; - - diff --git a/server/find-element-width-in-chars.html b/server/find-element-width-in-chars.html index fd00ae3..6f0f3ba 100644 --- a/server/find-element-width-in-chars.html +++ b/server/find-element-width-in-chars.html @@ -3,48 +3,46 @@ - + - - - - Measure Div Width in Characters - - - -
- This is a div with monospaced text. -
+ + + + Measure Div Width in Characters + + + +
This is a div with monospaced text.
- - + const div = document.querySelector(".monospaced-div"); + const charCount = getMonospacedCharCount(div); + console.log("Number of characters the div can hold:", charCount); + + diff --git a/server/ideas.md b/server/ideas.md new file mode 100755 index 0000000..b9c38e4 --- /dev/null +++ b/server/ideas.md @@ -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. diff --git a/server/models/character.js b/server/models/character.js index fa6544e..594deed 100755 --- a/server/models/character.js +++ b/server/models/character.js @@ -1,5 +1,6 @@ import * as roll from "../utils/dice.js"; import * as id from "../utils/id.js"; +import { Item } from "./item.js"; /** * A playable character. @@ -14,28 +15,31 @@ export class Character { * @protected * @type {number} The number of XP the character has. */ - _xp = 0; - get xp() { - return this._xp; - } + xp = 0; /** @protected @type {number} The character's level. */ - _level = 1; - get level() { - return this._level; - } + level = 1; - /** @protected @type {string} unique name used for chats when there's a name clash and also other things that require a unique character id */ - _id; - get id() { - return this._id; - } + /** @type {number} Awareness Skill */ + awareness; - /** @protected @type {string} username of the player that owns this character. */ - _username; - get username() { - return this._username; - } + /** @type {number} Grit Skill */ + grit; + + /** @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 */ ancestry; @@ -56,144 +60,40 @@ export class Character { itemSlots; /** @type {Set} Things the character is particularly proficient at. */ - proficiencies = new Set(); + skills = new Set(); - /** @type {Map 0"); + } + + const existingItemCount = this.items.get(item) || 0; + + this.items.set(item, count + existingItemCount); + } + + // todo removeItem(item, count) } diff --git a/server/models/game.js b/server/models/game.js index c28b1c4..c1ed815 100755 --- a/server/models/game.js +++ b/server/models/game.js @@ -7,15 +7,14 @@ * 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 { ItemTemplate } from "./item.js"; +import { ItemAttributes, ItemBlueprint } from "./item.js"; import { Player } from "./player.js"; export class Game { - - /** @type {Map} List of all item templates in the game */ - _itemTemplates = new Map(); + /** @type {Map} List of all item blueprints in the game */ + _itemBlueprints = new Map(); /** @type {Map} The list of locations in the game */ _locations = new Map(); @@ -40,10 +39,10 @@ export class Game { /** * Atomic player creation. - * - * @param {string} username - * @param {string?} passwordHash - * @param {string?} salt + * + * @param {string} username + * @param {string?} passwordHash + * @param {string?} salt * * @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise. */ @@ -55,7 +54,7 @@ export class Game { const player = new Player( username, 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); @@ -64,38 +63,45 @@ export class Game { } /** - * Create an ItemTemplate with a given ID - * - * @param {string} id - * @param {object} attributes - * - * @returns {ItemTemplate|false} - */ - createItemTemplate(id, attributes) { - - if (typeof id !== "string" || !id) { - throw new Error("Invalid id!"); + * Create an ItemBlueprint with a given blueprintId + * + * @param {string} blueprintId + * @param {ItemAttributes} attributes + * + * @returns {ItemBlueprint|false} + */ + addItemBlueprint(blueprintId, attributes) { + console.log(attributes); + if (typeof blueprintId !== "string" || !blueprintId) { + throw new Error("Invalid blueprintId!"); } - if (this._itemTemplates.has(id)) { - return false; + const existing = this._itemBlueprints.get(blueprintId); + + if (existing) { + console.debug("we tried to create the same item blueprint more than once", blueprintId, attributes); + return existing; } - /** @type {ItemTemplate} */ - const result = new ItemTemplate(id, attributes.name, attributes.itemSlots); + attributes.blueprintId = blueprintId; - for (const key of Object.keys(result)) { - if (key === "id") { - continue; - } - if (key in attributes) { - result[key] = attributes[key]; - } - } + const result = new ItemBlueprint(attributes); - - this._itemTemplates.set(id, result); + this._itemBlueprints.set(blueprintId, 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; + } } diff --git a/server/models/item.js b/server/models/item.js index 5576de7..2713cf9 100755 --- a/server/models/item.js +++ b/server/models/item.js @@ -1,12 +1,10 @@ /** - * Item templates are the built-in basic items of the game. - * A character cannot directly own one of these items, - * they can only own CharacterItems, and ItemTemplates can be used to - * generate these CharacterItems. + * Abstract class for documentation purposes. + * @abstract */ -export class ItemTemplate { - /** @constant @readonly @type {string} Item's machine-friendly name */ - id; +export class ItemAttributes { + /** @constant @readonly @type {string} Machine-friendly name for the blueprint */ + blueprintId; /** @constant @readonly @type {string} Item's human-friendly name */ name; @@ -18,9 +16,9 @@ export class ItemTemplate { itemSlots; /** @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; /** @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? */ 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 */ 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 * - * @param {string=null} id Item's machine-friendly name. - * @param {string} name. The Item's Name. - * @param {number} itemSlots number of item slots the item takes up in a character's inventory. + * @param {object} o Object whose attributes we copy */ - constructor(id, name, itemSlots) { + constructor(o) { + super(); - if (typeof id !== "string" || id.length < 1) { - throw new Error("id must be a string!"); + if (typeof o.blueprintId !== "string" || o.name.length < 1) { + throw new Error("blueprintId must be a string, but " + typeof o.blueprintId + " given."); } - if (typeof name !== "string" || name.length < 1) { - throw new Error("Name must be a string, but " + typeof name + " given."); + if (typeof o.name !== "string" || o.name.length < 1) { + 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!"); } - this.name = name; - this.id = id; - this.itemSlots = Number(itemSlots); + o.itemSlots = Number(o.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} */ createItem() { - return new ChracterItem( - this.id, - this.name, - this.description, - this.itemSlots, - ); + const item = new Item(); + + for (const [key, value] of Object.entries(this)) { + item[key] = value; + } + + 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 - * with the name of Shortsword and with the same properties as the orignial Shortsword ItemTemplate. - * - * If a character picks up a Pickaxe in the dungeon, a new CharacterItem is spawned and injected into - * the character's Equipment Map. If the item is dropped/destroyed/sold, the CharacterItem is removed from - * 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. + * If a character has two identical potions of healing, they are each represented + * by an object of this class. + * The only notable tweak to this rule is collective items like quivers that have + * arrows that are consumed. In this case, each individual arrow is not tracked + * as its own entity, only the quiver is tracked. */ -export class CharacterItem { - /** @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; - } -} +export class Item extends ItemAttributes {} diff --git a/server/models/location.js b/server/models/location.js index 0fb4cea..8cd2b17 100755 --- a/server/models/location.js +++ b/server/models/location.js @@ -9,37 +9,37 @@ import { Portal } from "./portal"; * or magical portals to distant locations. */ export class Location { - /** @protected @type string */ - _id; - get id() { - return this._id; - } + /** @protected @type string */ + _id; + get id() { + return this._id; + } - /** @protected @type string */ - _name; - get name() { - return this._name; - } + /** @protected @type string */ + _name; + get name() { + return this._name; + } - /** @protected @type string */ - _description; - get description() { - return this._description; - } + /** @protected @type string */ + _description; + get description() { + return this._description; + } - /** @protected @type {Map} */ - _portals = new Map(); - get portals() { - return this._portals; - } + /** @protected @type {Map} */ + _portals = new Map(); + get portals() { + return this._portals; + } - /** - */ - constructor(id, name, description) { - this._id = id; - this._name = name; - this._description = description; - } + /** + */ + constructor(id, name, description) { + this._id = id; + this._name = name; + this._description = description; + } } const l = new Location("foo", "bar", "baz"); diff --git a/server/models/player.js b/server/models/player.js index 09f0845..3007c57 100755 --- a/server/models/player.js +++ b/server/models/player.js @@ -1,5 +1,6 @@ import WebSocket from "ws"; import { Character } from "./character.js"; +import { Config } from "./../config.js"; /** * Player Account. @@ -7,66 +8,84 @@ import { Character } from "./character.js"; * Contain persistent player account info. */ export class Player { + /** @protected @type {string} unique username */ + _username; + get username() { + return this._username; + } - /** @protected @type {string} unique username */ - _username; - get username() { - return this._username; + /** @protected @type {string} */ + _passwordHash; + get passwordHash() { + return this._passwordHash; + } + + /** @protected @type {string} random salt used for hashing */ + _salt; + get salt() { + return this._salt; + } + + /** @protected @type {Date} */ + _createdAt = new Date(); + get createdAt() { + return this._createdAt; + } + + /** @type {Date} */ + blockedUntil; + + /** @type {Date|null} Date of the player's last websocket message. */ + lastActivityAt = null; + + /** @type {Date|null} Date of the player's last login. */ + lastSucessfulLoginAt = null; + + /** @type {number} Number of successful logins on this character */ + successfulLogins = 0; + + /** @type {number} Number of failed login attempts since the last good login attempt */ + failedPasswordsSinceLastLogin = 0; + + /** @protected @type {Set} */ + _characters = new Set(); // should this be a WeakSet? After all if the player is removed, their items might remain in the system, right? + get characters() { + return this._characters; + } + + /** + * @param {string} username + * @param {string} passwordHash + * @param {string} salt + */ + constructor(username, passwordHash, salt) { + this._username = username; + this._passwordHash = passwordHash; + this._salt = salt; + this._createdAt = new Date(); + } + + setPasswordHash(hashedPassword) { + this._passwordHash = hashedPassword; + } + + /** + * Add a character to the player's party + * + * @param {Character} character + * @returns {number|false} the new size of the players party if successful, or false if the character could not be added. + */ + addCharacter(character) { + if (this._characters.has(character)) { + return false; } - /** @protected @type {string} */ - _passwordHash; - get passwordHash() { - return this._passwordHash; + if (this._characters.size >= Config.maxPartySize) { + return false; } - /** @protected @type {string} random salt used for hashing */ - _salt; - get salt() { - return this._salt; - } + this._characters.add(character); - /** @protected @type {Date} */ - _createdAt = new Date(); - get createdAt() { - return this._createdAt; - } - - /** @type {Date} */ - blockedUntil; - - - /** @type {Date|null} Date of the player's last websocket message. */ - lastActivityAt = null; - - /** @type {Date|null} Date of the player's last login. */ - lastSucessfulLoginAt = null; - - /** @type {number} Number of successful logins on this character */ - successfulLogins = 0; - - /** @type {number} Number of failed login attempts since the last good login attempt */ - failedPasswordsSinceLastLogin = 0; - - /** @protected @type {Set} */ - _characters = new Set(); // should this be a WeakSet? After all if the player is removed, their items might remain in the system, right? - get characters() { - return this._characters; - } - - /** - * @param {string} username - * @param {string} passwordHash - * @param {string} salt - */ - constructor(username, passwordHash, salt) { - this._username = username; - this._passwordHash = passwordHash; - this._salt = salt; - this._createdAt = new Date(); - } - - setPasswordHash(hashedPassword) { - this._passwordHash = hashedPassword; - } + return this._characters.size; + } } diff --git a/server/models/portal.js b/server/models/portal.js index 9517bb1..f861cc5 100755 --- a/server/models/portal.js +++ b/server/models/portal.js @@ -8,18 +8,18 @@ * @todo Add encounters to portals */ export class Portal { - /** - * Target Location. - */ - _targetLocationId; + /** + * Target Location. + */ + _targetLocationId; - /** - * Description shown to the player when they inspect the portal from the source location. - */ - _description; + /** + * Description shown to the player when they inspect the portal from the source location. + */ + _description; - /** - * Description shown to the player when they traverse the portal. - */ - _traversalDescription; + /** + * Description shown to the player when they traverse the portal. + */ + _traversalDescription; } diff --git a/server/models/session.js b/server/models/session.js index a8f8e7c..1c20511 100755 --- a/server/models/session.js +++ b/server/models/session.js @@ -1,131 +1,129 @@ -import WebSocket from 'ws'; -import { Game } from './game.js'; -import { Player } from './player.js'; -import { StateInterface } from '../states/interface.js'; -import * as msg from '../utils/messages.js'; -import figlet from 'figlet'; +import WebSocket from "ws"; +import { Game } from "./game.js"; +import { Player } from "./player.js"; +import { StateInterface } from "../states/interface.js"; +import * as msg from "../utils/messages.js"; +import figlet from "figlet"; export class Session { + /** @protected @type {StateInterface} */ + _state; + get state() { + return this._state; + } - /** @protected @type {StateInterface} */ - _state; - get state() { - return this._state; + /** @protected @type {Game} */ + _game; + get game() { + return this._game; + } + + /** @type {Player} */ + _player; + get player() { + return this._player; + } + + /** @param {Player} player */ + set player(player) { + if (player instanceof Player) { + this._player = player; + return; } - /** @protected @type {Game} */ - _game; - get game() { - return this._game; + if (player === null) { + this._player = null; + return; } - /** @type {Player} */ - _player; - get player() { - return this._player; + throw Error( + `Can only set player to null or instance of Player, but received ${typeof player}`, + ); + } + + /** @type {WebSocket} */ + _websocket; + + /** + * @param {WebSocket} websocket + * @param {Game} game + */ + constructor(websocket, game) { + this._websocket = websocket; + this._game = game; + } + + /** Close the session and websocket */ + close() { + this._websocket.close(); + this._player = null; + } + + /** + * Send a message via our websocket. + * + * @param {string|number} messageType + * @param {...any} args + */ + send(messageType, ...args) { + this._websocket.send(JSON.stringify([messageType, ...args])); + } + + sendFigletMessage(message) { + console.debug("sendFigletMessage('%s')", message); + this.sendMessage(figlet.textSync(message), { preformatted: true }); + } + + /** @param {string} message Message to display to player */ + sendMessage(message, ...args) { + if (message.length === 0) { + console.debug("sending a zero-length message, weird"); } - - /** @param {Player} player */ - set player(player) { - - if (player instanceof Player) { - this._player = player; - return; - } - - if (player === null) { - this._player = null; - return; - } - - throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`); + if (Array.isArray(message)) { + message = message.join("\n"); } + this.send(msg.MESSAGE, message, ...args); + } - - /** @type {WebSocket} */ - _websocket; - - /** - * @param {WebSocket} websocket - * @param {Game} game - */ - constructor(websocket, game) { - this._websocket = websocket; - this._game = game; + /** + * @param {string} type prompt type (username, password, character name, etc.) + * @param {string|string[]} message The prompting message (please enter your character's name) + * @param {string} tag helps with message routing and handling. + */ + sendPrompt(type, message, tag = "", ...args) { + if (Array.isArray(message)) { + message = message.join("\n"); } + this.send(msg.PROMPT, type, message, tag, ...args); + } - /** Close the session and websocket */ - close() { - this._websocket.close(); - this._player = null; + /** @param {string} message The error message to display to player */ + sendError(message, ...args) { + this.send(msg.ERROR, message, ...args); + } + + /** @param {string} message The error message to display to player */ + sendDebug(message, ...args) { + this.send(msg.DEBUG, message, ...args); + } + + /** @param {string} message The calamitous error to display to player */ + sendCalamity(message, ...args) { + this.send(msg.CALAMITY, message, ...args); + } + + sendSystemMessage(arg0, ...rest) { + this.send(msg.SYSTEM, arg0, ...rest); + } + + /** + * @param {StateInterface} state + */ + setState(state) { + this._state = state; + console.debug("changing state", state.constructor.name); + if (typeof state.onAttach === "function") { + state.onAttach(); } - - /** - * Send a message via our websocket. - * - * @param {string|number} messageType - * @param {...any} args - */ - send(messageType, ...args) { - this._websocket.send(JSON.stringify([messageType, ...args])); - } - - sendFigletMessage(message) { - console.debug("sendFigletMessage('%s')", message); - this.sendMessage(figlet.textSync(message), { preformatted: true }); - } - - /** @param {string} message Message to display to player */ - sendMessage(message, ...args) { - if (message.length === 0) { - console.debug("sending a zero-length message, weird"); - } - if (Array.isArray(message)) { - message = message.join("\n"); - } - this.send(msg.MESSAGE, message, ...args); - } - - /** - * @param {string} type prompt type (username, password, character name, etc.) - * @param {string|string[]} message The prompting message (please enter your character's name) - * @param {string} tag helps with message routing and handling. - */ - sendPrompt(type, message, tag="default", ...args) { - if (Array.isArray(message)) { - message = message.join("\n"); - } - this.send(msg.PROMPT, type, message, tag, ...args); - } - - /** @param {string} message The error message to display to player */ - sendError(message, ...args) { - this.send(msg.ERROR, message, ...args); - } - - /** @param {string} message The error message to display to player */ - sendDebug(message, ...args) { - this.send(msg.DEBUG, message, ...args); - } - - /** @param {string} message The calamitous error to display to player */ - sendCalamity(message, ...args) { - this.send(msg.CALAMITY, message, ...args); - } - - sendSystemMessage(arg0, ...rest) { - this.send(msg.SYSTEM, arg0, ...rest); - } - - /** - * @param {StateInterface} state - */ - setState(state) { - this._state = state; - console.debug("changing state", state.constructor.name); - if (typeof state.onAttach === "function") { - state.onAttach(); - } - } - + } } diff --git a/server/package-lock.json b/server/package-lock.json index af2f548..ccd4571 100755 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,446 +1,446 @@ { - "name": "websocket-mud", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "websocket-mud", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "figlet": "^1.8.2", - "ws": "^8.14.2" - }, - "devDependencies": { - "nodemon": "^3.0.1", - "prettier": "3.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/figlet": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.2.tgz", - "integrity": "sha512-iPCpE9B/rOcjewIzDnagP9F2eySzGeHReX8WlrZQJkqFBk2wvq8gY0c6U6Hd2y9HnX1LQcYSeP7aEHoPt6sVKQ==", - "license": "MIT", - "bin": { - "figlet": "bin/index.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nodemon": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", - "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } + "name": "websocket-mud", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "websocket-mud", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "figlet": "^1.8.2", + "ws": "^8.14.2" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "prettier": "3.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true } + } + }, + "node_modules/figlet": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.2.tgz", + "integrity": "sha512-iPCpE9B/rOcjewIzDnagP9F2eySzGeHReX8WlrZQJkqFBk2wvq8gY0c6U6Hd2y9HnX1LQcYSeP7aEHoPt6sVKQ==", + "license": "MIT", + "bin": { + "figlet": "bin/index.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } + } } diff --git a/server/package.json b/server/package.json index c06bbc4..4502a45 100755 --- a/server/package.json +++ b/server/package.json @@ -1,7 +1,7 @@ { "name": "websocket-mud", "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", "type": "module", "scripts": { @@ -14,7 +14,7 @@ "game", "multiplayer" ], - "author": "Your Name", + "author": "Kim Ravn Hansen", "license": "MIT", "dependencies": { "figlet": "^1.8.2", @@ -26,5 +26,14 @@ }, "engines": { "node": ">=14.0.0" + }, + "prettier": { + "tabWidth": 4, + "printWidth": 120, + "singleQuote": false, + "trailingComma": "all", + "tabWidth": 4, + "bracketSpacing": true, + "objectWrap": "preserve" } } diff --git a/server/public/client.js b/server/public/client.js index e08826a..8cbe0e2 100755 --- a/server/public/client.js +++ b/server/public/client.js @@ -1,520 +1,505 @@ +import { crackdown } from "./crackdown.js"; + class MUDClient { + // + // Constructor + constructor() { + /** @type {WebSocket} Our WebSocket */ + this.websocket = null; - // - // Constructor - constructor() { - /** @type {WebSocket} Our WebSocket */ - this.websocket = null; - - /** @type {boolean} Are we in development mode (decided by the server); + /** @type {boolean} Are we in development mode (decided by the server); this.dev = false; /** @type {string|null} The message type of the last thing we were asked. */ + this.replyType = null; + + /** @type {string|null} The #tag of the last thing we were asked. */ + this.replyTag = null; + + /** @type {HTMLElement} The output "monitor" */ + this.output = document.getElementById("output"); + + /** @type {HTMLElement} The input element */ + this.input = document.getElementById("input"); + + /** @type {HTMLElement} The send/submit button */ + this.sendButton = document.getElementById("send"); + + /** @type {HTMLElement} Status indicator */ + this.status = document.getElementById("status"); + + // Passwords are crypted and salted before being sent to the server + // This means that if ANY of these three parameters below change, + // The server can no longer accept the passwords. + /** @type {string} Hashing method to use for client-side password hashing */ + this.digest = "SHA-256"; + + /** @type {string} Salt string to use for client-side password hashing */ + this.salt = "No salt, no shorts, no service"; + + /** @type {string} Number of times the hashing should be done */ + this.rounds = 1000; + + /** @type {string} the username also salts the password, so the username must never change. */ + this.username = ""; + + this.setupEventListeners(); + this.connect(); + } + + /** @param {string} password the password to be hashed */ + async hashPassword(password) { + const encoder = new TextEncoder(); + let data = encoder.encode(password + this.salt); + + for (let i = 0; i < this.rounds; i++) { + const hashBuffer = await crypto.subtle.digest(this.digest, data); + data = new Uint8Array(hashBuffer); // feed hash back in + } + + // Convert final hash to hex + const rawHash = Array.from(data) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`; + } + + connect() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}`; + + this.updateStatus("Connecting...", "connecting"); + + try { + this.websocket = new WebSocket(wsUrl); + + this.websocket.onopen = () => { + this.updateStatus("Connected", "connected"); + this.input.disabled = false; + this.sendButton.disabled = false; + this.input.focus(); + this.output.innerHTML = ""; + }; + + this.websocket.onmessage = (event) => { + console.debug(event); + const data = JSON.parse(event.data); + this.onMessage(data); + this.input.focus(); + }; + + this.websocket.onclose = () => { + this.updateStatus("Disconnected", "disconnected"); + this.input.disabled = true; + this.sendButton.disabled = true; + + // Attempt to reconnect after 3 seconds + setTimeout(() => this.connect(), 3000); + }; + + this.websocket.onerror = (error) => { + this.updateStatus("Connection Error", "error"); + this.writeToOutput("Connection error occurred. Retrying...", { + class: "error", + }); + }; + } catch (error) { + console.error(error); + this.updateStatus("Connection Failed", "error"); + setTimeout(() => this.connect(), 3000); + } + } + + setupEventListeners() { + document.addEventListener("keypress", (e) => { + if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { + this.input.focus(); + } + }); + this.input.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + this.onUserCommand(); + } + }); + + this.sendButton.addEventListener("click", () => { + this.onUserCommand(); + }); + + // Command history + this.commandHistory = []; + this.historyIndex = -1; + + this.input.addEventListener("keydown", (e) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + if (this.historyIndex < this.commandHistory.length - 1) { + this.historyIndex++; + this.input.value = + this.commandHistory[ + this.commandHistory.length - 1 - this.historyIndex + ]; + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (this.historyIndex > 0) { + this.historyIndex--; + this.input.value = + this.commandHistory[ + this.commandHistory.length - 1 - this.historyIndex + ]; + } else if (this.historyIndex === 0) { + this.historyIndex = -1; + this.input.value = ""; + } + } + }); + } + + /** + * Send a json-encoded message to the server via websocket. + * + * @param {messageType} string + * @param {...any} rest + */ + send(messageType, ...args) { + if (args.length === 0) { + this.websocket.send(JSON.stringify([messageType])); + return; + } + + this.websocket.send(JSON.stringify([messageType, ...args])); + } + + // + // Add a command to history so we can go back to previous commands with arrow keys. + _addCommandToHistory(command) { + // + // we do not add usernames or passwords to history. + if (this.replyType === "password" || this.replyType === "username") { + return; + } + + // + // Adding empty commands makes no sense. + // Why would the user navigate back through their history to + // find and empty command when they can just press enter. + if (command === "") { + return; + } + + // + // Add to command our history + // But not if the command was a password. + this.historyIndex = -1; + + // + // We do not add the same commands many times in a row. + if (this.commandHistory[this.commandHistory.length - 1] === command) { + return; + } + + // + // Add the command to the history stack + this.commandHistory.push(command); + if (this.commandHistory.length > 50) { + this.commandHistory.shift(); + } + } + + /** + * User has entered a command + */ + onUserCommand() { + // + // Trim user's input. + const command = this.input.value.trim(); + this.input.value = ""; + this.input.type = "text"; + + this._addCommandToHistory(command); + + // -- This is a sneaky command that should not be in production? + // + // In reality we want to use :clear, nor /clear + // :clear would be sent to the server, and we ask if it's okay + // to clear the screen right now, and only on a positive answer would we + // allow the screen to be cleared. Maybe..... + if (command === "/clear") { + this.output.innerHTML = ""; + this.input.value = ""; + return; + } + + // + // Don't allow sending messages (for now) + // Later on, prompts may give us the option to simply "press enter"; + if (!command) { + console.debug("Cannot send empty message - YET"); + return; + } + + // + // Can't send a message without a websocket + if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) { + return; + } + + // + // The server asked us for a password, so we send it. + // But we hash it first, so we don't send our stuff + // in the clear. + if (this.replyType === "password") { + this.hashPassword(command).then((pwHash) => { + this.send("reply", "password", pwHash, this.replyTag); this.replyType = null; - - /** @type {string|null} The #tag of the last thing we were asked. */ this.replyTag = null; - - /** @type {HTMLElement} The output "monitor" */ - this.output = document.getElementById("output"); - - /** @type {HTMLElement} The input element */ - this.input = document.getElementById("input"); - - /** @type {HTMLElement} The send/submit button */ - this.sendButton = document.getElementById("send"); - - /** @type {HTMLElement} Status indicator */ - this.status = document.getElementById("status"); - - // Passwords are crypted and salted before being sent to the server - // This means that if ANY of these three parameters below change, - // The server can no longer accept the passwords. - /** @type {string} Hashing method to use for client-side password hashing */ - this.digest = "SHA-256"; - - /** @type {string} Salt string to use for client-side password hashing */ - this.salt = "No salt, no shorts, no service"; - - /** @type {string} Number of times the hashing should be done */ - this.rounds = 1000; - - /** @type {string} the username also salts the password, so the username must never change. */ - this.username = ""; - - this.setupEventListeners(); - this.connect(); - } - - /** @param {string} password the password to be hashed */ - async hashPassword(password) { - const encoder = new TextEncoder(); - let data = encoder.encode(password + this.salt); - - for (let i = 0; i < this.rounds; i++) { - const hashBuffer = await crypto.subtle.digest(this.digest, data); - data = new Uint8Array(hashBuffer); // feed hash back in - } - - // Convert final hash to hex - const rawHash = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''); - - return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`; - } - - connect() { - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}`; - - this.updateStatus("Connecting...", "connecting"); - - try { - this.websocket = new WebSocket(wsUrl); - - this.websocket.onopen = () => { - this.updateStatus("Connected", "connected"); - this.input.disabled = false; - this.sendButton.disabled = false; - this.input.focus(); - this.output.innerHTML = ''; - }; - - this.websocket.onmessage = (event) => { - console.debug(event); - const data = JSON.parse(event.data); - this.onMessage(data); - this.input.focus(); - }; - - this.websocket.onclose = () => { - this.updateStatus("Disconnected", "disconnected"); - this.input.disabled = true; - this.sendButton.disabled = true; - - // Attempt to reconnect after 3 seconds - setTimeout(() => this.connect(), 3000); - }; - - this.websocket.onerror = (error) => { - this.updateStatus("Connection Error", "error"); - this.writeToOutput("Connection error occurred. Retrying...", { class: "error" }); - }; - } catch (error) { - console.error(error); - this.updateStatus("Connection Failed", "error"); - setTimeout(() => this.connect(), 3000); - } - } - - setupEventListeners() { - document.addEventListener("keypress", (e) => { - if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { - this.input.focus(); - } - }); - this.input.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - this.onUserCommand(); - } - }); - - this.sendButton.addEventListener("click", () => { - this.onUserCommand(); - }); - - // Command history - this.commandHistory = []; - this.historyIndex = -1; - - this.input.addEventListener("keydown", (e) => { - if (e.key === "ArrowUp") { - e.preventDefault(); - if (this.historyIndex < this.commandHistory.length - 1) { - this.historyIndex++; - this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex]; - } - } else if (e.key === "ArrowDown") { - e.preventDefault(); - if (this.historyIndex > 0) { - this.historyIndex--; - this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex]; - } else if (this.historyIndex === 0) { - this.historyIndex = -1; - this.input.value = ""; - } - } - }); - } - - /** - * Send a json-encoded message to the server via websocket. - * - * @param {messageType} string - * @param {...any} rest - */ - send(messageType, ...args) { - if (args.length === 0) { - this.websocket.send(JSON.stringify([messageType])); - return; - } - - this.websocket.send(JSON.stringify([messageType, ...args])); + }); + return; } // - // Add a command to history so we can go back to previous commands with arrow keys. - _addCommandToHistory(command) { - // - // we do not add usernames or passwords to history. - if (this.replyType === "password" || this.replyType === "username") { - return; - } - - // - // Adding empty commands makes no sense. - // Why would the user navigate back through their history to - // find and empty command when they can just press enter. - if (command === "") { - return; - } - - // - // Add to command our history - // But not if the command was a password. - this.historyIndex = -1; - - // - // We do not add the same commands many times in a row. - if (this.commandHistory[this.commandHistory.length - 1] === command) { - return; - } - - // - // Add the command to the history stack - this.commandHistory.push(command); - if (this.commandHistory.length > 50) { - this.commandHistory.shift(); - } - } - - /** - * User has entered a command - */ - onUserCommand() { - // - // Trim user's input. - const command = this.input.value.trim(); - this.input.value = ""; - this.input.type = "text"; - - this._addCommandToHistory(command); - - - // -- This is a sneaky command that should not be in production? - // - // In reality we want to use :clear, nor /clear - // :clear would be sent to the server, and we ask if it's okay - // to clear the screen right now, and only on a positive answer would we - // allow the screen to be cleared. Maybe..... - if (command === "/clear") { - this.output.innerHTML = ""; - this.input.value = ""; - return; - } - - // - // Don't allow sending messages (for now) - // Later on, prompts may give us the option to simply "press enter"; - if (!command) { - console.debug("Cannot send empty message - YET"); - return; - } - - // - // Can't send a message without a websocket - if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) { - return; - } - - // - // The server asked us for a password, so we send it. - // But we hash it first, so we don't send our stuff - // in the clear. - if (this.replyType === "password") { - this.hashPassword(command).then((pwHash) => { - this.send("reply", "password", pwHash, this.replyTag); - this.replyType = null; - this.replyTag = null; - }); - return; - } - - // - // When the player enters their username during the auth-phase, - // keep the username in the pocket for later. - if (this.replyType === "username") { - this.username = command; - } - - // - // We add our own command to the output stream so the - // player can see what they typed. - this.writeToOutput("> " + command, { class: "input" }); - - // - // Handle certain-commands differently. - const specialCommands = { ":quit": "quit", ":help": "help" }; - if (specialCommands[command]) { - this.send(specialCommands[command]); - return; - } - - // - // Handle replies - // We want to be in a place where ALL messages are replies. - // The game loop should always ask you for your next command, - // even if it does so silently - if (this.replyType) { - //-------------------------------------------------- - // The server asked the player a question, - // so we send the answer the way the server wants. - //-------------------------------------------------- - this.send("reply", this.replyType, command, this.replyTag); - this.replyType = null; - this.replyTag = null; - return; - } - - // - //----------------------------------------------------- - // The player sends a text-based command to the server - //----------------------------------------------------- - // ___ _ _ _ - // |_ _|_ __ ___ _ __ ___ _ __| |_ __ _ _ __ | |_| | - // | || '_ ` _ \| '_ \ / _ \| '__| __/ _` | '_ \| __| | - // | || | | | | | |_) | (_) | | | || (_| | | | | |_|_| - // |___|_| |_| |_| .__/ \___/|_| \__\__,_|_| |_|\__(_) - // |_| - // - // Aside from :help", ":quit", etc. we should not send - // unsolicited messages to the server without being - // prompted to do so. - this.send("c", command); - - } - - // ___ __ __ - // / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___ - // | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \ - // | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/ - // \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___| - // - /** @param {any[]} data*/ - onMessage(data) { - if (this.dev) { - console.debug(data); - } - const messageType = data.shift(); - - if (messageType === "dbg") { - return this.handleDebugMessages(data); - } - - if (messageType === "prompt") { - return this.handlePromptMessage(data); - } - - if (messageType === "e") { - return this.handleErrorMessage(data); - } - - if (messageType === "calamity") { - return this.handleCalamityMessage(data); - } - - if (messageType === "_") { - return this.handleSystemMessages(data); - } - - if (messageType === "m") { - return this.handleTextMessages(data); - } - - if (this.dev) { - this.writeToOutput(`unknown message type: ${messageType}: ${JSON.stringify(data)}`, "debug"); - } - console.debug("unknown message type", data); + // When the player enters their username during the auth-phase, + // keep the username in the pocket for later. + if (this.replyType === "username") { + this.username = command; } // - // "m" => normal/standard message to be displayed to the user - handleTextMessages(data) { - const options = { ...data[1] }; // coerce options into an object. + // We add our own command to the output stream so the + // player can see what they typed. + this.writeToOutput("> " + command, { class: "input" }); - - // normal text message to be shown to the player - this.writeToOutput(data[0], options); - return; + // + // Handle certain-commands differently. + const specialCommands = { ":quit": "quit", ":help": "help" }; + if (specialCommands[command]) { + this.send(specialCommands[command]); + return; } // - // Debug messages let the server send data to be displayed on the player's screen - // and also logged to the players browser's log. - handleDebugMessages(data) { - if (!this.dev) { - return; // debug messages are thrown away if we're not in dev mode. - } - this.writeToOutput(data, { class: "debug", preformatted: true }); - console.debug("DBG", data); + // Handle replies + // We want to be in a place where ALL messages are replies. + // The game loop should always ask you for your next command, + // even if it does so silently + if (this.replyType) { + //-------------------------------------------------- + // The server asked the player a question, + // so we send the answer the way the server wants. + //-------------------------------------------------- + this.send("reply", this.replyType, command, this.replyTag); + this.replyType = null; + this.replyTag = null; + return; } // - // "_" => system messages, not to be displayed - handleSystemMessages(data) { - - if (data.length < 2) { - console.debug("malformed system message", data); - return; - } - - console.debug("Incoming system message", data); - - /** @type {string} */ - const messageType = data.shift(); - - switch (messageType) { - case "dev": - // This is a message that tells us that the server is in - // "dev" mode, and that we should do the same. - this.dev = data[0]; - this.status.textContent = "[DEV] " + this.status.textContent; - break; - case "salt": - this.salt = data[0]; - console.debug("updating crypto salt", data[0]); - break; - default: - console.debug("unknown system message", messageType, data); - } - - // If we're in dev mode, we should output all system messages (in a shaded/faint fashion). - if (this.dev) { - this.writeToOutput(`system message: ${messageType} = ${JSON.stringify(data)}`, { class: "debug" }); - } - return; - } - + //----------------------------------------------------- + // The player sends a text-based command to the server + //----------------------------------------------------- + // ___ _ _ _ + // |_ _|_ __ ___ _ __ ___ _ __| |_ __ _ _ __ | |_| | + // | || '_ ` _ \| '_ \ / _ \| '__| __/ _` | '_ \| __| | + // | || | | | | | |_) | (_) | | | || (_| | | | | |_|_| + // |___|_| |_| |_| .__/ \___/|_| \__\__,_|_| |_|\__(_) + // |_| // - // "calamity" => lethal error. Close connection. - // Consider hard refresh of page to reset all variables - handleCalamityMessage(data) { - // - // We assume that calamity errors are pre-formatted, and we do not allow - // any of our own formatting-shenanigans to interfere with the error message - const options = { ...{ class: "error", "preformatted": true }, ...data[1] }; - this.writeToOutput(data[0], options); - return; + // Aside from :help", ":quit", etc. we should not send + // unsolicited messages to the server without being + // prompted to do so. + this.send("c", command); + } + + // ___ __ __ + // / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___ + // | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \ + // | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/ + // \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___| + // + /** @param {any[]} data*/ + onMessage(data) { + if (this.dev) { + console.debug(data); + } + const messageType = data.shift(); + + if (messageType === "dbg") { + return this.handleDebugMessages(data); } + if (messageType === "prompt") { + return this.handlePromptMessage(data); + } + + if (messageType === "e") { + return this.handleErrorMessage(data); + } + + if (messageType === "calamity") { + return this.handleCalamityMessage(data); + } + + if (messageType === "_") { + return this.handleSystemMessages(data); + } + + if (messageType === "m") { + return this.handleTextMessages(data); + } + + if (this.dev) { + this.writeToOutput( + `unknown message type: ${messageType}: ${JSON.stringify(data)}`, + "debug", + ); + } + console.debug("unknown message type", data); + } + + // + // "m" => normal/standard message to be displayed to the user + handleTextMessages(data) { + const options = { ...data[1] }; // coerce options into an object. + + // normal text message to be shown to the player + this.writeToOutput(data[0], options); + return; + } + + // + // Debug messages let the server send data to be displayed on the player's screen + // and also logged to the players browser's log. + handleDebugMessages(data) { + if (!this.dev) { + return; // debug messages are thrown away if we're not in dev mode. + } + this.writeToOutput(data, { class: "debug", preformatted: true }); + console.debug("DBG", data); + } + + // + // "_" => system messages, not to be displayed + handleSystemMessages(data) { + if (data.length < 2) { + console.debug("malformed system message", data); + return; + } + + console.debug("Incoming system message", data); + + /** @type {string} */ + const messageType = data.shift(); + + switch (messageType) { + case "dev": + // This is a message that tells us that the server is in + // "dev" mode, and that we should do the same. + this.dev = data[0]; + this.status.textContent = "[DEV] " + this.status.textContent; + break; + case "salt": + this.salt = data[0]; + console.debug("updating crypto salt", data[0]); + break; + default: + console.debug("unknown system message", messageType, data); + } + + // If we're in dev mode, we should output all system messages (in a shaded/faint fashion). + if (this.dev) { + this.writeToOutput( + `system message: ${messageType} = ${JSON.stringify(data)}`, + { class: "debug" }, + ); + } + return; + } + + // + // "calamity" => lethal error. Close connection. + // Consider hard refresh of page to reset all variables + handleCalamityMessage(data) { // - // "e" => non-lethal errors - handleErrorMessage(data) { - const options = { ...{ class: "error" }, ...data[1] }; - this.writeToOutput(data[0], options); - return; + // We assume that calamity errors are pre-formatted, and we do not allow + // any of our own formatting-shenanigans to interfere with the error message + const options = { ...{ class: "error", preformatted: true }, ...data[1] }; + this.writeToOutput(data[0], options); + return; + } + + // + // "e" => non-lethal errors + handleErrorMessage(data) { + const options = { ...{ class: "error" }, ...data[1] }; + this.writeToOutput(data[0], options); + return; + } + + // + // The prompt is the most important message type, + // it prompts us send a message back. We should not + // send messages back to the server without being + // prompted. + // In fact, we should ALWAYS be in a state of just-having-been-prompted. + handlePromptMessage(data) { + let [replyType, promptText, replyTag, options = {}] = data; + + this.replyType = replyType; + this.replyTag = replyTag; + this.writeToOutput(promptText, { ...{ class: "prompt" }, ...options }); + + // The server has asked for a password, so we set the + // input type to password for safety reasons. + if (replyType === "password") { + this.input.type = "password"; } - // - // The prompt is the most important message type, - // it prompts us send a message back. We should not - // send messages back to the server without being - // prompted. - // In fact, we should ALWAYS be in a state of just-having-been-prompted. - handlePromptMessage(data) { - let [replyType, promptText, replyTag, options = {}] = data; + return; + } - this.replyType = replyType; - this.replyTag = replyTag; - this.writeToOutput(promptText, { ...{ class: "prompt" }, ...options }); + /** + * Add output to the text. + * @param {string} text + * @param {object} options + */ + writeToOutput(text, options = {}) { + const el = document.createElement("span"); - // The server has asked for a password, so we set the - // input type to password for safety reasons. - if (replyType === "password") { - this.input.type = "password"; - } - - return; + if (typeof options.class === "string") { + el.className = options.class; } - /** - * Add output to the text. - * @param {string} text - * @param {object} options - */ - writeToOutput(text, options = {}) { - const el = document.createElement("span"); + // add end of line character "\n" unless + // options.addEol = false is set explicitly + const eol = options.addEol === false ? "" : "\n"; - if (typeof options.class === "string") { - el.className = options.class; - } - - // add end of line character "\n" unless - // options.addEol = false is set explicitly - const eol = options.addEol === false ? "" : "\n"; - - if (options.preformatted) { - el.textContent = text + eol; - el.className += " " + "preformatted"; - } else { - el.innerHTML = parseCrackdown(text) + eol; - } - this.output.appendChild(el); - this.output.scrollTop = this.output.scrollHeight; + if (options.preformatted) { + el.textContent = text + eol; + el.className += " " + "preformatted"; + } else { + el.innerHTML = crackdown(text) + eol; } + this.output.appendChild(el); + this.output.scrollTop = this.output.scrollHeight; + } - /** - * Update the status banner. - * - * @param {string} message - * @param {string} className - */ - updateStatus(message, className) { - this.status.textContent = this.dev - ? `[DEV] Status: ${message}` - : `Status: ${message}`; - this.status.className = className; - } + /** + * Update the status banner. + * + * @param {string} message + * @param {string} className + */ + updateStatus(message, className) { + this.status.textContent = this.dev + ? `[DEV] Status: ${message}` + : `Status: ${message}`; + this.status.className = className; + } } // Initialize the MUD client when the page loads 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 '&'; - case '<': return '<'; - case '>': return '>'; - case '"': return '"'; - case '\'': return '''; - case '`': return '`'; - default: return c; - } - }) - .replace(/---(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])---/g, '$1') // line-through - .replace(/___(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])___/g, '$1') // underline - .replace(/_(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])_/g, '$1') // italic - .replace(/\*(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\*/g, '$1') // bold - .replace(/\.{3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\.{3}/g, '$1') // undercurl - .replace(/\({3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){3}/g, '($1)') // faint with parentheses - .replace(/\({2}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){2}/g, '$1') // faint with parentheses - ; - - console.debug("crack output", text); - - return text; -} diff --git a/server/public/crackdown.js b/server/public/crackdown.js new file mode 100644 index 0000000..bc2096d --- /dev/null +++ b/server/public/crackdown.js @@ -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 "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + case "`": + return "`"; + default: + return c; + } + }) + .replace( + /---(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])---/g, + '$1', + ) // line-through + .replace( + /___(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])___/g, + '$1', + ) // underline + .replace( + /_(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])_/g, + '$1', + ) // italic + .replace( + /\*(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\*/g, + '$1', + ) // bold + .replace( + /\.{3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\.{3}/g, + '$1', + ) // undercurl + .replace( + /\({3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){3}/g, + '($1)', + ) // faint with parentheses + .replace( + /\({2}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){2}/g, + '$1', + ); // faint with parentheses + + console.debug("crack output", text); + + return text; +} diff --git a/server/public/index.html b/server/public/index.html index 4dce047..3da8470 100755 --- a/server/public/index.html +++ b/server/public/index.html @@ -1,23 +1,31 @@ - - - - WebSocket MUD - - - - - -
-
Connecting...
-
-
- - -
-
+ + + + WebSocket MUD + + + + + +
+
Connecting...
+
+
+ + +
+
- - + + diff --git a/server/public/style.css b/server/public/style.css index 26c70f9..1f0064e 100755 --- a/server/public/style.css +++ b/server/public/style.css @@ -1,136 +1,136 @@ -@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap"); body { - font-family: "Fira Code", monospace; - font-optical-sizing: auto; - font-size: 14px; - background-color: #1a1a1a; - color: #00ff00; - margin: 0; - padding: 0; - height: 100vh; - width: 100vw; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; + font-size: 14px; + background-color: #1a1a1a; + color: #00ff00; + margin: 0; + padding: 0; + height: 100vh; + width: 100vw; - display: flex; - flex-direction: column; - overflow: hidden; + display: flex; + flex-direction: column; + overflow: hidden; } #container { - display: flex; - flex-direction: column; - height: 100vh; - max-width: 99.9vw; - margin: 0 auto; - padding: 10px; - overflow: hidden; + display: flex; + flex-direction: column; + height: 100vh; + max-width: 99.9vw; + margin: 0 auto; + padding: 10px; + overflow: hidden; } #output { - flex: 1; - background-color: #000; - border: 2px solid #333; - padding: 15px; - overflow-y: auto; - white-space: pre-wrap; - line-height: 1.4; - margin-bottom: 20px; - font-family: "Fira Code", monospace; - font-optical-sizing: auto; - font-size: 14px; - width: 100ch; + flex: 1; + background-color: #000; + border: 2px solid #333; + padding: 15px; + overflow-y: auto; + white-space: pre-wrap; + line-height: 1.4; + margin-bottom: 20px; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; + font-size: 14px; + width: 100ch; } #input-container { - display: flex; - gap: 10px; + display: flex; + gap: 10px; } #input { - flex: 1; - background-color: #222; - border: 2px solid #333; - color: #00ff00; - padding: 10px; - font-family: "Fira Code", monospace; - font-optical-sizing: auto; - font-size: 14px; + flex: 1; + background-color: #222; + border: 2px solid #333; + color: #00ff00; + padding: 10px; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; + font-size: 14px; } #input:focus { - outline: none; - border-color: #00ff00; + outline: none; + border-color: #00ff00; } #send { - background-color: #333; - border: 2px solid #555; - color: #00ff00; - padding: 10px 20px; - font-family: "Fira Code", monospace; - font-optical-sizing: auto; - cursor: pointer; + background-color: #333; + border: 2px solid #555; + color: #00ff00; + padding: 10px 20px; + font-family: "Fira Code", monospace; + font-optical-sizing: auto; + cursor: pointer; } #send:hover { - background-color: #444; + background-color: #444; } #status { - background-color: #333; - padding: 5px 15px; - margin-bottom: 10px; - border-radius: 3px; + background-color: #333; + padding: 5px 15px; + margin-bottom: 10px; + border-radius: 3px; } .connected { - color: #00ff00; + color: #00ff00; } .disconnected { - color: #ff4444; + color: #ff4444; } .connecting { - color: #ffaa00; + color: #ffaa00; } .error { - color: #ff4444; + color: #ff4444; } .input { - color: #666; + color: #666; } .debug { - opacity: 0.33; + opacity: 0.33; } .prompt { - color: #00ccff; + color: #00ccff; } .bold { - font-weight: bold; + font-weight: bold; } .italic { - font-style: italic; + font-style: italic; } .strike { - text-decoration:line-through; + text-decoration: line-through; } .underline { - text-decoration: underline; + text-decoration: underline; } .undercurl { - text-decoration: wavy underline lime; + text-decoration: wavy underline lime; } .faint { - opacity: 0.42; + opacity: 0.42; } diff --git a/server/seeders/characerSeeder.js b/server/seeders/characerSeeder.js old mode 100644 new mode 100755 index 3573b61..4272fc8 --- a/server/seeders/characerSeeder.js +++ b/server/seeders/characerSeeder.js @@ -3,13 +3,560 @@ // | | | '_ \ / _` | '__/ _` |/ __| __/ _ \ '__| // | |___| | | | (_| | | | (_| | (__| || __/ | // \____|_| |_|\__,_|_| \__,_|\___|\__\___|_| -// // ____ _ // / ___| ___ ___ __| | ___ _ __ // \___ \ / _ \/ _ \/ _` |/ _ \ '__| // ___) | __/ __/ (_| | __/ | // |____/ \___|\___|\__,_|\___|_| -// -export class CharacterSeeder { -} +// ------------------------------------------------ +import { Character } from "../models/character.js"; +import { Game } from "../models/game.js"; +import { Player } from "../models/player.js"; +import * as roll from "../utils/dice.js"; +import { isIdSane } from "../utils/id.js"; +export class CharacterSeeder { + /** @type {Game} */ + constructor(game) { + /** @type {Game} */ + this.game = game; + } + + /** + * Create an item, using an item blueprint with the given name + * + * @param {string} itemBlueprintId id of the item blueprint + * @returns {Item|undefined} + */ + item(itemBlueprintId) {} + + /** + * @param {Character} character + * @param {...string} itemBlueprintIds + */ + addItemsToCharacter(character, ...itemBlueprintIds) { + for (const id of itemBlueprintIds) { + const blueprint = this.game.getItemBlueprint(id); + if (!blueprint) { + throw new Error(`No blueprint found for id: ${id}`); + } + const item = blueprint.createItem(); + character.addItem(item); + } + } + + /** + * @param {Character} character + * @param {...string} skills + */ + addSkillsToCharacter(character, ...skills) { + for (const skill of skills) { + if (!isIdSane(skill)) { + throw new Error(`Skill id >>${skill}<< is insane!`); + } + character.skills.add(skill); + } + } + + /** + * Foundation function + * @name FoundationFunction + * @function + * @param {Character} The character to which we apply this foundation. + */ + + createCharacter() { + const c = new Character(); + // + // Initializing + // + + // Rolling skills + + c.awareness = roll.d6() + 2; + c.grit = roll.d6() + 2; + c.knowledge = roll.d6() + 2; + c.magic = roll.d6() + 2; + c.meleeCombat = roll.d6() + 2; + c.rangedCombat = roll.d6() + 2; + c.skulduggery = roll.d6() + 2; + + switch (roll.d8()) { + case 1: + c.ancestry = "human"; + // Humans get +1 to all skills + c.awareness++; + c.grit++; + c.knowledge++; + c.magic++; + c.meleeCombat++; + c.rangedCombat++; + c.skulduggery++; + break; + case 2: + c.ancestry = "dwarven"; + c.meleeCombat = Math.max(c.meleeCombat, 10); + break; + case 3: + c.ancestry = "elven"; + c.rangedCombat = Math.max(c.rangedCombat, 10); + break; + case 4: + c.ancestry = "giant"; + c.meleeCombat = Math.max(c.grit, 10); + break; + case 5: + c.ancestry = "Gnomish"; + c.meleeCombat = Math.max(c.awareness, 10); + break; + case 6: + c.ancestry = "primordial"; + c.meleeCombat = Math.max(c.magic, 10); + break; + case 7: + c.ancestry = "draconic"; + c.meleeCombat = Math.max(c.knowledge, 10); + break; + case 8: + c.ancestry = "demonic"; + c.meleeCombat = Math.max(c.skulduggery, 10); + break; + default: + throw new Error("Logic error, ancestry d8() roll was out of scope"); + } + + this.applyFoundation(c); + } + + /** + * Create characters for the given player + * + * The characters are automatically added to the player's party + * + * @param {Player} player + * @param {number} partySize + * + * @return {Character[]} + */ + createParty(player, partySize) { + // + for (let i = 0; i < partySize; i++) { + const character = this.createCharacter(player); + } + } + + /** + * @param {Character} c + * @param {string|number} Foundation to add to character + */ + applyFoundation(c, foundation = "random") { + switch (foundation) { + case "random": + return this.applyFoundation(c, roll.dice(3)); + break; + + // + // Brawler + // ------ + case 1: + case ":brawler": + c.foundation = "Brawler"; + c.skills.add(":armor.light"); + c.silver = 40; + c.maxHitPoints = c.currentHitPoints = 15; + c.itemSlots = 7; + c.meleeCombat = Math.max(c.meleeCombat, 10); + c.knowledge = Math.min(c.knowledge, 10); + + this.addItemsToCharacter( + c, // + ":armor.light.studded_leather", + ":weapon.weird.spiked_gauntlets", + ); + + this.addSkillsToCharacter(c, ":weapon.weird.spiked_gauntlets"); + + break; + + // + // DRUID + // ------ + case 2: + case ":druid": + c.foundation = "Druid"; + c.silver = 40; + c.maxHitPoints = this.currentHitPoints = 15; + c.itemSlots = 7; + c.meleeCombat = Math.max(this.meleeCombat, 10); + c.knowledge = Math.min(this.knowledge, 10); + this.addItemsToCharacter( + c, // + ":armor.light.leather", + ":weapon.light.sickle", + ":kits.poisoners_kit", + ":kits.healers_kit", + ); + this.addSkillsToCharacter( + c, // + ":armor.light.leather", + ":armor.light.hide", + ":weapon.light.sickle", + ); + break; + case 3: + case ":fencer": + c.foundation = "Fencer"; + + // + // Stats + c.maxHitPoints = c.currentHitPoints = 15; + c.meleeCombat = Math.max(c.meleeCombat, 10); + c.magic = Math.min(c.magic, 10); + + // + // Skills + this.addSkillsToCharacter( + c, // + ":perk.two_weapons", // TODO: perks should be their own thing, and not a part of the skill system? + ":armor.light", + ); + + // + // Gear + c.silver = 40; + c.itemSlots = 5; + this.addItemsToCharacter( + c, // + ":armor.light.leather", + ":weapon.light.rapier", + ":weapon.light.dagger", + ); + + /* + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: Fencer +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +|Fencer + +|[unstyled] +* Light Armor + +|[unstyled] +* Leather +* Rapier +* Dagger +* 40 Silver Pieces + +|[unstyled] + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: GUARD +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Guard + +|[unstyled] +* Medium Armor + +|[unstyled] +* Halberd +* Bull's Eye Lantern +* Signal Whistle +* Map of Local Area +* 50 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 5 Item Slots +* Awareness raised to 10 +* Melee Combat raised to 10 +* Skulduggery limited to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: MAGICIAN +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Magician + +|[unstyled] +* None + +|[unstyled] +* Tier 2 Wand with random spell. +* Tier 1 Wand with random spell. +* 10 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 6 Item Slots +* Melee Combat limited to 10 +* Ranged Combat limited to 5 +* Magic raised to 10 +* Grit limited to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: MEDIC +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +|Medic + +|[unstyled] +* Light Armor +* Medium Armor + +|[unstyled] +* Club +* Sling +* 3 Daggers +* Healer's Kit +* 40 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 6 Item Slots + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: RECKLESS +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Reckless + +|[unstyled] + +|[unstyled] +* Great Axe +* 50 Silver Pieces + +|[unstyled] +* 20 Hit Points +* 7 Item Slots +* Melee Combat raised to 10 +* Awareness raised to 10 +* Grit raised to 10 +* Magic limited to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: ROVER +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Rover + +|[unstyled] +* Light Armor + +|[unstyled] +* Leather Armor +* Short Sword +* Longbow +* Snare Maker's Kit +* 25 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 5 Item Slots +* Magic Reduced to 10 +* Awareness raised to 10 +* Ranged Combat raised to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: SKIRMISHER +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Skirmisher + +|[unstyled] +* Light Armor +* Shields + +|[unstyled] +* Spear +* Small Shield +* 50 Silver Pieces + + +|[unstyled] +* 15 Hit Points +* 6 Item Slots +* Melee Combat raised to 10 +* Awareness raised to 10 +* Skulduggery raised to 10 +* Grit raised to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: SNEAK +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Sneak + +|[unstyled] +* Light Armor + +|[unstyled] +* 3 daggers +* Small Crossbow +* Poisoner's Kit +* 30 Silver Pieces + + +|[unstyled] +* 10 Hit Points +* 6 Item Slots +* Melee Combat raised to 10 +* Awareness raised to 10 +* Skulduggery raised to 10 +* Grit raised to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: SPELLSWORD +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Spellsword + +|[unstyled] + +|[unstyled] +* Tier 1 Wand with random spell. +* Longsword +* 30 Silver Pieces + +|[unstyled] +* 12 Hit Points +* 5 Item Slots +* Melee Combat raised to 10 +* Ranged Combat limited to 10 +* Magic raised to 10 +* Skulduggery limited to 10 +* Grit raised to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: SPELUNKER +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Spelunker + +|[unstyled] +* None + +|[unstyled] +* Spear +* Caltrops +* Bull's Eye Lantern +* Map Maker's Kit +* Chalk +* Caltrops +* 5 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 4 Item Slots +* Awareness raised to 10 +* Melee Combat raised to 10 +* Skulduggery raised to 10 +* Magic limited to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: SPIT'N'POLISH +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Spit'n' Polish + +|[unstyled] +* Heavy Armor +* Shield + +|[unstyled] +* Half-Plate +* Large Shield +* Long Sword +* 10 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 2 Item Slots +* Melee Combat raised to 10 +* Magic Reduced to 6 +* Awareness Reduced to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: STILETTO +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +| Stiletto + +|[unstyled] +* Light Armor + +|[unstyled] +* Padded Armor +* 3 Daggers +* Small Crossbow +* Poisoner's Kit +* 20 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 5 Item Slots +* Melee Combat raised to 10 +* Ranged Combat raised to 10 +* Awareness raised to 10 +* Magic limited to 6 +* Knowledge limited to 10 + +// +//--------------------------------------------------------------------------------------- +//HEADLINE: Tinkerer +//--------------------------------------------------------------------------------------- +| {counter:foundation} + +|Tinkerer + +|[unstyled] +* Light Armor + +|[unstyled] +* Studded Leather +* Wrench (club) +* Tinkerer's Kit +* 30 Silver Pieces + +|[unstyled] +* 10 Hit Points +* 5 Item Slots +* Awareness raised to 10 +* Knowledge raised to 10 + + */ + // + // WTF ?! + // ------ + default: + throw new Error(`Invalid foundation id ${foundation}`); + } + } +} diff --git a/server/seeders/gameSeeder.js b/server/seeders/gameSeeder.js old mode 100644 new mode 100755 index 3e8be48..1b52a1c --- a/server/seeders/gameSeeder.js +++ b/server/seeders/gameSeeder.js @@ -1,4 +1,5 @@ import { Game } from "../models/game.js"; +import { CharacterSeeder } from "./characerSeeder.js"; import { ItemSeeder } from "./itemSeeder.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 */ export class GameSeeder { - /** @returns {Game} */ createGame() { - - /** @type {Game} */ + /** @protected @constant @readonly @type {Game} */ this.game = new Game(); 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() { - console.info("seeding..."); + console.info("seeding"); // - (new PlayerSeeder(this.game)).seed(); // Create debug players - (new ItemSeeder(this.game)).seed(); // Create items, etc. + new PlayerSeeder(this.game).seed(); // Create debug players + new ItemSeeder(this.game).seed(); // Create items, etc. + new CharacterSeeder(this.game).createParty(this.game.getPlayer("user"), 3); // Create debug characters. // // Done diff --git a/server/seeders/itemSeeder.js b/server/seeders/itemSeeder.js index 39f56a3..55f2770 100755 --- a/server/seeders/itemSeeder.js +++ b/server/seeders/itemSeeder.js @@ -1,5 +1,5 @@ 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 { - /** @param {Game} game */ constructor(game) { this.game = game; } seed() { - + // // __ __ // \ \ / /__ __ _ _ __ ___ _ __ ___ // \ \ /\ / / _ \/ _` | '_ \ / _ \| '_ \/ __| @@ -26,46 +25,88 @@ export class ItemSeeder { // \_/\_/ \___|\__,_| .__/ \___/|_| |_|___/ // |_| //------------------------------------------------------- - this.game.createItemTemplate("weapons.light.dagger", { + this.game.addItemBlueprint(":weapon.light.dagger", { name: "Dagger", description: "Small shady blady", itemSlots: 0.5, damage: 3, melee: true, + skills: [":weapon.light"], ranged: true, - specialEffect: "effects.weapons.fast", + specialEffect: ":effect.weapon.fast", }); - this.game.createItemTemplate("weapons.light.sickle", { + + this.game.addItemBlueprint(":weapon.light.sickle", { name: "Sickle", description: "For cutting nuts, and branches", itemSlots: 1, 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", description: "Spikes with gauntlets on them!", itemSlots: 1, 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", }); - + // // _ // / \ _ __ _ __ ___ ___ _ __ ___ // / _ \ | '__| '_ ` _ \ / _ \| '__/ __| // / ___ \| | | | | | | | (_) | | \__ \ // /_/ \_\_| |_| |_| |_|\___/|_| |___/ // --------------------------------------- - // - this.game.createItemTemplate("armors.light.studded_leather", { - name: "Studded Leather", + this.game.addItemBlueprint(":armor.light.studded_leather", { + name: "Studded Leather Armor", description: "Padded and hardened leather with metal stud reinforcement", itemSlots: 3, 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, + }); } } - diff --git a/server/seeders/playerSeeder.js b/server/seeders/playerSeeder.js index 114ce47..543a62d 100755 --- a/server/seeders/playerSeeder.js +++ b/server/seeders/playerSeeder.js @@ -2,34 +2,33 @@ import { Game } from "../models/game.js"; import { Player } from "../models/player.js"; export class PlayerSeeder { - /** @param {Game} game */ - constructor(game) { + /** @param {Game} game */ + constructor(game) { + /** @type {Game} */ + this.game = game; + } - /** @type {Game} */ - this.game = game; - } + seed() { + // Examples of the word "pass" hashed by the client and then the server: + // Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them. + // "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4 + // "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef + // + // Since the server-side hashes have random salts, the hashes themselves can change for the same password. + // The client side hash must not have a random salt, otherwise, it must change every time. + // + // The hash below is just one has that represents the password "pass" sent via V1 of the "Kims Krappy Krypto" scheme. - seed() { - // Examples of the word "pass" hashed by the client and then the server: - // Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them. - // "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4 - // "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef - // - // Since the server-side hashes have random salts, the hashes themselves can change for the same password. - // The client side hash must not have a random salt, otherwise, it must change every time. - // - // The hash below is just one has that represents the password "pass" sent via V1 of the "Kims Krappy Krypto" scheme. + this.game.createPlayer( + "user", + "1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef", + "userSalt", + ); - this.game.createPlayer( - "user", - "1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef", - "userSalt", - ); - - this.game.createPlayer( - "admin", - "1000:a84760824d28a9b420ee5f175a04d1e3:a6694e5c9fd41d8ee59f0a6e34c822ee2ce337c187e2d5bb5ba8612d6145aa8e", - "adminSalt", - ); - } + this.game.createPlayer( + "admin", + "1000:a84760824d28a9b420ee5f175a04d1e3:a6694e5c9fd41d8ee59f0a6e34c822ee2ce337c187e2d5bb5ba8612d6145aa8e", + "adminSalt", + ); + } } diff --git a/server/server.js b/server/server.js index 4b9330e..3da963d 100755 --- a/server/server.js +++ b/server/server.js @@ -5,169 +5,186 @@ import fs from "fs"; import { Game } from "./models/game.js"; import * as msg from "./utils/messages.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 { Config } from "./config.js"; class MudServer { + constructor() { + /** @type {Game} */ + this.game = new GameSeeder().createGame(); + } - constructor() { - /** @type {Game} */ - this.game = (new GameSeeder()).createGame(); - } + // ____ ___ _ _ _ _ _____ ____ _____ _____ ____ + // / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \ + // | | | | | | \| | \| | _|| | | | | _| | | | | + // | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| | + // \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/ + //------------------------------------------------------ + // Handle New Socket Connections + //------------------------------ + /** @param {WebSocket} websocket */ + onConnectionEstabished(websocket) { + console.log("New connection established"); + const session = new Session(websocket, this.game); + session.sendSystemMessage("dev", true); - // ____ ___ _ _ _ _ _____ ____ _____ _____ ____ - // / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \ - // | | | | | | \| | \| | _|| | | | | _| | | | | - // | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| | - // \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/ - //------------------------------------------------------ - // Handle New Socket Connections - //------------------------------ - /** @param {WebSocket} websocket */ - onConnectionEstabished(websocket) { - console.log("New connection established"); - const session = new Session(websocket, this.game); - session.sendSystemMessage("dev", true) + // ____ _ ___ ____ _____ + // / ___| | / _ \/ ___|| ____| + // | | | | | | | \___ \| _| + // | |___| |__| |_| |___) | |___ + // \____|_____\___/|____/|_____| + //------------------------------- + // Handle Socket Closing + //---------------------- + websocket.on("close", () => { + if (!session.player) { + console.info("A player without a session disconnected"); + return; + } + //------------- + // TODO + //------------- + // Handle player logout (move the or hide their characters) + // + // Maybe session.onConnectionClosed() that calls session._state.onConnectionClosed() + // Maybe this.setState(new ConnectionClosedState()); + // Maybe both ?? + console.log(`Player ${session.player.username} disconnected`); + }); - // ____ _ ___ ____ _____ - // / ___| | / _ \/ ___|| ____| - // | | | | | | | \___ \| _| - // | |___| |__| |_| |___) | |___ - // \____|_____\___/|____/|_____| - //------------------------------- - // Handle Socket Closing - //---------------------- - websocket.on("close", () => { - if (!session.player) { - console.info("A player without a session disconnected"); - return; - } - //------------- - // TODO - //------------- - // Handle player logout (move the or hide their characters) - // - // Maybe session.onConnectionClosed() that calls session._state.onConnectionClosed() - // Maybe this.setState(new ConnectionClosedState()); - // Maybe both ?? - console.log(`Player ${session.player.username} disconnected`); + // __ __ _____ ____ ____ _ ____ _____ + // | \/ | ____/ ___/ ___| / \ / ___| ____| + // | |\/| | _| \___ \___ \ / _ \| | _| _| + // | | | | |___ ___) |__) / ___ \ |_| | |___ + // |_| |_|_____|____/____/_/ \_\____|_____| + //-------------------------------------------- + // HANDLE INCOMING MESSAGES + //------------------------- + websocket.on("message", (data) => { + try { + console.debug("incoming websocket message %s", data); - }); + if (!session.state) { + console.error( + "we received a message, but don't even have a state. Zark!", + ); + websocket.send( + msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?"), + ); + return; + } - // __ __ _____ ____ ____ _ ____ _____ - // | \/ | ____/ ___/ ___| / \ / ___| ____| - // | |\/| | _| \___ \___ \ / _ \| | _| _| - // | | | | |___ ___) |__) / ___ \ |_| | |___ - // |_| |_|_____|____/____/_/ \_\____|_____| - //-------------------------------------------- - // HANDLE INCOMING MESSAGES - //------------------------- - websocket.on("message", (data) => { - try { - console.debug("incoming websocket message %s", data); + const msgObj = new msg.ClientMessage(data.toString()); - if (!session.state) { - console.error("we received a message, but don't even have a state. Zark!"); - websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?")); - return; - } + if (msgObj.isQuitCommand()) { + //--------------------- + // TODO TODO TODO TODO + //--------------------- + // Set state = QuitState + // + websocket.send( + msg.prepare( + msg.MESSAGE, + "The quitting quitter quits... Typical. Cya!", + ), + ); + websocket.close(); + return; + } - const msgObj = new msg.ClientMessage(data.toString()); + if (typeof session.state.onMessage !== "function") { + console.error( + "we received a message, but we're not i a State to receive it", + ); + websocket.send( + msg.prepare( + msg.ERROR, + "Oh no! I don't know what to do with that message.", + ), + ); + return; + } + session.state.onMessage(msgObj); + } catch (error) { + console.trace( + "received an invalid message (error: %s)", + error, + data.toString(), + data, + ); + websocket.send(msg.prepare(msg.CALAMITY, error)); + } + }); - if (msgObj.isQuitCommand()) { - //--------------------- - // TODO TODO TODO TODO - //--------------------- - // Set state = QuitState - // - websocket.send(msg.prepare(msg.MESSAGE, "The quitting quitter quits... Typical. Cya!")); - websocket.close(); - return; - } + session.setState(new AuthState(session)); + } - if (typeof session.state.onMessage !== "function") { - console.error("we received a message, but we're not i a State to receive it"); - websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do with that message.")); - return; - } - session.state.onMessage(msgObj); - } catch (error) { - console.trace("received an invalid message (error: %s)", error, data.toString(), data); - websocket.send(msg.prepare( - msg.CALAMITY, - error - )); - } - }); + // ____ _____ _ ____ _____ + // / ___|_ _|/ \ | _ \_ _| + // \___ \ | | / _ \ | |_) || | + // ___) || |/ ___ \| _ < | | + // |____/ |_/_/ \_\_| \_\|_| + //----------------------------- + // Start the server + //----------------- + start() { + // + // The file types we allow to be served. + const contentTypes = { + ".js": "application/javascript", + ".css": "text/css", + ".html": "text/html", + }; - session.setState(new AuthState(session)); - } + // + // Create HTTP server for serving the client - Consider moving to own file + const httpServer = http.createServer((req, res) => { + let filePath = path.join( + "public", + req.url === "/" ? "index.html" : req.url, + ); + const ext = path.extname(filePath); + const contentType = contentTypes[ext]; - // ____ _____ _ ____ _____ - // / ___|_ _|/ \ | _ \_ _| - // \___ \ | | / _ \ | |_) || | - // ___) || |/ ___ \| _ < | | - // |____/ |_/_/ \_\_| \_\|_| - //----------------------------- - // Start the server - //----------------- - start() { + // + // Check if the requested file has a legal file type. + if (!contentType) { + // Invalid file, pretend it did not exist! + res.writeHead(404); + res.end(`File not found`); + console.log("Bad http request", req.url); + return; + } - // - // The file types we allow to be served. - const contentTypes = { - ".js": "application/javascript", - ".css": "text/css", - ".html": "text/html", - }; + // + // Check if the file exists. + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end(`File not found`); + console.log("Bad http request", req.url); + return; + } + res.writeHead(200, { "Content-Type": contentType }); + res.end(data); + }); + }); - // - // Create HTTP server for serving the client - Consider moving to own file - const httpServer = http.createServer((req, res) => { - let filePath = path.join("public", req.url === "/" ? "index.html" : req.url); - const ext = path.extname(filePath); - const contentType = contentTypes[ext]; + // + // Create WebSocket server + const websocketServer = new WebSocketServer({ server: httpServer }); - // - // Check if the requested file has a legal file type. - if (!contentType) { - // Invalid file, pretend it did not exist! - res.writeHead(404); - res.end(`File not found`); - console.log("Bad http request", req.url); - return; - } + websocketServer.on("connection", (ws) => { + this.onConnectionEstabished(ws); + }); - - // - // Check if the file exists. - fs.readFile(filePath, (err, data) => { - if (err) { - res.writeHead(404); - res.end(`File not found`); - console.log("Bad http request", req.url); - return; - } - res.writeHead(200, { "Content-Type": contentType }); - res.end(data); - }); - }); - - // - // Create WebSocket server - const websocketServer = new WebSocketServer({ server: httpServer }); - - websocketServer.on("connection", (ws) => { - this.onConnectionEstabished(ws); - }); - - console.info(`running environment: ${Config.env}`); - httpServer.listen(Config.port, () => { - console.log(`NUUHD server running on port ${Config.port}`); - console.log(`WebSocket server ready for connections`); - }); - } + console.info(`running environment: ${Config.env}`); + httpServer.listen(Config.port, () => { + console.log(`NUUHD server running on port ${Config.port}`); + console.log(`WebSocket server ready for connections`); + }); + } } // __ __ _ ___ _ _ diff --git a/server/states/Auth.js b/server/states/Auth.js deleted file mode 100755 index 7d77700..0000000 --- a/server/states/Auth.js +++ /dev/null @@ -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)); - } -} diff --git a/server/states/authState.js b/server/states/authState.js new file mode 100755 index 0000000..751076c --- /dev/null +++ b/server/states/authState.js @@ -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)); + } +} diff --git a/server/states/awaitCommands.js b/server/states/awaitCommands.js index 5d7db55..3c01469 100755 --- a/server/states/awaitCommands.js +++ b/server/states/awaitCommands.js @@ -7,44 +7,44 @@ import { Session } from "../models/session.js"; * It's here we listen for player commands. */ export class AwaitCommandsState { - /** - * @param {Session} session - */ - constructor(session) { - /** @type {Session} */ - this.session = session; - } + /** + * @param {Session} session + */ + constructor(session) { + /** @type {Session} */ + this.session = session; + } - onAttach() { - console.log("Session is entering the “main” state"); - this.session.sendMessage("Welcome to the game!"); - } + onAttach() { + console.log("Session is entering the “main” state"); + this.session.sendMessage("Welcome to the game!"); + } - /** @param {msg.ClientMessage} message */ - onMessage(message) { - if (message.hasCommand()) { - this.handleCommand(message); - } + /** @param {msg.ClientMessage} message */ + onMessage(message) { + if (message.hasCommand()) { + this.handleCommand(message); } + } - /** @param {msg.ClientMessage} message */ - handleCommand(message) { - switch (message.command) { - case "help": - this.session.sendFigletMessage("HELP"); - this.session.sendMessage([ - "---------------------------------------", - " *:help* this help screen", - " *:quit* quit the game", - "---------------------------------------", - ]); - break; - case "quit": - this.session.sendMessage("The quitting quitter quits, typical... Cya"); - this.session._websocket.close(); - break; - default: - this.session.sendMessage(`Unknown command: ${message.command}`); - } + /** @param {msg.ClientMessage} message */ + handleCommand(message) { + switch (message.command) { + case "help": + this.session.sendFigletMessage("HELP"); + this.session.sendMessage([ + "---------------------------------------", + " *:help* this help screen", + " *:quit* quit the game", + "---------------------------------------", + ]); + break; + case "quit": + this.session.sendMessage("The quitting quitter quits, typical... Cya"); + this.session._websocket.close(); + break; + default: + this.session.sendMessage(`Unknown command: ${message.command}`); } + } } diff --git a/server/states/characterCreation.js b/server/states/characterCreation.js deleted file mode 100755 index e296f19..0000000 --- a/server/states/characterCreation.js +++ /dev/null @@ -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); - } -} diff --git a/server/states/createPlayer.js b/server/states/createPlayer.js deleted file mode 100755 index 4a8d578..0000000 --- a/server/states/createPlayer.js +++ /dev/null @@ -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)); - } -} diff --git a/server/states/interface.js b/server/states/interface.js index 692cb10..72b2275 100755 --- a/server/states/interface.js +++ b/server/states/interface.js @@ -3,11 +3,11 @@ import { Session } from "../models/session.js"; /** @interface */ export class StateInterface { - /** @param {Session} session */ - constructor(session) { } + /** @param {Session} session */ + constructor(session) {} - onAttach() { } + onAttach() {} - /** @param {ClientMessage} message */ - onMessage(message) {} + /** @param {ClientMessage} message */ + onMessage(message) {} } diff --git a/server/states/justLoggedIn.js b/server/states/justLoggedIn.js index cc1f903..a037754 100755 --- a/server/states/justLoggedIn.js +++ b/server/states/justLoggedIn.js @@ -1,33 +1,35 @@ import { Session } from "../models/session.js"; -import { CharacterCreationState } from "./characterCreation.js"; +import { PartyCreationState } from "./partyCreationState.js"; import { AwaitCommandsState } from "./awaitCommands.js"; /** @interface */ export class JustLoggedInState { - /** @param {Session} session */ - constructor(session) { - /** @type {Session} */ - this.session = session; + /** @param {Session} session */ + constructor(session) { + /** @type {Session} */ + this.session = session; + } + + // Show welcome screen + onAttach() { + this.session.sendMessage([ + "", + "Welcome", + "", + "You can type “:quit” at any time to quit the game", + "", + ]); + + // + // Check if we need to create characters for the player + if (this.session.player.characters.size === 0) { + this.session.sendMessage( + "You haven't got any characters, so let's make some\n\n", + ); + this.session.setState(new PartyCreationState(this.session)); + return; } - // Show welcome screen - onAttach() { - this.session.sendMessage([ - "", - "Welcome", - "", - "You can type “:quit” at any time to quit the game", - "", - ]); - - // - // Check if we need to create characters for the player - if (this.session.player.characters.size === 0) { - this.session.sendMessage("You haven't got any characters, so let's make some\n\n"); - this.session.setState(new CharacterCreationState(this.session)); - return; - } - - this.session.setState(new AwaitCommandsState(this.session)); - } + this.session.setState(new AwaitCommandsState(this.session)); + } } diff --git a/server/states/partyCreationState.js b/server/states/partyCreationState.js new file mode 100755 index 0000000..a6242d9 --- /dev/null +++ b/server/states/partyCreationState.js @@ -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); + } +} diff --git a/server/states/playerCreationState.js b/server/states/playerCreationState.js new file mode 100755 index 0000000..d3868cc --- /dev/null +++ b/server/states/playerCreationState.js @@ -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)); + } +} diff --git a/server/tui.md b/server/tui.md index 766bde8..e8a8037 100644 --- a/server/tui.md +++ b/server/tui.md @@ -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: ### ASCII Shading Characters + These are basic ASCII characters often used for shading: + ``` Light shade: ░ (U+2591) Medium shade: ▒ (U+2592) @@ -11,7 +13,9 @@ Half block: ▄ (U+2584), ▀ (U+2580) ``` ### Additional UTF-8 Block Characters + These Unicode characters offer more granular shading or block patterns: + ``` Light block: ░ (U+2591) Medium block: ▒ (U+2592) @@ -26,7 +30,9 @@ Checkerboard: ▚ (U+259A), ▞ (U+259E) ``` ### Example Usage + Here’s an example of a simple shading gradient using some of these characters: + ``` Light to Dark: ░ ▒ ▓ █ Half blocks: ▀ ▄ ▌ ▐ @@ -34,16 +40,17 @@ Quadrant pattern: ▖ ▗ ▘ ▝ ``` ### Notes + - Not all terminals or text editors display Unicode characters consistently, so ASCII characters like `.:;#+` are sometimes used for basic shading in simpler environments. - If you want to create specific patterns or need more complex ASCII art, let me know, and I can generate or suggest more detailed designs! - If you meant something specific by "shading" (e.g., for a particular programming context or art style), please clarify, and I’ll tailor the response further. - - - Below is a collection of ASCII and UTF-8 characters suitable for creating frames, borders, or "windows" in a text-based user interface (TUI), such as those built with ncurses. These characters can be used to draw boxes, lines, and corners to simulate window-like structures in a terminal. +Below is a collection of ASCII and UTF-8 characters suitable for creating frames, borders, or "windows" in a text-based user interface (TUI), such as those built with ncurses. These characters can be used to draw boxes, lines, and corners to simulate window-like structures in a terminal. ### ASCII Characters for Frames + These are basic ASCII characters that work universally in most terminals: + ``` Horizontal line: - (U+002D) Vertical line: | (U+007C) @@ -56,6 +63,7 @@ Corners: ``` Example simple ASCII window: + ``` +----------+ | Content | @@ -63,9 +71,11 @@ Example simple ASCII window: ``` ### UTF-8 Box-Drawing Characters + Unicode provides a dedicated **Box Drawing** block (U+2500–U+257F) for creating more refined frames. These are widely supported in modern terminals and ncurses: #### Single-Line Box Drawing + ``` Horizontal line: ─ (U+2500) Vertical line: │ (U+2502) @@ -83,6 +93,7 @@ Intersections: ``` Example single-line window: + ``` ┌──────────┐ │ Content │ @@ -90,6 +101,7 @@ Example single-line window: ``` #### Double-Line Box Drawing + ``` Horizontal line: ═ (U+2550) Vertical line: ║ (U+2551) @@ -107,6 +119,7 @@ Intersections: ``` Example double-line window: + ``` ╔══════════╗ ║ Content ║ @@ -114,7 +127,9 @@ Example double-line window: ``` #### Mixed and Other Box-Drawing Characters + For more complex designs, you can mix single and double lines or use specialized characters: + ``` Single to double transitions: Horizontal single to double: ╼ (U+257C) @@ -127,6 +142,7 @@ Rounded corners (less common, not always supported): ``` Example with rounded corners: + ``` ╭──────────╮ │ Content │ @@ -134,7 +150,9 @@ Example with rounded corners: ``` ### Additional UTF-8 Characters for Decoration + These can enhance the appearance of your TUI: + ``` Block elements for borders or shading: Full block: █ (U+2588) @@ -145,7 +163,9 @@ Dark shade: ▓ (U+2593) ``` ### Example TUI Window with Content + Here’s a sample of a more complex window using single-line box-drawing characters: + ``` ┌────────────────────┐ │ My TUI Window │ @@ -156,12 +176,14 @@ Here’s a sample of a more complex window using single-line box-drawing charact ``` ### Notes for ncurses + - **ncurses Compatibility**: ncurses supports both ASCII and UTF-8 box-drawing characters, but you must ensure the terminal supports Unicode (e.g., `LANG=en_US.UTF-8` environment variable). Use `initscr()` and `start_color()` in ncurses to handle rendering. - **Terminal Support**: Some older terminals may not render UTF-8 characters correctly. Test your TUI in the target environment (e.g., xterm, gnome-terminal, or Alacritty). - **Fallback**: If Unicode support is unreliable, stick to ASCII (`-`, `|`, `+`) for maximum compatibility. - **ncurses Functions**: Use `box()` in ncurses to draw a border around a window automatically, or manually print characters with `mvaddch()` for custom designs. ### Tips + - Combine single and double lines for visual hierarchy (e.g., double lines for outer windows, single lines for inner sections). - If you need specific examples (e.g., a multi-window layout or a dialog box), let me know, and I can provide a detailed ASCII/UTF-8 mockup or even pseudocode for ncurses. - If you want a particular style (e.g., heavy lines, dashed lines, or specific layouts), please clarify, and I’ll tailor the response. diff --git a/server/utils/dice.js b/server/utils/dice.js index 8998d1c..c6bbbef 100755 --- a/server/utils/dice.js +++ b/server/utils/dice.js @@ -1,12 +1,12 @@ -export function withSides(sides) { +export function dice(sides) { const r = Math.random(); return Math.floor(r * sides) + 1; } export function d6() { - return withSides(6); + return dice(6); } export function d8() { - return withSides(8); + return dice(8); } diff --git a/server/utils/id.js b/server/utils/id.js index 2983c40..595f898 100755 --- a/server/utils/id.js +++ b/server/utils/id.js @@ -1,29 +1,52 @@ -export function cleanName(s) { - if (typeof s !== "string") { - throw new Error("String expected, but got a ", typeof s); +const UID_DIGITS = 12; +const MINI_UID_REGEX = /\.uid\.[a-z0-9]{6,}$/; +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() - .replace(" ", "_") - .replace(/[^a-zA-Z0-9_]/, "_"); + + if (id.length < 2) { + return false; + } + + 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. */ export function miniUid() { // 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) { - let res = ""; - for (const name of names) { - res += ":" + cleanName(name); - } - - return res + ":" + miniUid(); +export function appendMiniUid(str) { + return str + ".uid." + miniUid(); +} + +/** + * Does a given string end with ".uid.23khtasdz", etc. + * + * @param {string} str + */ +export function endsWithMiniUid(str) { + return MINI_UID_REGEX.test(str); +} + +export function appendOrReplaceMiniUid(str) { + return appendMiniUid(str.replace(MINI_UID_REGEX, "")); } diff --git a/server/utils/messages.js b/server/utils/messages.js index 3d08f67..ffe15a2 100755 --- a/server/utils/messages.js +++ b/server/utils/messages.js @@ -7,8 +7,8 @@ */ export const CALAMITY = "calamity"; -/** - * Tell recipient that an error has occurred +/** + * Tell recipient that an error has occurred * * Server-->Client-->Player */ @@ -21,7 +21,6 @@ export const ERROR = "e"; */ export const MESSAGE = "m"; - /** * Player has entered data, and sends it to server. * @@ -77,110 +76,126 @@ export const DEBUG = "dbg"; * Represents a message sent from client to server. */ export class ClientMessage { - /** - * @protected - * @type {any[]} _arr The array that contains the message data - */ - _attr; + /** + * @protected + * @type {any[]} _arr The array that contains the message data + */ + _attr; - /** The message type. - * - * One of the * constants from this document. - * - * @returns {string} - */ - get type() { - return this._attr[0]; + /** The message type. + * + * One of the * constants from this document. + * + * @returns {string} + */ + get type() { + return this._attr[0]; + } + + /** + * @param {string} msgData the raw text data in the websocket message. + */ + constructor(msgData) { + if (typeof msgData !== "string") { + throw new Error( + "Could not create client message. Attempting to parse json, but data was not even a string, it was a " + + typeof msgData, + ); + return; } - /** - * @param {string} msgData the raw text data in the websocket message. - */ - constructor(msgData) { - if (typeof msgData !== "string") { - throw new Error("Could not create client message. Attempting to parse json, but data was not even a string, it was a " + typeof msgData); - return; - } - - try { - this._attr = JSON.parse(msgData); - } catch (_) { - throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`); - } - - if (!Array.isArray(this._attr)) { - throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`); - } - - if (this._attr.length < 1) { - throw new Error("Could not create client message. Excpected an array with at least 1 element, but got an empty one"); - } - } - hasCommand() { - return this._attr.length > 1 && this._attr[0] === COMMAND; + try { + this._attr = JSON.parse(msgData); + } catch (_) { + throw new Error( + `Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`, + ); } - /** Does this message contain a username-response from the client? */ - isUsernameResponse() { - return this._attr.length === 4 - && this._attr[0] === REPLY - && this._attr[1] === "username" - && typeof this._attr[2] === "string"; + if (!Array.isArray(this._attr)) { + throw new Error( + `Could not create client message. Excpected an array, but got a ${typeof this._attr}`, + ); } - /** Does this message contain a password-response from the client? */ - isPasswordResponse() { - return this._attr.length === 4 - && this._attr[0] === REPLY - && this._attr[1] === "password" - && typeof this._attr[2] === "string"; + if (this._attr.length < 1) { + throw new Error( + "Could not create client message. Excpected an array with at least 1 element, but got an empty one", + ); + } + } + hasCommand() { + return this._attr.length > 1 && this._attr[0] === COMMAND; + } + + /** Does this message contain a username-response from the client? */ + isUsernameResponse() { + return ( + this._attr.length === 4 && + this._attr[0] === REPLY && + this._attr[1] === "username" && + typeof this._attr[2] === "string" + ); + } + + /** Does this message contain a password-response from the client? */ + isPasswordResponse() { + return ( + this._attr.length === 4 && + this._attr[0] === REPLY && + this._attr[1] === "password" && + typeof this._attr[2] === "string" + ); + } + + /** @returns {boolean} does this message indicate the player wants to quit */ + isQuitCommand() { + return this._attr[0] === QUIT; + } + + isHelpCommand() { + return this._attr[0] === HELP; + } + + /** @returns {boolean} is this a debug message? */ + isDebug() { + return this._attr.length === 2 && this._attr[0] === DEBUG; + } + + isIntegerResponse() { + return ( + this._attr.length === 4 && + this._attr[0] === REPLY && + this._attr[1] === "integer" && + (typeof this._attr[2] === "string" || + typeof this._attr[2] === "number") && + Number.isInteger(Number(this._attr[2])) + ); + } + + /** @returns {number} integer */ + get integer() { + if (!this.isIntegerResponse()) { + return undefined; } - /** @returns {boolean} does this message indicate the player wants to quit */ - isQuitCommand() { - return this._attr[0] === QUIT - } + return Number.parseInt(this._attr[2]); + } - isHelpCommand() { - return this._attr[0] === HELP - } + /** @returns {string|false} Get the username stored in this message */ + get username() { + return this.isUsernameResponse() ? this._attr[2] : false; + } - /** @returns {boolean} is this a debug message? */ - isDebug() { - return this._attr.length === 2 && this._attr[0] === DEBUG; - } + /** @returns {string|false} Get the password stored in this message */ + get password() { + return this.isPasswordResponse() ? this._attr[2] : false; + } - isIntegerResponse() { - return this._attr.length === 4 - && this._attr[0] === REPLY - && this._attr[1] === "integer" - && (typeof this._attr[2] === "string" || typeof this._attr[2] === "number") - && Number.isInteger(Number(this._attr[2])); - } - - /** @returns {number} integer */ - get integer() { - if (!this.isIntegerResponse()) { - return undefined; - } - - return Number.parseInt(this._attr[2]); - } - - /** @returns {string|false} Get the username stored in this message */ - get username() { - return this.isUsernameResponse() ? this._attr[2] : false; - } - - /** @returns {string|false} Get the password stored in this message */ - get password() { - return this.isPasswordResponse() ? this._attr[2] : false; - } - - /** @returns {string} */ - get command() { - return this.hasCommand() ? this._attr[1] : false; - } + /** @returns {string} */ + get command() { + return this.hasCommand() ? this._attr[1] : false; + } } /** @@ -190,5 +205,5 @@ export class ClientMessage { * @param {...any} args */ export function prepare(messageType, ...args) { - return JSON.stringify([messageType, ...args]); + return JSON.stringify([messageType, ...args]); } diff --git a/server/utils/security.js b/server/utils/security.js index 2fdc76f..afa0434 100755 --- a/server/utils/security.js +++ b/server/utils/security.js @@ -1,7 +1,6 @@ import { randomBytes, pbkdf2Sync } from "node:crypto"; import { Config } from "../config.js"; - // Settings (tune as needed) const ITERATIONS = 1000; const KEYLEN = 32; // 32-bit hash @@ -14,9 +13,11 @@ const DEV = process.env.NODE_ENV === "dev"; * @returns {string} */ export function generateHash(password) { - const salt = randomBytes(16).toString("hex"); // 128-bit salt - const hash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString("hex"); - return `${ITERATIONS}:${salt}:${hash}`; + const salt = randomBytes(16).toString("hex"); // 128-bit salt + const hash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString( + "hex", + ); + return `${ITERATIONS}:${salt}:${hash}`; } /** @@ -27,35 +28,41 @@ export function generateHash(password) { * @returns {boolean} */ export function verifyPassword(password_candidate, stored_password_hash) { - const [iterations, salt, hash] = stored_password_hash.split(":"); - const derived = pbkdf2Sync(password_candidate, salt, Number(iterations), KEYLEN, DIGEST).toString("hex"); - const success = hash === derived; - if (Config.dev || true) { - console.debug( - "Verifying password:\n" + - " Input : %s (the password as it was sent to us by the client)\n" + - " Given : %s (the input password hashed by us (not necessary for validation))\n" + - " Stored : %s (the password hash we have on file for the player)\n" + - " Derived : %s (the hashed version of the input password)\n" + - " Verified : %s (was the password valid)", - password_candidate, - generateHash(password_candidate), - stored_password_hash, - derived, - success, - ); - } - return success; + const [iterations, salt, hash] = stored_password_hash.split(":"); + const derived = pbkdf2Sync( + password_candidate, + salt, + Number(iterations), + KEYLEN, + DIGEST, + ).toString("hex"); + const success = hash === derived; + if (Config.dev || true) { + console.debug( + "Verifying password:\n" + + " Input : %s (the password as it was sent to us by the client)\n" + + " Given : %s (the input password hashed by us (not necessary for validation))\n" + + " Stored : %s (the password hash we have on file for the player)\n" + + " Derived : %s (the hashed version of the input password)\n" + + " Verified : %s (was the password valid)", + password_candidate, + generateHash(password_candidate), + stored_password_hash, + derived, + success, + ); + } + return success; } /** @param {string} candidate */ export function isUsernameSane(candidate) { - return /^[a-zA-Z0-9_]{4,}$/.test(candidate); + return /^[a-zA-Z0-9_]{4,}$/.test(candidate); } /** @param {string} candidate */ export function isPasswordSane(candidate) { - // We know the password must adhere to one of our client-side-hashed crypto schemes, - // so we can be fairly strict with the allowed passwords - return /^[a-zA-Z0-9_: -]{8,}$/.test(candidate); + // We know the password must adhere to one of our client-side-hashed crypto schemes, + // so we can be fairly strict with the allowed passwords + return /^[a-zA-Z0-9_: -]{8,}$/.test(candidate); } diff --git a/server/utils/tui.js b/server/utils/tui.js index 8057ab9..22e79f5 100755 --- a/server/utils/tui.js +++ b/server/utils/tui.js @@ -4,136 +4,134 @@ * @enum {string} */ export const FrameType = { + /** + * ╔════════════╗ + * ║ Hello, TUI ║ + * ╚════════════╝ + * + * @type {string} Double-lined frame + */ + Double: "Double", - /** - * ╔════════════╗ - * ║ Hello, TUI ║ - * ╚════════════╝ - * - * @type {string} Double-lined frame - */ - Double: "Double", + /** + * ┌────────────┐ + * │ Hello, TUI │ + * └────────────┘ + * + * @type {string} Single-lined frame + */ + Single: "Single", - /** - * ┌────────────┐ - * │ Hello, TUI │ - * └────────────┘ - * - * @type {string} Single-lined frame - */ - Single: "Single", + /** + * + * Hello, TUI + * + * + * @type {string} Double-lined frame + */ + Invisible: "Invisible", + /** + * ( ) + * ( Hello, TUI ) + * ( ) + * + * @type {string} Double-lined frame + */ + Parentheses: "Parentheses", - /** - * - * Hello, TUI - * - * - * @type {string} Double-lined frame - */ - Invisible: "Invisible", + /** + * +------------+ + * | Hello, TUI | + * +------------+ + * + * @type {string} Double-lined frame + */ + Basic: "Basic", - - /** - * ( ) - * ( Hello, TUI ) - * ( ) - * - * @type {string} Double-lined frame - */ - Parentheses: "Parentheses", - - /** - * +------------+ - * | Hello, TUI | - * +------------+ - * - * @type {string} Double-lined frame - */ - Basic: "Basic", - - - /** - * @protected - * Default values for the common frame types. - * - * [north, south, east, west, northwest, northeast, southwest, southeast] - */ - values: { - Basic: "--||++++", - Double: "══║║╔╗╚╝", - Invisible: " ", - Parentheses: " () ", - Single: "──││┌┐└┘", - } -} + /** + * @protected + * Default values for the common frame types. + * + * [north, south, east, west, northwest, northeast, southwest, southeast] + */ + values: { + Basic: "--||++++", + Double: "══║║╔╗╚╝", + Invisible: " ", + Parentheses: " () ", + Single: "──││┌┐└┘", + }, +}; export class FramingOptions { - /** @type {number=0} Vertical Padding; number of vertical whitespace (newlines) between the text and the frame. */ - vPadding = 0; + /** @type {number=0} Vertical Padding; number of vertical whitespace (newlines) between the text and the frame. */ + vPadding = 0; - /** @type {number=0} Margin ; number of newlines to to insert before and after the framed text */ - vMargin = 0; + /** @type {number=0} Margin ; number of newlines to to insert before and after the framed text */ + vMargin = 0; - /** @type {number=0} Horizontal Padding; number of whitespace characters to insert between the text and the sides of the frame. */ - hPadding = 0; + /** @type {number=0} Horizontal Padding; number of whitespace characters to insert between the text and the sides of the frame. */ + hPadding = 0; - /** @type {number=0} Margin ; number of newlines to to insert before and after the text, but inside the frame */ - hMargin = 0; + /** @type {number=0} Margin ; number of newlines to to insert before and after the text, but inside the frame */ + hMargin = 0; - /** @type {FrameType=FrameType.Double} Type of frame to put around the text */ - frameType = FrameType.Double; + /** @type {FrameType=FrameType.Double} Type of frame to put around the text */ + frameType = FrameType.Double; - /** @type {number=0} Pad each line to become at least this long */ - minLineWidth = 0; + /** @type {number=0} Pad each line to become at least this long */ + minLineWidth = 0; - // Light block: ░ (U+2591) - // Medium block: ▒ (U+2592) - // Dark block: ▓ (U+2593) - // Solid block: █ (U+2588) - /** @type {string} Single character to use as filler inside the frame. */ - paddingChar = " "; // character used for padding inside the frame. + // Light block: ░ (U+2591) + // Medium block: ▒ (U+2592) + // Dark block: ▓ (U+2593) + // Solid block: █ (U+2588) + /** @type {string} Single character to use as filler inside the frame. */ + paddingChar = " "; // character used for padding inside the frame. - /** @type {string} Single character to use as filler outside the frame. */ - marginChar = " "; + /** @type {string} Single character to use as filler outside the frame. */ + marginChar = " "; - /** @type {string} The 8 characters that make up the frame elements */ - frameChars = FrameType.values.Double; + /** @type {string} The 8 characters that make up the frame elements */ + frameChars = FrameType.values.Double; - /** - * @param {object} o - * @returns {FramingOptions} - */ - static fromObject(o) { - const result = new FramingOptions(); + /** + * @param {object} o + * @returns {FramingOptions} + */ + static fromObject(o) { + const result = new FramingOptions(); - result.vPadding = Math.max(0, Number.parseInt(o.vPadding) || 0); - result.hPadding = Math.max(0, Number.parseInt(o.hPadding) || 0); - result.vMargin = Math.max(0, Number.parseInt(o.vMargin) || 0); - result.hMargin = Math.max(0, Number.parseInt(o.hMargin) || 0); - result.minLineWidth = Math.max(0, Number.parseInt(o.hMargin) || 0); + result.vPadding = Math.max(0, Number.parseInt(o.vPadding) || 0); + result.hPadding = Math.max(0, Number.parseInt(o.hPadding) || 0); + result.vMargin = Math.max(0, Number.parseInt(o.vMargin) || 0); + result.hMargin = Math.max(0, Number.parseInt(o.hMargin) || 0); + result.minLineWidth = Math.max(0, Number.parseInt(o.hMargin) || 0); - result.paddingChar = String(o.paddingChar || " ")[0] || " "; - result.marginChar = String(o.marginChar || " ")[0] || " "; + result.paddingChar = String(o.paddingChar || " ")[0] || " "; + result.marginChar = String(o.marginChar || " ")[0] || " "; - // - // Do we have custom and valid frame chars? - if (typeof o.frameChars === "string" && o.frameChars.length === FrameType.values.Double.length) { - result.frameChars = o.frameChars; + // + // Do we have custom and valid frame chars? + if ( + typeof o.frameChars === "string" && + o.frameChars.length === FrameType.values.Double.length + ) { + result.frameChars = o.frameChars; - // - // do we have document frame type instead ? - } else if (o.frameType && FrameType.hasOwnProperty(o.frameType)) { - result.frameChars = FrameType.values[o.frameType]; + // + // do we have document frame type instead ? + } else if (o.frameType && FrameType.hasOwnProperty(o.frameType)) { + result.frameChars = FrameType.values[o.frameType]; - // Fall back to using "Double" frame - } else { - result.frameChars = FrameType.values.Double; - } - - - return result; + // Fall back to using "Double" frame + } else { + result.frameChars = FrameType.values.Double; } + + return result; + } } /** @@ -141,173 +139,198 @@ export class FramingOptions { * @param {FramingOptions} options */ export function frameText(text, options) { + if (!options) { + options = new FramingOptions(); + } - if (!options) { - options = new FramingOptions(); - } + if (!(options instanceof FramingOptions)) { + options = FramingOptions.fromObject(options); + } - if (!(options instanceof FramingOptions)) { - options = FramingOptions.fromObject(options); - } + // There is a point to this; each element in the array may contain newlines, + // so we have to combine everything into a long text and then split into + // individual lines afterwards. + if (Array.isArray(text)) { + text = text.join("\n"); + } - // There is a point to this; each element in the array may contain newlines, - // so we have to combine everything into a long text and then split into - // individual lines afterwards. - if (Array.isArray(text)) { - text = text.join("\n"); - } + if (typeof text !== "string") { + console.debug(text); + throw new Error( + `text argument was neither an array or a string, it was a ${typeof text}`, + ); + } - if (typeof text !== "string") { - console.debug(text); - throw new Error(`text argument was neither an array or a string, it was a ${typeof text}`); - } + /** @type {string[]} */ + const lines = text.split("\n"); - /** @type {string[]} */ - const lines = text.split("\n"); + const innerLineLength = Math.max( + lines.reduce((accumulator, currentLine) => { + if (currentLine.length > accumulator) { + return currentLine.length; + } + return accumulator; + }, 0), + options.minLineWidth, + ); - const innerLineLength = Math.max( - lines.reduce((accumulator, currentLine) => { - if (currentLine.length > accumulator) { - return currentLine.length; - } - return accumulator; - }, 0), options.minLineWidth); + const frameThickness = 1; // always 1 for now. - const frameThickness = 1; // always 1 for now. + const outerLineLength = + 0 + + innerLineLength + + frameThickness * 2 + + options.hPadding * 2 + + options.hMargin * 2; - const outerLineLength = 0 - + innerLineLength - + frameThickness * 2 - + options.hPadding * 2 - + options.hMargin * 2; + // get the frame characters from the frameType. + let [ + fNorth, // horizontal frame top lines + fSouth, // horizontal frame bottom lines + fWest, // vertical frame lines on the left side + fEast, // vertical frame lines on the right side + fNorthWest, // upper left frame corner + fNorthEast, // upper right frame corner + fSouthWest, // lower left frame corner + fSouthEast, // lower right frame corner + ] = options.frameChars.split(""); + if (fNorth === "§") { + fNorth = ""; + } + if (fSouth === "§") { + fSouth = ""; + } + if (fEast === "§") { + fEast = ""; + } + if (fWest === "§") { + fWest = ""; + } + if (fNorthEast === "§") { + fNorthEast = ""; + } + if (fSouthEast === "§") { + fSouthEast = ""; + } + if (fNorthWest === "§") { + fNorthWest = ""; + } + if (fSouthWest === "§") { + fSouthWest = ""; + } - // get the frame characters from the frameType. - let [ - fNorth, // horizontal frame top lines - fSouth, // horizontal frame bottom lines - fWest, // vertical frame lines on the left side - fEast, // vertical frame lines on the right side - fNorthWest, // upper left frame corner - fNorthEast, // upper right frame corner - fSouthWest, // lower left frame corner - fSouthEast, // lower right frame corner - ] = options.frameChars.split(""); - if (fNorth === "§") { fNorth = ""; } - if (fSouth === "§") { fSouth = ""; } - if (fEast === "§") { fEast = ""; } - if (fWest === "§") { fWest = ""; } - if (fNorthEast === "§") { fNorthEast = ""; } - if (fSouthEast === "§") { fSouthEast = ""; } - if (fNorthWest === "§") { fNorthWest = ""; } - if (fSouthWest === "§") { fSouthWest = ""; } + let output = ""; - let output = ""; + // + // GENERATE THE MARGIN SPACE ABOVE THE FRAMED TEXT + // + // ( we insert space characters even though ) + // ( they wouldn't normally be visible. But ) + // ( Some fonts might allow us to see blank ) + // ( space, and what if we want to nest many ) + // ( frames inside each other? ) + // + output += (options.marginChar.repeat(outerLineLength) + "\n").repeat( + options.vMargin, + ); - // - // GENERATE THE MARGIN SPACE ABOVE THE FRAMED TEXT - // - // ( we insert space characters even though ) - // ( they wouldn't normally be visible. But ) - // ( Some fonts might allow us to see blank ) - // ( space, and what if we want to nest many ) - // ( frames inside each other? ) - // - output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin); + // + // GENERATE THE TOP PART OF THE FRAME + // ╔════════════╗ + // + // + output += + "" + // Make sure JS knows we're adding a string. + options.marginChar.repeat(options.hMargin) + // the margin before the frame starts + fNorthWest + // northwest frame corner + fNorth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar + fNorthEast + // northeast frame corner + options.marginChar.repeat(options.hMargin) + // the margin after the frame ends + "\n"; + // + // GENERATE UPPER PADDING + // + // ║ ║ + // + // (the blank lines within the frame and above the text) + output += ( + options.marginChar.repeat(options.hMargin) + + fWest + + options.paddingChar.repeat(innerLineLength + options.hPadding * 2) + + fEast + + options.marginChar.repeat(options.hMargin) + + "\n" + ).repeat(options.vPadding); + // + // GENERATE FRAMED TEXT SEGMENT + // + // ║ My pretty ║ + // ║ text here ║ + // + // ( this could be done with a reduce() ) + // + for (const line of lines) { + output += + "" + // Make sure JS knows we're adding a string. + options.marginChar.repeat(options.hMargin) + // margin before frame + fWest + // vertical frame char + options.paddingChar.repeat(options.hPadding) + // padding before text + line.padEnd(innerLineLength, " ") + // The actual text. Pad it with normal space character, NOT custom space. + options.paddingChar.repeat(options.hPadding) + // padding after text + fEast + // vertical frame bar + options.marginChar.repeat(options.hMargin) + // margin after frame + "\n"; + } - // - // GENERATE THE TOP PART OF THE FRAME - // ╔════════════╗ - // - // - output += "" // Make sure JS knows we're adding a string. - + options.marginChar.repeat(options.hMargin) // the margin before the frame starts - + fNorthWest // northwest frame corner - + fNorth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar - + fNorthEast // northeast frame corner - + options.marginChar.repeat(options.hMargin) // the margin after the frame ends - + "\n"; - // - // GENERATE UPPER PADDING - // - // ║ ║ - // - // (the blank lines within the frame and above the text) - output += ( - options.marginChar.repeat(options.hMargin) - + fWest - + options.paddingChar.repeat(innerLineLength + options.hPadding * 2) - + fEast - + options.marginChar.repeat(options.hMargin) - + "\n" - ).repeat(options.vPadding); + // + // GENERATE LOWER PADDING + // + // ║ ║ + // + // ( the blank lines within the ) + // ( frame and below the text ) + // + // ( this code is a direct ) + // ( repeat of the code that ) + // ( generates top padding ) + output += ( + options.marginChar.repeat(options.hMargin) + + fWest + + options.paddingChar.repeat(innerLineLength + options.hPadding * 2) + + fEast + + options.marginChar.repeat(options.hMargin) + + "\n" + ).repeat(options.vPadding); - // - // GENERATE FRAMED TEXT SEGMENT - // - // ║ My pretty ║ - // ║ text here ║ - // - // ( this could be done with a reduce() ) - // - for (const line of lines) { - output += "" // Make sure JS knows we're adding a string. - + options.marginChar.repeat(options.hMargin) // margin before frame - + fWest // vertical frame char - + options.paddingChar.repeat(options.hPadding) // padding before text - + line.padEnd(innerLineLength, " ") // The actual text. Pad it with normal space character, NOT custom space. - + options.paddingChar.repeat(options.hPadding) // padding after text - + fEast // vertical frame bar - + options.marginChar.repeat(options.hMargin) // margin after frame - + "\n"; - } + // + // GENERATE THE BOTTOM PART OF THE FRAME + // + // ╚════════════╝ + // + output += + "" + // Make sure JS knows we're adding a string. + options.marginChar.repeat(options.hMargin) + // the margin before the frame starts + fSouthWest + // northwest frame corner + fSouth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar + fSouthEast + // northeast frame corner + options.marginChar.repeat(options.hMargin) + // the margin after the frame starts + "\n"; - // - // GENERATE LOWER PADDING - // - // ║ ║ - // - // ( the blank lines within the ) - // ( frame and below the text ) - // - // ( this code is a direct ) - // ( repeat of the code that ) - // ( generates top padding ) - output += ( - options.marginChar.repeat(options.hMargin) - + fWest - + options.paddingChar.repeat(innerLineLength + options.hPadding * 2) - + fEast - + options.marginChar.repeat(options.hMargin) - + "\n" - ).repeat(options.vPadding); + // + // GENERATE THE MARGIN SPACE BELOW THE FRAMED TEXT + // + // ( we insert space characters even though ) + // ( they wouldn't normally be visible. But ) + // ( Some fonts might allow us to see blank ) + // ( space, and what if we want to nest many ) + // ( frames inside each other? ) + // + output += (options.marginChar.repeat(outerLineLength) + "\n").repeat( + options.vMargin, + ); - - // - // GENERATE THE BOTTOM PART OF THE FRAME - // - // ╚════════════╝ - // - output += "" // Make sure JS knows we're adding a string. - + options.marginChar.repeat(options.hMargin) // the margin before the frame starts - + fSouthWest // northwest frame corner - + fSouth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar - + fSouthEast // northeast frame corner - + options.marginChar.repeat(options.hMargin) // the margin after the frame starts - + "\n"; - - // - // GENERATE THE MARGIN SPACE BELOW THE FRAMED TEXT - // - // ( we insert space characters even though ) - // ( they wouldn't normally be visible. But ) - // ( Some fonts might allow us to see blank ) - // ( space, and what if we want to nest many ) - // ( frames inside each other? ) - // - output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin); - - return output; + return output; } // Allow this script to be run directly from node as well as being included!