This commit is contained in:
Kim Ravn Hansen
2025-09-14 13:04:48 +02:00
parent 8c196bb6a1
commit ed91a7f2f7
43 changed files with 2279 additions and 1727 deletions

View File

@@ -1,51 +1,114 @@
const dev = process.env.NODE_ENV === "dev"; //
const env = process.env.PROD || (dev ? "dev" : "prod"); // ____ __ _ __ __ _
// / ___|___ _ __ / _(_) __ \ \ / /_ _| |_ _ ___ ___
// | | / _ \| '_ \| |_| |/ _` \ \ / / _` | | | | |/ _ \/ __|
// | |__| (_) | | | | _| | (_| |\ V / (_| | | |_| | __/\__ \
// \____\___/|_| |_|_| |_|\__, | \_/ \__,_|_|\__,_|\___||___/
// |___/
// ------------------------------------------------------------
//
// Change these values as necessary
//
// What is the name/type of environment we're running in?
const _env = process.env.MUUHD_ENV || "prod";
//
// Are we running in dev/development mode? Dev *cannot* be true if env==="prod"
const _dev = process.env.MUUHD_DEV || _env === "dev";
//
// What port should the server run on
const _port = process.env.MUUHD_PORT || 3000;
//
// How many players are allowed on this server.
const _maxPlayers = process.env.MUUHD_MAX_PLAYERS || (_dev ? 3 : 40);
//
// How many characters can be in a player's party;
const _maxPartySize = 4;
//
// When kicked out for too many failed password attempts, how long should the account be locked?
const _accountLockoutSeconds = 15 * 60 * 1000; // 15 minutes
//
// What is the random number seed of the server?
const _rngSeed = process.env.MUUHD_RNG_SEED || Date.now();
//
// Max size (in bytes) we allow incoming messages to be.
const _maxIncomingMessageSize = 1024;
//
//
//
//
// _ _ _ ____ _ _
// | | | | ___| |_ __ ___ _ __/ ___|| |_ _ __ _ _ ___| |_
// | |_| |/ _ \ | '_ \ / _ \ '__\___ \| __| '__| | | |/ __| __|
// | _ | __/ | |_) | __/ | ___) | |_| | | |_| | (__| |_
// |_| |_|\___|_| .__/ \___|_| |____/ \__|_| \__,_|\___|\__|
// |_|
// -------------------------------------------------------------
// No need to change the code below this line.
/** Config class */
export const Config = { export const Config = {
/** @readonly @type {string} the name of the environment we're running in */ /** @readonly @type {string} the name of the environment we're running in */
env: env, get env() {
return _env || "prod";
},
/** @readonly @type {boolean} are we running in development-mode? */ /** @readonly @type {boolean} are we running in development-mode? */
dev: dev, get dev() {
if (_dev === true) {
// no matter what, we do not allow dev mode in prod!
return this.env !== "prod";
}
/** return false;
* Port we're running the server on. },
*
* @readonly
* @const {number}
*/
port: process.env.PORT || 3000,
/** /** @readonly @constant {number} Port we're running the server on. */
* Maximum number of players allowed on the server. get port() {
* return _port | 0 || 3000;
* @readonly },
* @const {number}
*/
maxPlayers: dev ? 3 : 40,
/** /** @readonly @constant {number} Maximum number of players allowed on the server. */
* Max number of characters in a party. get maxPlayers() {
* By default, a player can only have a single party. return _maxPlayers | 0 || 3;
* Multiple parties may happen some day. },
*/
maxPartySize: 4,
/** /** @readonly @constant @type {number} Max number of characters in a party. */
* Number of failed logins allowed before user is locked out. get maxPartySize() {
* Also known as Account lockout threshold return _maxPartySize | 0 || 4;
* },
* @readonly
* @const {number}
*/
maxFailedLogins: 5,
/** /** @readonly @constant @constant {number} Number of failed logins allowed before user is locked out. Also known as Account lockout threshold */
* When a user has entered a wrong password too many times, get() {
* block them for this long before they can try again. return _maxFailedLogins | 0 || 4;
* },
* @readonly
* @const {number} /**
*/ * When a user has entered a wrong password too many times,
accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes. * block them for this long (in seconds) before they can try again.
*
* @readonly
* @constant {number}
*/
get accountLockoutSeconds() {
return _accountLockoutSeconds | 0 || 15 * 60; // 15 minutes.
},
/** @type {number} Initial seed for the random number generator. */
get rngSeed() {
return _rngSeed | 0 || Date.now();
},
/** @type {number} Max size (in bytes) of max incoming message */
get maxIncomingMessageSize() {
return _maxIncomingMessageSize | 0 || 1024;
},
}; };

View File

@@ -47,3 +47,64 @@ Any chats via the spell from then on is encrypted with the "group chat key".
All parties throw away the group chat key when the spell ends. All parties throw away the group chat key when the spell ends.
Each group chat has a name. Each group chat has a name.
```
____ _
| __ ) _ _ __ _ __ _ ___ | |__ _ _ __ _ __ _ ___
| _ \| | | |/ _` |/ _` |/ _ \ | '_ \| | | |/ _` |/ _` |/ _ \
| |_) | |_| | (_| | (_| | __/ | |_) | |_| | (_| | (_| | __/
|____/ \__, |\__, |\__, |\___| |_.__/ \__, |\__, |\__, |\___|
|___/ |___/ |___/ |___/ |___/ |___/
```
# CONSTRUCTION / BUILDING
- You can build a house / mansion / castle / wizard tower / underdark / cave / wattever.
- You can invite other players oveer for a tjat.
- You can build portals to other dimensions (instances),
and you can allow other players to try it out.
```
____ __ __ _
| _ \ _ _ _ __ __ _ ___ ___ _ __ | \/ | ___ __| | ___ ___
| | | | | | | '_ \ / _` |/ _ \/ _ \| '_ \ | |\/| |/ _ \ / _` |/ _ \/ __|
| |_| | |_| | | | | (_| | __/ (_) | | | | | | | | (_) | (_| | __/\__ \
|____/ \__,_|_| |_|\__, |\___|\___/|_| |_| |_| |_|\___/ \__,_|\___||___/
|___/
```
- `Caves`
- GameMode = _Spelunking_: Sir Whalemeat the Thurd is Spelunking in the Caves of Purh.
- Played like `Rogue`
- Procedurally (pre-)generated caves (Game of Life? automata?)
- Turn based: you take one action, and everything else gets one `tick`.
- 1 Location == 1 cave
- `Donjons`
- GameMode = _Crawling_: Lady Gurthie Firefoot is Crawling the Donjons of Speematoforr.
- Played like `Knights of Pen and Paper`
- Procedurally (pre-)generated dungeons
- Very simple square dungeon layout (like KoPaP).
- Every time you enter a non-explored space, you roll a die, and see what happens.
- Combat is like `Dark Queen of Krynn`
- 1 Location == 1 donjon room/area
- BSP (binary space partition) https://www.youtube.com/watch?v=TlLIOgWYVpI&t=374s
- `Overland`
- GameMode = _Traveling_: Swift Dangledonk the Slow is Traveling the Marshes of Moohfaahsaah
- Travel is like `Rogue`
- Combat is like `Dark Queen of Krynn`
- Static terrain.
- Random encounters.
- Each encounter has a randomly generated mini map (which is just monsters and a few obstacles)
- 1 Location == 1 area / screen
- `Settlements`
- GameMode = _Sojourning_: Swingleding the Mage is Sojourning in the City of Hovedstad.
- may be played like MUDs (`go west`, `go to town square`, etc.).
- Static (mostly)
- Combat is like `Dark Queen of Krynn`
- 1 Location == 1 area (an inn, etc.)
- `Dwelling`
- GameMode = _Hanging Out_: Wendlegloom Uklimuck is Hanging Out in The House of the Sitting Sun.
- Homes that players can own or build.
- Like `Rogue` but with tweaks such as detailed descriptions
of the cool stuff the players have done with the room.

View File

@@ -7,6 +7,7 @@
* Serializing this object effectively saves the game. * Serializing this object effectively saves the game.
*/ */
import { Config } from "../config.js";
import { isIdSane, miniUid } from "../utils/id.js"; import { isIdSane, miniUid } from "../utils/id.js";
import { Xorshift32 } from "../utils/random.js"; import { Xorshift32 } from "../utils/random.js";
import { Character } from "./character.js"; import { Character } from "./character.js";
@@ -14,6 +15,8 @@ import { ItemAttributes, ItemBlueprint } from "./item.js";
import { Player } from "./player.js"; import { Player } from "./player.js";
export class Game { export class Game {
_counter = 1_000_000;
/** @type {Map<string,ItemBlueprint>} List of all item blueprints in the game */ /** @type {Map<string,ItemBlueprint>} List of all item blueprints in the game */
_itemBlueprints = new Map(); _itemBlueprints = new Map();
@@ -34,22 +37,21 @@ export class Game {
*/ */
_players = new Map(); _players = new Map();
/** @protected @type {Xorshift32} */ /** @protected @type {Xorshift32} */
_rng; _random;
/** @type {Xorshift32} */ /** @type {Xorshift32} */
get rng() { get random() {
return this._rng; return this._random;
} }
/** @param {number} rngSeed Seed number used for randomization */ /** @param {number} rngSeed Seed number used for randomization */
constructor(rngSeed) { constructor() {
if (!Number.isInteger(rngSeed)) { this.rngSeed = Date.now();
throw new Error("rngSeed must be an integer"); }
}
this._rng = new Xorshift32(rngSeed); set rngSeed(rngSeed) {
this._random = new Xorshift32(rngSeed);
} }
getPlayer(username) { getPlayer(username) {
@@ -90,7 +92,6 @@ export class Game {
* @returns {ItemBlueprint|false} * @returns {ItemBlueprint|false}
*/ */
addItemBlueprint(blueprintId, attributes) { addItemBlueprint(blueprintId, attributes) {
console.log(attributes);
if (typeof blueprintId !== "string" || !blueprintId) { if (typeof blueprintId !== "string" || !blueprintId) {
throw new Error("Invalid blueprintId!"); throw new Error("Invalid blueprintId!");
} }

4
server/models/globals.js Executable file
View File

@@ -0,0 +1,4 @@
import { Game } from "./game.js";
/** @constant @readonly @type {Game} Global instance of Game */
export const gGame = new Game();

View File

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

View File

@@ -1,129 +1,142 @@
import WebSocket from "ws"; import WebSocket from "ws";
import { Game } from "./game.js";
import { Player } from "./player.js"; import { Player } from "./player.js";
import { StateInterface } from "../states/interface.js";
import * as msg from "../utils/messages.js"; import * as msg from "../utils/messages.js";
import figlet from "figlet"; import { mustBeString, mustBe } from "../utils/mustbe.js";
import { Scene } from "../scenes/scene.js";
import { gGame } from "./globals.js";
export class Session { export class Session {
/** @protected @type {StateInterface} */ /** @type {WebSocket} */
_state; _websocket;
get state() {
return this._state;
}
/** @protected @type {Game} */ /** @protected @type {Scene} */
_game; _scene;
get game() {
return this._game;
}
/** @type {Player} */ /** @readonly @constant @type {Scene} */
_player; get scene() {
get player() { return this._scene;
return this._player;
}
/** @param {Player} player */
set player(player) {
if (player instanceof Player) {
this._player = player;
return;
} }
if (player === null) { /** @type {Player} */
this._player = null; _player;
return;
get player() {
return this._player;
} }
throw Error( /** @param {Player} player */
`Can only set player to null or instance of Player, but received ${typeof player}`, set player(player) {
); if (player instanceof Player) {
} this._player = player;
return;
}
/** @type {WebSocket} */ if (player === null) {
_websocket; this._player = null;
return;
}
/** throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`);
* @param {WebSocket} websocket
* @param {Game} game
*/
constructor(websocket, game) {
this._websocket = websocket;
this._game = game;
}
/** Close the session and websocket */
close() {
this._websocket.close();
this._player = null;
}
/**
* Send a message via our websocket.
*
* @param {string|number} messageType
* @param {...any} args
*/
send(messageType, ...args) {
this._websocket.send(JSON.stringify([messageType, ...args]));
}
sendFigletMessage(message) {
console.debug("sendFigletMessage('%s')", message);
this.sendMessage(figlet.textSync(message), { preformatted: true });
}
/** @param {string} message Message to display to player */
sendMessage(message, ...args) {
if (message.length === 0) {
console.debug("sending a zero-length message, weird");
} }
if (Array.isArray(message)) {
message = message.join("\n"); /**
* @param {WebSocket} websocket
*/
constructor(websocket) {
this._websocket = websocket;
} }
this.send(msg.MESSAGE, message, ...args);
}
/** /**
* @param {string} type prompt type (username, password, character name, etc.) * @param {Scene} scene
* @param {string|string[]} message The prompting message (please enter your character's name) */
* @param {string} tag helps with message routing and handling. setScene(scene) {
*/ console.debug("changing scene", scene.constructor.name);
sendPrompt(type, message, tag = "", ...args) { if (!(scene instanceof Scene)) {
if (Array.isArray(message)) { throw new Error(`Expected instance of Scene, got a ${typeof scene}: >>${scene}<<`);
message = message.join("\n"); }
this._scene = scene;
scene.execute(this);
} }
this.send(msg.PROMPT, type, message, tag, ...args);
}
/** @param {string} message The error message to display to player */ /** Close the session and websocket */
sendError(message, ...args) { close() {
this.send(msg.ERROR, message, ...args); if (this._websocket) {
} this._websocket.close();
this._websocket = null;
/** @param {string} message The error message to display to player */ }
sendDebug(message, ...args) { this._player = null;
this.send(msg.DEBUG, message, ...args); this._scene = null;
} }
/** @param {string} message The calamitous error to display to player */ /**
sendCalamity(message, ...args) { * Send a message via our websocket.
this.send(msg.CALAMITY, message, ...args); *
} * @param {string|number} messageType
* @param {...any} args
sendSystemMessage(arg0, ...rest) { */
this.send(msg.SYSTEM, arg0, ...rest); send(messageType, ...args) {
} if (!this._websocket) {
console.error("Trying to send a message without a valid websocket", messageType, args);
/** return;
* @param {StateInterface} state }
*/ this._websocket.send(JSON.stringify([messageType, ...args]));
setState(state) { }
this._state = state;
console.debug("changing state", state.constructor.name); /**
if (typeof state.onAttach === "function") { * @overload
state.onAttach(); * @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {string?} context
*/ /**
* @overload
* @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {object?} options Any options for the text (client side text formatting, color-, font-, or style info, etc.).
*/
sendPrompt(text, options) {
options = options || {};
if (typeof options === "string") {
// if options is just a string, assume we meant to apply a context to the prompt
options = { context: options };
}
this.send(
msg.PROMPT, // message type
text, // TODO: prompt text must be string or an array of strings
mustBe(options, "object"),
);
}
/**
* Send text to be displayed to the client
*
* @param {string|string[]} text Text to send. If array, each element will be displayed as its own line on the client side.
* @param {object?} options message options for the client.
*/
sendText(text, options = {}) {
this.send(msg.TEXT, text, options);
}
/** @param {string|string[]} errorMessage */
sendError(errorMessage) {
this.send(msg.ERROR, mustBeString(errorMessage));
}
/**
* Send a calamity text and then close the connection.
* @param {string|string[]} errorMessage Text to send. If array, each element will be displayed as its own line on the client side.
*/
calamity(errorMessage) {
//
// The client should know not to format calamaties anyway, but we add “preformatted” anyway
this.send(msg.CALAMITY, errorMessage, { preformatted: true });
this.close();
}
/**
* @param {string} systemMessageType
* @param {any?} value
*/
sendSystemMessage(systemMessageType, value = undefined) {
this.send(msg.SYSTEM, mustBeString(systemMessageType), value);
} }
}
} }

