things+stuff

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

View File

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

View File

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

View File

@@ -3,10 +3,10 @@ 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,
env: env,
/** @readonly @type {boolean} are we running in development-mode? */
"dev": dev,
dev: dev,
/**
* Port we're running the server on.
@@ -49,5 +49,3 @@ export const Config = {
*/
accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes.
};

View File

@@ -3,11 +3,11 @@
<!-- 2. Get the width of a single character in the monospaced font (this can be done by creating a temporary element with the same font and measuring its width).-->
<!-- 3. Divide the container's width by the character's width to get the number of characters. -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Measure Div Width in Characters</title>
<style>
.monospaced-div {
@@ -17,19 +17,17 @@
padding: 10px;
}
</style>
</head>
<body>
<div class="monospaced-div">
This is a div with monospaced text.
</div>
</head>
<body>
<div class="monospaced-div">This is a div with monospaced text.</div>
<script>
function getMonospacedCharCount(div) {
// Create a temporary span to get the width of one character
const testChar = document.createElement('span');
testChar.textContent = '0'; // Monospaced fonts use "0" for width
const testChar = document.createElement("span");
testChar.textContent = "0"; // Monospaced fonts use "0" for width
testChar.style.fontFamily = window.getComputedStyle(div).fontFamily;
testChar.style.visibility = 'hidden'; // Hide the element
testChar.style.visibility = "hidden"; // Hide the element
document.body.appendChild(testChar);
const charWidth = testChar.offsetWidth; // Get width of a single character
@@ -42,9 +40,9 @@
return Math.floor(divWidth / charWidth);
}
const div = document.querySelector('.monospaced-div');
const div = document.querySelector(".monospaced-div");
const charCount = getMonospacedCharCount(div);
console.log('Number of characters the div can hold:', charCount);
console.log("Number of characters the div can hold:", charCount);
</script>
</body>
</body>
</html>

32
server/ideas.md Executable file
View File

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

View File

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

View File

