things + stff
This commit is contained in:
20
server/.vscode/launch.json
vendored
Executable file
20
server/.vscode/launch.json
vendored
Executable file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"env": {
|
||||
"NODE_ENV": "dev",
|
||||
},
|
||||
"program": "${workspaceFolder}/server.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
server/config.js
Executable file
5
server/config.js
Executable file
@@ -0,0 +1,5 @@
|
||||
export const ENV = process.env.NODE_ENV || "prod";
|
||||
export const DEV = ENV === "dev";
|
||||
export const PROD =!DEV;
|
||||
export const PORT = process.env.PORT || 3000;
|
||||
export const PARTY_MAX_SIZE = 4;
|
||||
50
server/find-element-width-in-chars.html
Normal file
50
server/find-element-width-in-chars.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!-- CHATGPT EXAMPLE -->
|
||||
<!-- 1. Get the width of the container (in pixels). -->
|
||||
<!-- 2. Get the width of a single character in the monospaced font (this can be done by creating a temporary element with the same font and measuring its width).-->
|
||||
<!-- 3. Divide the container's width by the character's width to get the number of characters. -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Measure Div Width in Characters</title>
|
||||
<style>
|
||||
.monospaced-div {
|
||||
font-family: "Courier New", Courier, monospace; /* Monospaced font */
|
||||
width: 360px; /* Example width in pixels */
|
||||
border: 1px solid #333;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="monospaced-div">
|
||||
This is a div with monospaced text.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getMonospacedCharCount(div) {
|
||||
// Create a temporary span to get the width of one character
|
||||
const testChar = document.createElement('span');
|
||||
testChar.textContent = '0'; // Monospaced fonts use "0" for width
|
||||
testChar.style.fontFamily = window.getComputedStyle(div).fontFamily;
|
||||
testChar.style.visibility = 'hidden'; // Hide the element
|
||||
document.body.appendChild(testChar);
|
||||
|
||||
const charWidth = testChar.offsetWidth; // Get width of a single character
|
||||
document.body.removeChild(testChar); // Remove the test element
|
||||
|
||||
// Get the width of the div and calculate how many characters fit
|
||||
const divWidth = div.offsetWidth;
|
||||
|
||||
// Return the number of characters that fit in the div width
|
||||
return Math.floor(divWidth / charWidth);
|
||||
}
|
||||
|
||||
const div = document.querySelector('.monospaced-div');
|
||||
const charCount = getMonospacedCharCount(div);
|
||||
console.log('Number of characters the div can hold:', charCount);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,7 +10,10 @@ export class Character {
|
||||
/** @type {string} character's name */
|
||||
name;
|
||||
|
||||
/** @protected @type {number} The number of XP the character has. */
|
||||
/**
|
||||
* @protected
|
||||
* @type {number} The number of XP the character has.
|
||||
*/
|
||||
_xp = 0;
|
||||
get xp() {
|
||||
return this._xp;
|
||||
@@ -61,15 +64,15 @@ export class Character {
|
||||
/**
|
||||
* @param {string} username The name of player who owns this character. Note that the game can own a character - somehow.
|
||||
* @param {string} name The name of the character
|
||||
* @param {boolean} initialize Should we initialize the character
|
||||
* @param {boolean=false} initialize Should we initialize the character
|
||||
*/
|
||||
constructor(playerUname, name, initialize) {
|
||||
constructor(username, name, initialize) {
|
||||
this.name = name;
|
||||
|
||||
// Initialize the unique name if this character.
|
||||
//
|
||||
// things to to hell if two characters with the same name are created at exactly the same time with the same random seed.
|
||||
this._id = id.fromName(playerUname, name);
|
||||
this._id = id.fromName(username, name);
|
||||
|
||||
// should we skip initialization of this object
|
||||
if (initialize !== true) {
|
||||
@@ -144,9 +147,7 @@ export class Character {
|
||||
this.meleeCombat = Math.max(this.skulduggery, 10);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
"Logic error, ancestry d8() roll was out of scope",
|
||||
);
|
||||
throw new Error("Logic error, ancestry d8() roll was out of scope");
|
||||
}
|
||||
|
||||
//
|
||||
@@ -172,11 +173,8 @@ export class Character {
|
||||
case 2:
|
||||
this.foundation = "druid";
|
||||
this.proficiencies.add("armor/natural");
|
||||
this.equipment
|
||||
.set("sickle", 1)
|
||||
.set("poisoner's kit", 1)
|
||||
.set("healer's kit", 1);
|
||||
default:
|
||||
this.equipment.set("sickle", 1).set("poisoner's kit", 1).set("healer's kit", 1);
|
||||
default: // case 2:
|
||||
this.foundation = "debug";
|
||||
this.proficiencies.add("heavy_armor");
|
||||
this.proficiencies.add("heavy_weapons");
|
||||
@@ -193,7 +191,3 @@ export class Character {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const c = new Character("username", "test", true);
|
||||
|
||||
console.log(c);
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
import WebSocket from "ws";
|
||||
import { Character } from "./character.js";
|
||||
import { ItemTemplate } from "./item.js";
|
||||
import { Player } from "./player.js";
|
||||
|
||||
export class Game {
|
||||
|
||||
/** @type {Map<string,ItemTemplate>} List of all item templates in the game */
|
||||
_itemTemplates = new Map();
|
||||
|
||||
|
||||
@@ -42,17 +42,13 @@ export class ItemTemplate {
|
||||
*/
|
||||
constructor(name, itemSlots, description, id) {
|
||||
if (typeof name !== "string") {
|
||||
throw new Error(
|
||||
"Name must be a string, but " + typeof name + " given.",
|
||||
);
|
||||
throw new Error("Name must be a string, but " + typeof name + " given.");
|
||||
}
|
||||
if (typeof description === "undefined") {
|
||||
description = "";
|
||||
}
|
||||
if (typeof description !== "string") {
|
||||
throw new Error(
|
||||
"Name must be a string, but " + typeof name + " given.",
|
||||
);
|
||||
throw new Error("Name must be a string, but " + typeof name + " given.");
|
||||
}
|
||||
if (!Number.isFinite(itemSlots)) {
|
||||
throw new Error("itemSlots must be a finite number!");
|
||||
@@ -71,12 +67,7 @@ export class ItemTemplate {
|
||||
}
|
||||
|
||||
createItem() {
|
||||
return new ChracterItem(
|
||||
this._id,
|
||||
this._name,
|
||||
this._description,
|
||||
this._itemSlots,
|
||||
);
|
||||
return new ChracterItem(this._id, this._name, this._description, this._itemSlots);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +110,3 @@ export class CharacterItem {
|
||||
this.itemSlots = itemSlots;
|
||||
}
|
||||
}
|
||||
|
||||
const i = new ItemTemplate("knife", 10000);
|
||||
|
||||
const ci = new CharacterItem();
|
||||
console.log(ci);
|
||||
|
||||
@@ -1,103 +1,48 @@
|
||||
import WebSocket from "ws";
|
||||
import { Character } from "./character.js";
|
||||
|
||||
/**
|
||||
* Player Account.
|
||||
*
|
||||
* 1. Contain persistent player account info.
|
||||
* 2. Contain the connection to the client machine if the player is currently playing the game.
|
||||
* 3. Contain session information.
|
||||
*
|
||||
* We can do this because we only allow a single websocket per player account.
|
||||
* You are not allowed to log in if a connection/socket is already open.
|
||||
*
|
||||
* We regularly ping and pong to ensure that stale connections are closed.
|
||||
*
|
||||
* Contain persistent player account info.
|
||||
*/
|
||||
export class Player {
|
||||
/** @protected @type {string} unique username */
|
||||
/**
|
||||
* @protected
|
||||
* @type {string} unique username
|
||||
*/
|
||||
_username;
|
||||
get username() {
|
||||
return this._username;
|
||||
}
|
||||
|
||||
/** @protected @type {string} */
|
||||
/**
|
||||
* @protected
|
||||
* @type {string}
|
||||
*/
|
||||
_passwordHash;
|
||||
get passwordHash() {
|
||||
return this._passwordHash;
|
||||
}
|
||||
|
||||
/** @protected @type {WebSocket} Player's current and only websocket. If undefined, the player is not logged in. */
|
||||
_websocket;
|
||||
get websocket() {
|
||||
return this._websocket;
|
||||
/** @protected @type {Set<Character>} */
|
||||
_characters = new Set();
|
||||
get characters() {
|
||||
return this._characters;
|
||||
}
|
||||
|
||||
/** @protected @type {Date} */
|
||||
_latestSocketReceived;
|
||||
|
||||
/**
|
||||
* @param {string} username
|
||||
* @param {string} passwordHash
|
||||
*/
|
||||
constructor(username, passwordHash) {
|
||||
this._username = username;
|
||||
this._passwordHash = passwordHash;
|
||||
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
|
||||
/** @param {WebSocket} websocket */
|
||||
clientConnected(websocket) {
|
||||
this._websocket = websocket;
|
||||
}
|
||||
|
||||
/***
|
||||
* Send a message back to the client via the WebSocket.
|
||||
*
|
||||
* @param {string} message
|
||||
* @return {boolean} success
|
||||
*/
|
||||
_send(data) {
|
||||
if (!this._websocket) {
|
||||
console.error(
|
||||
"Trying to send a message to an uninitialized websocket",
|
||||
this,
|
||||
data,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (this._websocket.readyState === WebSocket.OPEN) {
|
||||
this._websocket.send(JSON.stringify(data));
|
||||
return true;
|
||||
}
|
||||
if (this._websocket.readyState === WebSocket.CLOSED) {
|
||||
console.error(
|
||||
"Trying to send a message through a CLOSED websocket",
|
||||
this,
|
||||
data,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (this._websocket.readyState === WebSocket.CLOSING) {
|
||||
console.error(
|
||||
"Trying to send a message through a CLOSING websocket",
|
||||
this,
|
||||
data,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (this._websocket.readyState === WebSocket.CONNECTING) {
|
||||
console.error(
|
||||
"Trying to send a message through a CONNECTING (not yet open) websocket",
|
||||
this,
|
||||
data,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Trying to send a message through a websocket with an UNKNOWN readyState (%d)",
|
||||
this.websocket.readyState,
|
||||
this,
|
||||
data,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
sendPrompt() {
|
||||
this.sendMessage(`\n[${this.currentRoom}] > `);
|
||||
setPasswordHash(hashedPassword) {
|
||||
this._passwordHash = hashedPassword;
|
||||
}
|
||||
}
|
||||
|
||||
102
server/models/session.js
Executable file
102
server/models/session.js
Executable file
@@ -0,0 +1,102 @@
|
||||
import WebSocket from 'ws';
|
||||
import { Game } from './game.js';
|
||||
import { Player } from './player.js';
|
||||
import { StateInterface } from './states/interface.js';
|
||||
import * as msg from '../utils/messages.js';
|
||||
import figlet from 'figlet';
|
||||
|
||||
export class Session {
|
||||
|
||||
/** @protected @type {StateInterface} */
|
||||
_state;
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/** @protected @type {Game} */
|
||||
_game;
|
||||
get game() {
|
||||
return this._game;
|
||||
}
|
||||
|
||||
/** @type Date */
|
||||
latestPing;
|
||||
|
||||
/** @type {Player} */
|
||||
player;
|
||||
|
||||
/** @type {WebSocket} */
|
||||
websocket;
|
||||
|
||||
/**
|
||||
* @param {WebSocket} websocket
|
||||
* @param {Game} game
|
||||
*/
|
||||
constructor(websocket, game) {
|
||||
this.websocket = websocket;
|
||||
this._game = game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message via our websocket.
|
||||
*
|
||||
* @param {string|number} messageType
|
||||
* @param {...any} args
|
||||
*/
|
||||
send(messageType, ...args) {
|
||||
this.websocket.send(JSON.stringify([messageType, ...args]));
|
||||
}
|
||||
|
||||
sendFigletMessage(message) {
|
||||
console.debug("sendFigletMessage('%s')", message);
|
||||
this.sendMessage(figlet.textSync(message), { preformatted: true });
|
||||
}
|
||||
|
||||
/** @param {string} message Message to display to player */
|
||||
sendMessage(message, ...args) {
|
||||
if (message.length === 0) {
|
||||
console.debug("sending a zero-length message, weird");
|
||||
}
|
||||
if (Array.isArray(message)) {
|
||||
message = message.join("\n");
|
||||
}
|
||||
this.send(msg.MESSAGE, message, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type prompt type (username, password, character name, etc.)
|
||||
* @param {string} message The prompting message (please enter your character's name)
|
||||
*/
|
||||
sendPrompt(type, message,...args) {
|
||||
if (Array.isArray(message)) {
|
||||
message = message.join("\n");
|
||||
}
|
||||
this.send(msg.PROMPT, type, message,...args);
|
||||
}
|
||||
|
||||
/** @param {string} message The error message to display to player */
|
||||
sendError(message,...args) {
|
||||
this.send(msg.ERROR, 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
158
server/models/states/auth.js
Executable file
158
server/models/states/auth.js
Executable file
@@ -0,0 +1,158 @@
|
||||
import { Session } from "../session.js";
|
||||
import * as msg from "../../utils/messages.js";
|
||||
import * as security from "../../utils/security.js";
|
||||
import { JustLoggedInState } from "./justLoggedIn.js";
|
||||
import { CreatePlayerState } from "./createPlayer.js";
|
||||
|
||||
const STATE_EXPECT_USERNAME = "promptUsername";
|
||||
const STATE_EXPECT_PASSWORD = "promptPassword";
|
||||
const USERNAME_PROMPT = [
|
||||
"Please enter your username",
|
||||
"((type *:help* for help))",
|
||||
"((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()) {
|
||||
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 cfg/env
|
||||
this.session.setState(new CreatePlayerState(this.session));
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// do basic syntax checks on usernames
|
||||
if (!security.isUsernameSane(message.username)) {
|
||||
this.session.sendError(ERROR_INSANE_USERNAME);
|
||||
this.session.sendPrompt("username", USERNAME_PROMPT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.session.game.players.size === 0) {
|
||||
console.error("there are no players registered");
|
||||
}
|
||||
|
||||
const player = this.session.game.players.get(message.username);
|
||||
|
||||
//
|
||||
// handle invalid username
|
||||
if (!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.session.player = player;
|
||||
this.subState = STATE_EXPECT_PASSWORD;
|
||||
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;
|
||||
}
|
||||
|
||||
//
|
||||
// Verify the password against the hash we've stored.
|
||||
if (!security.verifyPassword(message.password, this.session.player.passwordHash)) {
|
||||
this.session.sendError("Incorrect password!");
|
||||
this.session.sendPrompt("password", PASSWORD_PROMPT);
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Password correct, check if player is an admin
|
||||
if (this.session.player.isAdmin) {
|
||||
// set state AdminJustLoggedIn
|
||||
}
|
||||
|
||||
//
|
||||
// Password was correct, go to main game
|
||||
this.session.setState(new JustLoggedInState(this.session));
|
||||
}
|
||||
}
|
||||
50
server/models/states/awaitCommands.js
Normal file
50
server/models/states/awaitCommands.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as msg from "../../utils/messages.js";
|
||||
import { Session } from "../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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
server/models/states/characterCreation.js
Executable file
105
server/models/states/characterCreation.js
Executable file
@@ -0,0 +1,105 @@
|
||||
import figlet from "figlet";
|
||||
import { Session } from "../session.js";
|
||||
import WebSocket from "ws";
|
||||
import { AuthState } from "./auth.js";
|
||||
import { ClientMessage } from "../../utils/messages.js";
|
||||
import { PARTY_MAX_SIZE } from "../../config.js";
|
||||
import { frameText } from "../../utils/tui.js";
|
||||
|
||||
export class CharacterCreationState {
|
||||
|
||||
/**
|
||||
* @proteted
|
||||
* @type {(msg: ClientMessage) => }
|
||||
*
|
||||
* NOTE: Should this be a stack?
|
||||
*/
|
||||
_dynamicMessageHandler;
|
||||
|
||||
/**
|
||||
* @param {Session} session
|
||||
*/
|
||||
constructor(session) {
|
||||
/** @type {Session} */
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* We attach (and execute) the next state
|
||||
*/
|
||||
onAttach() {
|
||||
const charCount = this.session.player.characters.size;
|
||||
|
||||
const createPartyLogo = frameText(
|
||||
figlet.textSync("Create Your Party"),
|
||||
{ hPadding: 2, vPadding: 1, hMargin: 2, vMargin: 1 },
|
||||
);
|
||||
|
||||
this.session.sendMessage(createPartyLogo, {preformatted:true});
|
||||
|
||||
this.session.sendMessage([
|
||||
"",
|
||||
`Current party size: ${charCount}`,
|
||||
`Max party size: ${PARTY_MAX_SIZE}`,
|
||||
]);
|
||||
|
||||
const min = 1;
|
||||
const max = PARTY_MAX_SIZE - charCount;
|
||||
const prompt = `Please enter an integer between ${min} - ${max} (or 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) => {
|
||||
const n = PARTY_MAX_SIZE;
|
||||
if (message.isHelpCommand()) {
|
||||
this.session.sendMessage([
|
||||
`Your party can consist of 1 to ${n} characters.`,
|
||||
"",
|
||||
"* Large parties tend live longer",
|
||||
`* If you have fewer than ${n} 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);
|
||||
}
|
||||
}
|
||||
167
server/models/states/createPlayer.js
Executable file
167
server/models/states/createPlayer.js
Executable file
@@ -0,0 +1,167 @@
|
||||
import WebSocket from "ws";
|
||||
import { Session } from "../session.js";
|
||||
import * as msg from "../../utils/messages.js";
|
||||
import * as security from "../../utils/security.js";
|
||||
import { AuthState } from "./auth.js";
|
||||
import { Player } from "../player.js";
|
||||
|
||||
const USERNAME_PROMPT = "Enter a valid username (4-20 characters, [a-z], [A-Z], [0-9], and underscore)";
|
||||
const PASSWORD_PROMPT = "Enter a valid password";
|
||||
const PASSWORD_PROMPT2 = "Enter your password again";
|
||||
const ERROR_INSANE_PASSWORD = "Invalid password.";
|
||||
const ERROR_INSANE_USERNAME = "Invalid username. It must be 4-20 characters, and may only contain [a-z], [A-Z], [0-9] and underscore"
|
||||
const ERROR_INCORRECT_PASSWOD = "Incorrect password.";
|
||||
|
||||
/** @property {Session} session */
|
||||
export class CreatePlayerState {
|
||||
|
||||
/**
|
||||
* @proteted
|
||||
* @type {(msg: ClientMessage) => }
|
||||
*
|
||||
* Allows us to dynamically set which
|
||||
* method handles incoming messages.
|
||||
*/
|
||||
_dynamicMessageHandler;
|
||||
|
||||
/** @protected @type {Player} */
|
||||
_player;
|
||||
/** @protected @type {string} */
|
||||
_password;
|
||||
|
||||
/**
|
||||
* @param {Session} session
|
||||
*/
|
||||
constructor(session) {
|
||||
/** @type {Session} */
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
onAttach() {
|
||||
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 taken = this.session.game.players.has(message.username);
|
||||
|
||||
//
|
||||
// handle taken/occupied username
|
||||
if (taken) {
|
||||
// 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 = new Player(message.username, undefined);
|
||||
|
||||
// _____ _
|
||||
// | ___(_)_ ___ __ ___ ___
|
||||
// | |_ | \ \/ / '_ ` _ \ / _ \
|
||||
// | _| | |> <| | | | | | __/
|
||||
// |_| |_/_/\_\_| |_| |_|\___|
|
||||
//
|
||||
// 1. Add mutex on the players table to avoid race conditions
|
||||
// 2. Prune "dead" players (players with 0 logins) after a short while
|
||||
this.session.game.players.set(message.username, this._player);
|
||||
this.session.sendMessage("Username available");
|
||||
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));
|
||||
}
|
||||
}
|
||||
13
server/models/states/interface.js
Executable file
13
server/models/states/interface.js
Executable file
@@ -0,0 +1,13 @@
|
||||
import { ClientMessage } from "../../utils/messages.js";
|
||||
import { Session } from "../session.js";
|
||||
|
||||
/** @interface */
|
||||
export class StateInterface {
|
||||
/** @param {Session} session */
|
||||
constructor(session) { }
|
||||
|
||||
onAttach() { }
|
||||
|
||||
/** @param {ClientMessage} message */
|
||||
onMessage(message) {}
|
||||
}
|
||||
36
server/models/states/justLoggedIn.js
Executable file
36
server/models/states/justLoggedIn.js
Executable file
@@ -0,0 +1,36 @@
|
||||
import { Session } from "../session.js";
|
||||
import { ClientMessage } from "../../utils/messages.js";
|
||||
import { CharacterCreationState } from "./characterCreation.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 CharacterCreationState(this.session));
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.setState(new AwaitCommandsState(this.session));
|
||||
}
|
||||
}
|
||||
13
server/package-lock.json
generated
13
server/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"figlet": "^1.8.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -127,6 +128,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/figlet": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.2.tgz",
|
||||
"integrity": "sha512-iPCpE9B/rOcjewIzDnagP9F2eySzGeHReX8WlrZQJkqFBk2wvq8gY0c6U6Hd2y9HnX1LQcYSeP7aEHoPt6sVKQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"figlet": "bin/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"start": "NODE_ENV=prod node server.js",
|
||||
"dev": "NODE_ENV=dev nodemon server.js"
|
||||
},
|
||||
"keywords": [
|
||||
"mud",
|
||||
@@ -17,6 +17,7 @@
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"figlet": "^1.8.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
330
server/public/client.js
Executable file
330
server/public/client.js
Executable file
@@ -0,0 +1,330 @@
|
||||
class MUDClient {
|
||||
|
||||
constructor() {
|
||||
/** @type {WebSocket} ws */
|
||||
this.websocket = null;
|
||||
|
||||
/** @type {boolean} Are we in development mode (decided by the server);
|
||||
this.dev = false;
|
||||
|
||||
/**
|
||||
* The last thing we were asked.
|
||||
* @type {string|null}
|
||||
*/
|
||||
this.serverExpects = null;
|
||||
this.output = document.getElementById("output");
|
||||
this.input = document.getElementById("input");
|
||||
this.sendButton = document.getElementById("send");
|
||||
this.status = document.getElementById("status");
|
||||
|
||||
// Passwords are crypted and salted before being sent to the server
|
||||
// This means that if ANY of these three parameters below change,
|
||||
// The server can no longer accept the passwords.
|
||||
this.digest = "SHA-256";
|
||||
this.salt = "V1_Kims_Krappy_Krypto";
|
||||
this.rounds = 1000;
|
||||
|
||||
this.username = ""; // the username also salts the password, so the username must never change.
|
||||
|
||||
this.setupEventListeners();
|
||||
this.connect();
|
||||
}
|
||||
async hashPassword(password) {
|
||||
const encoder = new TextEncoder();
|
||||
let data = encoder.encode(password + this.salt + this.username);
|
||||
|
||||
for (let i = 0; i < this.rounds; i++) {
|
||||
const hashBuffer = await crypto.subtle.digest(this.digest, data);
|
||||
data = new Uint8Array(hashBuffer); // feed hash back in
|
||||
}
|
||||
|
||||
// Convert final hash to hex
|
||||
const rawHash = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return `${this.salt}:${this.rounds}:${this.digest}:${rawHash}`;
|
||||
}
|
||||
|
||||
connect() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}`;
|
||||
|
||||
this.updateStatus("Connecting...", "connecting");
|
||||
|
||||
try {
|
||||
this.websocket = new WebSocket(wsUrl);
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
this.updateStatus("Connected", "connected");
|
||||
this.input.disabled = false;
|
||||
this.sendButton.disabled = false;
|
||||
this.input.focus();
|
||||
this.output.innerHTML = '';
|
||||
};
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
console.log(event);
|
||||
const data = JSON.parse(event.data);
|
||||
this.onMessage(data);
|
||||
this.input.focus();
|
||||
};
|
||||
|
||||
this.websocket.onclose = () => {
|
||||
this.updateStatus("Disconnected", "disconnected");
|
||||
this.input.disabled = true;
|
||||
this.sendButton.disabled = true;
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
|
||||
this.websocket.onerror = (error) => {
|
||||
this.updateStatus("Connection Error", "error");
|
||||
this.appendOutput("Connection error occurred. Retrying...", { class: "error" });
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.updateStatus("Connection Failed", "error");
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.input.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
this.sendButton.addEventListener("click", () => {
|
||||
this.sendMessage();
|
||||
});
|
||||
|
||||
// Command history
|
||||
this.commandHistory = [];
|
||||
this.historyIndex = -1;
|
||||
|
||||
this.input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (this.historyIndex < this.commandHistory.length - 1) {
|
||||
this.historyIndex++;
|
||||
this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex];
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (this.historyIndex > 0) {
|
||||
this.historyIndex--;
|
||||
this.input.value = this.commandHistory[this.commandHistory.length - 1 - this.historyIndex];
|
||||
} else if (this.historyIndex === 0) {
|
||||
this.historyIndex = -1;
|
||||
this.input.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage() {
|
||||
const message = this.input.value.trim();
|
||||
|
||||
// -- 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 (message === "/clear") {
|
||||
this.output.innerHTML = "";
|
||||
this.input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (message && this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
// Add to command history
|
||||
if (this.commandHistory[this.commandHistory.length - 1] !== message) {
|
||||
this.commandHistory.push(message);
|
||||
if (this.commandHistory.length > 50) {
|
||||
this.commandHistory.shift();
|
||||
}
|
||||
}
|
||||
this.historyIndex = -1;
|
||||
this.input.value = "";
|
||||
this.input.type = "text";
|
||||
|
||||
if (this.serverExpects === "password") {
|
||||
//--------------------------------------------------
|
||||
// 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.
|
||||
//--------------------------------------------------
|
||||
this.hashPassword(message).then((pwHash) => {
|
||||
this.websocket.send(JSON.stringify(["reply", "password", pwHash]))
|
||||
this.serverExpects = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.appendOutput("> " + message, { class: "input" });
|
||||
|
||||
if (message === ":quit") {
|
||||
this.websocket.send(JSON.stringify(["quit"]));
|
||||
return;
|
||||
}
|
||||
if (message === ":help") {
|
||||
this.websocket.send(JSON.stringify(["help"]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.serverExpects === "username") {
|
||||
//--------------------------------------------------
|
||||
// The server asked us for a user, so we send it.
|
||||
// We also store the username for later
|
||||
//--------------------------------------------------
|
||||
this.username = message;
|
||||
this.websocket.send(JSON.stringify(["reply", "username", message]))
|
||||
this.serverExpects = null;
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (this.serverExpects) {
|
||||
//--------------------------------------------------
|
||||
// The server asked the player a question,
|
||||
// so we send the answer the way the server wants.
|
||||
//--------------------------------------------------
|
||||
this.websocket.send(JSON.stringify(["reply", this.serverExpects, message]))
|
||||
this.serverExpects = null;
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
//-----------------------------------------------------
|
||||
// The player sends a text-based command to the server
|
||||
//-----------------------------------------------------
|
||||
this.websocket.send(JSON.stringify(["c", message]));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// ___ __ __
|
||||
// / _ \ _ __ | \/ | ___ ___ ___ __ _ __ _ ___
|
||||
// | | | | '_ \| |\/| |/ _ \/ __/ __|/ _` |/ _` |/ _ \
|
||||
// | |_| | | | | | | | __/\__ \__ \ (_| | (_| | __/
|
||||
// \___/|_| |_|_| |_|\___||___/___/\__,_|\__, |\___|
|
||||
//
|
||||
/** @param {any[]} data*/
|
||||
onMessage(data) {
|
||||
console.log(data);
|
||||
switch (data[0]) {
|
||||
case "prompt":
|
||||
this.serverExpects = data[1];
|
||||
this.appendOutput(data[2], { class: "prompt" });
|
||||
if (this.serverExpects === "password") {
|
||||
this.input.type = "password";
|
||||
}
|
||||
break;
|
||||
case "e": // error
|
||||
this.appendOutput(data[1], { class: "error" });
|
||||
break;
|
||||
case "calamity":
|
||||
this.appendOutput(data[1], { class: "error" });
|
||||
break;
|
||||
case "_": // system messages, not to be displayed
|
||||
if (data.length === 3 && data[1] === "dev") {
|
||||
this.dev = data[2];
|
||||
}
|
||||
|
||||
if (this.dev) {
|
||||
this.appendOutput(`system message: ${data[1]} = ${JSON.stringify(data[2])}`, { class: "debug" });
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
// normal text message to be shown to the player
|
||||
// formatting magic is allowed.
|
||||
//
|
||||
// TODO: styling, font size, etc.
|
||||
const args = typeof (data[2] === "object") ? data[2] : {};
|
||||
this.appendOutput(data[1], args);
|
||||
break;
|
||||
|
||||
this.appendOutput(data[1], {preformatted:true})
|
||||
default:
|
||||
if (this.dev) {
|
||||
msgType = data.shift();
|
||||
this.appendOutput(`unknown message type: ${msgType}: ${JSON.stringify(data)}`, "debug");
|
||||
}
|
||||
console.log("unknown message type", data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add output to the text.
|
||||
* @param {string} text
|
||||
* @param {object} options
|
||||
*/
|
||||
appendOutput(text, options = {}) {
|
||||
const el = document.createElement("span");
|
||||
|
||||
if (typeof options.class === "string") {
|
||||
el.className = options.class;
|
||||
}
|
||||
|
||||
|
||||
// Enter prompt answers on the same line as the prompt?
|
||||
// if (className !== "prompt") {
|
||||
// el.textContent = text + "\n";
|
||||
// } else {
|
||||
// el.textContent = text + " ";
|
||||
// }
|
||||
|
||||
// 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;
|
||||
} else {
|
||||
el.innerHTML = parseCrackdown(text) + eol;
|
||||
}
|
||||
this.output.appendChild(el);
|
||||
this.output.scrollTop = this.output.scrollHeight;
|
||||
}
|
||||
|
||||
updateStatus(message, className) {
|
||||
this.status.textContent = `Status: ${message}`;
|
||||
this.status.className = className;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the MUD client when the page loads
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
new MUDClient();
|
||||
});
|
||||
|
||||
function parseCrackdown(text) {
|
||||
console.log("starting crack parsing");
|
||||
console.log(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.log("crack output", text);
|
||||
|
||||
return text;
|
||||
|
||||
}
|
||||
@@ -4,201 +4,20 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebSocket MUD</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<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"
|
||||
id="input"
|
||||
placeholder="Enter command..."
|
||||
disabled
|
||||
/>
|
||||
<input type="text" autocomplete="off" id="input" placeholder="Enter command..." disabled />
|
||||
<button id="send" disabled>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class MUDClient {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.output = document.getElementById("output");
|
||||
this.input = document.getElementById("input");
|
||||
this.sendButton = document.getElementById("send");
|
||||
this.status = document.getElementById("status");
|
||||
|
||||
this.setupEventListeners();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
const protocol =
|
||||
window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}`;
|
||||
|
||||
this.updateStatus("Connecting...", "connecting");
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.updateStatus("Connected", "connected");
|
||||
this.input.disabled = false;
|
||||
this.sendButton.disabled = false;
|
||||
this.input.focus();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
console.log(event);
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.updateStatus("Disconnected", "disconnected");
|
||||
this.input.disabled = true;
|
||||
this.sendButton.disabled = true;
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this.updateStatus("Connection Error", "error");
|
||||
this.appendOutput(
|
||||
"Connection error occurred. Retrying...",
|
||||
"error",
|
||||
);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.updateStatus("Connection Failed", "error");
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.input.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
this.sendButton.addEventListener("click", () => {
|
||||
this.sendMessage();
|
||||
});
|
||||
|
||||
// Command history
|
||||
this.commandHistory = [];
|
||||
this.historyIndex = -1;
|
||||
|
||||
this.input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (
|
||||
this.historyIndex <
|
||||
this.commandHistory.length - 1
|
||||
) {
|
||||
this.historyIndex++;
|
||||
this.input.value =
|
||||
this.commandHistory[
|
||||
this.commandHistory.length -
|
||||
1 -
|
||||
this.historyIndex
|
||||
];
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (this.historyIndex > 0) {
|
||||
this.historyIndex--;
|
||||
this.input.value =
|
||||
this.commandHistory[
|
||||
this.commandHistory.length -
|
||||
1 -
|
||||
this.historyIndex
|
||||
];
|
||||
} else if (this.historyIndex === 0) {
|
||||
this.historyIndex = -1;
|
||||
this.input.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage() {
|
||||
const message = this.input.value.trim();
|
||||
if (
|
||||
message &&
|
||||
this.ws &&
|
||||
this.ws.readyState === WebSocket.OPEN
|
||||
) {
|
||||
// Add to command history
|
||||
if (
|
||||
this.commandHistory[
|
||||
this.commandHistory.length - 1
|
||||
] !== message
|
||||
) {
|
||||
this.commandHistory.push(message);
|
||||
if (this.commandHistory.length > 50) {
|
||||
this.commandHistory.shift();
|
||||
}
|
||||
}
|
||||
this.historyIndex = -1;
|
||||
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "command",
|
||||
content: message,
|
||||
}),
|
||||
);
|
||||
|
||||
this.input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
console.log(data);
|
||||
switch (data[0]) {
|
||||
case "error":
|
||||
this.appendOutput(data[1], "error");
|
||||
break;
|
||||
case "system":
|
||||
this.appendOutput(data[1], "system");
|
||||
break;
|
||||
default:
|
||||
this.appendOutput(data[1]);
|
||||
}
|
||||
}
|
||||
|
||||
appendOutput(text, className = "") {
|
||||
const div = document.createElement("div");
|
||||
if (className) {
|
||||
div.className = className;
|
||||
}
|
||||
|
||||
// Check if this looks like a prompt
|
||||
if (text.includes("] > ")) {
|
||||
div.className = "prompt";
|
||||
}
|
||||
|
||||
div.textContent = text;
|
||||
this.output.appendChild(div);
|
||||
this.output.scrollTop = this.output.scrollHeight;
|
||||
}
|
||||
|
||||
updateStatus(message, className) {
|
||||
this.status.textContent = `Status: ${message}`;
|
||||
this.status.className = className;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the MUD client when the page loads
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
new MUDClient();
|
||||
});
|
||||
</script>
|
||||
<script src="client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
57
server/public/style.css
Normal file → Executable file
57
server/public/style.css
Normal file → Executable file
@@ -1,21 +1,29 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: "Courier New", monospace;
|
||||
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;
|
||||
}
|
||||
|
||||
#container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 1200px;
|
||||
max-width: 99.9vw;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#output {
|
||||
@@ -25,9 +33,12 @@ body {
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
width: 100ch;
|
||||
}
|
||||
|
||||
#input-container {
|
||||
@@ -41,7 +52,8 @@ body {
|
||||
border: 2px solid #333;
|
||||
color: #00ff00;
|
||||
padding: 10px;
|
||||
font-family: "Courier New", monospace;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -55,7 +67,8 @@ body {
|
||||
border: 2px solid #555;
|
||||
color: #00ff00;
|
||||
padding: 10px 20px;
|
||||
font-family: "Courier New", monospace;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-optical-sizing: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -86,10 +99,38 @@ body {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.system {
|
||||
color: #aaaaaa;
|
||||
.input {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.debug {
|
||||
opacity: 0.33;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: #00ccff;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.strike {
|
||||
text-decoration:line-through;
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.undercurl {
|
||||
text-decoration: wavy underline lime;
|
||||
}
|
||||
|
||||
.faint {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
37
server/seed.js
Executable file
37
server/seed.js
Executable file
@@ -0,0 +1,37 @@
|
||||
import { Game } from "./models/game.js";
|
||||
import { Player } from "./models/player.js";
|
||||
|
||||
// ____ _____ _____ ____ _____ ____
|
||||
// / ___|| ____| ____| _ \| ____| _ \
|
||||
// \___ \| _| | _| | | | | _| | |_) |
|
||||
// ___) | |___| |___| |_| | |___| _ <
|
||||
// |____/|_____|_____|____/|_____|_| \_\
|
||||
//
|
||||
/** @param {Game} game */
|
||||
export class Seeder {
|
||||
seed(game) {
|
||||
/** @protected @type {Game} */
|
||||
this.game = game;
|
||||
|
||||
this.createPlayers();
|
||||
}
|
||||
|
||||
/** @protected */
|
||||
createPlayers() {
|
||||
// "pass" encrypted by client is:
|
||||
// "V1_Kims_Krappy_Krypto:1000:SHA-256:8bdff92251f55df078f7a12446748fbeeb308991008096bf2eed3fd8926d0301"
|
||||
// "pass" encrypted by client and then by server is:
|
||||
// "1000:833d63b13a187a0d8950c83ad6d955b9:4bdc9981dd245e7c77949e0166094264f98c62ae9f4f5ebbcda50728bbb8b080"
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
const passwordHash = "1000:bdaa0d7436caeaa4d278e7591870b68c:151b8f7e73a97a01af190a51b45ee389c2f4590a6449ddae6f25b9eab49cac0d";
|
||||
const player = new Player("user", passwordHash);
|
||||
this.game.players.set("user", player);
|
||||
|
||||
// const char = new Character(player.username, "Sir Debug The Strong", true);
|
||||
}
|
||||
}
|
||||
384
server/server.js
384
server/server.js
@@ -2,255 +2,183 @@ import WebSocket, { WebSocketServer } from "ws";
|
||||
import http from "http";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { Player } from "./models/player.js";
|
||||
import { Game } from "./models/game.js";
|
||||
import { ClientMessage, MSG_ERROR, MSG_MESSAGE, MSG_PROMPT, MSG_CALAMITY, } from "./utils/messages.js";
|
||||
|
||||
class Session {
|
||||
/** @type {boolean} */
|
||||
usernameProcessed = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
passwordProcessed = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
ready = false;
|
||||
|
||||
/** @type Date */
|
||||
latestPing;
|
||||
|
||||
/** @type {Player} */
|
||||
player;
|
||||
}
|
||||
import * as msg from "./utils/messages.js";
|
||||
import * as cfg from "./utils/config.js";
|
||||
import { Session } from "./models/session.js";
|
||||
import { Seeder } from "./seed.js";
|
||||
import { AuthState } from "./models/states/auth.js";
|
||||
|
||||
class MudServer {
|
||||
/** @type {Map<WebSocket,Session>} */
|
||||
sessions = new Map();
|
||||
|
||||
/** @type {Game} */
|
||||
game = new Game();
|
||||
|
||||
/**
|
||||
* Send a message via a websocket.
|
||||
*
|
||||
* @param {WebSocket} websocket
|
||||
* @param {string|number} messageType
|
||||
* @param {...any} args
|
||||
*/
|
||||
send(websocket, messageType, ...args) {
|
||||
// create array consisting of [messageType, args[0], args[1], ... ];
|
||||
websocket.send(JSON.stringify([messageType, ...args]));
|
||||
constructor() {
|
||||
/** @type {Game} */
|
||||
this.game = new Game();
|
||||
if (cfg.DEV) {
|
||||
(new Seeder()).seed(this.game);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WebSocket} websocket
|
||||
*/
|
||||
// ____ ___ _ _ _ _ _____ ____ _____ _____ ____
|
||||
// / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \
|
||||
// | | | | | | \| | \| | _|| | | | | _| | | | |
|
||||
// | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| |
|
||||
// \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/
|
||||
//------------------------------------------------------
|
||||
// Handle New Socket Connections
|
||||
//------------------------------
|
||||
/** @param {WebSocket} websocket */
|
||||
onConnectionEstabished(websocket) {
|
||||
console.log("New connection established");
|
||||
this.sessions[websocket] = new Session();
|
||||
const session = new Session(websocket, this.game);
|
||||
session.sendSystemMessage("dev", true)
|
||||
|
||||
websocket.on("message", (data) => {
|
||||
this.onIncomingMessage(websocket, data);
|
||||
});
|
||||
// ____ _ ___ ____ _____
|
||||
// / ___| | / _ \/ ___|| ____|
|
||||
// | | | | | | | \___ \| _|
|
||||
// | |___| |__| |_| |___) | |___
|
||||
// \____|_____\___/|____/|_____|
|
||||
//-------------------------------
|
||||
// Handle Socket Closing
|
||||
//----------------------
|
||||
websocket.on("close", () => {
|
||||
this.onConnectionClosed(websocket);
|
||||
if (!session.player) {
|
||||
console.info("A player without a session disconnected");
|
||||
return;
|
||||
}
|
||||
//-------------
|
||||
// TODO
|
||||
//-------------
|
||||
// Handle player logout (move the or hide their characters)
|
||||
//
|
||||
// Maybe session.onConnectionClosed() that calls session._state.onConnectionClosed()
|
||||
// Maybe this.setState(new ConnectionClosedState());
|
||||
// Maybe both ??
|
||||
console.log(`Player ${session.player.username} disconnected`);
|
||||
|
||||
});
|
||||
|
||||
this.send(websocket, MSG_MESSAGE, "Welcome to MUUUHD", "big");
|
||||
this.send(websocket, MSG_PROMPT, "Please enter your username");
|
||||
// __ __ _____ ____ ____ _ ____ _____
|
||||
// | \/ | ____/ ___/ ___| / \ / ___| ____|
|
||||
// | |\/| | _| \___ \___ \ / _ \| | _| _|
|
||||
// | | | | |___ ___) |__) / ___ \ |_| | |___
|
||||
// |_| |_|_____|____/____/_/ \_\____|_____|
|
||||
//--------------------------------------------
|
||||
// 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);
|
||||
} catch (error) {
|
||||
console.trace("received an invalid message (error: %s)", error, data.toString(), data);
|
||||
websocket.send(msg.prepare(
|
||||
msg.CALAMITY,
|
||||
error
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
session.setState(new AuthState(session));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WebSocket} websocket
|
||||
* @param {strings} data
|
||||
*/
|
||||
onIncomingMessage(websocket, data) {
|
||||
const session = this.sessions.get(websocket);
|
||||
|
||||
if (!session) {
|
||||
console.error(
|
||||
"Incoming message from a client without a session!",
|
||||
data,
|
||||
);
|
||||
this.send(
|
||||
websocket,
|
||||
MSG_ERROR,
|
||||
"terminal",
|
||||
"You do not have an active session. Go away!",
|
||||
);
|
||||
websocket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
try {
|
||||
message = new ClientMessage(data);
|
||||
} catch (error) {
|
||||
console.error("Bad websocket message", data, error);
|
||||
this.send(
|
||||
websocket,
|
||||
MSG_ERROR,
|
||||
"terminal",
|
||||
"You sent me a bad message! Goodbye...",
|
||||
);
|
||||
websocket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.usernameProcessed) {
|
||||
//
|
||||
//----------------------------------------------------
|
||||
// We haven"t gotten a username yet, so we expect one.
|
||||
//----------------------------------------------------
|
||||
if (!message.hasUsername()) {
|
||||
console.error("User should have sent a “username” message, but sent something else instead");
|
||||
this.send(websocket, MSG_CALAMITY, "I expected you to send me a username, but you sent me something else instead. You bad! Goodbye...");
|
||||
|
||||
// for now, just close the socket.
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
const player = this.game.players.get(message.username);
|
||||
|
||||
if (!player) {
|
||||
//----------------------------------------------------
|
||||
// Invalid Username.
|
||||
//----------------------------------------------------
|
||||
console.log("Invalid username sent during login: %s", username);
|
||||
this.send(websocket, MSG_ERROR, "Invalid username");
|
||||
this.send(
|
||||
websocket,
|
||||
MSG_PROMPT,
|
||||
"Please enter a valid username",
|
||||
);
|
||||
}
|
||||
|
||||
// correct username, tentatively assign player to session
|
||||
// even though we have not yet validated the password.
|
||||
session.player = player;
|
||||
session.usernameProcessed = true;
|
||||
this.send(websocket, MSG_MESSAGE, "Username received");
|
||||
this.send(websocket, MSG_PROMPT, "Enter your password");
|
||||
|
||||
return;
|
||||
}
|
||||
// ____ _____ _ ____ _____
|
||||
// / ___|_ _|/ \ | _ \_ _|
|
||||
// \___ \ | | / _ \ | |_) || |
|
||||
// ___) || |/ ___ \| _ < | |
|
||||
// |____/ |_/_/ \_\_| \_\|_|
|
||||
//-----------------------------
|
||||
// Start the server
|
||||
//-----------------
|
||||
start() {
|
||||
|
||||
//
|
||||
//----------------------------------------------------
|
||||
// The player has entered a valid username, now expect
|
||||
// a password.
|
||||
//----------------------------------------------------
|
||||
if (!session.passwordProcessed) {
|
||||
if (!message.hasPassword) {
|
||||
console.error(
|
||||
"Youser should have sent a “password” message, but sent this instead: %s",
|
||||
message.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The file types we allow to be served.
|
||||
const contentTypes = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".html": "text/html",
|
||||
};
|
||||
|
||||
//
|
||||
//----------------------------------------------------
|
||||
// Process the player's commands
|
||||
//----------------------------------------------------
|
||||
if (message.isCommand()) {
|
||||
// switch case for commands.
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"We have received a message we couldn't handle!!!",
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {WebSocket} websocket
|
||||
* @param {string} name
|
||||
*/
|
||||
createPlayer(websocket, name) {
|
||||
const player = new Player(name, websocket);
|
||||
this.players.set(websocket, player);
|
||||
this.players.set(name, player);
|
||||
|
||||
const startRoom = this.rooms.get("town_square");
|
||||
startRoom.addPlayer(player);
|
||||
|
||||
player.sendMessage(`Welcome, ${name}! You have entered the world.`);
|
||||
this.showRoom(player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a websocket connection is closing.
|
||||
*
|
||||
* @param {WebSocket} websocket
|
||||
*/
|
||||
onConnectionClosed(websocket) {
|
||||
const session = this.sessions.get(websocket);
|
||||
|
||||
if (session && session.player) {
|
||||
console.log(`Player ${player.username} disconnected`);
|
||||
// Create HTTP server for serving the client - Consider moving to own file
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
let filePath = path.join("public", req.url === "/" ? "index.html" : req.url);
|
||||
const ext = path.extname(filePath);
|
||||
const contentType = contentTypes[ext];
|
||||
|
||||
//
|
||||
// Handle player logout (move the or hide their characters)
|
||||
// this.game.playerLoggedOut();
|
||||
} else {
|
||||
console.log("A player without a session disconnected");
|
||||
}
|
||||
// Check if the requested file has a legal file type.
|
||||
if (!contentType) {
|
||||
// Invalid file, pretend it did not exist!
|
||||
res.writeHead(404);
|
||||
res.end(`File not found`);
|
||||
console.log("Bad http request", req.url);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessions.delete(websocket);
|
||||
|
||||
//
|
||||
// Check if the file exists.
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end(`File not found`);
|
||||
console.log("Bad http request", req.url);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
//
|
||||
// Create WebSocket server
|
||||
const websocketServer = new WebSocketServer({ server: httpServer });
|
||||
|
||||
websocketServer.on("connection", (ws) => {
|
||||
this.onConnectionEstabished(ws);
|
||||
});
|
||||
|
||||
console.info(`running in ${cfg.ENV} mode`);
|
||||
httpServer.listen(cfg.PORT, () => {
|
||||
console.log(`NUUHD server running on port ${cfg.PORT}`);
|
||||
console.log(`WebSocket server ready for connections`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP server for serving the client
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
// let filePath = path.join(__dirname, "public", req.url === "/" ? "index.html" : req.url);
|
||||
let filePath = path.join(
|
||||
"public",
|
||||
req.url === "/" ? "index.html" : req.url,
|
||||
);
|
||||
const ext = path.extname(filePath);
|
||||
|
||||
const contentTypes = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".html": "text/html",
|
||||
};
|
||||
|
||||
if (!contentTypes[ext]) {
|
||||
// Invalid file, pretend it did not exist!
|
||||
res.writeHead(404);
|
||||
res.end(`File ${filePath} not found (invalid $ext)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = contentTypes[ext];
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end(`File ${filePath} . ${ext} not found (${err})`);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
// Create WebSocket server
|
||||
const websocketServer = new WebSocketServer({ server: httpServer });
|
||||
const mudServer = new MudServer();
|
||||
|
||||
websocketServer.on("connection", (ws) => {
|
||||
mudServer.onConnectionEstabished(ws);
|
||||
});
|
||||
|
||||
// websocketServer.on("connection", mudServer.onConnectionEstabished);
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`MUD server running on port ${PORT}`);
|
||||
console.log(`WebSocket server ready for connections`);
|
||||
});
|
||||
// __ __ _ ___ _ _
|
||||
// | \/ | / \ |_ _| \ | |
|
||||
// | |\/| | / _ \ | || \| |
|
||||
// | | | |/ ___ \ | || |\ |
|
||||
// |_| |_/_/ \_\___|_| \_|
|
||||
//---------------------------
|
||||
// Code entry point
|
||||
//-----------------
|
||||
(new MudServer()).start();
|
||||
|
||||
169
server/tui.md
Normal file
169
server/tui.md
Normal file
@@ -0,0 +1,169 @@
|
||||
Here are some ASCII and UTF-8 characters commonly used for "shading" effects in text art or terminal displays. These characters provide varying levels of density or shading:
|
||||
|
||||
### ASCII Shading Characters
|
||||
These are basic ASCII characters often used for shading:
|
||||
```
|
||||
Light shade: ░ (U+2591)
|
||||
Medium shade: ▒ (U+2592)
|
||||
Dark shade: ▓ (U+2593)
|
||||
Full block: █ (U+2588)
|
||||
Half block: ▄ (U+2584), ▀ (U+2580)
|
||||
```
|
||||
|
||||
### Additional UTF-8 Block Characters
|
||||
These Unicode characters offer more granular shading or block patterns:
|
||||
```
|
||||
Light block: ░ (U+2591)
|
||||
Medium block: ▒ (U+2592)
|
||||
Dark block: ▓ (U+2593)
|
||||
Solid block: █ (U+2588)
|
||||
Upper half block: ▀ (U+2580)
|
||||
Lower half block: ▄ (U+2584)
|
||||
Left half block: ▌ (U+258C)
|
||||
Right half block: ▐ (U+2590)
|
||||
Quadrant blocks: ▖ (U+2596), ▗ (U+2597), ▘ (U+2598), ▝ (U+259D)
|
||||
Checkerboard: ▚ (U+259A), ▞ (U+259E)
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
Here’s an example of a simple shading gradient using some of these characters:
|
||||
```
|
||||
Light to Dark: ░ ▒ ▓ █
|
||||
Half blocks: ▀ ▄ ▌ ▐
|
||||
Quadrant pattern: ▖ ▗ ▘ ▝
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Not all terminals or text editors display Unicode characters consistently, so ASCII characters like `.:;#+` are sometimes used for basic shading in simpler environments.
|
||||
- If you want to create specific patterns or need more complex ASCII art, let me know, and I can generate or suggest more detailed designs!
|
||||
- If you meant something specific by "shading" (e.g., for a particular programming context or art style), please clarify, and I’ll tailor the response further.
|
||||
|
||||
|
||||
|
||||
Below is a collection of ASCII and UTF-8 characters suitable for creating frames, borders, or "windows" in a text-based user interface (TUI), such as those built with ncurses. These characters can be used to draw boxes, lines, and corners to simulate window-like structures in a terminal.
|
||||
|
||||
### ASCII Characters for Frames
|
||||
These are basic ASCII characters that work universally in most terminals:
|
||||
```
|
||||
Horizontal line: - (U+002D)
|
||||
Vertical line: | (U+007C)
|
||||
Cross/intersection: + (U+002B)
|
||||
Corners:
|
||||
Top-left: + (U+002B) or `
|
||||
Top-right: + (U+002B) or '
|
||||
Bottom-left: + (U+002B) or ,
|
||||
Bottom-right: + (U+002B) or .
|
||||
```
|
||||
|
||||
Example simple ASCII window:
|
||||
```
|
||||
+----------+
|
||||
| Content |
|
||||
+----------+
|
||||
```
|
||||
|
||||
### UTF-8 Box-Drawing Characters
|
||||
Unicode provides a dedicated **Box Drawing** block (U+2500–U+257F) for creating more refined frames. These are widely supported in modern terminals and ncurses:
|
||||
|
||||
#### Single-Line Box Drawing
|
||||
```
|
||||
Horizontal line: ─ (U+2500)
|
||||
Vertical line: │ (U+2502)
|
||||
Corners:
|
||||
Top-left: ┌ (U+250C)
|
||||
Top-right: ┐ (U+2510)
|
||||
Bottom-left: └ (U+2514)
|
||||
Bottom-right: ┘ (U+2518)
|
||||
Intersections:
|
||||
T-junction top: ┬ (U+252C)
|
||||
T-junction bottom: ┴ (U+2534)
|
||||
T-junction left: ├ (U+251C)
|
||||
T-junction right: ┤ (U+2524)
|
||||
Cross: ┼ (U+253C)
|
||||
```
|
||||
|
||||
Example single-line window:
|
||||
```
|
||||
┌──────────┐
|
||||
│ Content │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
#### Double-Line Box Drawing
|
||||
```
|
||||
Horizontal line: ═ (U+2550)
|
||||
Vertical line: ║ (U+2551)
|
||||
Corners:
|
||||
Top-left: ╔ (U+2554)
|
||||
Top-right: ╗ (U+2557)
|
||||
Bottom-left: ╚ (U+255A)
|
||||
Bottom-right: ╝ (U+255D)
|
||||
Intersections:
|
||||
T-junction top: ╦ (U+2566)
|
||||
T-junction bottom: ╩ (U+2569)
|
||||
T-junction left: ╠ (U+2560)
|
||||
T-junction right: ╣ (U+2563)
|
||||
Cross: ╬ (U+256C)
|
||||
```
|
||||
|
||||
Example double-line window:
|
||||
```
|
||||
╔══════════╗
|
||||
║ Content ║
|
||||
╚══════════╝
|
||||
```
|
||||
|
||||
#### Mixed and Other Box-Drawing Characters
|
||||
For more complex designs, you can mix single and double lines or use specialized characters:
|
||||
```
|
||||
Single to double transitions:
|
||||
Horizontal single to double: ╼ (U+257C)
|
||||
Vertical single to double: ╽ (U+257D)
|
||||
Rounded corners (less common, not always supported):
|
||||
Top-left: ╭ (U+256D)
|
||||
Top-right: ╮ (U+256E)
|
||||
Bottom-left: ╰ (U+2570)
|
||||
Bottom-right: ╯ (U+256F)
|
||||
```
|
||||
|
||||
Example with rounded corners:
|
||||
```
|
||||
╭──────────╮
|
||||
│ Content │
|
||||
╰──────────╯
|
||||
```
|
||||
|
||||
### Additional UTF-8 Characters for Decoration
|
||||
These can enhance the appearance of your TUI:
|
||||
```
|
||||
Block elements for borders or shading:
|
||||
Full block: █ (U+2588)
|
||||
Half blocks: ▀ (U+2580), ▄ (U+2584), ▌ (U+258C), ▐ (U+2590)
|
||||
Light shade for background: ░ (U+2591)
|
||||
Medium shade: ▒ (U+2592)
|
||||
Dark shade: ▓ (U+2593)
|
||||
```
|
||||
|
||||
### Example TUI Window with Content
|
||||
Here’s a sample of a more complex window using single-line box-drawing characters:
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ My TUI Window │
|
||||
├────────────────────┤
|
||||
│ Item 1 [ OK ] │
|
||||
│ Item 2 [Cancel] │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
### Notes for ncurses
|
||||
- **ncurses Compatibility**: ncurses supports both ASCII and UTF-8 box-drawing characters, but you must ensure the terminal supports Unicode (e.g., `LANG=en_US.UTF-8` environment variable). Use `initscr()` and `start_color()` in ncurses to handle rendering.
|
||||
- **Terminal Support**: Some older terminals may not render UTF-8 characters correctly. Test your TUI in the target environment (e.g., xterm, gnome-terminal, or Alacritty).
|
||||
- **Fallback**: If Unicode support is unreliable, stick to ASCII (`-`, `|`, `+`) for maximum compatibility.
|
||||
- **ncurses Functions**: Use `box()` in ncurses to draw a border around a window automatically, or manually print characters with `mvaddch()` for custom designs.
|
||||
|
||||
### Tips
|
||||
- Combine single and double lines for visual hierarchy (e.g., double lines for outer windows, single lines for inner sections).
|
||||
- If you need specific examples (e.g., a multi-window layout or a dialog box), let me know, and I can provide a detailed ASCII/UTF-8 mockup or even pseudocode for ncurses.
|
||||
- If you want a particular style (e.g., heavy lines, dashed lines, or specific layouts), please clarify, and I’ll tailor the response.
|
||||
|
||||
Let me know if you need help implementing this in ncurses or want more specific frame designs!
|
||||
4
server/utils/config.js
Normal file
4
server/utils/config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const ENV = process.env.NODE_ENV || "prod";
|
||||
export const DEV = ENV === "dev";
|
||||
export const PROD =!DEV;
|
||||
export const PORT = process.env.PORT || 3000;
|
||||
@@ -5,45 +5,73 @@
|
||||
* or
|
||||
* Server-->Client-->Plater
|
||||
*/
|
||||
export const MSG_CALAMITY = "calamity";
|
||||
export const CALAMITY = "calamity";
|
||||
|
||||
/** Tell recipient that an error has occurred */
|
||||
export const MSG_ERROR = "e";
|
||||
/**
|
||||
* Tell recipient that an error has occurred
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
export const ERROR = "e";
|
||||
|
||||
/**
|
||||
* Message to be displayed.
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
export const MSG_MESSAGE = "m";
|
||||
export const MESSAGE = "m";
|
||||
|
||||
|
||||
/**
|
||||
* Message contains the player's password (or hash or whatever).
|
||||
* Player has entered data, and sends it to server.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const MSG_PASSWORD = "pass";
|
||||
export const REPLY = "reply";
|
||||
|
||||
/**
|
||||
* Player wants to quit.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const QUIT = "quit";
|
||||
|
||||
/**
|
||||
* Player wants help
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const HELP = "help";
|
||||
|
||||
/**
|
||||
* Server tells the client to prompt the player for some data
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*
|
||||
* Server tells the client to prompt the player for some info
|
||||
*/
|
||||
export const MSG_PROMPT = "ask";
|
||||
|
||||
/**
|
||||
* Client sends the player's username to the server
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const MSG_USERNAME = "user";
|
||||
export const PROMPT = "prompt";
|
||||
|
||||
/**
|
||||
* Player has entered a command, and wants to do something.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
export const MSG_COMMAND = "c";
|
||||
export const COMMAND = "c";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -53,16 +81,16 @@ export class ClientMessage {
|
||||
* @protected
|
||||
* @type {any[]} _arr The array that contains the message data
|
||||
*/
|
||||
_arr;
|
||||
_attr;
|
||||
|
||||
/** The message type.
|
||||
*
|
||||
* One of the MSG_* constants from this document.
|
||||
* One of the * constants from this document.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get type() {
|
||||
return this._arr[0];
|
||||
return this._attr[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,66 +98,101 @@ export class ClientMessage {
|
||||
*/
|
||||
constructor(msgData) {
|
||||
if (typeof msgData !== "string") {
|
||||
throw new Error(
|
||||
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
|
||||
typeof msgData,
|
||||
);
|
||||
throw new Error("Could not create client message. Attempting to parse json, but data was not even a string, it was a " + typeof msgData);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._arr = JSON.parse(msgData);
|
||||
this._attr = JSON.parse(msgData);
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
|
||||
);
|
||||
throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`);
|
||||
}
|
||||
|
||||
if (typeof this._arr !== "array") {
|
||||
throw new Error(
|
||||
`Could not create client message. Excpected an array, but got a ${typeof this._arr}`,
|
||||
);
|
||||
if (!Array.isArray(this._attr)) {
|
||||
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`);
|
||||
}
|
||||
|
||||
if (this._arr.length < 1) {
|
||||
throw new Error(
|
||||
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
this._arr = arr;
|
||||
}
|
||||
|
||||
/** Does this message contain a message that should be displayed to the user the "normal" way? */
|
||||
isMessage() {
|
||||
return this._arr[0] === "m";
|
||||
hasCommand() {
|
||||
return this._attr.length > 1 && this._attr[0] === COMMAND;
|
||||
}
|
||||
|
||||
/** Does this message contain a username-response from the client? */
|
||||
hasUsername() {
|
||||
return this._arr[0] === MSG_USERNAME;
|
||||
isUsernameResponse() {
|
||||
return this._attr.length === 3
|
||||
&& this._attr[0] === REPLY
|
||||
&& this._attr[1] === "username"
|
||||
&& typeof this._attr[2] === "string";
|
||||
}
|
||||
|
||||
/** Does this message contain a password-response from the client? */
|
||||
hasPassword() {
|
||||
return this._arr[0] === MSG_PASSWORD;
|
||||
isPasswordResponse() {
|
||||
return this._attr.length === 3
|
||||
&& 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 === 3
|
||||
&& this._attr[0] === REPLY
|
||||
&& this._attr[1] === "integer"
|
||||
&& (typeof this._attr[2] === "string" || typeof this._attr[2] === "number")
|
||||
&& Number.isInteger(Number(this._attr[2]));
|
||||
}
|
||||
|
||||
/** @returns {number} integer */
|
||||
get integer() {
|
||||
if (!this.isIntegerResponse()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Number.parseInt(this._attr[2]);
|
||||
}
|
||||
|
||||
get debugInfo() {
|
||||
return this.isDebug() ? this._attr[1] : undefined;
|
||||
}
|
||||
|
||||
/** @returns {string|false} Get the username stored in this message */
|
||||
get username() {
|
||||
return this.hasUsername() ? this._arr[1] : false;
|
||||
return this.isUsernameResponse() ? this._attr[2] : false;
|
||||
}
|
||||
|
||||
/** @returns {string|false} Get the password stored in this message */
|
||||
get password() {
|
||||
return this.hasPassword() ? this._arr[1] : false;
|
||||
return this.isPasswordResponse() ? this._attr[2] : false;
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
get command() {
|
||||
return this.isCommand() ? this._attr[1] : false;
|
||||
}
|
||||
|
||||
isCommand() {
|
||||
return this._raw[0] === MSG_COMMAND;
|
||||
return this.hasCommand() ? this._attr[1] : false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a message type and some args, create a string that can be sent from the server to the client (or vise versa)
|
||||
*
|
||||
* @param {string} messageType
|
||||
* @param {...any} args
|
||||
*/
|
||||
export function prepare(messageType, ...args) {
|
||||
return JSON.stringify([messageType, ...args]);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { randomBytes, pbkdf2Sync, randomInt } from "node:crypto";
|
||||
|
||||
// Settings (tune as needed)
|
||||
const ITERATIONS = 100_000; // Slow enough to deter brute force
|
||||
const KEYLEN = 64; // 512-bit hash
|
||||
const DIGEST = "sha512";
|
||||
|
||||
/**
|
||||
* Generate a hash from a plaintext password.
|
||||
* @param {String} password
|
||||
* @returns String
|
||||
*/
|
||||
export function hash(password) {
|
||||
const salt = randomBytes(16).toString("hex"); // 128-bit salt
|
||||
const hash = pbkdf2Sync(
|
||||
password,
|
||||
salt,
|
||||
ITERATIONS,
|
||||
KEYLEN,
|
||||
DIGEST,
|
||||
).toString("hex");
|
||||
return `${ITERATIONS}:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a password is correct against a given hash.
|
||||
*
|
||||
* @param {String} password
|
||||
* @param {String} hashed_password
|
||||
* @returns Boolean
|
||||
*/
|
||||
export function verify(password, hashed_password) {
|
||||
const [iterations, salt, hash] = hashed_password.split(":");
|
||||
const derived = pbkdf2Sync(
|
||||
password,
|
||||
salt,
|
||||
Number(iterations),
|
||||
KEYLEN,
|
||||
DIGEST,
|
||||
).toString("hex");
|
||||
return hash === derived;
|
||||
}
|
||||
59
server/utils/security.js
Executable file
59
server/utils/security.js
Executable file
@@ -0,0 +1,59 @@
|
||||
import { randomBytes, pbkdf2Sync } from "node:crypto";
|
||||
import { DEV } from "./config.js";
|
||||
|
||||
// Settings (tune as needed)
|
||||
const ITERATIONS = 1000;
|
||||
const KEYLEN = 32; // 32-bit hash
|
||||
const DIGEST = "sha256";
|
||||
|
||||
/**
|
||||
* Generate a hash from a plaintext password.
|
||||
* @param {string} password
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateHash(password) {
|
||||
const salt = randomBytes(16).toString("hex"); // 128-bit salt
|
||||
const hash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString("hex");
|
||||
return `${ITERATIONS}:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a password is correct against a given hash.
|
||||
*
|
||||
* @param {string} password_candidate
|
||||
* @param {string} stored_password_hash
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifyPassword(password_candidate, stored_password_hash) {
|
||||
const [iterations, salt, hash] = stored_password_hash.split(":");
|
||||
const derived = pbkdf2Sync(password_candidate, salt, Number(iterations), KEYLEN, DIGEST).toString("hex");
|
||||
const success = hash === derived;
|
||||
if (DEV) {
|
||||
console.debug(
|
||||
"Verifying password:\n" +
|
||||
" Input : %s\n" +
|
||||
" Stored : %s\n" +
|
||||
" Given : %s\n" +
|
||||
" Derived : %s\n" +
|
||||
" Success : %s",
|
||||
password_candidate,
|
||||
generateHash(password_candidate),
|
||||
stored_password_hash,
|
||||
derived,
|
||||
success,
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/** @param {string} candidate */
|
||||
export function isUsernameSane(candidate) {
|
||||
return /^[a-zA-Z0-9_]{4,}$/.test(candidate);
|
||||
}
|
||||
|
||||
/** @param {string} candidate */
|
||||
export function isPasswordSane(candidate) {
|
||||
// We know the password must adhere to one of our client-side-hashed crypto schemes,
|
||||
// so we can be fairly strict with the allowed passwords
|
||||
return /^[a-zA-Z0-9_: -]{8,}$/.test(candidate);
|
||||
}
|
||||
306
server/utils/tui.js
Executable file
306
server/utils/tui.js
Executable file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @readonly
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const FrameType = {
|
||||
|
||||
/**
|
||||
* ╔════════════╗
|
||||
* ║ Hello, TUI ║
|
||||
* ╚════════════╝
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Double: "Double",
|
||||
|
||||
/**
|
||||
* ┌────────────┐
|
||||
* │ Hello, TUI │
|
||||
* └────────────┘
|
||||
*
|
||||
* @type {string} Single-lined frame
|
||||
*/
|
||||
Single: "Single",
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* Hello, TUI
|
||||
*
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Invisible: "Invisible",
|
||||
|
||||
|
||||
/**
|
||||
* ( )
|
||||
* ( Hello, TUI )
|
||||
* ( )
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Parentheses: "Parentheses",
|
||||
|
||||
/**
|
||||
* +------------+
|
||||
* | Hello, TUI |
|
||||
* +------------+
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Basic: "Basic",
|
||||
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Default values for the common frame types.
|
||||
*
|
||||
* [north, south, east, west, northwest, northeast, southwest, southeast]
|
||||
*/
|
||||
values: {
|
||||
Basic: "--||++++",
|
||||
Double: "══║║╔╗╚╝",
|
||||
Invisible: " ",
|
||||
Parentheses: " () ",
|
||||
Single: "──││┌┐└┘",
|
||||
}
|
||||
}
|
||||
|
||||
export class FramingOptions {
|
||||
/** @type {number=0} Vertical Padding; number of vertical whitespace (newlines) between the text and the frame. */
|
||||
vPadding = 0;
|
||||
|
||||
/** @type {number=0} Margin ; number of newlines to to insert before and after the framed text */
|
||||
vMargin = 0;
|
||||
|
||||
/** @type {number=0} Horizontal Padding; number of whitespace characters to insert between the text and the sides of the frame. */
|
||||
hPadding = 0;
|
||||
|
||||
/** @type {number=0} Margin ; number of newlines to to insert before and after the text, but inside the frame */
|
||||
hMargin = 0;
|
||||
|
||||
/** @type {FrameType=FrameType.Double} Type of frame to put around the text */
|
||||
frameType = FrameType.Double;
|
||||
|
||||
/** @type {number=0} Pad each line to become at least this long */
|
||||
minLineWidth = 0;
|
||||
|
||||
// Light block: ░ (U+2591)
|
||||
// Medium block: ▒ (U+2592)
|
||||
// Dark block: ▓ (U+2593)
|
||||
// Solid block: █ (U+2588)
|
||||
/** @type {string} Single character to use as filler inside the frame. */
|
||||
paddingChar = " "; // character used for padding inside the frame.
|
||||
|
||||
/** @type {string} Single character to use as filler outside the frame. */
|
||||
marginChar = " ";
|
||||
|
||||
/** @type {string} The 8 characters that make up the frame elements */
|
||||
frameChars = FrameType.values.Double;
|
||||
|
||||
/**
|
||||
* @param {object} o
|
||||
* @returns {FramingOptions}
|
||||
*/
|
||||
static fromObject(o) {
|
||||
const result = new FramingOptions();
|
||||
|
||||
result.vPadding = Math.max(0, Number.parseInt(o.vPadding) || 0);
|
||||
result.hPadding = Math.max(0, Number.parseInt(o.hPadding) || 0);
|
||||
result.vMargin = Math.max(0, Number.parseInt(o.vMargin) || 0);
|
||||
result.hMargin = Math.max(0, Number.parseInt(o.hMargin) || 0);
|
||||
result.minLineWidth = Math.max(0, Number.parseInt(o.hMargin) || 0);
|
||||
|
||||
result.paddingChar = String(o.paddingChar || " ")[0] || " ";
|
||||
result.marginChar = String(o.marginChar || " ")[0] || " ";
|
||||
|
||||
//
|
||||
// Do we have custom and valid frame chars?
|
||||
if (typeof o.frameChars === "string" && o.frameChars.length === FrameType.values.Double.length) {
|
||||
result.frameChars = o.frameChars;
|
||||
|
||||
//
|
||||
// do we have document frame type instead ?
|
||||
} else if (o.frameType && FrameType.hasOwnProperty(o.frameType)) {
|
||||
result.frameChars = FrameType.values[o.frameType];
|
||||
|
||||
// Fall back to using "Double" frame
|
||||
} else {
|
||||
result.frameChars = FrameType.values.Double;
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|string[]} text the text to be framed. If array, each element will be treated as one line, and they are joined so the whole is to be framed.
|
||||
* @param {FramingOptions} options
|
||||
*/
|
||||
export function frameText(text, options) {
|
||||
|
||||
if (!options) {
|
||||
options = new FramingOptions();
|
||||
}
|
||||
|
||||
if (!(options instanceof FramingOptions)) {
|
||||
options = FramingOptions.fromObject(options);
|
||||
}
|
||||
|
||||
// There is a point to this; each element in the array may contain newlines,
|
||||
// so we have to combine everything into a long text and then split into
|
||||
// individual lines afterwards.
|
||||
if (Array.isArray(text)) {
|
||||
text = text.join("\n");
|
||||
}
|
||||
|
||||
if (typeof text !== "string") {
|
||||
console.debug(text);
|
||||
throw new Error(`text argument was neither an array or a string, it was a ${typeof text}`);
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const lines = text.split("\n");
|
||||
|
||||
const innerLineLength = Math.max(
|
||||
lines.reduce((accumulator, currentLine) => {
|
||||
if (currentLine.length > accumulator) {
|
||||
return currentLine.length;
|
||||
}
|
||||
return accumulator;
|
||||
}, 0), options.minLineWidth);
|
||||
|
||||
const frameThickness = 1; // always 1 for now.
|
||||
|
||||
const outerLineLength = 0
|
||||
+ innerLineLength
|
||||
+ frameThickness * 2
|
||||
+ options.hPadding * 2
|
||||
+ options.hMargin * 2;
|
||||
|
||||
// get the frame characters from the frameType.
|
||||
const [
|
||||
fNorth, // horizontal frame top lines
|
||||
fSouth, // horizontal frame bottom lines
|
||||
fWest, // vertical frame lines on the left side
|
||||
fEast, // vertical frame lines on the right side
|
||||
fNorthWest, // upper left frame corner
|
||||
fNorthEast, // upper right frame corner
|
||||
fSouthWest, // lower left frame corner
|
||||
fSouthEast, // lower right frame corner
|
||||
] = options.frameChars.split("");
|
||||
|
||||
let output = "";
|
||||
|
||||
//
|
||||
// GENERATE THE MARGIN SPACE ABOVE THE FRAMED TEXT
|
||||
//
|
||||
// ( we insert space characters even though )
|
||||
// ( they wouldn't normally be visible. But )
|
||||
// ( Some fonts might allow us to see blank )
|
||||
// ( space, and what if we want to nest many )
|
||||
// ( frames inside each other? )
|
||||
//
|
||||
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin);
|
||||
|
||||
|
||||
//
|
||||
// GENERATE THE TOP PART OF THE FRAME
|
||||
// ╔════════════╗
|
||||
//
|
||||
//
|
||||
output += "" // Make sure JS knows we're adding a string.
|
||||
+ options.marginChar.repeat(options.hMargin) // the margin before the frame starts
|
||||
+ fNorthWest // northwest frame corner
|
||||
+ fNorth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar
|
||||
+ fNorthEast // northeast frame corner
|
||||
+ options.marginChar.repeat(options.hMargin) // the margin after the frame ends
|
||||
+ "\n";
|
||||
//
|
||||
// GENERATE UPPER PADDING
|
||||
//
|
||||
// ║ ║
|
||||
//
|
||||
// (the blank lines within the frame and above the text)
|
||||
output += (
|
||||
options.marginChar.repeat(options.hMargin)
|
||||
+ fWest
|
||||
+ options.paddingChar.repeat(innerLineLength + options.hPadding * 2)
|
||||
+ fEast
|
||||
+ options.marginChar.repeat(options.hMargin)
|
||||
+ "\n"
|
||||
).repeat(options.vPadding);
|
||||
|
||||
//
|
||||
// GENERATE FRAMED TEXT SEGMENT
|
||||
//
|
||||
// ║ My pretty ║
|
||||
// ║ text here ║
|
||||
//
|
||||
// ( this could be done with a reduce() )
|
||||
//
|
||||
for (const line of lines) {
|
||||
output += "" // Make sure JS knows we're adding a string.
|
||||
+ options.marginChar.repeat(options.hMargin) // margin before frame
|
||||
+ fWest // vertical frame char
|
||||
+ options.paddingChar.repeat(options.hPadding) // padding before text
|
||||
+ line.padEnd(innerLineLength, " ") // The actual text. Pad it with normal space character, NOT custom space.
|
||||
+ options.paddingChar.repeat(options.hPadding) // padding after text
|
||||
+ fEast // vertical frame bar
|
||||
+ options.marginChar.repeat(options.hMargin) // margin after frame
|
||||
+ "\n";
|
||||
}
|
||||
|
||||
//
|
||||
// GENERATE LOWER PADDING
|
||||
//
|
||||
// ║ ║
|
||||
//
|
||||
// ( the blank lines within the )
|
||||
// ( frame and below the text )
|
||||
//
|
||||
// ( this code is a direct )
|
||||
// ( repeat of the code that )
|
||||
// ( generates top padding )
|
||||
output += (
|
||||
options.marginChar.repeat(options.hMargin)
|
||||
+ fWest
|
||||
+ options.paddingChar.repeat(innerLineLength + options.hPadding * 2)
|
||||
+ fEast
|
||||
+ options.marginChar.repeat(options.hMargin)
|
||||
+ "\n"
|
||||
).repeat(options.vPadding);
|
||||
|
||||
|
||||
//
|
||||
// GENERATE THE BOTTOM PART OF THE FRAME
|
||||
//
|
||||
// ╚════════════╝
|
||||
//
|
||||
output += "" // Make sure JS knows we're adding a string.
|
||||
+ options.marginChar.repeat(options.hMargin) // the margin before the frame starts
|
||||
+ fSouthWest // northwest frame corner
|
||||
+ fSouth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar
|
||||
+ fSouthEast // northeast frame corner
|
||||
+ options.marginChar.repeat(options.hMargin) // the margin after the frame starts
|
||||
+ "\n";
|
||||
|
||||
//
|
||||
// GENERATE THE MARGIN SPACE BELOW THE FRAMED TEXT
|
||||
//
|
||||
// ( we insert space characters even though )
|
||||
// ( they wouldn't normally be visible. But )
|
||||
// ( Some fonts might allow us to see blank )
|
||||
// ( space, and what if we want to nest many )
|
||||
// ( frames inside each other? )
|
||||
//
|
||||
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Allow this script to be run directly from node as well as being included!
|
||||
// https://stackoverflow.com/a/66309132/5622463
|
||||
Reference in New Issue
Block a user