View File

@@ -5,8 +5,8 @@
"main": "server.js", "main": "server.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "NODE_ENV=prod node server.js", "start": "MUUHD_ENV=prod node server.js",
"dev": "NODE_ENV=dev nodemon server.js" "dev": "MUUHD_ENV=dev nodemon server.js"
}, },
"keywords": [ "keywords": [
"mud", "mud",
@@ -35,6 +35,7 @@
"trailingComma": "all", "trailingComma": "all",
"tabWidth": 4, "tabWidth": 4,
"bracketSpacing": true, "bracketSpacing": true,
"objectWrap": "preserve" "objectWrap": "preserve",
"arrowParens": "always"
} }
} }

View File

@@ -1,505 +1,447 @@
import { crackdown } from "./crackdown.js"; import { crackdown } from "./crackdown.js";
const MsgContext.REPLY = "R";
const QUIT = "QUIT";
const HELP = "HELP";
const COLON = ":";
const helpRegex = /^:help(?:\s+(.*))?$/;
const colonRegex = /^:([a-z0-9_]+)(?:\s+(.*))?$/;
class MUDClient { class MUDClient {
// //
// Constructor // Constructor
constructor() { constructor() {
/** @type {WebSocket} Our WebSocket */ /** @type {WebSocket} Our WebSocket */
this.websocket = null; this.websocket = null;
/** @type {boolean} Are we in development mode (decided by the server); /** @type {boolean} Are we in development mode (decided by the server); */
this.dev = false; this.dev = false;
/** @type {string|null} The message type of the last thing we were asked. */ this.promptOptions = {};
this.replyType = null; this.shouldReply = false;
/** @type {string|null} The #tag of the last thing we were asked. */ /** @type {HTMLElement} The output "monitor" */
this.replyTag = null; this.output = document.getElementById("output");
/** @type {HTMLElement} The output "monitor" */ /** @type {HTMLElement} The input element */
this.output = document.getElementById("output"); this.input = document.getElementById("input");
/** @type {HTMLElement} The input element */ /** @type {HTMLElement} Status indicator */
this.input = document.getElementById("input"); this.status = document.getElementById("status");
/** @type {HTMLElement} The send/submit button */ // Passwords are crypted and salted before being sent to the server
this.sendButton = document.getElementById("send"); // This means that if ANY of these three parameters below change,
// The server can no longer accept the passwords.
/** @type {string} Hashing method to use for client-side password hashing */
this.digest = "SHA-256";
/** @type {HTMLElement} Status indicator */ /** @type {string} Salt string to use for client-side password hashing */
this.status = document.getElementById("status"); this.salt = "No salt, no shorts, no service";
// Passwords are crypted and salted before being sent to the server /** @type {string} Number of times the hashing should be done */
// This means that if ANY of these three parameters below change, this.rounds = 1000;
// The server can no longer accept the passwords.
/** @type {string} Hashing method to use for client-side password hashing */
this.digest = "SHA-256";
/** @type {string} Salt string to use for client-side password hashing */ /** @type {string} the username also salts the password, so the username must never change. */
this.salt = "No salt, no shorts, no service"; this.username = "";
/** @type {string} Number of times the hashing should be done */ this.setupEventListeners();
this.rounds = 1000; this.connect();
/** @type {string} the username also salts the password, so the username must never change. */
this.username = "";
this.setupEventListeners();
this.connect();
}
/** @param {string} password the password to be hashed */
async hashPassword(password) {
const encoder = new TextEncoder();
let data = encoder.encode(password + this.salt);
for (let i = 0; i < this.rounds; i++) {
const hashBuffer = await crypto.subtle.digest(this.digest, data);
data = new Uint8Array(hashBuffer); // feed hash back in
} }
// Convert final hash to hex /** @param {string} password the password to be hashed */
const rawHash = Array.from(data) async hashPassword(password) {
.map((b) => b.toString(16).padStart(2, "0")) const encoder = new TextEncoder();
.join(""); let data = encoder.encode(password + this.salt);
return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`; for (let i = 0; i < this.rounds; i++) {
} const hashBuffer = await crypto.subtle.digest(this.digest, data);
data = new Uint8Array(hashBuffer); // feed hash back in
}
connect() { // Convert final hash to hex
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const rawHash = Array.from(data)
const wsUrl = `${protocol}//${window.location.host}`; .map((b) => b.toString(16).padStart(2, "0"))
.join("");
this.updateStatus("Connecting...", "connecting"); return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`;
}
try { connect() {
this.websocket = new WebSocket(wsUrl); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}`;
this.websocket.onopen = () => { this.updateStatus("Connecting...", "connecting");
this.updateStatus("Connected", "connected");
this.input.disabled = false;
this.sendButton.disabled = false;
this.input.focus();
this.output.innerHTML = "";
};
this.websocket.onmessage = (event) => { try {
console.debug(event); this.websocket = new WebSocket(wsUrl);
const data = JSON.parse(event.data);
this.onMessage(data);
this.input.focus();
};
this.websocket.onclose = () => { this.websocket.onopen = () => {
this.updateStatus("Disconnected", "disconnected"); this.updateStatus("Connected", "connected");
this.input.disabled = true; this.input.disabled = false;
this.sendButton.disabled = true; this.input.focus();
this.output.innerHTML = "";
};
// Attempt to reconnect after 3 seconds this.websocket.onmessage = (event) => {
setTimeout(() => this.connect(), 3000); console.debug(event);
}; const data = JSON.parse(event.data);
this.onMessageReceived(data);
this.input.focus();
};
this.websocket.onerror = (error) => { this.websocket.onclose = () => {
this.updateStatus("Connection Error", "error"); this.updateStatus("Disconnected", "disconnected");
this.writeToOutput("Connection error occurred. Retrying...", { this.input.disabled = true;
class: "error",
// Attempt to reconnect after 3 seconds
setTimeout(() => this.connect(), 3000);
};
this.websocket.onerror = (error) => {
this.updateStatus("Connection Error", "error");
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
};
} catch (error) {
console.error(error);
this.updateStatus("Connection Failed", "error");
setTimeout(() => this.connect(), 3000);
}
}
setupEventListeners() {
document.addEventListener("keypress", (e) => {
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
this.input.focus();
}
});
this.input.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.onUserCommand();
}
}); });
};
} catch (error) {
console.error(error);
this.updateStatus("Connection Failed", "error");
setTimeout(() => this.connect(), 3000);
} }
}
setupEventListeners() { /**
document.addEventListener("keypress", (e) => { * Send a json-encoded message to the server via websocket.
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { *
this.input.focus(); * @param {messageType} string
} * @param {...any} rest
}); */
this.input.addEventListener("keypress", (e) => { send(messageType, ...args) {
if (e.key === "Enter") { console.log("sending", messageType, args);
this.onUserCommand();
}
});
this.sendButton.addEventListener("click", () => { if (args.length === 0) {
this.onUserCommand(); this.websocket.send(JSON.stringify([messageType]));
}); return;
// Command history
this.commandHistory = [];
this.historyIndex = -1;
this.input.addEventListener("keydown", (e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
if (this.historyIndex < this.commandHistory.length - 1) {
this.historyIndex++;
this.input.value =
this.commandHistory[
this.commandHistory.length - 1 - this.historyIndex
];
} }
} else if (e.key === "ArrowDown") {
e.preventDefault(); this.websocket.send(JSON.stringify([messageType, ...args]));
if (this.historyIndex > 0) { }
this.historyIndex--;
this.input.value = /**
this.commandHistory[ * User has entered a command
this.commandHistory.length - 1 - this.historyIndex */
]; async onUserCommand() {
} else if (this.historyIndex === 0) { /** @type {string} */
this.historyIndex = -1; const inputText = this.input.value.trim(); // Trim user's input.
this.input.value = "";
this.input.value = ""; // Reset the input text field
this.input.type = "text"; // Make sure it reverts to being a text input (as opposed to being a password input)
// -- This is a sneaky command that should not be in production?
//
// In reality we want to use :clear, nor /clear
// :clear would be sent to the server, and we ask if it's okay
// to clear the screen right now, and only on a positive answer would we
// allow the screen to be cleared. Maybe.....
if (inputText === "/clear") {
this.output.innerHTML = "";
this.input.value = "";
return;
} }
}
});
}
/** //
* Send a json-encoded message to the server via websocket. // Don't allow sending messages (for now)
* // Later on, prompts may give us the option to simply "press enter";
* @param {messageType} string if (!inputText) {
* @param {...any} rest console.debug("Cannot send empty message - YET");
*/ return;
send(messageType, ...args) { }
if (args.length === 0) {
this.websocket.send(JSON.stringify([messageType])); //
return; // Can't send a message without a websocket
if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) {
return;
}
//
// The quit command has its own message type
if (inputText === ":quit") {
this.send(QUIT);
this.writeToOutput("> " + inputText, { class: "input" });
return;
}
// _ _
// _ | |__ ___| |_ __
// (_) | '_ \ / _ \ | '_ \
// _ | | | | __/ | |_) |
// (_) |_| |_|\___|_| .__/
// |_|
// ------------------------
//
// The quit command has its own message type
let help = helpRegex.exec(inputText);
if (help) {
console.log("here");
help[1] ? this.send(HELP, help[1].trim()) : this.send(HELP);
this.writeToOutput("> " + inputText, { class: "input" });
return;
}
// _
// _ ___ ___ _ __ ___ _ __ ___ __ _ _ __ __| |
// (_) / __/ _ \| '_ ` _ \| '_ ` _ \ / _` | '_ \ / _` |
// _ | (_| (_) | | | | | | | | | | | (_| | | | | (_| |
// (_) \___\___/|_| |_| |_|_| |_| |_|\__,_|_| |_|\__,_|
//------------------------------------------------------
let colonCommand = colonRegex.exec(inputText);
if (colonCommand) {
this.send(COLON, colonCommand[1], colonCommand[2]);
this.writeToOutput("> " + inputText, { class: "input" });
return;
}
//
// The server doesn't want any input from us, so we just ignore this input
if (!this.shouldReply) {
// the server is not ready for data!
return;
}
// _
// _ __ ___ _ __ | |_ _
// | '__/ _ \ '_ \| | | | |
// | | | __/ |_) | | |_| |
// |_| \___| .__/|_|\__, |
// |_| |___/
//-------------------------
// We handle replies below
//-------------------------
// The server wants a password, let's hash it before sending it.
if (this.promptOptions.password) {
inputText = await this.hashPassword(inputText);
}
//
// The server wants a username, let's save it in case we need it.
if (this.promptOptions.username) {
this.username = inputText;
}
this.send(REPLY, inputText);
this.shouldReply = false;
this.promptOptions = {};
//
// We add our own command to the output stream so the
// player can see what they typed.
this.writeToOutput("> " + inputText, { class: "input" });
return;
} }
this.websocket.send(JSON.stringify([messageType, ...args])); // ___ __ __
} // / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___
// | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \
// // | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/
// Add a command to history so we can go back to previous commands with arrow keys. // \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___|
_addCommandToHistory(command) {
// //
// we do not add usernames or passwords to history. /** @param {any[]} data*/
if (this.replyType === "password" || this.replyType === "username") { onMessageReceived(data) {
return; if (this.dev) {
console.debug(data);
}
const messageType = data.shift();
// prompt
if (messageType === "P") {
return this.handlePromptMessage(data);
}
// text message
if (messageType === "T") {
return this.handleTextMessages(data);
}
// error
if (messageType === "E") {
return this.handleErrorMessage(data);
}
// fatal error / calamity
if (messageType === "CALAMITY") {
return this.handleCalamityMessage(data);
}
// system message
if (messageType === "_") {
return this.handleSystemMessages(data);
}
// debug
if (messageType === "dbg") {
return this.handleDebugMessages(data);
}
if (this.dev) {
this.writeToOutput(`unknown message type: ${messageType}: ${JSON.stringify(data)}`, {
class: "debug",
verbatim: true,
});
}
console.debug("unknown message type", data);
} }
// //
// Adding empty commands makes no sense. // "m" => normal/standard message to be displayed to the user
// Why would the user navigate back through their history to handleTextMessages(data) {
// find and empty command when they can just press enter. const options = { ...data[1] }; // coerce options into an object.
if (command === "") {
return; // normal text message to be shown to the player
this.writeToOutput(data[0], options);
return;
} }
// //
// Add to command our history // Debug messages let the server send data to be displayed on the player's screen
// But not if the command was a password. // and also logged to the players browser's log.
this.historyIndex = -1; handleDebugMessages(data) {
if (!this.dev) {
// return; // debug messages are thrown away if we're not in dev mode.
// We do not add the same commands many times in a row. }
if (this.commandHistory[this.commandHistory.length - 1] === command) { this.writeToOutput(data, { class: "debug", verbatim: true });
return; console.debug("DBG", data);
} }
// //
// Add the command to the history stack // "_" => system messages, not to be displayed
this.commandHistory.push(command); handleSystemMessages(data) {
if (this.commandHistory.length > 50) { if (data.length < 2) {
this.commandHistory.shift(); console.debug("malformed system message", data);
} return;
} }
/** console.debug("Incoming system message", data);
* User has entered a command
*/
onUserCommand() {
//
// Trim user's input.
const command = this.input.value.trim();
this.input.value = "";
this.input.type = "text";
this._addCommandToHistory(command); /** @type {string} */
const messageType = data.shift();
// -- This is a sneaky command that should not be in production? switch (messageType) {
// case "username":
// In reality we want to use :clear, nor /clear this.username = data[0];
// :clear would be sent to the server, and we ask if it's okay break;
// to clear the screen right now, and only on a positive answer would we case "dev":
// allow the screen to be cleared. Maybe..... // This is a message that tells us that the server is in
if (command === "/clear") { // "dev" mode, and that we should do the same.
this.output.innerHTML = ""; this.dev = data[0];
this.input.value = ""; this.status.textContent = "[DEV] " + this.status.textContent;
return; break;
case "salt":
this.salt = data[0];
console.debug("updating crypto salt", data[0]);
break;
default:
console.debug("unknown system message", messageType, data);
}
// If we're in dev mode, we should output all system messages (in a shaded/faint fashion).
if (this.dev) {
this.writeToOutput(`system message: ${messageType} = ${JSON.stringify(data)}`, { class: "debug" });
}
return;
} }
// //
// Don't allow sending messages (for now) // "calamity" => lethal error. Close connection.
// Later on, prompts may give us the option to simply "press enter"; // Consider hard refresh of page to reset all variables
if (!command) { handleCalamityMessage(data) {
console.debug("Cannot send empty message - YET"); //
return; // We assume that calamity errors are pre-formatted, and we do not allow
// any of our own formatting-shenanigans to interfere with the error message
const options = { ...{ class: "error", verbatim: true }, ...data[1] };
this.writeToOutput(data[0], options);
return;
} }
// //
// Can't send a message without a websocket // "e" => non-lethal errors
if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) { handleErrorMessage(data) {
return; const options = { ...{ class: "error" }, ...data[1] };
this.writeToOutput(data[0], options);
return;
} }
// //
// The server asked us for a password, so we send it. // The prompt is the most important message type,
// But we hash it first, so we don't send our stuff // it prompts us send a message back. We should not
// in the clear. // send messages back to the server without being
if (this.replyType === "password") { // prompted.
this.hashPassword(command).then((pwHash) => { // In fact, we should ALWAYS be in a state of just-having-been-prompted.
this.send("reply", "password", pwHash, this.replyTag); handlePromptMessage(data) {
this.replyType = null; let [promptText, options = {}] = data;
this.replyTag = null;
}); this.shouldReply = true;
return;
this.promptOptions = { ...{ class: "prompt" }, ...options };
//
this.writeToOutput(promptText, this.promptOptions);
//
// The server has asked for a password, so we set the
// input type to password for safety reasons.
if (options.password) {
this.input.type = "password";
}
return;
} }
// /**
// When the player enters their username during the auth-phase, * Add output to the text.
// keep the username in the pocket for later. * @param {string} text
if (this.replyType === "username") { * @param {object} options
this.username = command; */
writeToOutput(text, options = {}) {
// tweak the data-formatting so we can iterate and create multiple elements
const lines = Array.isArray(text) ? text : [text];
for (const line of lines) {
const element = document.createElement("div");
if (options.verbatim) {
element.textContent = line;
element.className = "verbatim";
} else {
element.innerHTML = crackdown(line);
}
this.output.appendChild(element);
this.output.scrollTop = this.output.scrollHeight;
}
} }
// /**
// We add our own command to the output stream so the * Update the status banner.
// player can see what they typed. *
this.writeToOutput("> " + command, { class: "input" }); * @param {string} message
* @param {string} className
// */
// Handle certain-commands differently. updateStatus(message, className) {
const specialCommands = { ":quit": "quit", ":help": "help" }; this.status.textContent = this.dev ? `[DEV] Status: ${message}` : `Status: ${message}`;
if (specialCommands[command]) { this.status.className = className;
this.send(specialCommands[command]);
return;
} }
//
// Handle replies
// We want to be in a place where ALL messages are replies.
// The game loop should always ask you for your next command,
// even if it does so silently
if (this.replyType) {
//--------------------------------------------------
// The server asked the player a question,
// so we send the answer the way the server wants.
//--------------------------------------------------
this.send("reply", this.replyType, command, this.replyTag);
this.replyType = null;
this.replyTag = null;
return;
}
//
//-----------------------------------------------------
// The player sends a text-based command to the server
//-----------------------------------------------------
// ___ _ _ _
// |_ _|_ __ ___ _ __ ___ _ __| |_ __ _ _ __ | |_| |
// | || '_ ` _ \| '_ \ / _ \| '__| __/ _` | '_ \| __| |
// | || | | | | | |_) | (_) | | | || (_| | | | | |_|_|
// |___|_| |_| |_| .__/ \___/|_| \__\__,_|_| |_|\__(_)
// |_|
//
// Aside from :help", ":quit", etc. we should not send
// unsolicited messages to the server without being
// prompted to do so.
this.send("c", command);
}
// ___ __ __
// / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___
// | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \
// | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/
// \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___|
//
/** @param {any[]} data*/
onMessage(data) {
if (this.dev) {
console.debug(data);
}
const messageType = data.shift();
if (messageType === "dbg") {
return this.handleDebugMessages(data);
}
if (messageType === "prompt") {
return this.handlePromptMessage(data);
}
if (messageType === "e") {
return this.handleErrorMessage(data);
}
if (messageType === "calamity") {
return this.handleCalamityMessage(data);
}
if (messageType === "_") {
return this.handleSystemMessages(data);
}
if (messageType === "m") {
return this.handleTextMessages(data);
}
if (this.dev) {
this.writeToOutput(
`unknown message type: ${messageType}: ${JSON.stringify(data)}`,
"debug",
);
}
console.debug("unknown message type", data);
}
//
// "m" => normal/standard message to be displayed to the user
handleTextMessages(data) {
const options = { ...data[1] }; // coerce options into an object.
// normal text message to be shown to the player
this.writeToOutput(data[0], options);
return;
}
//
// Debug messages let the server send data to be displayed on the player's screen
// and also logged to the players browser's log.
handleDebugMessages(data) {
if (!this.dev) {
return; // debug messages are thrown away if we're not in dev mode.
}
this.writeToOutput(data, { class: "debug", preformatted: true });
console.debug("DBG", data);
}
//
// "_" => system messages, not to be displayed
handleSystemMessages(data) {
if (data.length < 2) {
console.debug("malformed system message", data);
return;
}
console.debug("Incoming system message", data);
/** @type {string} */
const messageType = data.shift();
switch (messageType) {
case "dev":
// This is a message that tells us that the server is in
// "dev" mode, and that we should do the same.
this.dev = data[0];
this.status.textContent = "[DEV] " + this.status.textContent;
break;
case "salt":
this.salt = data[0];
console.debug("updating crypto salt", data[0]);
break;
default:
console.debug("unknown system message", messageType, data);
}
// If we're in dev mode, we should output all system messages (in a shaded/faint fashion).
if (this.dev) {
this.writeToOutput(
`system message: ${messageType} = ${JSON.stringify(data)}`,
{ class: "debug" },
);
}
return;
}
//
// "calamity" => lethal error. Close connection.
// Consider hard refresh of page to reset all variables
handleCalamityMessage(data) {
//
// We assume that calamity errors are pre-formatted, and we do not allow
// any of our own formatting-shenanigans to interfere with the error message
const options = { ...{ class: "error", preformatted: true }, ...data[1] };
this.writeToOutput(data[0], options);
return;
}
//
// "e" => non-lethal errors
handleErrorMessage(data) {
const options = { ...{ class: "error" }, ...data[1] };
this.writeToOutput(data[0], options);
return;
}
//
// The prompt is the most important message type,
// it prompts us send a message back. We should not
// send messages back to the server without being
// prompted.
// In fact, we should ALWAYS be in a state of just-having-been-prompted.
handlePromptMessage(data) {
let [replyType, promptText, replyTag, options = {}] = data;
this.replyType = replyType;
this.replyTag = replyTag;
this.writeToOutput(promptText, { ...{ class: "prompt" }, ...options });
// The server has asked for a password, so we set the
// input type to password for safety reasons.
if (replyType === "password") {
this.input.type = "password";
}
return;
}
/**
* Add output to the text.
* @param {string} text
* @param {object} options
*/
writeToOutput(text, options = {}) {
const el = document.createElement("span");
if (typeof options.class === "string") {
el.className = options.class;
}
// add end of line character "\n" unless
// options.addEol = false is set explicitly
const eol = options.addEol === false ? "" : "\n";
if (options.preformatted) {
el.textContent = text + eol;
el.className += " " + "preformatted";
} else {
el.innerHTML = crackdown(text) + eol;
}
this.output.appendChild(el);
this.output.scrollTop = this.output.scrollHeight;
}
/**
* Update the status banner.
*
* @param {string} message
* @param {string} className
*/
updateStatus(message, className) {
this.status.textContent = this.dev
? `[DEV] Status: ${message}`
: `Status: ${message}`;
this.status.className = className;
}
} }
// Initialize the MUD client when the page loads // Initialize the MUD client when the page loads
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
new MUDClient(); new MUDClient();
}); });