@@ -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<string,ItemTemplate>} List of all item templates in the game */
_itemTemplates = new Map();
/** @type {Map<string,ItemBlueprint>} List of all item blueprints in the game */
_itemBlueprints = new Map();
/** @type {Map<string,Location>} The list of locations in the game */
_locations = new Map();
@@ -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
* Create an ItemBlueprint with a given blueprintId
*
* @param {string} id
* @param {object} attributes
* @param {string} blueprintId
* @param {ItemAttributes} attributes
*
* @returns {ItemTemplate|false}
* @returns {ItemBlueprint|false}
*/
createItemTemplate(id, attributes) {
if (typeof id !== "string" || !id) {
throw new Error("Invalid id!");
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;
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import WebSocket from "ws";
import { Character } from "./character.js";
import { Config } from "./../config.js";
/**
* Player Account.
@@ -7,7 +8,6 @@ import { Character } from "./character.js";
* Contain persistent player account info.
*/
export class Player {
/** @protected @type {string} unique username */
_username;
get username() {
@@ -35,7 +35,6 @@ export class Player {
/** @type {Date} */
blockedUntil;
/** @type {Date|null} Date of the player's last websocket message. */
lastActivityAt = null;
@@ -69,4 +68,24 @@ export class Player {
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;
}
if (this._characters.size >= Config.maxPartySize) {
return false;
}
this._characters.add(character);
return this._characters.size;
}
}

View File

@@ -1,12 +1,11 @@
import WebSocket from 'ws';
import { 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() {
@@ -27,7 +26,6 @@ export class Session {
/** @param {Player} player */
set player(player) {
if (player instanceof Player) {
this._player = player;
return;
@@ -38,10 +36,11 @@ export class Session {
return;
}
throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`);
throw Error(
`Can only set player to null or instance of Player, but received ${typeof player}`,
);
}
/** @type {WebSocket} */
_websocket;
@@ -91,7 +90,7 @@ export class Session {
* @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) {
sendPrompt(type, message, tag = "", ...args) {
if (Array.isArray(message)) {
message = message.join("\n");
}
@@ -127,5 +126,4 @@ export class Session {
state.onAttach();
}
}
}

View File

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

View File

@@ -1,5 +1,6 @@
class MUDClient {
import { crackdown } from "./crackdown.js";
class MUDClient {
//
// Constructor
constructor() {
@@ -57,7 +58,9 @@ class MUDClient {
}
// Convert final hash to hex
const rawHash = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
const rawHash = Array.from(data)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`;
}
@@ -76,7 +79,7 @@ class MUDClient {
this.input.disabled = false;
this.sendButton.disabled = false;
this.input.focus();
this.output.innerHTML = '';
this.output.innerHTML = "";
};
this.websocket.onmessage = (event) => {
@@ -97,7 +100,9 @@ class MUDClient {
this.websocket.onerror = (error) => {
this.updateStatus("Connection Error", "error");
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
this.writeToOutput("Connection error occurred. Retrying...", {
class: "error",
});
};
} catch (error) {
console.error(error);
@@ -131,13 +136,19 @@ class MUDClient {
e.preventDefault();
if (this.historyIndex < this.commandHistory.length - 1) {
this.historyIndex++;
this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex];
this.input.value =
this.commandHistory[
this.commandHistory.length - 1 - this.historyIndex
];
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (this.historyIndex > 0) {
this.historyIndex--;
this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex];
this.input.value =
this.commandHistory[
this.commandHistory.length - 1 - this.historyIndex
];
} else if (this.historyIndex === 0) {
this.historyIndex = -1;
this.input.value = "";
@@ -209,7 +220,6 @@ class MUDClient {
this._addCommandToHistory(command);
// -- This is a sneaky command that should not be in production?
//
// In reality we want to use :clear, nor /clear
@@ -300,7 +310,6 @@ class MUDClient {
// unsolicited messages to the server without being
// prompted to do so.
this.send("c", command);
}
// ___ __ __
@@ -341,7 +350,10 @@ class MUDClient {
}
if (this.dev) {
this.writeToOutput(`unknown message type: ${messageType}: ${JSON.stringify(data)}`, "debug");
this.writeToOutput(
`unknown message type: ${messageType}: ${JSON.stringify(data)}`,
"debug",
);
}
console.debug("unknown message type", data);
}
@@ -351,7 +363,6 @@ class MUDClient {
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;
@@ -371,7 +382,6 @@ class MUDClient {
//
// "_" => system messages, not to be displayed
handleSystemMessages(data) {
if (data.length < 2) {
console.debug("malformed system message", data);
return;
@@ -399,7 +409,10 @@ class MUDClient {
// If we're in dev mode, we should output all system messages (in a shaded/faint fashion).
if (this.dev) {
this.writeToOutput(`system message: ${messageType} = ${JSON.stringify(data)}`, { class: "debug" });
this.writeToOutput(
`system message: ${messageType} = ${JSON.stringify(data)}`,
{ class: "debug" },
);
}
return;
}
@@ -411,7 +424,7 @@ class MUDClient {
//
// 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] };
const options = { ...{ class: "error", preformatted: true }, ...data[1] };
this.writeToOutput(data[0], options);
return;
}
@@ -466,7 +479,7 @@ class MUDClient {
el.textContent = text + eol;
el.className += " " + "preformatted";
} else {
el.innerHTML = parseCrackdown(text) + eol;
el.innerHTML = crackdown(text) + eol;
}
this.output.appendChild(el);
this.output.scrollTop = this.output.scrollHeight;
@@ -490,31 +503,3 @@ class MUDClient {
document.addEventListener("DOMContentLoaded", () => {
new MUDClient();
});
function parseCrackdown(text) {
console.debug("starting crack parsing");
console.debug(text);
return text.replace(/[&<>"'`]/g, (c) => {
switch (c) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case '\'': return '&#039;';
case '`': return '&#096;';
default: return c;
}
})
.replace(/---(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])---/g, '<span class="strike">$1</span>') // line-through
.replace(/___(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])___/g, '<span class="underline">$1</span>') // underline
.replace(/_(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])_/g, '<span class="italic">$1</span>') // italic
.replace(/\*(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\*/g, '<span class="bold">$1</span>') // bold
.replace(/\.{3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\.{3}/g, '<span class="undercurl">$1</span>') // undercurl
.replace(/\({3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){3}/g, '<span class="faint">($1)</span>') // faint with parentheses
.replace(/\({2}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){2}/g, '<span class="faint">$1</span>') // faint with parentheses
;
console.debug("crack output", text);
return text;
}

View File

@@ -0,0 +1,67 @@
// ____ _ ____
// / ___|_ __ __ _ ___| | _| _ \ _____ ___ __
// | | | '__/ _` |/ __| |/ / | | |/ _ \ \ /\ / / '_ \
// | |___| | | (_| | (__| <| |_| | (_) \ V V /| | | |
// \____|_| \__,_|\___|_|\_\____/ \___/ \_/\_/ |_| |_|
//
//
// _ __ __ _ _ __ ___ ___ _ __
// | '_ \ / _` | '__/ __|/ _ \ '__|
// | |_) | (_| | | \__ \ __/ |
// | .__/ \__,_|_| |___/\___|_|
// |_|
export function crackdown(text) {
console.debug("starting crack parsing");
console.debug(text);
return text
.replace(/[&<>"'`]/g, (c) => {
switch (c) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#039;";
case "`":
return "&#096;";
default:
return c;
}
})
.replace(
/---(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])---/g,
'<span class="strike">$1</span>',
) // line-through
.replace(
/___(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])___/g,
'<span class="underline">$1</span>',
) // underline
.replace(
/_(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])_/g,
'<span class="italic">$1</span>',
) // italic
.replace(
/\*(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\*/g,
'<span class="bold">$1</span>',
) // bold
.replace(
/\.{3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\.{3}/g,
'<span class="undercurl">$1</span>',
) // undercurl
.replace(
/\({3}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){3}/g,
'<span class="faint">($1)</span>',
) // faint with parentheses
.replace(
/\({2}(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])\){2}/g,
'<span class="faint">$1</span>',
); // faint with parentheses
console.debug("crack output", text);
return text;
}

View File

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

View File

@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap");
body {
font-family: "Fira Code", monospace;
@@ -120,7 +120,7 @@ body {
}
.strike {
text-decoration:line-through;
text-decoration: line-through;
}
.underline {

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

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

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

@@ -1,4 +1,5 @@
import { Game } from "../models/game.js";
import { 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

View File

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

View File

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

View File

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

View File

@@ -1,184 +0,0 @@
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { CreatePlayerState } from "./createPlayer.js";
import { JustLoggedInState } from "./justLoggedIn.js";
import { Session } from "../models/session.js";
import { Config } from "../config.js";
const STATE_EXPECT_USERNAME = "promptUsername";
const STATE_EXPECT_PASSWORD = "promptPassword";
const USERNAME_PROMPT = [
"Please enter your _username_:",
"((type *:create* if you want to create a new user))",
];
const PASSWORD_PROMPT = "Please enter your password";
const ERROR_INSANE_PASSWORD = "Invalid password.";
const ERROR_INSANE_USERNAME = "Username invalid, must be at 4-20 characters, and may only contain [a-z], [A-Z], [0-9] and underscore"
const ERROR_INCORRECT_PASSWOD = "Incorrect password.";
/** @property {Session} session */
export class AuthState {
subState = STATE_EXPECT_USERNAME;
/**
* @param {Session} session
*/
constructor(session) {
/** @type {Session} */
this.session = session;
}
onAttach() {
this.session.sendFigletMessage("M U U H D");
this.session.sendPrompt("username", USERNAME_PROMPT);
}
/** @param {msg.ClientMessage} message */
onMessage(message) {
if (this.subState === STATE_EXPECT_USERNAME) {
this.receiveUsername(message);
return;
}
if (this.subState === STATE_EXPECT_PASSWORD) {
this.receivePassword(message);
return;
}
console.error("Logic error, we received a message after we should have been logged in");
this.session.sendError("I received a message didn't know what to do with!");
}
/** @param {msg.ClientMessage} message */
receiveUsername(message) {
//
// handle invalid message types
if (!message.isUsernameResponse()) {
console.debug("what?!", message);
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
//
// Handle the creation of new players
if (message.username === ":create") {
// TODO:
// Set gamestate = CreateNewPlayer
//
// Also check if player creation is allowed in config/env
this.session.setState(new CreatePlayerState(this.session));
return;
}
//
// do basic syntax checks on usernames
if (!security.isUsernameSane(message.username)) {
this.session.sendError(ERROR_INSANE_USERNAME);
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
this.player = this.session.game.getPlayer(message.username);
//
// handle invalid username
if (!this.player) {
//
// This is a security risk. In the perfect world we would allow the player to enter both
// username and password before kicking them out, but since the player's username is not
// an email address, and we discourage from using “important” usernames, then we tell the
// player that they entered an invalid username right away.
//
// NOTE FOR ACCOUNT CREATION
// Do adult-word checks, so we dont have Fucky_McFuckFace
// https://www.npmjs.com/package/glin-profanity
this.session.sendError("Incorrect username, try again");
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
//
// username was correct, proceed to next step
this.subState = STATE_EXPECT_PASSWORD;
this.session.sendSystemMessage("salt", this.player.salt);
this.session.sendPrompt("password", PASSWORD_PROMPT);
}
/** @param {msg.ClientMessage} message */
receivePassword(message) {
//
// handle invalid message types
if (!message.isPasswordResponse()) {
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
//
// Check of the password is sane. This is both bad from a security point
// of view, and technically not necessary as insane passwords couldn't
// reside in the player lists. However, let's save some CPU cycles on
// not hashing an insane password 1000+ times.
// This is technically bad practice, but since this is just a game,
// do it anyway.
if (!security.isPasswordSane(message.password)) {
this.session.sendError(ERROR_INSANE_PASSWORD);
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = new Date() + Config.maxFailedLogins,
this.session.sendCalamity("You have been locked out for too many failed password attempts, come back later");
this.session.close();
return;
}
//
// Handle blocked users.
// They don't even get to have their password verified.
if (this.player.blockedUntil > (new Date())) {
this.session.sendCalamity("You have been locked out for too many failed password attempts, come back later");
this.session.close();
return;
}
//
// Verify the password against the hash we've stored.
if (!security.verifyPassword(message.password, this.player.passwordHash)) {
this.session.sendError("Incorrect password!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`);
return;
}
this.player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0;
this.session.player = this.player;
//
// Password correct, check if player is an admin
if (this.player.isAdmin) {
// set state AdminJustLoggedIn
}
//
// Password was correct, go to main game
this.session.setState(new JustLoggedInState(this.session));
}
}

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

@@ -0,0 +1,185 @@
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { PlayerCreationState } from "./playerCreationState.js";
import { JustLoggedInState } from "./justLoggedIn.js";
import { Session } from "../models/session.js";
import { Config } from "../config.js";
const STATE_EXPECT_USERNAME = "promptUsername";
const STATE_EXPECT_PASSWORD = "promptPassword";
const USERNAME_PROMPT = [
"Please enter your _username_:",
"((type *:create* if you want to create a new user))",
];
const PASSWORD_PROMPT = "Please enter your password";
const ERROR_INSANE_PASSWORD = "Invalid password.";
const ERROR_INSANE_USERNAME =
"Username invalid, must be at 4-20 characters, and may only contain [a-z], [A-Z], [0-9] and underscore";
const ERROR_INCORRECT_PASSWOD = "Incorrect password.";
/** @property {Session} session */
export class AuthState {
subState = STATE_EXPECT_USERNAME;
/**
* @param {Session} session
*/
constructor(session) {
/** @type {Session} */
this.session = session;
}
onAttach() {
this.session.sendFigletMessage("M U U H D");
this.session.sendPrompt("username", USERNAME_PROMPT);
}
/** @param {msg.ClientMessage} message */
onMessage(message) {
if (this.subState === STATE_EXPECT_USERNAME) {
this.receiveUsername(message);
return;
}
if (this.subState === STATE_EXPECT_PASSWORD) {
this.receivePassword(message);
return;
}
console.error(
"Logic error, we received a message after we should have been logged in",
);
this.session.sendError("I received a message didn't know what to do with!");
}
/** @param {msg.ClientMessage} message */
receiveUsername(message) {
//
// handle invalid message types
if (!message.isUsernameResponse()) {
console.debug("what?!", message);
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
//
// Handle the creation of new players
if (message.username === ":create") {
// TODO:
// Set gamestate = CreateNewPlayer
//
// Also check if player creation is allowed in config/env
this.session.setState(new PlayerCreationState(this.session));
return;
}
//
// do basic syntax checks on usernames
if (!security.isUsernameSane(message.username)) {
this.session.sendError(ERROR_INSANE_USERNAME);
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
this.player = this.session.game.getPlayer(message.username);
//
// handle invalid username
if (!this.player) {
//
// This is a security risk. In the perfect world we would allow the player to enter both
// username and password before kicking them out, but since the player's username is not
// an email address, and we discourage from using “important” usernames, then we tell the
// player that they entered an invalid username right away.
//
// NOTE FOR ACCOUNT CREATION
// Do adult-word checks, so we dont have Fucky_McFuckFace
// https://www.npmjs.com/package/glin-profanity
this.session.sendError("Incorrect username, try again");
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
//
// username was correct, proceed to next step
this.subState = STATE_EXPECT_PASSWORD;
this.session.sendSystemMessage("salt", this.player.salt);
this.session.sendPrompt("password", PASSWORD_PROMPT);
}
/** @param {msg.ClientMessage} message */
receivePassword(message) {
//
// handle invalid message types
if (!message.isPasswordResponse()) {
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
//
// Check of the password is sane. This is both bad from a security point
// of view, and technically not necessary as insane passwords couldn't
// reside in the player lists. However, let's save some CPU cycles on
// not hashing an insane password 1000+ times.
// This is technically bad practice, but since this is just a game,
// do it anyway.
if (!security.isPasswordSane(message.password)) {
this.session.sendError(ERROR_INSANE_PASSWORD);
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
((this.blockedUntil = new Date() + Config.maxFailedLogins),
this.session.sendCalamity(
"You have been locked out for too many failed password attempts, come back later",
));
this.session.close();
return;
}
//
// Handle blocked users.
// They don't even get to have their password verified.
if (this.player.blockedUntil > new Date()) {
this.session.sendCalamity(
"You have been locked out for too many failed password attempts, come back later",
);
this.session.close();
return;
}
//
// Verify the password against the hash we've stored.
if (!security.verifyPassword(message.password, this.player.passwordHash)) {
this.session.sendError("Incorrect password!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(
`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`,
);
return;
}
this.player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0;
this.session.player = this.player;
//
// Password correct, check if player is an admin
if (this.player.isAdmin) {
// set state AdminJustLoggedIn
}
//
// Password was correct, go to main game
this.session.setState(new JustLoggedInState(this.session));
}
}

View File

@@ -1,109 +0,0 @@
import figlet from "figlet";
import { Session } from "../models/session.js";
import { ClientMessage } from "../utils/messages.js";
import { frameText } from "../utils/tui.js";
import { Config } from "../config.js";
export class CharacterCreationState {
/**
* @proteted
* @type {(msg: ClientMessage) => }
*
* NOTE: Should this be a stack?
*/
_dynamicMessageHandler;
/**
* @param {Session} session
*/
constructor(session) {
/** @type {Session} */
this.session = session;
}
/**
* We attach (and execute) the next state
*/
onAttach() {
const charCount = this.session.player.characters.size;
//NOTE: could use async to optimize performance
const createPartyLogo = frameText(
figlet.textSync("Create Your Party"),
{ vPadding: 0, frameChars: "§=§§§§§§" },
);
this.session.sendMessage(createPartyLogo, { preformatted: true });
this.session.sendMessage([
"",
`Current party size: ${charCount}`,
`Max party size: ${Config.maxPartySize}`,
]);
const min = 1;
const max = Config.maxPartySize - charCount;
const prompt = [
`Please enter an integer between ${min} - ${max}`,
"((type *:help* to get more info about party size))",
];
this.session.sendMessage(`You can create a party with ${min} - ${max} characters, how big should your party be?`);
this.session.sendPrompt("integer", prompt);
/** @param {ClientMessage} message */
this._dynamicMessageHandler = (message) => {
if (message.isHelpCommand()) {
const mps = Config.maxPartySize; // short var name for easy doctype writing.
this.session.sendMessage([
`Your party can consist of 1 to ${mps} characters.`,
"",
"* Large parties tend live longer",
`* If you have fewer than ${mps} characters, you can`,
" hire extra characters in your local inn.",
"* large parties level slower because there are more",
" characters to share the Experience Points",
"* The individual members of small parties get better",
" loot because they don't have to share, but it",
" a lot of skill to accumulate loot as fast a larger",
" party can"
]);
return;
}
if (!message.isIntegerResponse()) {
this.session.sendError("You didn't enter a number");
this.session.sendPrompt("integer", prompt);
return;
}
const numCharactersToCreate = message.integer;
if (numCharactersToCreate > max) {
this.session.sendError("Number too high");
this.session.sendPrompt("integer", prompt);
return;
}
if (numCharactersToCreate < min) {
this.session.sendError("Number too low");
this.session.sendPrompt("integer", prompt);
return;
}
this.session.sendMessage(`Let's create ${numCharactersToCreate} character(s) for you :)`);
this._dynamicMessageHandler = undefined;
};
}
/** @param {ClientMessage} message */
onMessage(message) {
if (this._dynamicMessageHandler) {
this._dynamicMessageHandler(message);
return;
}
this.session.sendMessage("pong", message.type);
}
}

View File

@@ -1,165 +0,0 @@
import { Session } from "../models/session.js";
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { Player } from "../models/player.js";
import { AuthState } from "./Auth.js";
import { Config } from "../config.js";
const USERNAME_PROMPT = "Enter a valid username (4-20 characters, [a-z], [A-Z], [0-9], and underscore)";
const PASSWORD_PROMPT = "Enter a valid password";
const PASSWORD_PROMPT2 = "Enter your password again";
const ERROR_INSANE_PASSWORD = "Invalid password.";
const ERROR_INSANE_USERNAME = "Invalid username. It must be 4-20 characters, and may only contain [a-z], [A-Z], [0-9] and underscore"
const ERROR_INCORRECT_PASSWOD = "Incorrect password.";
/** @property {Session} session */
export class CreatePlayerState {
/**
* @proteted
* @type {(msg: ClientMessage) => }
*
* Allows us to dynamically set which
* method handles incoming messages.
*/
_dynamicMessageHandler;
/** @protected @type {Player} */
_player;
/** @protected @type {string} */
_password;
/**
* @param {Session} session
*/
constructor(session) {
/** @type {Session} */
this.session = session;
}
onAttach() {
//
// If there are too many players, stop allowing new players in.
if (this.session.game._players.size >= Config.maxPlayers) {
this.session.sendCalamity("Server is full, no more players can be created");
this.session.close();
}
this.session.sendFigletMessage("New Player");
this.session.sendPrompt("username", USERNAME_PROMPT);
// our initial substate is to receive a username
this.setMessageHandler(this.receiveUsername);
}
/** @param {msg.ClientMessage} message */
onMessage(message) {
this._dynamicMessageHandler(message);
}
/* @param {(msg: ClientMessage) => } handler */
setMessageHandler(handler) {
this._dynamicMessageHandler = handler;
}
/** @param {msg.ClientMessage} message */
receiveUsername(message) {
//
// NOTE FOR ACCOUNT CREATION
// Do adult-word checks, so we dont have Fucky_McFuckFace
// https://www.npmjs.com/package/glin-profanity
//
// handle invalid message types
if (!message.isUsernameResponse()) {
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
//
// do basic syntax checks on usernames
if (!security.isUsernameSane(message.username)) {
this.session.sendError(ERROR_INSANE_USERNAME);
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
const player = this.session.game.createPlayer(message.username);
//
// handle taken/occupied username
if (player === false) {
// Telling the user right away that the username is taken can
// lead to data leeching. But fukkit.
this.session.sendError(`Username _${message.username}_ was taken by another player.`);
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
this._player = player;
this.session.sendSystemMessage("salt", player.salt);
this.session.sendMessage(`Username _*${message.username}*_ is available, and I've reserved it for you :)`);
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.setMessageHandler(this.receivePassword);
}
/** @param {msg.ClientMessage} message */
receivePassword(message) {
//
// handle invalid message types
if (!message.isPasswordResponse()) {
console.log("Invalid message type, expected password reply", message);
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
//
// Check that it's been hashed thoroughly before being sent here.
if (!security.isPasswordSane(message.password)) {
this.session.sendError(ERROR_INSANE_PASSWORD);
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
this._password = message.password; // it's relatively safe to store the PW here temporarily. The client already hashed the hell out of it.
this.session.sendPrompt("password", PASSWORD_PROMPT2);
this.setMessageHandler(this.receivePasswordConfirmation);
}
/** @param {msg.ClientMessage} memssage */
receivePasswordConfirmation(message) {
//
// handle invalid message types
if (!message.isPasswordResponse()) {
console.log("Invalid message type, expected password reply", message);
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.setMessageHandler(this.receivePassword);
return;
}
//
// Handle mismatching passwords
if (message.password !== this._password) {
this.session.sendError("Incorrect, you have to enter your password twice in a row successfully");
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.setMessageHandler(this.receivePassword);
return;
}
//
// Success!
// Take the user to the login screen.
this.session.sendMessage("*_Success_* ✅ You will now be asked to log in again, sorry for that ;)");
this._player.setPasswordHash(security.generateHash(this._password));
this.session.setState(new AuthState(this.session));
}
}

View File

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

View File

@@ -1,5 +1,5 @@
import { Session } from "../models/session.js";
import { CharacterCreationState } from "./characterCreation.js";
import { PartyCreationState } from "./partyCreationState.js";
import { AwaitCommandsState } from "./awaitCommands.js";
/** @interface */
@@ -23,8 +23,10 @@ export class JustLoggedInState {
//
// 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));
this.session.sendMessage(
"You haven't got any characters, so let's make some\n\n",
);
this.session.setState(new PartyCreationState(this.session));
return;
}

View File

@@ -0,0 +1,108 @@
import figlet from "figlet";
import { Session } from "../models/session.js";
import { ClientMessage } from "../utils/messages.js";
import { frameText } from "../utils/tui.js";
import { Config } from "../config.js";
export class PartyCreationState {
/**
* @proteted
* @type {(msg: ClientMessage) => }
*
* NOTE: Should this be a stack?
*/
_dynamicMessageHandler;
/** @param {Session} session */
constructor(session) {
/** @type {Session} */
this.session = session;
}
/** We attach (and execute) the next state */
onAttach() {
const charCount = this.session.player.characters.size;
//NOTE: could use async to optimize performance
const createPartyLogo = frameText(figlet.textSync("Create Your Party"), {
vPadding: 0,
frameChars: "§=§§§§§§",
});
this.session.sendMessage(createPartyLogo, { preformatted: true });
this.session.sendMessage([
"",
`Current party size: ${charCount}`,
`Max party size: ${Config.maxPartySize}`,
]);
const min = 1;
const max = Config.maxPartySize - charCount;
const prompt = [
`Please enter an integer between ${min} - ${max}`,
"((type *:help* to get more info about party size))",
];
this.session.sendMessage(
`You can create a party with ${min} - ${max} characters, how big should your party be?`,
);
this.session.sendPrompt("integer", prompt);
/** @param {ClientMessage} message */
this._dynamicMessageHandler = (message) => {
if (message.isHelpCommand()) {
const mps = Config.maxPartySize; // short var name for easy doctype writing.
this.session.sendMessage([
`Your party can consist of 1 to ${mps} characters.`,
"",
"* Large parties tend live longer",
`* If you have fewer than ${mps} characters, you can`,
" hire extra characters in your local inn.",
"* large parties level slower because there are more",
" characters to share the Experience Points",
"* The individual members of small parties get better",
" loot because they don't have to share, but it",
" a lot of skill to accumulate loot as fast a larger",
" party can",
]);
return;
}
if (!message.isIntegerResponse()) {
this.session.sendError("You didn't enter a number");
this.session.sendPrompt("integer", prompt);
return;
}
const numCharactersToCreate = message.integer;
if (numCharactersToCreate > max) {
this.session.sendError("Number too high");
this.session.sendPrompt("integer", prompt);
return;
}
if (numCharactersToCreate < min) {
this.session.sendError("Number too low");
this.session.sendPrompt("integer", prompt);
return;
}
this.session.sendMessage(
`Let's create ${numCharactersToCreate} character(s) for you :)`,
);
this._dynamicMessageHandler = undefined;
};
}
/** @param {ClientMessage} message */
onMessage(message) {
if (this._dynamicMessageHandler) {
this._dynamicMessageHandler(message);
return;
}
this.session.sendMessage("pong", message.type);
}
}

View File

@@ -0,0 +1,173 @@
import { Session } from "../models/session.js";
import * as msg from "../utils/messages.js";
import * as security from "../utils/security.js";
import { Player } from "../models/player.js";
import { AuthState } from "./authState.js";
import { Config } from "../config.js";
const USERNAME_PROMPT =
"Enter a valid username (4-20 characters, [a-z], [A-Z], [0-9], and underscore)";
const PASSWORD_PROMPT = "Enter a valid password";
const PASSWORD_PROMPT2 = "Enter your password again";
const ERROR_INSANE_PASSWORD = "Invalid password.";
const ERROR_INSANE_USERNAME =
"Invalid username. It must be 4-20 characters, and may only contain [a-z], [A-Z], [0-9] and underscore";
const ERROR_INCORRECT_PASSWOD = "Incorrect password.";
/** @property {Session} session */
export class PlayerCreationState {
/**
* @proteted
* @type {(msg: ClientMessage) => }
*
* Allows us to dynamically set which
* method handles incoming messages.
*/
_dynamicMessageHandler;
/** @protected @type {Player} */
_player;
/** @protected @type {string} */
_password;
/**
* @param {Session} session
*/
constructor(session) {
/** @type {Session} */
this.session = session;
}
onAttach() {
//
// If there are too many players, stop allowing new players in.
if (this.session.game._players.size >= Config.maxPlayers) {
this.session.sendCalamity(
"Server is full, no more players can be created",
);
this.session.close();
}
this.session.sendFigletMessage("New Player");
this.session.sendPrompt("username", USERNAME_PROMPT);
// our initial substate is to receive a username
this.setMessageHandler(this.receiveUsername);
}
/** @param {msg.ClientMessage} message */
onMessage(message) {
this._dynamicMessageHandler(message);
}
/* @param {(msg: ClientMessage) => } handler */
setMessageHandler(handler) {
this._dynamicMessageHandler = handler;
}
/** @param {msg.ClientMessage} message */
receiveUsername(message) {
//
// NOTE FOR ACCOUNT CREATION
// Do adult-word checks, so we dont have Fucky_McFuckFace
// https://www.npmjs.com/package/glin-profanity
//
// handle invalid message types
if (!message.isUsernameResponse()) {
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
//
// do basic syntax checks on usernames
if (!security.isUsernameSane(message.username)) {
this.session.sendError(ERROR_INSANE_USERNAME);
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
const player = this.session.game.createPlayer(message.username);
//
// handle taken/occupied username
if (player === false) {
// Telling the user right away that the username is taken can
// lead to data leeching. But fukkit.
this.session.sendError(
`Username _${message.username}_ was taken by another player.`,
);
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
this._player = player;
this.session.sendSystemMessage("salt", player.salt);
this.session.sendMessage(
`Username _*${message.username}*_ is available, and I've reserved it for you :)`,
);
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.setMessageHandler(this.receivePassword);
}
/** @param {msg.ClientMessage} message */
receivePassword(message) {
//
// handle invalid message types
if (!message.isPasswordResponse()) {
console.log("Invalid message type, expected password reply", message);
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
//
// Check that it's been hashed thoroughly before being sent here.
if (!security.isPasswordSane(message.password)) {
this.session.sendError(ERROR_INSANE_PASSWORD);
this.session.sendPrompt("password", PASSWORD_PROMPT);
return;
}
this._password = message.password; // it's relatively safe to store the PW here temporarily. The client already hashed the hell out of it.
this.session.sendPrompt("password", PASSWORD_PROMPT2);
this.setMessageHandler(this.receivePasswordConfirmation);
}
/** @param {msg.ClientMessage} memssage */
receivePasswordConfirmation(message) {
//
// handle invalid message types
if (!message.isPasswordResponse()) {
console.log("Invalid message type, expected password reply", message);
this.session.sendError("Incorrect message type!");
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.setMessageHandler(this.receivePassword);
return;
}
//
// Handle mismatching passwords
if (message.password !== this._password) {
this.session.sendError(
"Incorrect, you have to enter your password twice in a row successfully",
);
this.session.sendPrompt("password", PASSWORD_PROMPT);
this.setMessageHandler(this.receivePassword);
return;
}
//
// Success!
// Take the user to the login screen.
this.session.sendMessage(
"*_Success_* ✅ You will now be asked to log in again, sorry for that ;)",
);
this._player.setPasswordHash(security.generateHash(this._password));
this.session.setState(new AuthState(this.session));
}
}

View File

@@ -1,7 +1,9 @@
Here are some ASCII and UTF-8 characters commonly used for "shading" effects in text art or terminal displays. These characters provide varying levels of density or shading:
### 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
Heres 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 Ill tailor the response further.
Below is a collection of ASCII and UTF-8 characters suitable for creating frames, borders, or "windows" in a text-based user interface (TUI), such as those built with ncurses. These characters can be used to draw boxes, lines, and corners to simulate window-like structures in a terminal.
Below is a collection of ASCII and UTF-8 characters suitable for creating frames, borders, or "windows" in a text-based user interface (TUI), such as those built with ncurses. These characters can be used to draw boxes, lines, and corners to simulate window-like structures in a terminal.
### ASCII Characters for Frames
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+2500U+257F) for creating more refined frames. These are widely supported in modern terminals and ncurses:
#### Single-Line Box Drawing
```
Horizontal line: ─ (U+2500)
Vertical line: │ (U+2502)
@@ -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
Heres a sample of a more complex window using single-line box-drawing characters:
```
┌────────────────────┐
│ My TUI Window │
@@ -156,12 +176,14 @@ Heres 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 Ill tailor the response.

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ export const ERROR = "e";
*/
export const MESSAGE = "m";
/**
* Player has entered data, and sends it to server.
*
@@ -98,22 +97,31 @@ export class ClientMessage {
*/
constructor(msgData) {
if (typeof msgData !== "string") {
throw new Error("Could not create client message. Attempting to parse json, but data was not even a string, it was a " + typeof msgData);
throw new Error(
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData,
);
return;
}
try {
this._attr = JSON.parse(msgData);
} catch (_) {
throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`);
throw new Error(
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
);
}
if (!Array.isArray(this._attr)) {
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`);
throw new Error(
`Could not create client message. Excpected an array, but got a ${typeof this._attr}`,
);
}
if (this._attr.length < 1) {
throw new Error("Could not create client message. Excpected an array with at least 1 element, but got an empty one");
throw new Error(
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
);
}
}
hasCommand() {
@@ -122,27 +130,31 @@ export class ClientMessage {
/** 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";
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";
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
return this._attr[0] === QUIT;
}
isHelpCommand() {
return this._attr[0] === HELP
return this._attr[0] === HELP;
}
/** @returns {boolean} is this a debug message? */
@@ -151,11 +163,14 @@ export class ClientMessage {
}
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]));
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 */

View File

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

View File

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