This commit is contained in:
Kim Ravn Hansen
2025-09-05 13:38:17 +02:00
parent 438f4fe59e
commit 3835ad1de3
9 changed files with 613 additions and 622 deletions

3
server/.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

1
server/.prettierrc Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -11,52 +11,21 @@ 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. */
* Alive?
*
* @protected
* @type {boolean}
*/
_alive = true;
get alive() {
return _alive;
}
/**
* @protected
* @type {number} The number of XP the character has.
*/
_xp = 0; _xp = 0;
get xp() { get xp() { return this._xp; }
return this._xp;
}
/** /** @protected @type {number} The character's level. */
* @protected
* @type {number} The character's level.
*/
_level = 1; _level = 1;
get level() { get level() { return this._level; }
return this._level;
}
/** /** @protected @type {string} unique name used for chats when there's a name clash and also other things that require a unique character id */
* @protected
* @type {string} unique name used for chats when there's a name clash and also other things that require a unique character id
*/
_id; _id;
get id() { get id() { return this._id; }
return this._id;
}
/** /** @protected @type {string} username of the player that owns this character. */
* @protected
* @type {string} username of the player that owns this character.
*/
_username; _username;
get username() { get username() { return this._username; }
return this._username;
}
/** @type {string} Bloodline background */ /** @type {string} Bloodline background */
ancestry; ancestry;
@@ -83,7 +52,7 @@ export class Character {
equipment = new Map(); equipment = new Map();
/** /**
* @param {string} playerUname 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} initialize Should we initialize the character
*/ */

View File

@@ -7,6 +7,7 @@
* Serializing this object effectively saves the game. * Serializing this object effectively saves the game.
*/ */
import WebSocket from "ws";
import { Character } from "./character"; import { Character } from "./character";
import { ItemTemplate } from "./item"; import { ItemTemplate } from "./item";
@@ -28,7 +29,13 @@ class Game{
/** /**
* @protected * @protected
* @type {Map<string,Player>} The list of users in the game * @type {Map<string,Player>} Map of users in the game username->Player
*/ */
_players = new Map(); _playersByName = new Map();
/**
* @protected
* @type {Map<WebSocket,Player>} Map of users in the game username->Player
*/
_playersBySocket = new Map();
} }

View File

@@ -1,5 +1,78 @@
import WebSocket from 'ws';
/**
* Player Account.
*
* 1. Contain persistent player account info.
* 2. Contain the connection to the client machine if the player is currently playing the game.
* 3. Contain session information.
*
* We can do this because we only allow a single websocket per player account.
* You are not allowed to log in if a connection/socket is already open.
*
* We regularly ping and pong to ensure that stale connections are closed.
*
*/
export class Player{ export class Player{
/** @protected @type {string} unique username */
_username; _username;
get username() { return this._username; }
/** @protected @type {string} */
_passwordHash; _passwordHash;
alias; get passwordHash() { return this._passwordHash; }
/** @protected @type {WebSocket} Player's current and only websocket. If undefined, the player is not logged in. */
_websocket;
get websocket() { return this._websocket; }
/** @protected @type {Date} */
_latestSocketReceived;
constructor(username, passwordHash) {
this._username = username;
} }
/** @param {WebSocket} websocket */
clientConnected(websocket) {
this._websocket = websocket;
}
/***
* Send a message back to the client via the WebSocket.
*
* @param {string} message
* @return {boolean} success
*/
_send(data) {
if (!this._websocket) {
console.error("Trying to send a message to an uninitialized websocket", this, data)
return false;
}
if (this._websocket.readyState === WebSocket.OPEN) {
this._websocket.send(JSON.stringify(data));
return true;
}
if (this._websocket.readyState === WebSocket.CLOSED) {
console.error("Trying to send a message through a CLOSED websocket", this, data);
return false;
}
if (this._websocket.readyState === WebSocket.CLOSING) {
console.error("Trying to send a message through a CLOSING websocket", this, data);
return false;
}
if (this._websocket.readyState === WebSocket.CONNECTING) {
console.error("Trying to send a message through a CONNECTING (not yet open) websocket", this, data);
return false;
}
console.error("Trying to send a message through a websocket with an UNKNOWN readyState (%d)", this.websocket.readyState, this, data);
return false;
}
sendPrompt() {
this.sendMessage(`\n[${this.currentRoom}] > `);
}
}

View File

@@ -12,7 +12,8 @@
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1",
"prettier": "3.6.2"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -302,6 +303,22 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pstree.remy": { "node_modules/pstree.remy": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",

View File