107
server/public/crackdown.js Normal file → Executable file
View File

@@ -10,58 +10,61 @@
// | |_) | (_| | | \__ \ __/ | // | |_) | (_| | | \__ \ __/ |
// | .__/ \__,_|_| |___/\___|_| // | .__/ \__,_|_| |___/\___|_|
// |_| // |_|
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); const capture = "([a-zA-Z0-9:()-](?:.*[a-zA-Z0-9:()-])?)";
const skipSpace = "\\s*";
return text; const htmlEscapeRegex = /[&<>"'`]/g; // used to escape html characters
/**
* @type {Array.string[]}
*
* The order of the elements of this array matters.
*/
const opcodes = [
["(^|\\n)=", "($|\\n)", "$1<h1>$2</h1>$3"],
["(^|\\n)==", "($|\\n)", "$1<h2>$2</h2>$3"],
["---", "---", "<span class='strike'>$1</span>"],
["___", "___", "<span class='underline'>$1</span>"],
["(?:[.]{3})", "(?:[.]{3})", "<span class='undercurl'>$1</span>"],
["(?:[(]{2})", "(?:[)]{2})", "<span class='faint'>$1</span>"],
["_", "_", "<span class='italic'>$1</span>"],
["\\*", "\\*", "<span class='bold'>$1</span>"],
["\\[\\[([a-zA-Z0-9_ ]+)\\[\\[", "\\]\\]", "<span class='$1'>$2</span>"],
];
/** @type{Array.Array.<Regexp,string>} */
const regexes = [];
for (const [left, right, replacement] of opcodes) {
regexes.push([new RegExp(left + skipSpace + capture + skipSpace + right, "g"), replacement]);
}
/** @param {string} text */
export function crackdown(text) {
text.replace(htmlEscapeRegex, (c) => {
switch (c) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#039;";
case "`":
return "&#096;";
default:
return c;
}
});
for (const k in regexes) {
const [regex, replacement] = regexes[k];
text = text.replace(regex, replacement);
}
console.debug("crack output", text);
return text;
} }

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import { PasswordPrompt } from "./passwordPrompt.js";
import { Player } from "../../models/player.js";
import { Scene } from "../scene.js";
import { UsernamePrompt } from "./usernamePrompt.js";
/** @property {Session} session */
export class AuthenticationScene extends Scene {
introText = [
"= Welcome", //
];
/** @type {Player} */
player;
onReady() {
// current prompt
this.doPrompt(new UsernamePrompt(this));
}
/** @param {Player} player */
usernameAccepted(player) {
this.player = player;
this.doPrompt(new PasswordPrompt(this));
}
passwordAccepted() {
this.player.loggedIn = true;
this.session.player = this.player;
if (this.player.admin) {
this.session.setScene("new AdminJustLoggedInScene");
} else {
this.session.setScene("new JustLoggedInScene");
}
}
}

