stuffy
This commit is contained in:
147
server/config.js
147
server/config.js
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
4
server/models/globals.js
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Game } from "./game.js";
|
||||||
|
|
||||||
|
/** @constant @readonly @type {Game} Global instance of Game */
|
||||||
|
export const gGame = new Game();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
107
server/public/crackdown.js
Normal file → Executable 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 "&";
|
|
||||||
case "<":
|
|
||||||
return "<";
|
|
||||||
case ">":
|
|
||||||
return ">";
|
|
||||||
case '"':
|
|
||||||
return """;
|
|
||||||
case "'":
|
|
||||||
return "'";
|
|
||||||
case "`":
|
|
||||||
return "`";
|
|
||||||
default:
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.replace(
|
|
||||||
/---(([a-zA-Z0-9:].*?[a-zA-Z0-9:])|[a-zA-Z0-9:])---/g,
|
|
||||||
'<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 "&";
|
||||||
|
case "<":
|
||||||
|
return "<";
|
||||||
|
case ">":
|
||||||
|
return ">";
|
||||||
|
case '"':
|
||||||
|
return """;
|
||||||
|
case "'":
|
||||||
|
return "'";
|
||||||
|
case "`":
|
||||||
|
return "`";
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
37
server/scenes/authentication/authenticationScene.js
Executable file
37
server/scenes/authentication/authenticationScene.js
Executable 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
server/scenes/authentication/passwordPrompt.js
Executable file
80
server/scenes/authentication/passwordPrompt.js
Executable 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
64
server/scenes/authentication/usernamePrompt.js
Executable file
64
server/scenes/authentication/usernamePrompt.js
Executable 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/scenes/gameLoop/gameScene.js
Executable file
26
server/scenes/gameLoop/gameScene.js
Executable 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
13
server/scenes/interface.js
Executable 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) {}
|
||||||
|
}
|
||||||
74
server/scenes/justLoggedIn/justLoggedInScene.js
Executable file
74
server/scenes/justLoggedIn/justLoggedInScene.js
Executable 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
97
server/scenes/partyCreation/partyCreationScene.js
Executable file
97
server/scenes/partyCreation/partyCreationScene.js
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
server/scenes/playerCreation/createUasswprdPrompt.js
Normal file
80
server/scenes/playerCreation/createUasswprdPrompt.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
58
server/scenes/playerCreation/createUsernamePrompt.js
Executable file
58
server/scenes/playerCreation/createUsernamePrompt.js
Executable 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
server/scenes/playerCreation/playerCreationSene.js
Executable file
50
server/scenes/playerCreation/playerCreationSene.js
Executable 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
196
server/scenes/prompt.js
Executable 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
58
server/scenes/scene.js
Executable 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
202
server/server.js
202
server/server.js
@@ -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
|
||||||
//-----------------
|
//-----------------
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
4
server/test.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class Mufassa {
|
||||||
|
x = 42;
|
||||||
|
}
|
||||||
|
const foo = new Mufassa();
|
||||||
0
server/utils/dice.js
Executable file → Normal file
0
server/utils/dice.js
Executable file → Normal file
0
server/utils/id.js
Executable file → Normal file
0
server/utils/id.js
Executable file → Normal 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
44
server/utils/mustbe.js
Executable 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
83
server/utils/parseArgs.js
Normal 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
39
server/utils/random.js
Executable file → Normal 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
0
server/utils/security.js
Executable file → Normal file
0
server/utils/tui.js
Executable file → Normal file
0
server/utils/tui.js
Executable file → Normal file
Reference in New Issue
Block a user