@@ -8,14 +8,20 @@
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js" "dev": "nodemon server.js"
}, },
"keywords": ["mud", "websocket", "game", "multiplayer"], "keywords": [
"mud",
"websocket",
"game",
"multiplayer"
],
"author": "Your Name", "author": "Your Name",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1",
"prettier": "3.6.2"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View File

@@ -1,194 +1,73 @@
const WebSocket = require('ws'); import WebSocket, { WebSocketServer } from "ws";
const http = require('http'); import http from "http";
const path = require('path'); import path from "path";
const fs = require('fs'); import fs from "fs";
import { Player } from "./models/player.js";
import { Game } from "./models/game.js";
/** /**
* Player * Parse a string with json-encoded data without throwing exceptions.
* @property WebSocket websocket
*/
class Player {
/**
* *
* @param {String} name * @param {string} data
* @param {WebSocket} websocket * @return {any}
*/ */
constructor(name, websocket) { function parseJson(data) {
this.name = name; if (typeof data !== "string") {
this.websocket = websocket; console.error("Attempting to parse json, but data was not even a string", data);
this.currentRoom = 'town_square'; return;
this.health = 100;
this.inventory = [];
this.level = 1;
} }
/*** try {
* Send a message back to the client via the websocket. return JSON.parse(data)
* } catch (error) {
* @param {string} message console.error('Error parsing data as json:', error, data);
*/
sendMessage(message) {
if (this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify({
type: 'message',
content: message
}));
}
}
sendPrompt() {
this.sendMessage(`\n[${this.currentRoom}] > `);
}
}
class Room {
/**
*
* @param {string} id
* @param {string} name
* @param {string} description
* @param {string[]} exits
*/
constructor(id, name, description, exits = {}) {
this.id = id;
this.name = name;
this.description = description;
this.exits = exits; // { north: 'room_id', south: 'room_id' }
this.players = new Set();
this.items = [];
this.npcs = [];
}
/**
* Add a player to the list of active players.
*
* (an active player is a player that currently has an active web socketA)
*
* @param {Player} player
*/
addPlayer(player) {
this.players.add(player);
this.broadcastToRoom(`${player.name} enters the room.`, player);
}
/**
* Remove a player from the list of active players.
*
* (an active player is a player that currently has an active web socketA)
*
* @param {Player} player
*/
removePlayer(player) {
this.players.delete(player);
this.broadcastToRoom(`${player.name} leaves the room.`, player);
}
/**
* Send a message to all other players in this room.
*
* @param {string} message
* @param {Player} excludePlayer A single player to exclude from the broadcast
*/
broadcastToRoom(message, excludePlayer = null) {
// for (const player of this.players) {
// if (player !== excludePlayer) {
// player.sendMessage(message);
// }
// }
this.getPlayersExcept(excludePlayer).forEach((player) => {
player.sendMessage(message);
})
}
getPlayersExcept(excludePlayer) {
return Array.from(this.players).filter(p => p !== excludePlayer);
} }
} }
class MudServer { class MudServer {
constructor() { constructor() {
this.players = new Map(); // websocket -> Player this.game = new Game();
this.rooms = new Map();
this.playersByName = new Map(); // name -> Player
this.initializeRooms();
}
initializeRooms() {
const townSquare = new Room(
'town_square',
'Town Square',
'You are standing in the bustling town square. A fountain sits in the center, and cobblestone paths lead in all directions. The inn lies to the north, and a mysterious forest path leads east.',
{ north: 'inn', east: 'forest_entrance' }
);
const inn = new Room(
'inn',
'The Rusty Dragon Inn',
'A cozy tavern filled with the aroma of hearty stew and ale. Adventurers gather around wooden tables, sharing tales of their exploits.',
{ south: 'town_square' }
);
const forestEntrance = new Room(
'forest_entrance',
'Forest Entrance',
'The edge of a dark, mysterious forest. Ancient trees tower overhead, and you can hear strange sounds echoing from within.',
{ west: 'town_square', north: 'deep_forest' }
);
const deepForest = new Room(
'deep_forest',
'Deep Forest',
'You are deep within the forest. Shadows dance between the trees, and you feel like you\'re being watched.',
{ south: 'forest_entrance' }
);
this.rooms.set('town_square', townSquare);
this.rooms.set('inn', inn);
this.rooms.set('forest_entrance', forestEntrance);
this.rooms.set('deep_forest', deepForest);
} }
/** /**
*
* @param {WebSocket} ws * @param {WebSocket} ws
*/ */
handleConnection(ws) { onConnectionEstabished(ws) {
console.log('New connection established'); console.log('New connection established');
ws.send(JSON.stringify({ ws.send(JSON.stringify(
type: 'message', ["m", "Welcome to the WebSocket MUD!\nWhat is your username name?"]
content: 'Welcome to the WebSocket MUD!\nWhat is your character name?' ));
}));
ws.on('message', (data) => { ws.on('message', (data) => {
try { this.onIncomingMessage(parseJson(data));
const message = JSON.parse(data);
this.handleMessage(ws, message);
} catch (error) {
console.error('Error parsing message:', error);
}
}); });
ws.on('close', () => { ws.on('close', () => {
this.handleDisconnection(ws); this.onConnectionClosed(ws);
}); });
} }
/** /**
*
* @param {WebSocket} ws * @param {WebSocket} ws
* @param {strings} message * @param {strings} message
* @returns * @returns
*/ */
handleMessage(ws, message) { onIncomingMessage(ws, message) {
const player = this.players.get(ws); const player = this.players.get(ws);
if (!player) { if (!player) {
// Player hasn't been created yet, expecting name // Player hasn't been created yet, expecting name
const name = message.content.trim(); const name = message.content.trim();
if (name && !this.playersByName.has(name)) { if (name && !this.players.has(name)) {
this.createPlayer(ws, name); this.createPlayer(ws, name);
} else { } else {
/**
* @todo: send an array instead of object.
* element 1 is the type
* element 2 is the content
* element 3+ are expansions
*/
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: 'message', type: 'message',
content: 'Invalid name or name already taken. Please choose another:' content: 'Invalid name or name already taken. Please choose another:'
@@ -209,9 +88,9 @@ class MudServer {
createPlayer(ws, name) { createPlayer(ws, name) {
const player = new Player(name, ws); const player = new Player(name, ws);
this.players.set(ws, player); this.players.set(ws, player);
this.playersByName.set(name, player); this.players.set(name, player);
const startRoom = this.rooms.get(player.currentRoom); const startRoom = this.rooms.get("town_square");
startRoom.addPlayer(player); startRoom.addPlayer(player);
player.sendMessage(`Welcome, ${name}! You have entered the world.`); player.sendMessage(`Welcome, ${name}! You have entered the world.`);
@@ -354,7 +233,7 @@ class MudServer {
} }
showOnlinePlayers(player) { showOnlinePlayers(player) {
const playerList = Array.from(this.playersByName.keys()); const playerList = Array.from(this.players.keys());
player.sendMessage(`Online players (${playerList.length}): ${playerList.join(', ')}`); player.sendMessage(`Online players (${playerList.length}): ${playerList.join(', ')}`);
} }
@@ -380,7 +259,10 @@ Available Commands:
player.sendMessage(helpText); player.sendMessage(helpText);
} }
handleDisconnection(ws) { /**
* Called when a websocket connection is closing.
*/
onConnectionClosed(ws) {
const player = this.players.get(ws); const player = this.players.get(ws);
if (player) { if (player) {
console.log(`Player ${player.name} disconnected`); console.log(`Player ${player.name} disconnected`);
@@ -393,14 +275,15 @@ Available Commands:
// Clean up references // Clean up references
this.players.delete(ws); this.players.delete(ws);
this.playersByName.delete(player.name); this.players.delete(player.name);
} }
} }
} }
// Create HTTP server for serving the client // Create HTTP server for serving the client
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
let filePath = path.join(__dirname, 'public', req.url === '/' ? 'index.html' : req.url); // 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 ext = path.extname(filePath);
const contentTypes = { const contentTypes = {
@@ -409,13 +292,15 @@ const server = http.createServer((req, res) => {
'.html': 'text/html', '.html': 'text/html',
}; };
if (!contentType[ext]) { if (!contentTypes[ext]) {
// Invalid file, pretend it did not exist! // Invalid file, pretend it did not exist!
res.writeHead(404); res.writeHead(404);
res.end('File not found'); res.end('File not found');
return; return;
} }
const contentType = contentTypes[ext];
fs.readFile(filePath, (err, data) => { fs.readFile(filePath, (err, data) => {
if (err) { if (err) {
res.writeHead(404); res.writeHead(404);
@@ -428,11 +313,11 @@ const server = http.createServer((req, res) => {
}); });
// Create WebSocket server // Create WebSocket server
const wss = new WebSocket.Server({ server }); const wss = new WebSocketServer({ server });
const mudServer = new MudServer(); const mudServer = new MudServer();
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
mudServer.handleConnection(ws); mudServer.onConnectionEstabished(ws);
}); });
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;

30
server/utils/password.js Normal file
View File

@@ -0,0 +1,30 @@
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;
}