View File

@@ -0,0 +1,80 @@
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { Config } from "../../config.js";
import { context } from "../../utils/messages.js";
export class PasswordPrompt extends Prompt {
//
promptText = "Please enter your password";
//
// Let the client know that we're asking for a password
// so it can set <input type="password">
promptOptions = { password: true };
get player() {
return this.scene.player;
}
onReply(text) {
//
// 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(text)) {
this.sendError("Insane password");
this.execute();
return;
}
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = Date.now() + Config.accountLockoutSeconds;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}
//
// Handle blocked users.
// They don't even get to have their password verified.
if (this.player.blockedUntil > Date.now()) {
//
// Try to re-login too soon, and your lockout lasts longer.
this.blockedUntil += Config.accountLockoutSeconds;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}
//
// Verify the password against the hash we've stored.
if (!security.verifyPassword(text, this.player.passwordHash)) {
this.sendError("Incorrect password!");
this.player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`);
this.execute();
return;
}
this.player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0;
//
// We do not allow a player to be logged in more than once!
if (this.player.loggedIn) {
this.calamity("This player is already logged in");
return;
}
this.scene.passwordAccepted();
//
// Password was correct, go to main game
this.session.setState(new JustLoggedInState(this.session));
}
}

View File

@@ -0,0 +1,64 @@
import { Player } from "../../models/player.js";
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { context } from "../../utils/messages.js";
import { gGame } from "../../models/globals.js";
import { PlayerCreationScene } from "../playerCreation/playerCreationSene.js";
import { Config } from "../../config.js";
export class UsernamePrompt extends Prompt {
//
promptText = [
"Please enter your username:", //
"(((type *:create* if you want to create a new user)))", //
];
//
// When player types :help
helpText = [
"This is where you log in.",
"If you don't already have a player profile on this server, you can type *:create* to create one",
];
//
// Let the client know that we're asking for a username
promptOptions = { username: true };
//
// User entered ":create"
onColon_create() {
// User creation scene.
this.scene.session.setScene(new PlayerCreationScene(this.scene));
}
//
// User replied to our prompt
onReply(text) {
//
// do basic syntax checks on usernames
if (!security.isUsernameSane(text)) {
console.info("Someone entered insane username: '%s'", text);
this.sendError("Incorrect username, try again");
this.execute();
return;
}
//
// try and fetch the player object from the game
const player = gGame.getPlayer(text);
//
// handle invalid username
if (!player) {
console.info("Someone entered incorrect username: '%s'", text);
this.sendError("Incorrect username, try again");
this.execute();
return;
}
//
// Tell daddy that we're done
this.scene.usernameAccepted(player);
}
}

View File

@@ -0,0 +1,26 @@
import { Scene } from "../scene.js";
/**
* Main game state
*
* It's here we listen for player commands.
*/
export class GameScene extends Scene {
introText = "= Welcome";
onReady() {
//
// Find out which state the player and their characters are in
// Find out where we are
// Re-route to the relevant scene if necessary.
//
// IDEA:
// Does a player have a previous state?
// The state that was on the previous session?
//
// If player does not have a previous session
// then we start in the Adventurers Guild in the Hovedstad
//
this.doPrompt("new commandprompt or whatever");
}
}

13
server/scenes/interface.js Executable file
View File

@@ -0,0 +1,13 @@
import { WebsocketMessage } from "../utils/messages.js";
import { Session } from "../models/session.js";
/** @interface */
export class StateInterface {
/** @param {Session} session */
constructor(session) {}
onAttach() {}
/** @param {WebsocketMessage} message */
onMessage(message) {}
}

View File

@@ -0,0 +1,74 @@
import { Session } from "../models/session.js";
import { PartyCreationState } from "./partyCreationState.js";
import { AwaitCommandsState } from "./awaitCommands.js";
const castle = `
█▐▀▀▀▌▄
█ ▐▀▀▀▌▌▓▌
█ ▄▄ ▄▄▀
█ ▐▀▀▀▀
▄█▄
▓▀ ▀▌
▓▀ ▓▄
▄▓ ▐▓
▄▓ ▀▌
▓▀▀▀▀▀▓ ▓▀▀▀▀▓ ▐█▀▀▀▀▓
█ █ █ ▓░ ▓▌ ▓░
█ ▀▀▀▀▀ ▀▀▀▀▀ ▓░
▓▒ ▓░
▀▓▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█
▐▌ █
▓▀▀▀▀█ ▐█▀▀▀█ ▐█▀▀▀▓▒ ▐▌ █ ▐▓▀▀▀▓▒ ▓▀▀▀▓▒ █▀▀▀▀▓
█ █ ▐▌ █ ▐▌ ▓▒ ▐▌ ▐██░ █ ▐█ ▓▄ █ ▐▌ █ ▐█
▓░ ▐▀▀▀ ▐▀▀▀ █░ ▐▌ ▓██▌ █ ▐█ ▀▀▀▀ ▀▀▀ ▐▌
▓▒ █ ▐▌ ▀██▌ █ █ ▐▌
▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▓▀ ▐▌ █ ▀▌▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▓
▐▌ █ ▐▌ █ █ ▐▌
▐▌ █ ▐▌ █ █ ▐▌
▐▌ ▓▌ █▀▀▀▀▀█ ▐▌▐▌▀▀▀▀█ ▓▀▀▀▀▓▄ █ █▀▀▀▀▀█ ▓▌ ▐▌
▓▌ ██▌ █ █ ▓▌▓▒ █ █ ▐▌ █ █ █ ▓██ ▐▓
▓▒ ▐██▌ █ █ ▓██░ ▐█
▓ ▐▐ █ █ ▐▐ █
█ █ █ █
█ █ ▄▄▄ █ █
█ █ ▄▀▀ ▀▀▓▄ █ █
█ █ ▄▌ ▀▓ █ █
▐█ █ ▓▀ ▐█ █ ▓▒
▐▌ █ ▐▓ ▐▌ ▐█ ▓▒
▐▌ █ █ █ ▐█ ▐▌
▐▌ ▓░ █ █ ▐▌ ▐▌
▓▒ ▓░ █ ▓▒ ▐▌ ▐▓
▓░ ▓░ ▐▌ ▀▌ ▐▌ ▐█
▀▌▄▄ ▓▄▄ ▐█ ▓▌ ▄▄▄▐▌ ▄▄▄▀
▐▐▐▀▀▀▀▐▐▐ ▐▐▀▀▀▀▀▀▐▐
`;
/** @interface */
export class JustLoggedInState {
/** @param {Session} session */
constructor(session) {
/** @type {Session} */
this.session = session;
}
// Show welcome screen
onAttach() {
this.session.sendText(castle);
this.session.sendText(["", "Welcome", "", "You can type “:quit” at any time to quit the game", ""]);
//
// Check if we need to create characters for the player
if (this.session.player.characters.size === 0) {
this.session.sendText("You haven't got any characters, so let's make some\n\n");
this.session.setState(new PartyCreationState(this.session));
return;
}
for (const character of this.session.player.characters) {
this.session.sendText(`Character: ${character.name} (${character.foundation})`);
}
this.session.setState(new AwaitCommandsState(this.session));
}
}

View File

@@ -0,0 +1,97 @@
import figlet from "figlet";
import { Session } from "../models/session.js";
import { WebsocketMessage } from "../utils/messages.js";
import { frameText } from "../utils/tui.js";
import { Config } from "../config.js";
import { State } from "./state.js";
export class PartyCreationState extends State {
/**
* @proteted
* @type {(msg: WebsocketMessage) => }
*
* 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.sendText(createPartyLogo, { preformatted: true });
this.session.sendText([
"",
`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.sendText(`You can create a party with ${min} - ${max} characters, how big should your party be?`);
/** @param {WebsocketMessage} message */
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
}
/** @param {WebsocketMessage} m */
receivePlayerCount(m) {
if (m.isHelpRequest()) {
return this.partySizeHelp();
}
if (!m.isInteger()) {
this.sendError("You didn't enter an integer");
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
return;
}
const numCharactersToCreate = Number(message.text);
if (numCharactersToCreate > max) {
this.sendError("Number too high");
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
return;
}
if (numCharactersToCreate < min) {
this.sendError("Number too low");
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
return;
}
this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`);
}
partySizeHelp() {
this.sendText([
`Your party can consist of 1 to ${mps} characters.`,
"",
"* Large parties tend live longer",
`* If you have fewer than ${Config.maxPartySize} 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;
}
}

