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 */
|
/** @type {string} character's name */
|
||||||
name;
|
name;
|
||||||
|
|
||||||
/** @protected @type {number} The number of XP the character has. */
|
/**
|
||||||
|
* @protected
|
||||||
|
* @type {number} The number of XP the character has.
|
||||||
|
*/
|
||||||
_xp = 0;
|
_xp = 0;
|
||||||
get xp() {
|
get xp() {
|
||||||
return this._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} 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 {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;
|
this.name = name;
|
||||||
|
|
||||||
// Initialize the unique name if this character.
|
// 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.
|
// 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
|
// should we skip initialization of this object
|
||||||
if (initialize !== true) {
|
if (initialize !== true) {
|
||||||
@@ -144,9 +147,7 @@ export class Character {
|
|||||||
this.meleeCombat = Math.max(this.skulduggery, 10);
|
this.meleeCombat = Math.max(this.skulduggery, 10);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error("Logic error, ancestry d8() roll was out of scope");
|
||||||
"Logic error, ancestry d8() roll was out of scope",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -172,11 +173,8 @@ export class Character {
|
|||||||
case 2:
|
case 2:
|
||||||
this.foundation = "druid";
|
this.foundation = "druid";
|
||||||
this.proficiencies.add("armor/natural");
|
this.proficiencies.add("armor/natural");
|
||||||
this.equipment
|
this.equipment.set("sickle", 1).set("poisoner's kit", 1).set("healer's kit", 1);
|
||||||
.set("sickle", 1)
|
default: // case 2:
|
||||||
.set("poisoner's kit", 1)
|
|
||||||
.set("healer's kit", 1);
|
|
||||||
default:
|
|
||||||
this.foundation = "debug";
|
this.foundation = "debug";
|
||||||
this.proficiencies.add("heavy_armor");
|
this.proficiencies.add("heavy_armor");
|
||||||
this.proficiencies.add("heavy_weapons");
|
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 WebSocket from "ws";
|
||||||
import { Character } from "./character.js";
|
import { Character } from "./character.js";
|
||||||
import { ItemTemplate } from "./item.js";
|
import { ItemTemplate } from "./item.js";
|
||||||
|
import { Player } from "./player.js";
|
||||||
|
|
||||||
export class Game {
|
export class Game {
|
||||||
|
|
||||||
/** @type {Map<string,ItemTemplate>} List of all item templates in the game */
|
/** @type {Map<string,ItemTemplate>} List of all item templates in the game */
|
||||||
_itemTemplates = new Map();
|
_itemTemplates = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -42,17 +42,13 @@ export class ItemTemplate {
|
|||||||
*/
|
*/
|
||||||
constructor(name, itemSlots, description, id) {
|
constructor(name, itemSlots, description, id) {
|
||||||
if (typeof name !== "string") {
|
if (typeof name !== "string") {
|
||||||
throw new Error(
|
throw new Error("Name must be a string, but " + typeof name + " given.");
|
||||||
"Name must be a string, but " + typeof name + " given.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (typeof description === "undefined") {
|
if (typeof description === "undefined") {
|
||||||
description = "";
|
description = "";
|
||||||
}
|
}
|
||||||
if (typeof description !== "string") {
|
if (typeof description !== "string") {
|
||||||
throw new Error(
|
throw new Error("Name must be a string, but " + typeof name + " given.");
|
||||||
"Name must be a string, but " + typeof name + " given.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(itemSlots)) {
|
if (!Number.isFinite(itemSlots)) {
|
||||||
throw new Error("itemSlots must be a finite number!");
|
throw new Error("itemSlots must be a finite number!");
|
||||||
@@ -71,12 +67,7 @@ export class ItemTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createItem() {
|
createItem() {
|
||||||
return new ChracterItem(
|
return new ChracterItem(this._id, this._name, this._description, this._itemSlots);
|
||||||
this._id,
|
|
||||||
this._name,
|
|
||||||
this._description,
|
|
||||||
this._itemSlots,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +110,3 @@ export class CharacterItem {
|
|||||||
this.itemSlots = itemSlots;
|
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 WebSocket from "ws";
|
||||||
|
import { Character } from "./character.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Player Account.
|
* Player Account.
|
||||||
*
|
*
|
||||||
* 1. Contain persistent player account info.
|
* 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.
|
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export class Player {
|
export class Player {
|
||||||
/** @protected @type {string} unique username */
|
/**
|
||||||
|
* @protected
|
||||||
|
* @type {string} unique username
|
||||||
|
*/
|
||||||
_username;
|
_username;
|
||||||
get username() {
|
get username() {
|
||||||
return this._username;
|
return this._username;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @protected @type {string} */
|
/**
|
||||||
|
* @protected
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
_passwordHash;
|
_passwordHash;
|
||||||
get passwordHash() {
|
get passwordHash() {
|
||||||
return this._passwordHash;
|
return this._passwordHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @protected @type {WebSocket} Player's current and only websocket. If undefined, the player is not logged in. */
|
/** @protected @type {Set<Character>} */
|
||||||
_websocket;
|
_characters = new Set();
|
||||||
get websocket() {
|
get characters() {
|
||||||
return this._websocket;
|
return this._characters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @protected @type {Date} */
|
/**
|
||||||
_latestSocketReceived;
|
* @param {string} username
|
||||||
|
* @param {string} passwordHash
|
||||||
|
*/
|
||||||
constructor(username, passwordHash) {
|
constructor(username, passwordHash) {
|
||||||
this._username = username;
|
this._username = username;
|
||||||
|
this._passwordHash = passwordHash;
|
||||||
|
|
||||||
|
this.createdAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {WebSocket} websocket */
|
setPasswordHash(hashedPassword) {
|
||||||
clientConnected(websocket) {
|
this._passwordHash = hashedPassword;
|
||||||
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}] > `);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"figlet": "^1.8.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "NODE_ENV=prod node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "NODE_ENV=dev nodemon server.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mud",
|
"mud",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"figlet": "^1.8.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WebSocket MUD</title>
|
<title>WebSocket MUD</title>
|
||||||
<link rel="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="container">
|
<div id="container">
|
||||||
<div id="status" class="connecting">Connecting...</div>
|
<div id="status" class="connecting">Connecting...</div>
|
||||||
<div id="output"></div>
|
<div id="output"></div>
|
||||||
<div id="input-container">
|
<div id="input-container">
|
||||||
<input
|
<input type="text" autocomplete="off" id="input" placeholder="Enter command..." disabled />
|
||||||
type="text"
|
|
||||||
id="input"
|
|
||||||
placeholder="Enter command..."
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<button id="send" disabled>Send</button>
|
<button id="send" disabled>Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="client.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 {
|
body {
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Fira Code", monospace;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-size: 14px;
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
color: #00ff00;
|
color: #00ff00;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container {
|
#container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
max-width: 1200px;
|
max-width: 99.9vw;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#output {
|
#output {
|
||||||
@@ -25,9 +33,12 @@ body {
|
|||||||
padding: 15px;
|
padding: 15px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
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 {
|
#input-container {
|
||||||
@@ -41,7 +52,8 @@ body {
|
|||||||
border: 2px solid #333;
|
border: 2px solid #333;
|
||||||
color: #00ff00;
|
color: #00ff00;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Fira Code", monospace;
|
||||||
|
font-optical-sizing: auto;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +67,8 @@ body {
|
|||||||
border: 2px solid #555;
|
border: 2px solid #555;
|
||||||
color: #00ff00;
|
color: #00ff00;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Fira Code", monospace;
|
||||||
|
font-optical-sizing: auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +99,38 @@ body {
|
|||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system {
|
.input {
|
||||||
color: #aaaaaa;
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug {
|
||||||
|
opacity: 0.33;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt {
|
.prompt {
|
||||||
color: #00ccff;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
346
server/server.js
346
server/server.js
@@ -2,255 +2,183 @@ import WebSocket, { WebSocketServer } from "ws";
|
|||||||
import http from "http";
|
import http from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { Player } from "./models/player.js";
|
|
||||||
import { Game } from "./models/game.js";
|
import { Game } from "./models/game.js";
|
||||||
import { ClientMessage, MSG_ERROR, MSG_MESSAGE, MSG_PROMPT, MSG_CALAMITY, } from "./utils/messages.js";
|
import * as msg from "./utils/messages.js";
|
||||||
|
import * as cfg from "./utils/config.js";
|
||||||
class Session {
|
import { Session } from "./models/session.js";
|
||||||
/** @type {boolean} */
|
import { Seeder } from "./seed.js";
|
||||||
usernameProcessed = false;
|
import { AuthState } from "./models/states/auth.js";
|
||||||
|
|
||||||
/** @type {boolean} */
|
|
||||||
passwordProcessed = false;
|
|
||||||
|
|
||||||
/** @type {boolean} */
|
|
||||||
ready = false;
|
|
||||||
|
|
||||||
/** @type Date */
|
|
||||||
latestPing;
|
|
||||||
|
|
||||||
/** @type {Player} */
|
|
||||||
player;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MudServer {
|
class MudServer {
|
||||||
/** @type {Map<WebSocket,Session>} */
|
|
||||||
sessions = new Map();
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
/** @type {Game} */
|
/** @type {Game} */
|
||||||
game = new Game();
|
this.game = new Game();
|
||||||
|
if (cfg.DEV) {
|
||||||
/**
|
(new Seeder()).seed(this.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]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ____ ___ _ _ _ _ _____ ____ _____ _____ ____
|
||||||
* @param {WebSocket} websocket
|
// / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \
|
||||||
*/
|
// | | | | | | \| | \| | _|| | | | | _| | | | |
|
||||||
|
// | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| |
|
||||||
|
// \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/
|
||||||
|
//------------------------------------------------------
|
||||||
|
// Handle New Socket Connections
|
||||||
|
//------------------------------
|
||||||
|
/** @param {WebSocket} websocket */
|
||||||
onConnectionEstabished(websocket) {
|
onConnectionEstabished(websocket) {
|
||||||
console.log("New connection established");
|
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", () => {
|
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");
|
// | \/ | ____/ ___/ ___| / \ / ___| ____|
|
||||||
}
|
// | |\/| | _| \___ \___ \ / _ \| | _| _|
|
||||||
|
// | | | | |___ ___) |__) / ___ \ |_| | |___
|
||||||
/**
|
// |_| |_|_____|____/____/_/ \_\____|_____|
|
||||||
* @param {WebSocket} websocket
|
//--------------------------------------------
|
||||||
* @param {strings} data
|
// HANDLE INCOMING MESSAGES
|
||||||
*/
|
//-------------------------
|
||||||
onIncomingMessage(websocket, data) {
|
websocket.on("message", (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 {
|
try {
|
||||||
message = new ClientMessage(data);
|
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) {
|
} catch (error) {
|
||||||
console.error("Bad websocket message", data, error);
|
console.trace("received an invalid message (error: %s)", error, data.toString(), data);
|
||||||
this.send(
|
websocket.send(msg.prepare(
|
||||||
websocket,
|
msg.CALAMITY,
|
||||||
MSG_ERROR,
|
error
|
||||||
"terminal",
|
));
|
||||||
"You sent me a bad message! Goodbye...",
|
}
|
||||||
);
|
});
|
||||||
websocket.close();
|
|
||||||
return;
|
session.setState(new AuthState(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
// Start the server
|
||||||
this.send(websocket, MSG_CALAMITY, "I expected you to send me a username, but you sent me something else instead. You bad! Goodbye...");
|
//-----------------
|
||||||
|
start() {
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
//----------------------------------------------------
|
// The file types we allow to be served.
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
//----------------------------------------------------
|
|
||||||
// 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`);
|
|
||||||
|
|
||||||
//
|
|
||||||
// Handle player logout (move the or hide their characters)
|
|
||||||
// this.game.playerLoggedOut();
|
|
||||||
} else {
|
|
||||||
console.log("A player without a session disconnected");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sessions.delete(websocket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = {
|
const contentTypes = {
|
||||||
".js": "application/javascript",
|
".js": "application/javascript",
|
||||||
".css": "text/css",
|
".css": "text/css",
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!contentTypes[ext]) {
|
//
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
//
|
||||||
|
// Check if the requested file has a legal file type.
|
||||||
|
if (!contentType) {
|
||||||
// Invalid file, pretend it did not exist!
|
// Invalid file, pretend it did not exist!
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end(`File ${filePath} not found (invalid $ext)`);
|
res.end(`File not found`);
|
||||||
|
console.log("Bad http request", req.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = contentTypes[ext];
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// Check if the file exists.
|
||||||
fs.readFile(filePath, (err, data) => {
|
fs.readFile(filePath, (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end(`File ${filePath} . ${ext} not found (${err})`);
|
res.end(`File not found`);
|
||||||
|
console.log("Bad http request", req.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(200, { "Content-Type": contentType });
|
res.writeHead(200, { "Content-Type": contentType });
|
||||||
res.end(data);
|
res.end(data);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create WebSocket server
|
//
|
||||||
const websocketServer = new WebSocketServer({ server: httpServer });
|
// Create WebSocket server
|
||||||
const mudServer = new MudServer();
|
const websocketServer = new WebSocketServer({ server: httpServer });
|
||||||
|
|
||||||
websocketServer.on("connection", (ws) => {
|
websocketServer.on("connection", (ws) => {
|
||||||
mudServer.onConnectionEstabished(ws);
|
this.onConnectionEstabished(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
// websocketServer.on("connection", mudServer.onConnectionEstabished);
|
console.info(`running in ${cfg.ENV} mode`);
|
||||||
|
httpServer.listen(cfg.PORT, () => {
|
||||||
const PORT = process.env.PORT || 3000;
|
console.log(`NUUHD server running on port ${cfg.PORT}`);
|
||||||
httpServer.listen(PORT, () => {
|
|
||||||
console.log(`MUD server running on port ${PORT}`);
|
|
||||||
console.log(`WebSocket server ready for connections`);
|
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
|
* or
|
||||||
* Server-->Client-->Plater
|
* 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.
|
* Message to be displayed.
|
||||||
*
|
*
|
||||||
* Server-->Client-->Player
|
* 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
|
* 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-->Client-->Player
|
||||||
*
|
|
||||||
* Server tells the client to prompt the player for some info
|
|
||||||
*/
|
*/
|
||||||
export const MSG_PROMPT = "ask";
|
export const PROMPT = "prompt";
|
||||||
|
|
||||||
/**
|
|
||||||
* Client sends the player's username to the server
|
|
||||||
*
|
|
||||||
* Player-->Client-->Server
|
|
||||||
*/
|
|
||||||
export const MSG_USERNAME = "user";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Player has entered a command, and wants to do something.
|
* Player has entered a command, and wants to do something.
|
||||||
*
|
*
|
||||||
* Player-->Client-->Server
|
* 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.
|
* Represents a message sent from client to server.
|
||||||
@@ -53,16 +81,16 @@ export class ClientMessage {
|
|||||||
* @protected
|
* @protected
|
||||||
* @type {any[]} _arr The array that contains the message data
|
* @type {any[]} _arr The array that contains the message data
|
||||||
*/
|
*/
|
||||||
_arr;
|
_attr;
|
||||||
|
|
||||||
/** The message type.
|
/** The message type.
|
||||||
*
|
*
|
||||||
* One of the MSG_* constants from this document.
|
* One of the * constants from this document.
|
||||||
*
|
*
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
get type() {
|
get type() {
|
||||||
return this._arr[0];
|
return this._attr[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,66 +98,101 @@ export class ClientMessage {
|
|||||||
*/
|
*/
|
||||||
constructor(msgData) {
|
constructor(msgData) {
|
||||||
if (typeof msgData !== "string") {
|
if (typeof msgData !== "string") {
|
||||||
throw new Error(
|
throw new Error("Could not create client message. Attempting to parse json, but data was not even a string, it was a " + typeof msgData);
|
||||||
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
|
|
||||||
typeof msgData,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._arr = JSON.parse(msgData);
|
this._attr = JSON.parse(msgData);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error(
|
throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`);
|
||||||
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this._arr !== "array") {
|
if (!Array.isArray(this._attr)) {
|
||||||
throw new Error(
|
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`);
|
||||||
`Could not create client message. Excpected an array, but got a ${typeof this._arr}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._arr.length < 1) {
|
if (this._attr.length < 1) {
|
||||||
throw new Error(
|
throw new Error("Could not create client message. Excpected an array with at least 1 element, but got an empty one");
|
||||||
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._arr = arr;
|
|
||||||
}
|
}
|
||||||
|
hasCommand() {
|
||||||
/** Does this message contain a message that should be displayed to the user the "normal" way? */
|
return this._attr.length > 1 && this._attr[0] === COMMAND;
|
||||||
isMessage() {
|
|
||||||
return this._arr[0] === "m";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this message contain a username-response from the client? */
|
/** Does this message contain a username-response from the client? */
|
||||||
hasUsername() {
|
isUsernameResponse() {
|
||||||
return this._arr[0] === MSG_USERNAME;
|
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? */
|
/** Does this message contain a password-response from the client? */
|
||||||
hasPassword() {
|
isPasswordResponse() {
|
||||||
return this._arr[0] === MSG_PASSWORD;
|
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 */
|
/** @returns {string|false} Get the username stored in this message */
|
||||||
get username() {
|
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 */
|
/** @returns {string|false} Get the password stored in this message */
|
||||||
get password() {
|
get password() {
|
||||||
return this.hasPassword() ? this._arr[1] : false;
|
return this.isPasswordResponse() ? this._attr[2] : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
get command() {
|
get command() {
|
||||||
return this.isCommand() ? this._attr[1] : false;
|
return this.hasCommand() ? this._attr[1] : false;
|
||||||
}
|
|
||||||
|
|
||||||
isCommand() {
|
|
||||||
return this._raw[0] === MSG_COMMAND;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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