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 = {
|
||||
/** @readonly @type {string} the name of the environment we're running in */
|
||||
env: env,
|
||||
/** @readonly @type {string} the name of the environment we're running in */
|
||||
get env() {
|
||||
return _env || "prod";
|
||||
},
|
||||
|
||||
/** @readonly @type {boolean} are we running in development-mode? */
|
||||
dev: dev,
|
||||
/** @readonly @type {boolean} are we running in development-mode? */
|
||||
get dev() {
|
||||
if (_dev === true) {
|
||||
// no matter what, we do not allow dev mode in prod!
|
||||
return this.env !== "prod";
|
||||
}
|
||||
|
||||
/**
|
||||
* Port we're running the server on.
|
||||
*
|
||||
* @readonly
|
||||
* @const {number}
|
||||
*/
|
||||
port: process.env.PORT || 3000,
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Maximum number of players allowed on the server.
|
||||
*
|
||||
* @readonly
|
||||
* @const {number}
|
||||
*/
|
||||
maxPlayers: dev ? 3 : 40,
|
||||
/** @readonly @constant {number} Port we're running the server on. */
|
||||
get port() {
|
||||
return _port | 0 || 3000;
|
||||
},
|
||||
|
||||
/**
|
||||
* Max number of characters in a party.
|
||||
* By default, a player can only have a single party.
|
||||
* Multiple parties may happen some day.
|
||||
*/
|
||||
maxPartySize: 4,
|
||||
/** @readonly @constant {number} Maximum number of players allowed on the server. */
|
||||
get maxPlayers() {
|
||||
return _maxPlayers | 0 || 3;
|
||||
},
|
||||
|
||||
/**
|
||||
* Number of failed logins allowed before user is locked out.
|
||||
* Also known as Account lockout threshold
|
||||
*
|
||||
* @readonly
|
||||
* @const {number}
|
||||
*/
|
||||
maxFailedLogins: 5,
|
||||
/** @readonly @constant @type {number} Max number of characters in a party. */
|
||||
get maxPartySize() {
|
||||
return _maxPartySize | 0 || 4;
|
||||
},
|
||||
|
||||
/**
|
||||
* When a user has entered a wrong password too many times,
|
||||
* block them for this long before they can try again.
|
||||
*
|
||||
* @readonly
|
||||
* @const {number}
|
||||
*/
|
||||
accountLockoutDurationMs: 15 * 60 * 1000, // 15 minutes.
|
||||
/** @readonly @constant @constant {number} Number of failed logins allowed before user is locked out. Also known as Account lockout threshold */
|
||||
get() {
|
||||
return _maxFailedLogins | 0 || 4;
|
||||
},
|
||||
|
||||
/**
|
||||
* When a user has entered a wrong password too many times,
|
||||
* 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.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import { Config } from "../config.js";
|
||||
import { isIdSane, miniUid } from "../utils/id.js";
|
||||
import { Xorshift32 } from "../utils/random.js";
|
||||
import { Character } from "./character.js";
|
||||
@@ -14,6 +15,8 @@ import { ItemAttributes, ItemBlueprint } from "./item.js";
|
||||
import { Player } from "./player.js";
|
||||
|
||||
export class Game {
|
||||
_counter = 1_000_000;
|
||||
|
||||
/** @type {Map<string,ItemBlueprint>} List of all item blueprints in the game */
|
||||
_itemBlueprints = new Map();
|
||||
|
||||
@@ -34,22 +37,21 @@ export class Game {
|
||||
*/
|
||||
_players = new Map();
|
||||
|
||||
|
||||
/** @protected @type {Xorshift32} */
|
||||
_rng;
|
||||
_random;
|
||||
|
||||
/** @type {Xorshift32} */
|
||||
get rng() {
|
||||
return this._rng;
|
||||
get random() {
|
||||
return this._random;
|
||||
}
|
||||
|
||||
/** @param {number} rngSeed Seed number used for randomization */
|
||||
constructor(rngSeed) {
|
||||
if (!Number.isInteger(rngSeed)) {
|
||||
throw new Error("rngSeed must be an integer");
|
||||
}
|
||||
constructor() {
|
||||
this.rngSeed = Date.now();
|
||||
}
|
||||
|
||||
this._rng = new Xorshift32(rngSeed);
|
||||
set rngSeed(rngSeed) {
|
||||
this._random = new Xorshift32(rngSeed);
|
||||
}
|
||||
|
||||
getPlayer(username) {
|
||||
@@ -90,7 +92,6 @@ export class Game {
|
||||
* @returns {ItemBlueprint|false}
|
||||
*/
|
||||
addItemBlueprint(blueprintId, attributes) {
|
||||
console.log(attributes);
|
||||
if (typeof blueprintId !== "string" || !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 { Character } from "./character.js";
|
||||
import { Config } from "./../config.js";
|
||||
import { Scene } from "../scenes/scene.js";
|
||||
|
||||
/**
|
||||
* Player Account.
|
||||
@@ -8,84 +9,90 @@ import { Config } from "./../config.js";
|
||||
* Contain persistent player account info.
|
||||
*/
|
||||
export class Player {
|
||||
/** @protected @type {string} unique username */
|
||||
_username;
|
||||
get username() {
|
||||
return this._username;
|
||||
}
|
||||
|
||||
/** @protected @type {string} */
|
||||
_passwordHash;
|
||||
get passwordHash() {
|
||||
return this._passwordHash;
|
||||
}
|
||||
|
||||
/** @protected @type {string} random salt used for hashing */
|
||||
_salt;
|
||||
get salt() {
|
||||
return this._salt;
|
||||
}
|
||||
|
||||
/** @protected @type {Date} */
|
||||
_createdAt = new Date();
|
||||
get createdAt() {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
/** @type {Date} */
|
||||
blockedUntil;
|
||||
|
||||
/** @type {Date|null} Date of the player's last websocket message. */
|
||||
lastActivityAt = null;
|
||||
|
||||
/** @type {Date|null} Date of the player's last login. */
|
||||
lastSucessfulLoginAt = null;
|
||||
|
||||
/** @type {number} Number of successful logins on this character */
|
||||
successfulLogins = 0;
|
||||
|
||||
/** @type {number} Number of failed login attempts since the last good login attempt */
|
||||
failedPasswordsSinceLastLogin = 0;
|
||||
|
||||
/** @protected @type {Set<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;
|
||||
/** @protected @type {string} unique username */
|
||||
_username;
|
||||
get username() {
|
||||
return this._username;
|
||||
}
|
||||
|
||||
if (this._characters.size >= Config.maxPartySize) {
|
||||
return false;
|
||||
/** @protected @type {string} */
|
||||
_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 { Game } from "./game.js";
|
||||
import { Player } from "./player.js";
|
||||
import { StateInterface } from "../states/interface.js";
|
||||
import * as msg from "../utils/messages.js";
|
||||
import figlet from "figlet";
|
||||
import { mustBeString, mustBe } from "../utils/mustbe.js";
|
||||
import { Scene } from "../scenes/scene.js";
|
||||
import { gGame } from "./globals.js";
|
||||
|
||||
export class Session {
|
||||
/** @protected @type {StateInterface} */
|
||||
_state;
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
/** @type {WebSocket} */
|
||||
_websocket;
|
||||
|
||||
/** @protected @type {Game} */
|
||||
_game;
|
||||
get game() {
|
||||
return this._game;
|
||||
}
|
||||
/** @protected @type {Scene} */
|
||||
_scene;
|
||||
|
||||
/** @type {Player} */
|
||||
_player;
|
||||
get player() {
|
||||
return this._player;
|
||||
}
|
||||
|
||||
/** @param {Player} player */
|
||||
set player(player) {
|
||||
if (player instanceof Player) {
|
||||
this._player = player;
|
||||
return;
|
||||
/** @readonly @constant @type {Scene} */
|
||||
get scene() {
|
||||
return this._scene;
|
||||
}
|
||||
|
||||
if (player === null) {
|
||||
this._player = null;
|
||||
return;
|
||||
/** @type {Player} */
|
||||
_player;
|
||||
|
||||
get player() {
|
||||
return this._player;
|
||||
}
|
||||
|
||||
throw Error(
|
||||
`Can only set player to null or instance of Player, but received ${typeof player}`,
|
||||
);
|
||||
}
|
||||
/** @param {Player} player */
|
||||
set player(player) {
|
||||
if (player instanceof Player) {
|
||||
this._player = player;
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {WebSocket} */
|
||||
_websocket;
|
||||
if (player === null) {
|
||||
this._player = null;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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");
|
||||
throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`);
|
||||
}
|
||||
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 {string|string[]} message The prompting message (please enter your character's name)
|
||||
* @param {string} tag helps with message routing and handling.
|
||||
*/
|
||||
sendPrompt(type, message, tag = "", ...args) {
|
||||
if (Array.isArray(message)) {
|
||||
message = message.join("\n");
|
||||
/**
|
||||
* @param {Scene} scene
|
||||
*/
|
||||
setScene(scene) {
|
||||
console.debug("changing scene", scene.constructor.name);
|
||||
if (!(scene instanceof Scene)) {
|
||||
throw new Error(`Expected instance of Scene, got a ${typeof scene}: >>${scene}<<`);
|
||||
}
|
||||
this._scene = scene;
|
||||
scene.execute(this);
|
||||
}
|
||||
this.send(msg.PROMPT, type, message, tag, ...args);
|
||||
}
|
||||
|
||||
/** @param {string} message The error message to display to player */
|
||||
sendError(message, ...args) {
|
||||
this.send(msg.ERROR, message, ...args);
|
||||
}
|
||||
|
||||
/** @param {string} message The error message to display to player */
|
||||
sendDebug(message, ...args) {
|
||||
this.send(msg.DEBUG, message, ...args);
|
||||
}
|
||||
|
||||
/** @param {string} message The calamitous error to display to player */
|
||||
sendCalamity(message, ...args) {
|
||||
this.send(msg.CALAMITY, message, ...args);
|
||||
}
|
||||
|
||||
sendSystemMessage(arg0, ...rest) {
|
||||
this.send(msg.SYSTEM, arg0, ...rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StateInterface} state
|
||||
*/
|
||||
setState(state) {
|
||||
this._state = state;
|
||||
console.debug("changing state", state.constructor.name);
|
||||
if (typeof state.onAttach === "function") {
|
||||
state.onAttach();
|
||||
/** Close the session and websocket */
|
||||
close() {
|
||||
if (this._websocket) {
|
||||
this._websocket.close();
|
||||
this._websocket = null;
|
||||
}
|
||||
this._player = null;
|
||||
this._scene = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message via our websocket.
|
||||
*
|
||||
* @param {string|number} messageType
|
||||
* @param {...any} args
|
||||
*/
|
||||
send(messageType, ...args) {
|
||||
if (!this._websocket) {
|
||||
console.error("Trying to send a message without a valid websocket", messageType, args);
|
||||
return;
|
||||
}
|
||||
this._websocket.send(JSON.stringify([messageType, ...args]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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(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",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=prod node server.js",
|
||||
"dev": "NODE_ENV=dev nodemon server.js"
|
||||
"start": "MUUHD_ENV=prod node server.js",
|
||||
"dev": "MUUHD_ENV=dev nodemon server.js"
|
||||
},
|
||||
"keywords": [
|
||||
"mud",
|
||||
@@ -35,6 +35,7 @@
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 4,
|
||||
"bracketSpacing": true,
|
||||
"objectWrap": "preserve"
|
||||
"objectWrap": "preserve",
|
||||
"arrowParens": "always"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,505 +1,447 @@
|
||||
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 {
|
||||
//
|
||||
// Constructor
|
||||
constructor() {
|
||||
/** @type {WebSocket} Our WebSocket */
|
||||
this.websocket = null;
|
||||
//
|
||||
// Constructor
|
||||
constructor() {
|
||||
/** @type {WebSocket} Our WebSocket */
|
||||
this.websocket = null;
|
||||
|
||||
/** @type {boolean} Are we in development mode (decided by the server);
|
||||
/** @type {boolean} Are we in development mode (decided by the server); */
|
||||
this.dev = false;
|
||||
|
||||
/** @type {string|null} The message type of the last thing we were asked. */
|
||||
this.replyType = null;
|
||||
this.promptOptions = {};
|
||||
this.shouldReply = false;
|
||||
|
||||
/** @type {string|null} The #tag of the last thing we were asked. */
|
||||
this.replyTag = null;
|
||||
/** @type {HTMLElement} The output "monitor" */
|
||||
this.output = document.getElementById("output");
|
||||
|
||||
/** @type {HTMLElement} The output "monitor" */
|
||||
this.output = document.getElementById("output");
|
||||
/** @type {HTMLElement} The input element */
|
||||
this.input = document.getElementById("input");
|
||||
|
||||
/** @type {HTMLElement} The input element */
|
||||
this.input = document.getElementById("input");
|
||||
/** @type {HTMLElement} Status indicator */
|
||||
this.status = document.getElementById("status");
|
||||
|
||||
/** @type {HTMLElement} The send/submit button */
|
||||
this.sendButton = document.getElementById("send");
|
||||
// Passwords are crypted and salted before being sent to the server
|
||||
// This means that if ANY of these three parameters below change,
|
||||
// The server can no longer accept the passwords.
|
||||
/** @type {string} Hashing method to use for client-side password hashing */
|
||||
this.digest = "SHA-256";
|
||||
|
||||
/** @type {HTMLElement} Status indicator */
|
||||
this.status = document.getElementById("status");
|
||||
/** @type {string} Salt string to use for client-side password hashing */
|
||||
this.salt = "No salt, no shorts, no service";
|
||||
|
||||
// Passwords are crypted and salted before being sent to the server
|
||||
// This means that if ANY of these three parameters below change,
|
||||
// The server can no longer accept the passwords.
|
||||
/** @type {string} Hashing method to use for client-side password hashing */
|
||||
this.digest = "SHA-256";
|
||||
/** @type {string} Number of times the hashing should be done */
|
||||
this.rounds = 1000;
|
||||
|
||||
/** @type {string} Salt string to use for client-side password hashing */
|
||||
this.salt = "No salt, no shorts, no service";
|
||||
/** @type {string} the username also salts the password, so the username must never change. */
|
||||
this.username = "";
|
||||
|
||||
/** @type {string} Number of times the hashing should be done */
|
||||
this.rounds = 1000;
|
||||
|
||||
/** @type {string} the username also salts the password, so the username must never change. */
|
||||
this.username = "";
|
||||
|
||||
this.setupEventListeners();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** @param {string} password the password to be hashed */
|
||||
async hashPassword(password) {
|
||||
const encoder = new TextEncoder();
|
||||
let data = encoder.encode(password + this.salt);
|
||||
|
||||
for (let i = 0; i < this.rounds; i++) {
|
||||
const hashBuffer = await crypto.subtle.digest(this.digest, data);
|
||||
data = new Uint8Array(hashBuffer); // feed hash back in
|
||||
this.setupEventListeners();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// Convert final hash to hex
|
||||
const rawHash = Array.from(data)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
/** @param {string} password the password to be hashed */
|
||||
async hashPassword(password) {
|
||||
const encoder = new TextEncoder();
|
||||
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() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}`;
|
||||
// Convert final hash to hex
|
||||
const rawHash = Array.from(data)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
this.updateStatus("Connecting...", "connecting");
|
||||
return `KimsKrappyKryptoV1:${this.salt}:${this.rounds}:${this.digest}:${rawHash}`;
|
||||
}
|
||||
|
||||
try {
|
||||
this.websocket = new WebSocket(wsUrl);
|
||||
connect() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}`;
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
this.updateStatus("Connected", "connected");
|
||||
this.input.disabled = false;
|
||||
this.sendButton.disabled = false;
|
||||
this.input.focus();
|
||||
this.output.innerHTML = "";
|
||||
};
|
||||
this.updateStatus("Connecting...", "connecting");
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
console.debug(event);
|
||||
const data = JSON.parse(event.data);
|
||||
this.onMessage(data);
|
||||
this.input.focus();
|
||||
};
|
||||
try {
|
||||
this.websocket = new WebSocket(wsUrl);
|
||||
|
||||
this.websocket.onclose = () => {
|
||||
this.updateStatus("Disconnected", "disconnected");
|
||||
this.input.disabled = true;
|
||||
this.sendButton.disabled = true;
|
||||
this.websocket.onopen = () => {
|
||||
this.updateStatus("Connected", "connected");
|
||||
this.input.disabled = false;
|
||||
this.input.focus();
|
||||
this.output.innerHTML = "";
|
||||
};
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
this.websocket.onmessage = (event) => {
|
||||
console.debug(event);
|
||||
const data = JSON.parse(event.data);
|
||||
this.onMessageReceived(data);
|
||||
this.input.focus();
|
||||
};
|
||||
|
||||
this.websocket.onerror = (error) => {
|
||||
this.updateStatus("Connection Error", "error");
|
||||
this.writeToOutput("Connection error occurred. Retrying...", {
|
||||
class: "error",
|
||||
this.websocket.onclose = () => {
|
||||
this.updateStatus("Disconnected", "disconnected");
|
||||
this.input.disabled = true;
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
|
||||
this.websocket.onerror = (error) => {
|
||||
this.updateStatus("Connection Error", "error");
|
||||
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.updateStatus("Connection Failed", "error");
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.addEventListener("keypress", (e) => {
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
this.input.focus();
|
||||
}
|
||||
});
|
||||
this.input.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
this.onUserCommand();
|
||||
}
|
||||
});
|
||||
};
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Send a json-encoded message to the server via websocket.
|
||||
*
|
||||
* @param {messageType} string
|
||||
* @param {...any} rest
|
||||
*/
|
||||
send(messageType, ...args) {
|
||||
console.log("sending", messageType, args);
|
||||
|
||||
this.sendButton.addEventListener("click", () => {
|
||||
this.onUserCommand();
|
||||
});
|
||||
|
||||
// Command history
|
||||
this.commandHistory = [];
|
||||
this.historyIndex = -1;
|
||||
|
||||
this.input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (this.historyIndex < this.commandHistory.length - 1) {
|
||||
this.historyIndex++;
|
||||
this.input.value =
|
||||
this.commandHistory[
|
||||
this.commandHistory.length - 1 - this.historyIndex
|
||||
];
|
||||
if (args.length === 0) {
|
||||
this.websocket.send(JSON.stringify([messageType]));
|
||||
return;
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (this.historyIndex > 0) {
|
||||
this.historyIndex--;
|
||||
this.input.value =
|
||||
this.commandHistory[
|
||||
this.commandHistory.length - 1 - this.historyIndex
|
||||
];
|
||||
} else if (this.historyIndex === 0) {
|
||||
this.historyIndex = -1;
|
||||
this.input.value = "";
|
||||
|
||||
this.websocket.send(JSON.stringify([messageType, ...args]));
|
||||
}
|
||||
|
||||
/**
|
||||
* User has entered a command
|
||||
*/
|
||||
async onUserCommand() {
|
||||
/** @type {string} */
|
||||
const inputText = this.input.value.trim(); // Trim user's input.
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {messageType} string
|
||||
* @param {...any} rest
|
||||
*/
|
||||
send(messageType, ...args) {
|
||||
if (args.length === 0) {
|
||||
this.websocket.send(JSON.stringify([messageType]));
|
||||
return;
|
||||
//
|
||||
// Don't allow sending messages (for now)
|
||||
// Later on, prompts may give us the option to simply "press enter";
|
||||
if (!inputText) {
|
||||
console.debug("Cannot send empty message - YET");
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Can't send a message without a websocket
|
||||
if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// The 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.
|
||||
if (this.replyType === "password" || this.replyType === "username") {
|
||||
return;
|
||||
/** @param {any[]} data*/
|
||||
onMessageReceived(data) {
|
||||
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.
|
||||
// Why would the user navigate back through their history to
|
||||
// find and empty command when they can just press enter.
|
||||
if (command === "") {
|
||||
return;
|
||||
// "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;
|
||||
}
|
||||
|
||||
//
|
||||
// Add to command our history
|
||||
// But not if the command was a password.
|
||||
this.historyIndex = -1;
|
||||
|
||||
//
|
||||
// We do not add the same commands many times in a row.
|
||||
if (this.commandHistory[this.commandHistory.length - 1] === command) {
|
||||
return;
|
||||
// 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", verbatim: true });
|
||||
console.debug("DBG", data);
|
||||
}
|
||||
|
||||
//
|
||||
// Add the command to the history stack
|
||||
this.commandHistory.push(command);
|
||||
if (this.commandHistory.length > 50) {
|
||||
this.commandHistory.shift();
|
||||
}
|
||||
}
|
||||
// "_" => system messages, not to be displayed
|
||||
handleSystemMessages(data) {
|
||||
if (data.length < 2) {
|
||||
console.debug("malformed system message", data);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* User has entered a command
|
||||
*/
|
||||
onUserCommand() {
|
||||
//
|
||||
// Trim user's input.
|
||||
const command = this.input.value.trim();
|
||||
this.input.value = "";
|
||||
this.input.type = "text";
|
||||
console.debug("Incoming system message", data);
|
||||
|
||||
this._addCommandToHistory(command);
|
||||
/** @type {string} */
|
||||
const messageType = data.shift();
|
||||
|
||||
// -- This is a sneaky command that should not be in production?
|
||||
//
|
||||
// In reality we want to use :clear, nor /clear
|
||||
// :clear would be sent to the server, and we ask if it's okay
|
||||
// to clear the screen right now, and only on a positive answer would we
|
||||
// allow the screen to be cleared. Maybe.....
|
||||
if (command === "/clear") {
|
||||
this.output.innerHTML = "";
|
||||
this.input.value = "";
|
||||
return;
|
||||
switch (messageType) {
|
||||
case "username":
|
||||
this.username = data[0];
|
||||
break;
|
||||
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;
|
||||
}
|
||||
|
||||
//
|
||||
// Don't allow sending messages (for now)
|
||||
// Later on, prompts may give us the option to simply "press enter";
|
||||
if (!command) {
|
||||
console.debug("Cannot send empty message - YET");
|
||||
return;
|
||||
// "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", verbatim: true }, ...data[1] };
|
||||
this.writeToOutput(data[0], options);
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Can't send a message without a websocket
|
||||
if (!(this.websocket && this.websocket.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
// "e" => non-lethal errors
|
||||
handleErrorMessage(data) {
|
||||
const options = { ...{ class: "error" }, ...data[1] };
|
||||
this.writeToOutput(data[0], options);
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// The server asked us for a password, so we send it.
|
||||
// But we hash it first, so we don't send our stuff
|
||||
// in the clear.
|
||||
if (this.replyType === "password") {
|
||||
this.hashPassword(command).then((pwHash) => {
|
||||
this.send("reply", "password", pwHash, this.replyTag);
|
||||
this.replyType = null;
|
||||
this.replyTag = null;
|
||||
});
|
||||
return;
|
||||
// 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 [promptText, options = {}] = data;
|
||||
|
||||
this.shouldReply = true;
|
||||
|
||||
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,
|
||||
// keep the username in the pocket for later.
|
||||
if (this.replyType === "username") {
|
||||
this.username = command;
|
||||
/**
|
||||
* Add output to the text.
|
||||
* @param {string} text
|
||||
* @param {object} options
|
||||
*/
|
||||
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
|
||||
// player can see what they typed.
|
||||
this.writeToOutput("> " + command, { class: "input" });
|
||||
|
||||
//
|
||||
// Handle certain-commands differently.
|
||||
const specialCommands = { ":quit": "quit", ":help": "help" };
|
||||
if (specialCommands[command]) {
|
||||
this.send(specialCommands[command]);
|
||||
return;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
//
|
||||
// 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
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebSocket MUD</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="status" class="connecting">Connecting...</div>
|
||||
<div id="output"></div>
|
||||
<div id="input-container">
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
id="input"
|
||||
placeholder="Enter command..."
|
||||
disabled
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="send" disabled>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebSocket MUD</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="status" class="connecting">Connecting...</div>
|
||||
<div id="output"></div>
|
||||
<div id="input-container">
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
id="input"
|
||||
placeholder="Enter command..."
|
||||
disabled
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="client.js"></script>
|
||||
</body>
|
||||
<script type="module" src="client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,136 +1,150 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap");
|
||||
|
||||
body {
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
background-color: #1a1a1a;
|
||||
color: #00ff00;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
background-color: #1a1a1a;
|
||||
color: #00ff00;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 99.9vw;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 99.9vw;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#output {
|
||||
flex: 1;
|
||||
background-color: #000;
|
||||
border: 2px solid #333;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 20px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
width: 100ch;
|
||||
flex: 1;
|
||||
background-color: #000;
|
||||
border: 2px solid #333;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 20px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
width: 100ch;
|
||||
}
|
||||
|
||||
#input-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#input {
|
||||
flex: 1;
|
||||
background-color: #222;
|
||||
border: 2px solid #333;
|
||||
color: #00ff00;
|
||||
padding: 10px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
background-color: #222;
|
||||
border: 2px solid #333;
|
||||
color: #00ff00;
|
||||
padding: 10px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#input:focus {
|
||||
outline: none;
|
||||
border-color: #00ff00;
|
||||
outline: none;
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
#send {
|
||||
background-color: #333;
|
||||
border: 2px solid #555;
|
||||
color: #00ff00;
|
||||
padding: 10px 20px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
cursor: pointer;
|
||||
background-color: #333;
|
||||
border: 2px solid #555;
|
||||
color: #00ff00;
|
||||
padding: 10px 20px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#send:hover {
|
||||
background-color: #444;
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#status {
|
||||
background-color: #333;
|
||||
padding: 5px 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 3px;
|
||||
background-color: #333;
|
||||
padding: 5px 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
display: inline-block;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.connected {
|
||||
color: #00ff00;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
color: #ff4444;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.connecting {
|
||||
color: #ffaa00;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4444;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.input {
|
||||
color: #666;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.debug {
|
||||
opacity: 0.33;
|
||||
opacity: 0.33;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: #00ccff;
|
||||
color: #00ccff;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.strike {
|
||||
text-decoration: line-through;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.undercurl {
|
||||
text-decoration: wavy underline lime;
|
||||
text-decoration: wavy underline rgb(00 100% 00 / 40%);
|
||||
}
|
||||
|
||||
.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 { Game } from "../models/game.js";
|
||||
import { gGame } from "../models/globals.js";
|
||||
import { Player } from "../models/player.js";
|
||||
import * as roll from "../utils/dice.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 {
|
||||
/** @type {Game} */
|
||||
constructor(game) {
|
||||
/** @type {Game} */
|
||||
this.game = game;
|
||||
constructor() {
|
||||
// stupid convenience hack that only works if we only have a single Game in the system.
|
||||
// Which we easily could have.!!
|
||||
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) {
|
||||
for (const id of itemBlueprintIds) {
|
||||
const blueprint = this.game.getItemBlueprint(id);
|
||||
const blueprint = gGame.getItemBlueprint(id);
|
||||
if (!blueprint) {
|
||||
throw new Error(`No blueprint found for id: ${id}`);
|
||||
}
|
||||
@@ -74,9 +87,9 @@ export class CharacterSeeder {
|
||||
// Rolling skills
|
||||
|
||||
c.name =
|
||||
this.game.rng.oneOf("sir", "madam", "mister", "miss", "", "", "") +
|
||||
" random " +
|
||||
this.game.rng.get().toString();
|
||||
gGame.random.oneOf("sir ", "madam ", "mister ", "miss ", "", "", "") + // prefix
|
||||
"random " + // name
|
||||
gGame.random.get().toString(); // suffix
|
||||
|
||||
c.awareness = roll.d6() + 2;
|
||||
c.grit = roll.d6() + 2;
|
||||
@@ -86,7 +99,8 @@ export class CharacterSeeder {
|
||||
c.rangedCombat = roll.d6() + 2;
|
||||
c.skulduggery = roll.d6() + 2;
|
||||
|
||||
switch (roll.d8()) {
|
||||
let ancestryId = roll.d8();
|
||||
switch (ancestryId) {
|
||||
case 1:
|
||||
c.ancestry = "human";
|
||||
// Humans get +1 to all skills
|
||||
@@ -111,7 +125,7 @@ export class CharacterSeeder {
|
||||
c.meleeCombat = Math.max(c.grit, 10);
|
||||
break;
|
||||
case 5:
|
||||
c.ancestry = "Gnomish";
|
||||
c.ancestry = "gnomish";
|
||||
c.meleeCombat = Math.max(c.awareness, 10);
|
||||
break;
|
||||
case 6:
|
||||
@@ -127,12 +141,12 @@ export class CharacterSeeder {
|
||||
c.meleeCombat = Math.max(c.skulduggery, 10);
|
||||
break;
|
||||
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);
|
||||
|
||||
console.log(c);
|
||||
console.debug(c);
|
||||
|
||||
return c;
|
||||
}
|
||||
@@ -163,7 +177,7 @@ export class CharacterSeeder {
|
||||
applyFoundation(c, foundation = ":random") {
|
||||
switch (foundation) {
|
||||
case ":random":
|
||||
return this.applyFoundation(c, roll.dice(3));
|
||||
return this.applyFoundation(c, roll.d(3));
|
||||
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 { ItemSeeder } from "./itemSeeder.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
|
||||
*/
|
||||
export class GameSeeder {
|
||||
/** @returns {Game} */
|
||||
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() {
|
||||
seed() {
|
||||
console.info("seeding");
|
||||
|
||||
//
|
||||
new PlayerSeeder(this.game).seed(); // Create debug players
|
||||
new ItemSeeder(this.game).seed(); // Create items, etc.
|
||||
new CharacterSeeder(this.game).createParty(this.game.getPlayer("user"), 3); // Create debug characters.
|
||||
gGame.rngSeed = Config.rngSeed;
|
||||
new PlayerSeeder().seed(); // Create debug players
|
||||
new ItemSeeder().seed(); // Create items, etc.
|
||||
new CharacterSeeder().createParty(gGame.getPlayer("user"), 3); // Create debug characters.
|
||||
|
||||
//
|
||||
// Done
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Game } from "../models/game.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
|
||||
export class ItemSeeder {
|
||||
/** @param {Game} game */
|
||||
constructor(game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
seed() {
|
||||
//
|
||||
// __ __
|
||||
@@ -25,7 +20,7 @@ export class ItemSeeder {
|
||||
// \_/\_/ \___|\__,_| .__/ \___/|_| |_|___/
|
||||
// |_|
|
||||
//-------------------------------------------------------
|
||||
this.game.addItemBlueprint(":weapon.light.dagger", {
|
||||
gGame.addItemBlueprint(":weapon.light.dagger", {
|
||||
name: "Dagger",
|
||||
description: "Small shady blady",
|
||||
itemSlots: 0.5,
|
||||
@@ -35,7 +30,7 @@ export class ItemSeeder {
|
||||
specialEffect: ":effect.weapon.fast",
|
||||
});
|
||||
|
||||
this.game.addItemBlueprint(":weapon.light.sickle", {
|
||||
gGame.addItemBlueprint(":weapon.light.sickle", {
|
||||
name: "Sickle",
|
||||
description: "For cutting nuts, and branches",
|
||||
itemSlots: 1,
|
||||
@@ -43,7 +38,7 @@ export class ItemSeeder {
|
||||
specialEffect: ":effect.weapon.sickle",
|
||||
});
|
||||
|
||||
this.game.addItemBlueprint(":weapon.weird.spiked_gauntlets", {
|
||||
gGame.addItemBlueprint(":weapon.weird.spiked_gauntlets", {
|
||||
name: "Spiked Gauntlets",
|
||||
description: "Spikes with gauntlets on them!",
|
||||
itemSlots: 1,
|
||||
@@ -51,7 +46,7 @@ export class ItemSeeder {
|
||||
specialEffect: "TBD",
|
||||
});
|
||||
|
||||
this.game.addItemBlueprint(":weapon.light.rapier", {
|
||||
gGame.addItemBlueprint(":weapon.light.rapier", {
|
||||
name: "Rapier",
|
||||
description: "Fancy musketeer sword",
|
||||
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",
|
||||
description: "Padded and hardened leather with metal stud reinforcement",
|
||||
itemSlots: 3,
|
||||
specialEffect: "TBD",
|
||||
armorHitPoints: 10,
|
||||
});
|
||||
this.game.addItemBlueprint(":armor.light.leather", {
|
||||
gGame.addItemBlueprint(":armor.light.leather", {
|
||||
name: "Leather Armor",
|
||||
description: "Padded and hardened leather",
|
||||
itemSlots: 2,
|
||||
@@ -81,8 +76,6 @@ export class ItemSeeder {
|
||||
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",
|
||||
description: "Allows you to create poisons that can be applied to weapons",
|
||||
itemSlots: 2,
|
||||
@@ -99,7 +92,7 @@ export class ItemSeeder {
|
||||
maxCount: 20,
|
||||
});
|
||||
|
||||
this.game.addItemBlueprint(":kit.healers_kit", {
|
||||
gGame.addItemBlueprint(":kit.healers_kit", {
|
||||
name: "Healer's Kit",
|
||||
description: "Allows you to heal your teammates outside of combat",
|
||||
itemSlots: 2,
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { Game } from "../models/game.js";
|
||||
import { gGame } from "../models/globals.js";
|
||||
import { Player } from "../models/player.js";
|
||||
|
||||
export class PlayerSeeder {
|
||||
/** @param {Game} game */
|
||||
constructor(game) {
|
||||
/** @type {Game} */
|
||||
this.game = game;
|
||||
}
|
||||
seed() {
|
||||
// Examples of the word "pass" hashed by the client and then the server:
|
||||
// Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them.
|
||||
// "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4
|
||||
// "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef
|
||||
//
|
||||
// Since the server-side hashes have random salts, the hashes themselves can change for the same password.
|
||||
// The client side hash must not have a random salt, otherwise, it must change every time.
|
||||
//
|
||||
// The hash below is just one has that represents the password "pass" sent via V1 of the "Kims Krappy Krypto" scheme.
|
||||
|
||||
seed() {
|
||||
// Examples of the word "pass" hashed by the client and then the server:
|
||||
// Note that the word "pass" has gajillions of hashed representations, all depending on the salts used to hash them.
|
||||
// "pass" hashed by client: KimsKrappyKryptoV1:userSalt:1000:SHA-256:b106e097f92ff7c288ac5048efb15f1a39a15e5d64261bbbe3f7eacee24b0ef4
|
||||
// "pass" hashed by server: 1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef
|
||||
//
|
||||
// Since the server-side hashes have random salts, the hashes themselves can change for the same password.
|
||||
// The client side hash must not have a random salt, otherwise, it must change every time.
|
||||
//
|
||||
// The hash below is just one has that represents the password "pass" sent via V1 of the "Kims Krappy Krypto" scheme.
|
||||
gGame.createPlayer(
|
||||
"user",
|
||||
"1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef",
|
||||
"userSalt",
|
||||
);
|
||||
|
||||
this.game.createPlayer(
|
||||
"user",
|
||||
"1000:15d79316f95ff6c89276308e4b9eb64d:2178d5ded9174c667fe0624690180012f13264a52900fe7067a13f235f4528ef",
|
||||
"userSalt",
|
||||
);
|
||||
|
||||
this.game.createPlayer(
|
||||
"admin",
|
||||
"1000:a84760824d28a9b420ee5f175a04d1e3:a6694e5c9fd41d8ee59f0a6e34c822ee2ce337c187e2d5bb5ba8612d6145aa8e",
|
||||
"adminSalt",
|
||||
);
|
||||
}
|
||||
gGame.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 path from "path";
|
||||
import fs from "fs";
|
||||
import { Game } from "./models/game.js";
|
||||
import * as msg from "./utils/messages.js";
|
||||
import { Session } from "./models/session.js";
|
||||
import { AuthState } from "./states/authState.js";
|
||||
import { GameSeeder } from "./seeders/gameSeeder.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 {
|
||||
/** @type {Xorshift32} */
|
||||
rng;
|
||||
|
||||
/** @param {number?} rngSeed seed for the pseudo-random number generator. */
|
||||
constructor(rngSeed = undefined) {
|
||||
/** @type {Game} */
|
||||
this.game = new GameSeeder().createGame(rngSeed || Date.now());
|
||||
constructor() {
|
||||
new GameSeeder().seed();
|
||||
}
|
||||
|
||||
// ____ ___ _ _ _ _ _____ ____ _____ _____ ____
|
||||
// / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \
|
||||
// | | | | | | \| | \| | _|| | | | | _| | | | |
|
||||
@@ -35,9 +29,11 @@ class MudServer {
|
||||
//------------------------------
|
||||
/** @param {WebSocket} websocket */
|
||||
onConnectionEstabished(websocket) {
|
||||
console.log("New connection established");
|
||||
const session = new Session(websocket, this.game);
|
||||
session.sendSystemMessage("dev", true);
|
||||
console.info("New connection established");
|
||||
const session = new Session(websocket, gGame);
|
||||
if (Config.dev) {
|
||||
websocket.send(msg.prepareToSend(msg.SYSTEM, "dev", true));
|
||||
}
|
||||
|
||||
// ____ _ ___ ____ _____
|
||||
// / ___| | / _ \/ ___|| ____|
|
||||
@@ -48,75 +44,36 @@ class MudServer {
|
||||
// Handle Socket Closing
|
||||
//----------------------
|
||||
websocket.on("close", () => {
|
||||
if (!session.player) {
|
||||
console.info("A player without a session disconnected");
|
||||
return;
|
||||
try {
|
||||
this.close(session);
|
||||
} 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) => {
|
||||
try {
|
||||
console.debug("incoming websocket message %s", data);
|
||||
|
||||
if (!session.state) {
|
||||
console.error("we received a message, but don't even have a state. Zark!");
|
||||
websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?"));
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
this.onMessage(session, data);
|
||||
} catch (error) {
|
||||
console.trace("received an invalid message (error: %s)", error, data.toString(), data);
|
||||
websocket.send(msg.prepare(msg.CALAMITY, error));
|
||||
console.error(error, data.toString(), data);
|
||||
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() {
|
||||
//
|
||||
// The file types we allow to be served.
|
||||
@@ -136,10 +93,11 @@ class MudServer {
|
||||
//
|
||||
// Check if the requested file has a legal file type.
|
||||
if (!contentType) {
|
||||
//
|
||||
// Invalid file, pretend it did not exist!
|
||||
res.writeHead(404);
|
||||
res.end(`File not found`);
|
||||
console.log("Bad http request", req.url);
|
||||
console.warn("Bad http request", req.url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,7 +107,7 @@ class MudServer {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end(`File not found`);
|
||||
console.log("Bad http request", req.url);
|
||||
console.warn("Bad http request", req.url);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
@@ -165,19 +123,109 @@ class MudServer {
|
||||
this.onConnectionEstabished(ws);
|
||||
});
|
||||
|
||||
console.info(`running environment: ${Config.env}`);
|
||||
console.info(`Environment: ${Config.env}`);
|
||||
httpServer.listen(Config.port, () => {
|
||||
console.log(`NUUHD server running on port ${Config.port}`);
|
||||
console.log(`WebSocket server ready for connections`);
|
||||
console.info(`NUUHD server running on port ${Config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// __ __ _____ ____ ____ _ ____ _____
|
||||
// | \/ | ____/ ___/ ___| / \ / ___| ____|
|
||||
// | |\/| | _| \___ \___ \ / _ \| | _| _|
|
||||
// | | | | |___ ___) |__) / ___ \ |_| | |___
|
||||
// |_| |_|_____|____/____/_/ \_\____|_____|
|
||||
//--------------------------------------------
|
||||
/**
|
||||
* 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
|
||||
//-----------------
|
||||
|
||||
@@ -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
|
||||
* or
|
||||
* Server-->Client-->Plater
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Message to be displayed.
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
export const MESSAGE = "m";
|
||||
/** @constant @readonly @type {string} _arr The array that contains the message data */
|
||||
type;
|
||||
|
||||
/**
|
||||
* Player has entered data, and sends it to server.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const REPLY = "reply";
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player wants to quit.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const QUIT = "quit";
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(msgData);
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Player wants help
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const HELP = "help";
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._data}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server tells the client to prompt the player for some data
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
export const PROMPT = "prompt";
|
||||
if (data.length < 1) {
|
||||
throw new Error(
|
||||
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Player has entered a command, and wants to do something.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const COMMAND = "c";
|
||||
this.type = mustBeString(data[0]);
|
||||
|
||||
/**
|
||||
* Server tells the client to prompt the player for some data
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
export const SYSTEM = "_";
|
||||
|
||||
/**
|
||||
* Debug message, to be completely ignored in production
|
||||
*
|
||||
* Client-->Server
|
||||
* or
|
||||
* Server-->Client-->Plater
|
||||
*/
|
||||
export const DEBUG = "dbg";
|
||||
|
||||
/**
|
||||
* Represents a message sent from client to server.
|
||||
*/
|
||||
export class ClientMessage {
|
||||
/**
|
||||
* @protected
|
||||
* @type {any[]} _arr The array that contains the message data
|
||||
*/
|
||||
_attr;
|
||||
|
||||
/** 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;
|
||||
switch (this.type) {
|
||||
case MsgContext.REPLY: // player ==> client ==> server
|
||||
this.text = mustBeString(data[1]);
|
||||
break;
|
||||
case HELP: // player ==> client ==> server
|
||||
this.text = data[1] === undefined ? "" : mustBeString(data[1]).trim();
|
||||
break;
|
||||
case COLON: // player ==> client ==> server
|
||||
this.command = mustMatch(data[1], /^[a-z0-9_]+$/);
|
||||
this.argLine = data[2]; // parse??
|
||||
break;
|
||||
case DEBUG: // server ==> client
|
||||
case MsgContext.ERROR: // server ==> client ==> player
|
||||
case QUIT: // player ==> client ==> server
|
||||
case SYSTEM: // client <==> server
|
||||
case PROMPT: // server ==> client ==> player
|
||||
case TEXT: // server ==> client ==> player
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown message type: >>${typeof this.type}<<`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this._attr = JSON.parse(msgData);
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
|
||||
);
|
||||
isQuit() {
|
||||
return this.type === QUIT;
|
||||
}
|
||||
|
||||
if (!Array.isArray(this._attr)) {
|
||||
throw new Error(
|
||||
`Could not create client message. Excpected an array, but got a ${typeof this._attr}`,
|
||||
);
|
||||
isHelp() {
|
||||
return this.type === HELP;
|
||||
}
|
||||
|
||||
if (this._attr.length < 1) {
|
||||
throw new Error(
|
||||
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
|
||||
);
|
||||
}
|
||||
}
|
||||
hasCommand() {
|
||||
return this._attr.length > 1 && this._attr[0] === COMMAND;
|
||||
}
|
||||
|
||||
/** Does this message contain a username-response from the client? */
|
||||
isUsernameResponse() {
|
||||
return (
|
||||
this._attr.length === 4 &&
|
||||
this._attr[0] === REPLY &&
|
||||
this._attr[1] === "username" &&
|
||||
typeof this._attr[2] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** Does this message contain a password-response from the client? */
|
||||
isPasswordResponse() {
|
||||
return (
|
||||
this._attr.length === 4 &&
|
||||
this._attr[0] === REPLY &&
|
||||
this._attr[1] === "password" &&
|
||||
typeof this._attr[2] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {boolean} does this message indicate the player wants to quit */
|
||||
isQuitCommand() {
|
||||
return this._attr[0] === QUIT;
|
||||
}
|
||||
|
||||
isHelpCommand() {
|
||||
return this._attr[0] === HELP;
|
||||
}
|
||||
|
||||
/** @returns {boolean} is this a debug message? */
|
||||
isDebug() {
|
||||
return this._attr.length === 2 && this._attr[0] === DEBUG;
|
||||
}
|
||||
|
||||
isIntegerResponse() {
|
||||
return (
|
||||
this._attr.length === 4 &&
|
||||
this._attr[0] === REPLY &&
|
||||
this._attr[1] === "integer" &&
|
||||
(typeof this._attr[2] === "string" ||
|
||||
typeof this._attr[2] === "number") &&
|
||||
Number.isInteger(Number(this._attr[2]))
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {number} integer */
|
||||
get integer() {
|
||||
if (!this.isIntegerResponse()) {
|
||||
return undefined;
|
||||
isColon() {
|
||||
return this.type === COLON;
|
||||
}
|
||||
|
||||
return Number.parseInt(this._attr[2]);
|
||||
}
|
||||
isReply() {
|
||||
return this.type === MsgContext.REPLY;
|
||||
}
|
||||
|
||||
/** @returns {string|false} Get the username stored in this message */
|
||||
get username() {
|
||||
return this.isUsernameResponse() ? this._attr[2] : false;
|
||||
}
|
||||
isSysMessage() {
|
||||
return this.type === SYSTEM;
|
||||
}
|
||||
|
||||
/** @returns {string|false} Get the password stored in this message */
|
||||
get password() {
|
||||
return this.isPasswordResponse() ? this._attr[2] : false;
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
get command() {
|
||||
return this.hasCommand() ? this._attr[1] : false;
|
||||
}
|
||||
isDebug() {
|
||||
return this.type === DEBUG;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +189,6 @@ export class ClientMessage {
|
||||
* @param {string} messageType
|
||||
* @param {...any} args
|
||||
*/
|
||||
export function prepare(messageType, ...args) {
|
||||
return JSON.stringify([messageType, ...args]);
|
||||
export function prepareToSend(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 {
|
||||
/* @type {number} */
|
||||
initialSeed;
|
||||
|
||||
/**
|
||||
* State holds a single uint32.
|
||||
* It's useful for staying within modulo 2**32.
|
||||
*
|
||||
* @type {Uint32Array}
|
||||
*/
|
||||
state;
|
||||
|
||||
/** @param {number} 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. */
|
||||
shuffle() {
|
||||
//
|
||||
// Run the actual xorshift32 algorithm
|
||||
let x = this.state;
|
||||
x ^= x << 13;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5;
|
||||
x = (x >>> 0) / 4294967296;
|
||||
console.log("RNG Shuffle: Initial State: %d", this.state);
|
||||
this.state[0] ^= this.state[0] << 13;
|
||||
this.state[0] ^= this.state[0] >>> 17;
|
||||
this.state[0] ^= this.state[0] << 5;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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