View File

@@ -0,0 +1,80 @@
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { Config } from "../../config.js";
import { context } from "../../utils/messages.js";
export class CreatePasswordPrompt extends Prompt {
//
promptText = ["Enter a password"];
//
// Let the client know that we're asking for a password
// so it can set <input type="password">
promptOptions = { password: true };
get player() {
return this.scene.player;
}
onReply(text) {
//
// 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(text)) {
this.sendError("Insane password");
this.execute();
return;
}
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = Date.now() + Config.accountLockoutSeconds;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}
//
// Handle blocked users.
// They don't even get to have their password verified.
if (this.player.blockedUntil > Date.now()) {
//
// Try to re-login too soon, and your lockout lasts longer.
this.blockedUntil += Config.accountLockoutSeconds;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}
//
// Verify the password against the hash we've stored.
if (!security.verifyPassword(text, this.player.passwordHash)) {
this.sendError("Incorrect password!");
this.player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`);
this.execute();
return;
}
this.player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0;
//
// We do not allow a player to be logged in more than once!
if (this.player.loggedIn) {
this.calamity("This player is already logged in");
return;
}
this.scene.passwordAccepted();
//
// Password was correct, go to main game
this.session.setState(new JustLoggedInState(this.session));
}
}

View File

@@ -0,0 +1,58 @@
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { gGame } from "../../models/globals.js";
import { PlayerCreationScene } from "./playerCreationSene.js";
import { Config } from "../../config.js";
export class CreateUsernamePrompt extends Prompt {
//
promptText = [
"Enter your username", //
"((type *:help* for more info))" //
];
//
// When player types :help
helpText = [
"Your username.",
"It's used, along with your password, when you log in.",
"Other players can see it.",
"Other players can use it to chat or trade with you",
"It may only consist of the letters _a-z_, _A-Z_, _0-9_, and _underscore_",
];
//
// Let the client know that we're asking for a username
promptOptions = { username: true };
onReply(text) {
//
// do basic syntax checks on usernames
if (!security.isUsernameSane(text)) {
console.info("Someone entered insane username: '%s'", text);
this.sendError(
"Incorrect username, try again.",
);
this.execute();
return;
}
//
// try and fetch the player object from the game
const player = gGame.getPlayer(text);
//
// handle invalid username
if (player) {
console.info("Someone tried to create a user with an occupied username: '%s'", text);
this.sendError("Occupied, try something else");
this.execute();
return;
}
//
// Tell daddy that we're done
this.scene.onUsernameAccepted(player);
}
}

View File

@@ -0,0 +1,50 @@
import { Config } from "../../config.js";
import { gGame } from "../../models/globals.js";
import { Scene } from "../scene.js";
import { CreateUsernamePrompt } from "./createUsernamePrompt.js";
export class PlayerCreationScene extends Scene {
introText = "= Create Player";
/** @protected @type {Player} */
player;
/** @protected @type {string} */
password;
onReady() {
//
// If there are too many players, stop allowing new players in.
if (gGame._players.size >= Config.maxPlayers) {
this.session.calamity("Server is full, no more players can be created");
}
this.doPrompt(new CreateUsernamePrompt(this));
}
/**
* Called when the player has entered a valid and available username.
*
* @param {string} username
*/
onUsernameAccepted(username) {
const player = gGame.createPlayer(username);
this.player = player;
this.session.sendSystemMessage("salt", player.salt);
this.session.sendText(`Username _*${username}*_ is available, and I've reserved it for you :)`);
this.doPrompt("new passwordprompt");
}
/**
*
* Called when the player has entered a password and confirmed it.
*
* @param {string} password
*/
onPasswordAccepted(password) {
this.password = password;
this.session.sendText("*_Success_* ✅ You will now be asked to log in again, sorry for that ;)");
this.player.setPasswordHash(security.generateHash(this.password));
}
}

196
server/scenes/prompt.js Executable file
View File

@@ -0,0 +1,196 @@
import figlet from "figlet";
import { gGame } from "../models/globals.js";
import * as msg from "../utils/messages.js";
import { Session } from "../models/session.js";
import { Scene } from "./scene.js";
import { WebsocketMessage } from "../utils/messages.js";
import { mustBe, mustBeString } from "../utils/mustbe.js";
/**
* @typedef {object} PromptMethods
* @property {function(...any): any} [onColon_*] - Any method starting with "onColon_"
*/
/**
* @abstract
* @implements {PromptMethods}
* @class
* @dynamic Methods are dynamically created:
* - onColon(...)
*/
export class Prompt {
/** @protected @readonly @constant @type {Scene} */
scene;
//
// Extra info about the prompt we send to the client.
promptOptions = undefined;
/**
* Dictionary of help topics.
* Keys: string matching /^[a-z]+$/ (topic name)
* Values: string containing the help text
*
* @constant
* @readonly
* @type {Record<string, string>}
*/
helpText = {
"": "Sorry, no help available. Figure it out yourself, adventurer", // default help text
};
/** @type {string|string[]} Default prompt text to send if we don't want to send something in the execute() call. */
promptText = [
"Please enter some very important info", // Stupid placeholder text
"((or type :quit to run away))", // strings in double parentheses is rendered shaded/faintly
];
/** @type {object|string} If string: the prompt's context (username, password, etc) of object, it's all the message's options */
promptOptions = {};
/** @type {Session} */
get session() {
return this.scene.session;
}
/** @param {Scene} scene */
constructor(scene) {
if (!(scene instanceof Scene)) {
throw new Error("Expected an instance of >>Scene<< but got " + typeof scene);
}
this.scene = scene;
}
/**
* Triggered when the prompt has been attached to a scene, and is ready to go.
*
* It's here you want to send the prompt text via the sendPrompt() method
*/
execute() {
this.sendPrompt(this.promptText, this.promptOptions);
}
/** Triggered when user types `:help` without any topic */
onHelp(topic) {
let h = this.helpText;
if (typeof h === "string" || Array.isArray(h)) {
h = { "": h };
}
//
// Fix data formatting shorthand
// So lazy dev set help = "fooo" instead of help = { "": "fooo" }.
if (h[topic]) {
this.sendText(h[topic]);
return;
}
this.onHelpFallback(topic);
}
/**
* Triggered when a user types a :command that begins with a colon
*
* @param {string} command
* @param {string} argLine
*/
onColon(command, argLine) {
const methodName = "onColon_" + command;
const method = this[methodName];
if (typeof method === "function") {
method.call(this, argLine);
return;
}
//
// For static "denial of commands" such as :inv ==> "you cannot access your inventory right now"
if (typeof method === "string") {
this.sendText(method);
}
// :inv ==> you cannot INV right now
this.sendError(`You cannot ${command.toUpperCase()} right now`);
}
/**
* Triggered when the player asks for help on a topic, and we dont have an onHelp_thatParticularTopic method.
*
* @param {string} topic
*/
onHelpFallback(topic) {
this.sendError(`Sorry, no help available for topic “${topic}`);
}
/**
* Handle ":quit" messages
*
* The session will terminate no matter what. This just gives the State a chance to clean up before dying.
*
* @param {WebsocketMessage} message
*
*/
onQuit() {}
/**
* Triggered when the player replies to the prompt-message sent by this prompt-object.
*
* @param {WebsocketMessage} message The incoming reply
*/
onReply(message) {}
/**
* @overload
* @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {string} context
*/ /**
* @overload
* @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {object} options Any options for the text (client side text formatting, color-, font-, or style info, etc.).
*/
sendPrompt(...args) {
this.session.sendPrompt(...args);
}
/**
* Send text to be displayed to the client
*
* @param {string|string[]} text Text to send. If array, it will be joined/imploded with newline characters.
* @param {object?} options message options for the client.
*/
sendText(...args) {
this.session.sendText(...args);
}
/** @param {string} errorMessage */
sendError(...args) {
this.session.sendError(...args);
}
/**
* @param {string} systemMessageType
* @param {any?} value
*/
sendSystemMessage(...args) {
this.session.sendSystemMessage(...args);
}
/**
* Send a calamity text and then close the connection.
* @param {string} errorMessage
*/
calamity(...args) {
this.session.calamity();
}
//
// Easter ægg
onColon_pull_out_wand = "You cannot pull out your wand right now! But thanks for trying 😘🍌🍆";
//
// Easter ægg2
onColon_imperial(argLine) {
const n = Number(argLine);
this.sendText(`${n} centimeters is only ${n / 2.54} inches. This is why americans have such small wands`);
}
}

58
server/scenes/scene.js Executable file
View File

@@ -0,0 +1,58 @@
import { Session } from "../models/session.js";
import { Prompt } from "./prompt.js";
const MsgContext.ERROR_INSANE_PASSWORD = "Invalid password.";
const MsgContext.ERROR_INCORRECT_PASSWOD = "Incorrect password.";
/**
* @abstract
* @method onReady
*/
export class Scene {
/**
* @type {string|string[]} This text is shown when the scene begins
*/
introText = "";
/** @readonly @constant @type {Session} */
_session;
get session() {
return this._session;
}
/**
* The Prompt that is currently active.
* I.e. the handler for the latest question we asked.
*
* @readonly
* @type {Prompt}
*/
_prompt;
get prompt() {
return this._prompt;
}
constructor() {
}
/** @param {Session} session */
execute(session) {
this._session = session;
if (this.introText) {
this.session.sendText(this.introText);
}
this.onReady();
}
/** @abstract */
onReady() {
throw new Error("Abstract method must be implemented by subclass");
}
/**
* @param {Prompt} prompt
*/
doPrompt(prompt) {
this._prompt = prompt;
prompt.execute();
}
}

View File

@@ -10,16 +10,29 @@
// |____/ \___|\___|\__,_|\___|_| // |____/ \___|\___|\__,_|\___|_|
// ------------------------------------------------ // ------------------------------------------------
import { Character } from "../models/character.js"; import { Character } from "../models/character.js";
import { Game } from "../models/game.js"; import { gGame } from "../models/globals.js";
import { Player } from "../models/player.js"; import { Player } from "../models/player.js";
import * as roll from "../utils/dice.js";
import { isIdSane } from "../utils/id.js"; import { isIdSane } from "../utils/id.js";
// stupid convenience hack that only works if we only have a single Game in the system.
// Which we easily could have.!!
let roll = {};
export class CharacterSeeder { export class CharacterSeeder {
/** @type {Game} */ constructor() {
constructor(game) { // stupid convenience hack that only works if we only have a single Game in the system.
/** @type {Game} */ // Which we easily could have.!!
this.game = game; roll = {
d: (max, min = 1) => {
return gGame.random.within(min, max);
},
d6: () => {
return gGame.random.within(1, 6);
},
d8: () => {
return gGame.random.within(1, 8);
},
};
} }
/** /**
@@ -36,7 +49,7 @@ export class CharacterSeeder {
*/ */
addItemsToCharacter(character, ...itemBlueprintIds) { addItemsToCharacter(character, ...itemBlueprintIds) {
for (const id of itemBlueprintIds) { for (const id of itemBlueprintIds) {
const blueprint = this.game.getItemBlueprint(id); const blueprint = gGame.getItemBlueprint(id);
if (!blueprint) { if (!blueprint) {
throw new Error(`No blueprint found for id: ${id}`); throw new Error(`No blueprint found for id: ${id}`);
} }
@@ -74,9 +87,9 @@ export class CharacterSeeder {
// Rolling skills // Rolling skills
c.name = c.name =
this.game.rng.oneOf("sir", "madam", "mister", "miss", "", "", "") + gGame.random.oneOf("sir ", "madam ", "mister ", "miss ", "", "", "") + // prefix
" random " + "random " + // name
this.game.rng.get().toString(); gGame.random.get().toString(); // suffix
c.awareness = roll.d6() + 2; c.awareness = roll.d6() + 2;
c.grit = roll.d6() + 2; c.grit = roll.d6() + 2;
@@ -86,7 +99,8 @@ export class CharacterSeeder {
c.rangedCombat = roll.d6() + 2; c.rangedCombat = roll.d6() + 2;
c.skulduggery = roll.d6() + 2; c.skulduggery = roll.d6() + 2;
switch (roll.d8()) { let ancestryId = roll.d8();
switch (ancestryId) {
case 1: case 1:
c.ancestry = "human"; c.ancestry = "human";
// Humans get +1 to all skills // Humans get +1 to all skills
@@ -111,7 +125,7 @@ export class CharacterSeeder {
c.meleeCombat = Math.max(c.grit, 10); c.meleeCombat = Math.max(c.grit, 10);
break; break;
case 5: case 5:
c.ancestry = "Gnomish"; c.ancestry = "gnomish";
c.meleeCombat = Math.max(c.awareness, 10); c.meleeCombat = Math.max(c.awareness, 10);
break; break;
case 6: case 6:
@@ -127,12 +141,12 @@ export class CharacterSeeder {
c.meleeCombat = Math.max(c.skulduggery, 10); c.meleeCombat = Math.max(c.skulduggery, 10);
break; break;
default: default:
throw new Error("Logic error, ancestry d8() roll was out of scope"); throw new Error(`Logic error, ancestry d8() roll of ${ancestryId} was out of scope"`);
} }
this.applyFoundation(c); this.applyFoundation(c);
console.log(c); console.debug(c);
return c; return c;
} }
@@ -163,7 +177,7 @@ export class CharacterSeeder {
applyFoundation(c, foundation = ":random") { applyFoundation(c, foundation = ":random") {
switch (foundation) { switch (foundation) {
case ":random": case ":random":
return this.applyFoundation(c, roll.dice(3)); return this.applyFoundation(c, roll.d(3));
break; break;
// //

View File

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

View File

@@ -1,5 +1,5 @@
import { Game } from "../models/game.js";
import { ItemBlueprint } from "../models/item.js"; import { ItemBlueprint } from "../models/item.js";
import { gGame } from "../models/globals.js";
// //
// ___ _ _____ _ _ // ___ _ _____ _ _
@@ -11,11 +11,6 @@ import { ItemBlueprint } from "../models/item.js";
// //
// Seed the Game.ItemBlueprint store // Seed the Game.ItemBlueprint store
export class ItemSeeder { export class ItemSeeder {
/** @param {Game} game */
constructor(game) {
this.game = game;
}
seed() { seed() {
// //
// __ __ // __ __
@@ -25,7 +20,7 @@ export class ItemSeeder {
// \_/\_/ \___|\__,_| .__/ \___/|_| |_|___/ // \_/\_/ \___|\__,_| .__/ \___/|_| |_|___/
// |_| // |_|
//------------------------------------------------------- //-------------------------------------------------------
this.game.addItemBlueprint(":weapon.light.dagger", { gGame.addItemBlueprint(":weapon.light.dagger", {
name: "Dagger", name: "Dagger",
description: "Small shady blady", description: "Small shady blady",
itemSlots: 0.5, itemSlots: 0.5,
@@ -35,7 +30,7 @@ export class ItemSeeder {
specialEffect: ":effect.weapon.fast", specialEffect: ":effect.weapon.fast",
}); });
this.game.addItemBlueprint(":weapon.light.sickle", { gGame.addItemBlueprint(":weapon.light.sickle", {
name: "Sickle", name: "Sickle",
description: "For cutting nuts, and branches", description: "For cutting nuts, and branches",
itemSlots: 1, itemSlots: 1,
@@ -43,7 +38,7 @@ export class ItemSeeder {
specialEffect: ":effect.weapon.sickle", specialEffect: ":effect.weapon.sickle",
}); });
this.game.addItemBlueprint(":weapon.weird.spiked_gauntlets", { gGame.addItemBlueprint(":weapon.weird.spiked_gauntlets", {
name: "Spiked Gauntlets", name: "Spiked Gauntlets",
description: "Spikes with gauntlets on them!", description: "Spikes with gauntlets on them!",
itemSlots: 1, itemSlots: 1,
@@ -51,7 +46,7 @@ export class ItemSeeder {
specialEffect: "TBD", specialEffect: "TBD",
}); });
this.game.addItemBlueprint(":weapon.light.rapier", { gGame.addItemBlueprint(":weapon.light.rapier", {
name: "Rapier", name: "Rapier",
description: "Fancy musketeer sword", description: "Fancy musketeer sword",
itemSlots: 1, itemSlots: 1,
@@ -66,14 +61,14 @@ export class ItemSeeder {
// / ___ \| | | | | | | | (_) | | \__ \ // / ___ \| | | | | | | | (_) | | \__ \
// /_/ \_\_| |_| |_| |_|\___/|_| |___/ // /_/ \_\_| |_| |_| |_|\___/|_| |___/
// --------------------------------------- // ---------------------------------------
this.game.addItemBlueprint(":armor.light.studded_leather", { gGame.addItemBlueprint(":armor.light.studded_leather", {
name: "Studded Leather Armor", name: "Studded Leather Armor",
description: "Padded and hardened leather with metal stud reinforcement", description: "Padded and hardened leather with metal stud reinforcement",
itemSlots: 3, itemSlots: 3,
specialEffect: "TBD", specialEffect: "TBD",
armorHitPoints: 10, armorHitPoints: 10,
}); });
this.game.addItemBlueprint(":armor.light.leather", { gGame.addItemBlueprint(":armor.light.leather", {
name: "Leather Armor", name: "Leather Armor",
description: "Padded and hardened leather", description: "Padded and hardened leather",
itemSlots: 2, itemSlots: 2,
@@ -81,8 +76,6 @@ export class ItemSeeder {
armorHitPoints: 6, armorHitPoints: 6,
}); });
console.log(this.game._itemBlueprints);
// //
// _ ___ _ // _ ___ _
// | |/ (_) |_ ___ // | |/ (_) |_ ___
@@ -90,7 +83,7 @@ export class ItemSeeder {
// | . \| | |_\__ \ // | . \| | |_\__ \
// |_|\_\_|\__|___/ // |_|\_\_|\__|___/
// ------------------- // -------------------
this.game.addItemBlueprint(":kit.poisoners_kit", { gGame.addItemBlueprint(":kit.poisoners_kit", {
name: "Poisoner's Kit", name: "Poisoner's Kit",
description: "Allows you to create poisons that can be applied to weapons", description: "Allows you to create poisons that can be applied to weapons",
itemSlots: 2, itemSlots: 2,
@@ -99,7 +92,7 @@ export class ItemSeeder {
maxCount: 20, maxCount: 20,
}); });
this.game.addItemBlueprint(":kit.healers_kit", { gGame.addItemBlueprint(":kit.healers_kit", {
name: "Healer's Kit", name: "Healer's Kit",
description: "Allows you to heal your teammates outside of combat", description: "Allows you to heal your teammates outside of combat",
itemSlots: 2, itemSlots: 2,

View File

@@ -1,34 +1,28 @@
import { Game } from "../models/game.js"; import { gGame } from "../models/globals.js";
import { Player } from "../models/player.js"; import { Player } from "../models/player.js";
export class PlayerSeeder { export class PlayerSeeder {
/** @param {Game} game */ seed() {
constructor(game) { // Examples of the word "pass" hashed by the client and then the server:
/** @type {Game} */ // Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them.
this.game = game; // "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4
} // "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef
//
// Since the server-side hashes have random salts, the hashes themselves can change for the same password.
// The client side hash must not have a random salt, otherwise, it must change every time.
//
// The hash below is just one has that represents the password "pass" sent via V1 of the "Kims Krappy Krypto" scheme.
seed() { gGame.createPlayer(
// Examples of the word "pass" hashed by the client and then the server: "user",
// Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them. "1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef",
// "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4 "userSalt",
// "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef );
//
// Since the server-side hashes have random salts, the hashes themselves can change for the same password.
// The client side hash must not have a random salt, otherwise, it must change every time.
//
// The hash below is just one has that represents the password "pass" sent via V1 of the "Kims Krappy Krypto" scheme.
this.game.createPlayer( gGame.createPlayer(
"user", "admin",
"1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef", "1000:a84760824d28a9b420ee5f175a04d1e3:a6694e5c9fd41d8ee59f0a6e34c822ee2ce337c187e2d5bb5ba8612d6145aa8e",
"userSalt", "adminSalt",
); );
}
this.game.createPlayer(
"admin",
"1000:a84760824d28a9b420ee5f175a04d1e3:a6694e5c9fd41d8ee59f0a6e34c822ee2ce337c187e2d5bb5ba8612d6145aa8e",
"adminSalt",
);
}
} }

View File

@@ -2,12 +2,12 @@ import WebSocket, { WebSocketServer } from "ws";
import http from "http"; import http from "http";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { Game } from "./models/game.js";
import * as msg from "./utils/messages.js"; import * as msg from "./utils/messages.js";
import { Session } from "./models/session.js"; import { Session } from "./models/session.js";
import { AuthState } from "./states/authState.js";
import { GameSeeder } from "./seeders/gameSeeder.js"; import { GameSeeder } from "./seeders/gameSeeder.js";
import { Config } from "./config.js"; import { Config } from "./config.js";
import { gGame } from "./models/globals.js";
import { AuthenticationScene } from "./scenes/authentication/authenticationScene.js";
// __ __ _ _ ____ ____ // __ __ _ _ ____ ____
// | \/ | | | | _ \ / ___| ___ _ ____ _____ _ __ // | \/ | | | | _ \ / ___| ___ _ ____ _____ _ __
@@ -16,15 +16,9 @@ import { Config } from "./config.js";
// |_| |_|\___/|____/ |____/ \___|_| \_/ \___|_| // |_| |_|\___/|____/ |____/ \___|_| \_/ \___|_|
// ----------------------------------------------------- // -----------------------------------------------------
class MudServer { class MudServer {
/** @type {Xorshift32} */ constructor() {
rng; new GameSeeder().seed();
/** @param {number?} rngSeed seed for the pseudo-random number generator. */
constructor(rngSeed = undefined) {
/** @type {Game} */
this.game = new GameSeeder().createGame(rngSeed || Date.now());
} }
// ____ ___ _ _ _ _ _____ ____ _____ _____ ____ // ____ ___ _ _ _ _ _____ ____ _____ _____ ____
// / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \ // / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \
// | | | | | | \| | \| | _|| | | | | _| | | | | // | | | | | | \| | \| | _|| | | | | _| | | | |
@@ -35,9 +29,11 @@ class MudServer {
//------------------------------ //------------------------------
/** @param {WebSocket} websocket */ /** @param {WebSocket} websocket */
onConnectionEstabished(websocket) { onConnectionEstabished(websocket) {
console.log("New connection established"); console.info("New connection established");
const session = new Session(websocket, this.game); const session = new Session(websocket, gGame);
session.sendSystemMessage("dev", true); if (Config.dev) {
websocket.send(msg.prepareToSend(msg.SYSTEM, "dev", true));
}
// ____ _ ___ ____ _____ // ____ _ ___ ____ _____
// / ___| | / _ \/ ___|| ____| // / ___| | / _ \/ ___|| ____|
@@ -48,75 +44,36 @@ class MudServer {
// Handle Socket Closing // Handle Socket Closing
//---------------------- //----------------------
websocket.on("close", () => { websocket.on("close", () => {
if (!session.player) { try {
console.info("A player without a session disconnected"); this.close(session);
return; } catch (e) {
console.error("Failed during closing of websocket");
} }
//-------------
// TODO
//-------------
// Handle player logout (move the or hide their characters)
//
// Maybe session.onConnectionClosed() that calls session._state.onConnectionClosed()
// Maybe this.setState(new ConnectionClosedState());
// Maybe both ??
console.log(`Player ${session.player.username} disconnected`);
}); });
// __ __ _____ ____ ____ _ ____ _____
// | \/ | ____/ ___/ ___| / \ / ___| ____|
// | |\/| | _| \___ \___ \ / _ \| | _| _|
// | | | | |___ ___) |__) / ___ \ |_| | |___
// |_| |_|_____|____/____/_/ \_\____|_____|
//--------------------------------------------
// HANDLE INCOMING MESSAGES
//-------------------------
websocket.on("message", (data) => { websocket.on("message", (data) => {
try { try {
console.debug("incoming websocket message %s", data); this.onMessage(session, data);
if (!session.state) {
console.error("we received a message, but don't even have a state. Zark!");
websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?"));
return;
}
const msgObj = new msg.ClientMessage(data.toString());
if (msgObj.isQuitCommand()) {
//---------------------
// TODO TODO TODO TODO
//---------------------
// Set state = QuitState
//
websocket.send(msg.prepare(msg.MESSAGE, "The quitting quitter quits... Typical. Cya!"));
websocket.close();
return;
}
if (typeof session.state.onMessage !== "function") {
console.error("we received a message, but we're not i a State to receive it");
websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do with that message."));
return;
}
session.state.onMessage(msgObj);
} catch (error) { } catch (error) {
console.trace("received an invalid message (error: %s)", error, data.toString(), data); console.error(error, data.toString(), data);
websocket.send(msg.prepare(msg.CALAMITY, error)); websocket.send(msg.prepareToSend(msg.CALAMITY, error));
session.close();
} }
}); });
session.setState(new AuthState(session)); session.setScene(new AuthenticationScene(session));
} }
// ____ _____ _ ____ _____ // _ _ _____ _____ ____ ____ _____ _ ____ _____
// / ___|_ _|/ \ | _ \_ _| // | | | |_ _|_ _| _ \ / ___|_ _|/ \ | _ \_ _|
// \___ \ | | / _ \ | |_) || | // | |_| | | | | | | |_) |___\___ \ | | / _ \ | |_) || |
// ___) || |/ ___ \| _ < | | // | _ | | | | | | __/_____|__) || |/ ___ \| _ < | |
// |____/ |_/_/ \_\_| \_\|_| // |_| |_| |_| |_| |_| |____/ |_/_/ \_\_| \_\|_|
//----------------------------- //----------------------------------------------------------
// Start the server //
//----------------- // Start the server
//
//----------------------------------------------------------
start() { start() {
// //
// The file types we allow to be served. // The file types we allow to be served.
@@ -136,10 +93,11 @@ class MudServer {
// //
// Check if the requested file has a legal file type. // Check if the requested file has a legal file type.
if (!contentType) { if (!contentType) {
//
// Invalid file, pretend it did not exist! // Invalid file, pretend it did not exist!
res.writeHead(404); res.writeHead(404);
res.end(`File not found`); res.end(`File not found`);
console.log("Bad http request", req.url); console.warn("Bad http request", req.url);
return; return;
} }
@@ -149,7 +107,7 @@ class MudServer {
if (err) { if (err) {
res.writeHead(404); res.writeHead(404);
res.end(`File not found`); res.end(`File not found`);
console.log("Bad http request", req.url); console.warn("Bad http request", req.url);
return; return;
} }
res.writeHead(200, { "Content-Type": contentType }); res.writeHead(200, { "Content-Type": contentType });
@@ -165,19 +123,109 @@ class MudServer {
this.onConnectionEstabished(ws); this.onConnectionEstabished(ws);
}); });
console.info(`running environment: ${Config.env}`); console.info(`Environment: ${Config.env}`);
httpServer.listen(Config.port, () => { httpServer.listen(Config.port, () => {
console.log(`NUUHD server running on port ${Config.port}`); console.info(`NUUHD server running on port ${Config.port}`);
console.log(`WebSocket server ready for connections`);
}); });
} }
// __ __ _____ ____ ____ _ ____ _____
// | \/ | ____/ ___/ ___| / \ / ___| ____|
// | |\/| | _| \___ \___ \ / _ \| | _| _|
// | | | | |___ ___) |__) / ___ \ |_| | |___
// |_| |_|_____|____/____/_/ \_\____|_____|
//--------------------------------------------
/**
* Handle incoming message
* @param {Session} session
* @param {WebSocket.RawData} data
*/
onMessage(session, data) {
//
// Check if message too big
if (data.byteLength > Config.maxIncomingMessageSize) {
console.error("Message was too big!", Config.maxIncomingMessageSize, data.byteLength);
session.calamity(254, "batman");
return;
}
console.debug("Incoming websocket message %s", data);
//
// Sanity check. Do we even have a scene to route the message to?
if (!session.scene) {
console.error("No scene!", data.toString());
session.calamity("We received a message, but we're not in a state to handle it. Zark!");
return;
}
const msgObj = new msg.WebsocketMessage(data.toString());
//
// Handle replies to prompts. The main workhorse of the game.
if (msgObj.isReply()) {
return session.scene.prompt.onReply(msgObj.text);
}
//
// Handle :help commands
if (msgObj.isHelp()) {
return session.scene.prompt.onHelp(msgObj.text);
}
//
// Handle QUIT messages. When the player types :quit
if (msgObj.isQuit()) {
session.scene.onQuit();
session.close(0, "Closing the socket, graceful goodbye!");
return;
}
//
// Handle any text that starts with ":" that isn't :help or :quit
if (msgObj.isColon()) {
return session.scene.prompt.onColon(msgObj.command, msgObj.argLine);
}
//
// Handle system messages
if (msgObj.isSysMessage()) {
console.log("SYS message", msgObj);
return;
}
//
// Handle debug messages
if (msgObj.isDebug()) {
console.log("DBG message", msgObj);
return;
}
//
// How did we end up down here?
console.warn("Unknown message type: >>%s<<", msgObj.type, msgObj);
}
// ____ _ ___ ____ _____
// / ___| | / _ \/ ___|| ____|
// | | | | | | | \___ \| _|
// | |___| |__| |_| |___) | |___
// \____|_____\___/|____/|_____|
//-------------------------------
// Handle Socket Closing
//----------------------
close(session) {
const playerName = session.player ? session.player.username : "[unauthenticated]";
console.info(playerName + " disconnected");
session.close();
}
} }
// __ __ _ ___ _ _ // __ __ _ ___ _ _
// | \/ | / \ |_ _| \ | | // | \/ | / \ |_ _| \ | |
// | |\/| | / _ \ | || \| | // | |\/| | / _ \ | || \| |
// | | | |/ ___ \ | || |\ | // | | | |/ ___ \ | || |\ |
// |_| |_/_/ \_\___|_| \_| A // |_| |_/_/ \_\___|_| \_|
//--------------------------- //---------------------------
// Code entry point // Code entry point
//----------------- //-----------------

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
import { Session } from "../models/session.js";
import { PartyCreationState } from "./partyCreationState.js";
import { AwaitCommandsState } from "./awaitCommands.js";
/** @interface */
export class JustLoggedInState {
/** @param {Session} session */
constructor(session) {
/** @type {Session} */
this.session = session;
}
// Show welcome screen
onAttach() {
this.session.sendMessage(["", "Welcome", "", "You can type “:quit” at any time to quit the game", ""]);
//
// Check if we need to create characters for the player
if (this.session.player.characters.size === 0) {
this.session.sendMessage("You haven't got any characters, so let's make some\n\n");
this.session.setState(new PartyCreationState(this.session));
return;
}
const replacer = (key, value) => {
if (value instanceof Set) {
return [...value]; // turn Set into array
}
return value;
}
this.session.sendMessage(JSON.stringify(this.session.player.characters.entries(), replacer, "\t"));
this.session.setState(new AwaitCommandsState(this.session));
}
}

View File

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

@@ -1,173 +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 "./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));
}
}

4
server/test.js Normal file
View File

@@ -0,0 +1,4 @@
class Mufassa {
x = 42;
}
const foo = new Mufassa();

0
server/utils/dice.js Executable file → Normal file
View File

0
server/utils/id.js Executable file → Normal file
View File

View File

@@ -1,201 +1,186 @@
import { mustBe, mustBeInteger, mustBeString, mustMatch } from "./mustbe.js";
const colonCommandRegex = /^:([a-z0-9_]+)(:?\s*(.*))?$/;
/** /**
* Very bad logic error. Player must quit game, refresh page, and log in again. * Enum-like object holding placeholder tokens.
* *
* Client-->Server * @readonly
* or * @enum {string}
* Server-->Client-->Plater
*/ */
export const CALAMITY = "calamity"; export const MsgContext = Object.freeze({
PASSWORD: ":password",
USERNAME: ":username",
});
export const MsgTtype = Object.freeze({
/**
* Very bad logic error. Player must quit game, refresh page, and log in again.
*
* Client-->Server
* or
* Server-->Client-->Plater
*/
CALAMITY: "CALAMITY",
/**
* Tell recipient that an error has occurred
*
* Server-->Client-->Player
*/
MsgContext.ERROR: "E",
/**
* Message to be displayed.
*
* Server-->Client-->Player
*/
TEXT: "T",
/**
* Player has entered data, and sends it to server.
*
* Player-->Client-->Server
*/
MsgContext.REPLY: "R",
/**
* Player wants to quit.
*
* Player-->Client-->Server
*/
QUIT: "QUIT",
/**
* Player wants help
*
* Player-->Client-->Server
*/
HELP: "HELP",
/**
* Server tells the client to prompt the player for some data
*
* Server-->Client-->Player
*/
PROMPT: "P",
/**
* Server tells the client to prompt the player for some data
*
* Server-->Client-->Player
*/
SYSTEM: "_",
/**
* Debug message, to be completely ignored in production
*
* Client-->Server
* or
* Server-->Client-->Plater
*/
DEBUG: "dbg",
/**
* Player sent colon-prefixed, an out-of-order, command
*
* Player-->Client-->Server
*/
COLON: ":",
});
/** /**
* Tell recipient that an error has occurred * Represents a message sent to/from client
* *
* Server-->Client-->Player * @property {string?} command
* @property {string?} argLine
*/ */
export const ERROR = "e"; export class WebsocketMessage {
/** @protected @type {any[]} _arr The array that contains the message data */
_data;
/** /** @constant @readonly @type {string} _arr The array that contains the message data */
* Message to be displayed. type;
*
* Server-->Client-->Player
*/
export const MESSAGE = "m";
/** /**
* Player has entered data, and sends it to server. * @param {string} msgData the raw text data in the websocket message.
* */
* Player-->Client-->Server constructor(msgData) {
*/ if (typeof msgData !== "string") {
export const REPLY = "reply"; 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;
}
/** let data;
* Player wants to quit. try {
* data = JSON.parse(msgData);
* Player-->Client-->Server } catch (_) {
*/ throw new Error(
export const QUIT = "quit"; `Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
);
}
/** if (!Array.isArray(data)) {
* Player wants help throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._data}`);
* }
* Player-->Client-->Server
*/
export const HELP = "help";
/** if (data.length < 1) {
* Server tells the client to prompt the player for some data throw new Error(
* "Could not create client message. Excpected an array with at least 1 element, but got an empty one",
* Server-->Client-->Player );
*/ }
export const PROMPT = "prompt";
/** this.type = mustBeString(data[0]);
* Player has entered a command, and wants to do something.
*
* Player-->Client-->Server
*/
export const COMMAND = "c";
/** switch (this.type) {
* Server tells the client to prompt the player for some data case MsgContext.REPLY: // player ==> client ==> server
* this.text = mustBeString(data[1]);
* Server-->Client-->Player break;
*/ case HELP: // player ==> client ==> server
export const SYSTEM = "_"; this.text = data[1] === undefined ? "" : mustBeString(data[1]).trim();
break;
/** case COLON: // player ==> client ==> server
* Debug message, to be completely ignored in production this.command = mustMatch(data[1], /^[a-z0-9_]+$/);
* this.argLine = data[2]; // parse??
* Client-->Server break;
* or case DEBUG: // server ==> client
* Server-->Client-->Plater case MsgContext.ERROR: // server ==> client ==> player
*/ case QUIT: // player ==> client ==> server
export const DEBUG = "dbg"; case SYSTEM: // client <==> server
case PROMPT: // server ==> client ==> player
/** case TEXT: // server ==> client ==> player
* Represents a message sent from client to server. break;
*/ default:
export class ClientMessage { throw new Error(`Unknown message type: >>${typeof this.type}<<`);
/** }
* @protected
* @type {any[]} _arr The array that contains the message data
*/
_attr;
/** The message type.
*
* One of the * constants from this document.
*
* @returns {string}
*/
get type() {
return this._attr[0];
}
/**
* @param {string} msgData the raw text data in the websocket message.
*/
constructor(msgData) {
if (typeof msgData !== "string") {
throw new Error(
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData,
);
return;
} }
try { isQuit() {
this._attr = JSON.parse(msgData); return this.type === QUIT;
} catch (_) {
throw new Error(
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
);
} }
if (!Array.isArray(this._attr)) { isHelp() {
throw new Error( return this.type === HELP;
`Could not create client message. Excpected an array, but got a ${typeof this._attr}`,
);
} }
if (this._attr.length < 1) { isColon() {
throw new Error( return this.type === COLON;
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
);
}
}
hasCommand() {
return this._attr.length > 1 && this._attr[0] === COMMAND;
}
/** Does this message contain a username-response from the client? */
isUsernameResponse() {
return (
this._attr.length === 4 &&
this._attr[0] === REPLY &&
this._attr[1] === "username" &&
typeof this._attr[2] === "string"
);
}
/** Does this message contain a password-response from the client? */
isPasswordResponse() {
return (
this._attr.length === 4 &&
this._attr[0] === REPLY &&
this._attr[1] === "password" &&
typeof this._attr[2] === "string"
);
}
/** @returns {boolean} does this message indicate the player wants to quit */
isQuitCommand() {
return this._attr[0] === QUIT;
}
isHelpCommand() {
return this._attr[0] === HELP;
}
/** @returns {boolean} is this a debug message? */
isDebug() {
return this._attr.length === 2 && this._attr[0] === DEBUG;
}
isIntegerResponse() {
return (
this._attr.length === 4 &&
this._attr[0] === REPLY &&
this._attr[1] === "integer" &&
(typeof this._attr[2] === "string" ||
typeof this._attr[2] === "number") &&
Number.isInteger(Number(this._attr[2]))
);
}
/** @returns {number} integer */
get integer() {
if (!this.isIntegerResponse()) {
return undefined;
} }
return Number.parseInt(this._attr[2]); isReply() {
} return this.type === MsgContext.REPLY;
}
/** @returns {string|false} Get the username stored in this message */ isSysMessage() {
get username() { return this.type === SYSTEM;
return this.isUsernameResponse() ? this._attr[2] : false; }
}
/** @returns {string|false} Get the password stored in this message */ isDebug() {
get password() { return this.type === DEBUG;
return this.isPasswordResponse() ? this._attr[2] : false; }
}
/** @returns {string} */
get command() {
return this.hasCommand() ? this._attr[1] : false;
}
} }
/** /**
@@ -204,6 +189,6 @@ export class ClientMessage {
* @param {string} messageType * @param {string} messageType
* @param {...any} args * @param {...any} args
*/ */
export function prepare(messageType, ...args) { export function prepareToSend(messageType, ...args) {
return JSON.stringify([messageType, ...args]); return JSON.stringify([messageType, ...args]);
} }

44
server/utils/mustbe.js Executable file
View File

@@ -0,0 +1,44 @@
export function mustBe(value, ...types) {
//
// empty type enforcement.
// Means we just want value to be define
if (types.length === 0 && typeof value !== "undefined") {
return value;
}
//
// value has a valid type
if (types.includes(typeof value)) {
return value;
}
// NOTE: only checks first element of array if it's a string.
if (types.includes("strings[]") && Array.isArray(value) && (value.length === 0 || typeof value[0] === "string")) {
return value;
}
throw new Error("Invalid data type. Expected >>" + types.join(" or ") + "<< but got " + typeof value);
}
export function mustBeString(value) {
return mustBe(value, "string");
}
export function mustBeInteger(value) {
if (typeof value === "number" && Number.isSafeInteger(value)) {
return value;
}
}
/**
*
* @param {string} str
* @param {RegExp} regex
*/
export function mustMatch(str, regex) {
if (!regex.test(str)) {
throw new Error(`String did not satisfy ${regex}`);
}
return str;
}

83
server/utils/parseArgs.js Normal file
View File

@@ -0,0 +1,83 @@
import { mustBeString } from "./mustbe.js";
/**
* Parse a command string into arguments. For use with colon-commands.
*
* @param {string} cmdString;
* @returns {(string|number)[]} Command arguments
*/
export function parseArgs(cmdString) {
mustBeString(cmdString);
const args = [];
const quoteChars = ["'", '"', "`"];
const backslash = "\\";
let currentArg = ""; // The arg we are currently constructing
let inQuotes = false; // are we inside quotes of some kind?
let currentQuoteChar = ""; // if were in quotes, which are they?
const push = (value) => {
const n = Number(value);
if (Number.isSafeInteger(n)) {
args.push(n);
} else if (Number.isFinite(n)) {
args.push(n);
} else {
args.push(value);
}
};
for (let i = 0; i < cmdString.length; i++) {
const char = cmdString[i];
const nextChar = cmdString[i + 1];
if (!inQuotes) {
// Not in quotes - look for quote start or whitespace
if (quoteChars.includes(char)) {
inQuotes = true;
currentQuoteChar = char;
} else if (char === " " || char === "\t") {
// Whitespace - end current arg if it exists
if (currentArg) {
push(currentArg);
currentArg = "";
}
// Skip multiple whitespace
while (cmdString[i + 1] === " " || cmdString[i + 1] === "\t") i++;
} else {
currentArg += char;
}
} else {
// Inside quotes
if (char === currentQuoteChar) {
// Found matching quote - end quoted section
inQuotes = false;
currentQuoteChar = "";
} else if (char === backslash && (nextChar === currentQuoteChar || nextChar === backslash)) {
// Escape sequence - add the escaped character
currentArg += nextChar;
//
// Todo, maybe add support for \n newlines? Why would I ?
//
i++; // Skip next character
} else {
currentArg += char;
}
}
}
// Add final argument if exists
if (currentArg) {
push(currentArg);
}
if (currentQuoteChar) {
// We allow quotes to not be terminated
// It allows players to do stuff like `:say "wolla my lovely friend` and not have the text modified or misinterpreted in any way
// May be good for chat where you dont want every word split into individual arguments
}
return args;
}
console.log(parseArgs("\"k1m er '-9 ' `anus pikke`"));

39
server/utils/random.js Executable file → Normal file
View File

@@ -4,24 +4,44 @@
*/ */
export class Xorshift32 { export class Xorshift32 {
/* @type {number} */ /* @type {number} */
initialSeed;
/**
* State holds a single uint32.
* It's useful for staying within modulo 2**32.
*
* @type {Uint32Array}
*/
state; state;
/** @param {number} seed */ /** @param {number} seed */
constructor(seed) { constructor(seed) {
this.state = seed | 0; if (seed === undefined) {
const maxInt32 = 2 ** 32;
seed = Math.floor(Math.random() * (maxInt32 - 1)) + 1;
}
seed = seed | 0;
console.info("RNG Initial Seed %d", seed);
this.state = Uint32Array.of(seed);
} }
/** @protected Shuffle the internal state. */ /** @protected Shuffle the internal state. */
shuffle() { shuffle() {
// console.log("RNG Shuffle: Initial State: %d", this.state);
// Run the actual xorshift32 algorithm this.state[0] ^= this.state[0] << 13;
let x = this.state; this.state[0] ^= this.state[0] >>> 17;
x ^= x << 13; this.state[0] ^= this.state[0] << 5;
x ^= x >>> 17;
x ^= x << 5;
x = (x >>> 0) / 4294967296;
this.state = x; // We could also do something like this:
// x ^= x << 13;
// x ^= x >> 17;
// x ^= x << 5;
// return x;
// But we'd have to xor the x with 2^32 after every op,
// we get that "for free" by using the uint32array
console.log("RNG Shuffle: Exit State: %d", this.state);
return this.state[0];
} }
/** /**
@@ -77,4 +97,3 @@ export class Xorshift32 {
return num + greaterThanOrEqual; return num + greaterThanOrEqual;
} }
} }

0
server/utils/security.js Executable file → Normal file
View File

0
server/utils/tui.js Executable file → Normal file
View File