stuff and things

This commit is contained in:
Kim Ravn Hansen
2025-09-05 17:47:28 +02:00
parent 3835ad1de3
commit 8bcbdbfe35
6 changed files with 306 additions and 221 deletions

View File

@@ -8,10 +8,10 @@
*/ */
import WebSocket from "ws"; import WebSocket from "ws";
import { Character } from "./character"; import { Character } from "./character.js";
import { ItemTemplate } from "./item"; import { ItemTemplate } from "./item.js";
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();
@@ -28,14 +28,10 @@ class Game{
_characters = new Map(); _characters = new Map();
/** /**
* All players ever registered, mapped by name => player.
*
* @protected * @protected
* @type {Map<string,Player>} Map of users in the game username->Player * @type {Map<string,Player>} Map of users in the game username->Player
*/ */
_playersByName = new Map(); _players = new Map(); get players() { return this._players; }
/**
* @protected
* @type {Map<WebSocket,Player>} Map of users in the game username->Player
*/
_playersBySocket = new Map();
} }

View File

@@ -1,4 +1,4 @@
import { cleanIdentifier } from "../utils/helpers"; import { cleanName } from "../utils/id.js";
/** /**
* Item templates are the built-in basic items of the game. * Item templates are the built-in basic items of the game.
@@ -56,7 +56,7 @@ export class ItemTemplate {
throw new Error("itemSlots must be a finite number!"); throw new Error("itemSlots must be a finite number!");
} }
if (typeof id === "undefined") { if (typeof id === "undefined") {
id = cleanIdentifier(name); id = cleanName(name);
} }
if (typeof id !== "string") { if (typeof id !== "string") {
throw new Error("id must be a string!"); throw new Error("id must be a string!");

View File

@@ -132,7 +132,7 @@
this.updateStatus('Connecting...', 'connecting'); this.updateStatus('Connecting...', 'connecting');
try { try {
this.ws = new WebSocket(wsUrl); this. s = new WebSocket(wsUrl);
this.ws.onopen = () => { this.ws.onopen = () => {
this.updateStatus('Connected', 'connected'); this.updateStatus('Connected', 'connected');

View File

@@ -4,90 +4,147 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import { Player } from "./models/player.js"; 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";
class Session {
/** @type {boolean} */
usernameProcessed = false;
/** /** @type {boolean} */
* Parse a string with json-encoded data without throwing exceptions. passwordProcessed = false;
*
* @param {string} data
* @return {any}
*/
function parseJson(data) {
if (typeof data !== "string") {
console.error("Attempting to parse json, but data was not even a string", data);
return;
}
try { /** @type {boolean} */
return JSON.parse(data) ready = false;
} catch (error) {
console.error('Error parsing data as json:', error, data); /** @type Date */
} latestPing;
/** @type {Player} */
player;
} }
class MudServer { class MudServer {
constructor() { /** @type {Map<WebSocket,Session>} */
this.game = new Game(); sessions = new Map();
/** @type {Game} */
game = new Game();
/**
* Send a message via a websocket.
*
* @param {WebSocket} websocket
* @param {string|number} messageType
* @param {...any} args
*/
send(websocket, messageType, ...args) {
// create array consisting of [messageType, args[0], args[1], ... ];
websocket.send(JSON.stringify([messageType, ...args]));
} }
/** /**
* @param {WebSocket} ws * @param {WebSocket} websocket
*/ */
onConnectionEstabished(ws) { onConnectionEstabished(websocket) {
console.log('New connection established'); console.log("New connection established");
this.sessions[websocket] = new Session();
ws.send(JSON.stringify( websocket.on("message", (data) => { this.onIncomingMessage(websocket, data) });
["m", "Welcome to the WebSocket MUD!\nWhat is your username name?"] websocket.on("close", () => { this.onConnectionClosed(websocket); });
));
ws.on('message', (data) => {
this.onIncomingMessage(parseJson(data));
});
ws.on('close', () => { this.send(websocket, MSG_MESSAGE, "Welcome to MUUUHD", "big");
this.onConnectionClosed(ws); this.send(websocket, MSG_PROMPT, "Please enter your username");
});
} }
/** /**
* @param {WebSocket} ws * @param {WebSocket} websocket
* @param {strings} message * @param {strings} data
* @returns
*/ */
onIncomingMessage(ws, message) { onIncomingMessage(websocket, data) {
const player = this.players.get(ws); const session = this.sessions.get(websocket);
if (!session) {
console.error("Incoming message from a client without a session!", data);
this.send(websocket, MSG_ERROR, "terminal", "You do not have an active session. Go away!")
websocket.close();
return
}
let message;
try {
message = new ClientMessage(data);
} catch (error) {
console.error("Bad websocket message", data, error);
this.send(websocket, MSG_ERROR, "terminal", "You sent me a bad message! Goodbye...")
websocket.close();
return
}
if (!session.usernameProcessed) {
//
//----------------------------------------------------
// We haven"t gotten a username yet, so we expect one.
//----------------------------------------------------
if (!message.hasUsername()) {
console.error("User should have sent a “username” message, but sent something else instead")
this.send(websocket, MSG_CALAMITY, "I expected you to send me a username, but you sent me something else instead. You bad! Goodbye...")
// for now, just close the socket.
websocket.close();
}
const player = this.game.players.get(message.username);
if (!player) { if (!player) {
// Player hasn't been created yet, expecting name // player not found - for now, just close the connection - make a better
const name = message.content.trim(); console.log("Invalid username sent during login: %s", username);
if (name && !this.players.has(name)) { this.send(websocket, MSG_ERROR, "Invalid username");
this.createPlayer(ws, name); this.send(websocket, MSG_PROMPT, "Please enter a valid username");
} 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({
type: 'message',
content: 'Invalid name or name already taken. Please choose another:'
}));
} }
// 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; return;
} }
// Process command //
this.processCommand(player, message.content.trim()); //----------------------------------------------------
// 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} ws * @param {WebSocket} websocket
* @param {string} name * @param {string} name
*/ */
createPlayer(ws, name) { createPlayer(websocket, name) {
const player = new Player(name, ws); const player = new Player(name, websocket);
this.players.set(ws, player); this.players.set(websocket, player);
this.players.set(name, player); this.players.set(name, player);
const startRoom = this.rooms.get("town_square"); const startRoom = this.rooms.get("town_square");
@@ -103,199 +160,116 @@ class MudServer {
* @param {string} input * @param {string} input
*/ */
processCommand(player, input) { processCommand(player, input) {
const args = input.toLowerCase().split(' '); const args = input.toLowerCase().split(" ");
const command = args[0]; const command = args[0];
switch (command) { switch (command) {
case 'look': case "look":
case 'l': case "l":
this.showRoom(player); this.showRoom(player);
break; break;
case 'go': case "go":
case 'move': case "move":
if (args[1]) { if (args[1]) {
this.movePlayer(player, args[1]); this.movePlayer(player, args[1]);
} else { } else {
player.sendMessage('Go where?'); player.sendMessage("Go where?");
} }
break; break;
case 'north': case "north":
case 'n': case "n":
this.movePlayer(player, 'north'); this.movePlayer(player, "north");
break; break;
case 'south': case "south":
case 's': case "s":
this.movePlayer(player, 'south'); this.movePlayer(player, "south");
break; break;
case 'east': case "east":
case 'e': case "e":
this.movePlayer(player, 'east'); this.movePlayer(player, "east");
break; break;
case 'west': case "west":
case 'w': case "w":
this.movePlayer(player, 'west'); this.movePlayer(player, "west");
break; break;
case 'say': case "say":
if (args.length > 1) { if (args.length > 1) {
const message = args.slice(1).join(' '); const message = args.slice(1).join(" ");
this.sayToRoom(player, message); this.sayToRoom(player, message);
} else { } else {
player.sendMessage('Say what?'); player.sendMessage("Say what?");
} }
break; break;
case 'who': case "who":
this.showOnlinePlayers(player); this.showOnlinePlayers(player);
break; break;
case 'inventory': case "inventory":
case 'inv': case "inv":
this.showInventory(player); this.showInventory(player);
break; break;
case 'help': case "help":
this.showHelp(player); this.showHelp(player);
break; break;
case 'quit': case "quit":
player.sendMessage('Goodbye!'); player.sendMessage("Goodbye!");
player.websocket.close(); player.websocket.close();
break; break;
default: default:
player.sendMessage(`Unknown command: ${command}. Type 'help' for available commands.`); player.sendMessage(`Unknown command: ${command}. Type "help" for available commands.`);
} }
player.sendPrompt(); player.sendPrompt();
} }
/**
*
* @param {Player} player
* @param {*} direction
* @returns
*/
movePlayer(player, direction) {
const currentRoom = this.rooms.get(player.currentRoom);
const newRoomId = currentRoom.exits[direction];
if (!newRoomId) {
player.sendMessage('You cannot go that way.');
return;
}
const newRoom = this.rooms.get(newRoomId);
if (!newRoom) {
player.sendMessage('That area is not accessible right now.');
return;
}
// Remove from current room and add to new room
currentRoom.removePlayer(player);
player.currentRoom = newRoomId;
newRoom.addPlayer(player);
this.showRoom(player);
}
showRoom(player) {
const room = this.rooms.get(player.currentRoom);
let description = `\n=== ${room.name} ===\n`;
description += `${room.description}\n`;
// Show exits
const exits = Object.keys(room.exits);
if (exits.length > 0) {
description += `\nExits: ${exits.join(', ')}`;
}
// Show other players
const otherPlayers = room.getPlayersExcept(player);
if (otherPlayers.length > 0) {
description += `\nPlayers here: ${otherPlayers.map(p => p.name).join(', ')}`;
}
player.sendMessage(description);
}
sayToRoom(player, message) {
const room = this.rooms.get(player.currentRoom);
const fullMessage = `${player.name} says: "${message}"`;
room.broadcastToRoom(fullMessage, player);
player.sendMessage(`You say: "${message}"`);
}
showOnlinePlayers(player) {
const playerList = Array.from(this.players.keys());
player.sendMessage(`Online players (${playerList.length}): ${playerList.join(', ')}`);
}
showInventory(player) {
if (player.inventory.length === 0) {
player.sendMessage('Your inventory is empty.');
} else {
player.sendMessage(`Inventory: ${player.inventory.join(', ')}`);
}
}
showHelp(player) {
const helpText = `
Available Commands:
- look, l: Look around the current room
- go <direction>, <direction>: Move in a direction (north, south, east, west, n, s, e, w)
- say <message>: Say something to other players in the room
- who: See who else is online
- inventory, inv: Check your inventory
- help: Show this help message
- quit: Leave the game
`;
player.sendMessage(helpText);
}
/** /**
* Called when a websocket connection is closing. * Called when a websocket connection is closing.
*
* @param {WebSocket} websocket
*/ */
onConnectionClosed(ws) { onConnectionClosed(websocket) {
const player = this.players.get(ws); const session = this.sessions.get(websocket);
if (player) {
console.log(`Player ${player.name} disconnected`);
// Remove from room if (session && session.player) {
const room = this.rooms.get(player.currentRoom); console.log(`Player ${player.username} disconnected`);
if (room) {
room.removePlayer(player); //
// Handle player logout (move the or hide their characters)
// this.game.playerLoggedOut();
} else {
console.log("A player without a session disconnected");
} }
// Clean up references this.sessions.delete(websocket);
this.players.delete(ws);
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); 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 = {
'.js': 'application/javascript', ".js": "application/javascript",
'.css': 'text/css', ".css": "text/css",
'.html': 'text/html', ".html": "text/html",
}; };
if (!contentTypes[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 ${filePath} not found (invalid $ext)`);
return; return;
} }
@@ -304,19 +278,19 @@ const server = http.createServer((req, res) => {
fs.readFile(filePath, (err, data) => { fs.readFile(filePath, (err, data) => {
if (err) { if (err) {
res.writeHead(404); res.writeHead(404);
res.end('File not found'); res.end(`File ${filePath} . ${ext} not found (${err})`);
return; return;
} }
res.writeHead(200, { 'Content-Type': contentType }); res.writeHead(200, { "Content-Type": contentType });
res.end(data); res.end(data);
}); });
}); });
// Create WebSocket server // Create WebSocket server
const wss = new WebSocketServer({ server }); const websocketServer = new WebSocketServer({ server });
const mudServer = new MudServer(); const mudServer = new MudServer();
wss.on('connection', (ws) => { websocketServer.on("connection", (ws) => {
mudServer.onConnectionEstabished(ws); mudServer.onConnectionEstabished(ws);
}); });

View File

@@ -1,12 +0,0 @@
export function rollDice(sides) {
const r = Math.random()
return Math.floor(r * sides) + 1;
}
export function d6() {
return rollDice(6);
}
export function d8() {
return rollDice(8);
}

127
server/utils/messages.js Executable file
View File

@@ -0,0 +1,127 @@
/**
* Very bad logic error. Player must quit game, refresh page, and log in again.
*
* Client-->Server
* or
* Server-->Client-->Plater
*/
export const MSG_CALAMITY = "calamity";
/** Tell recipient that an error has occurred */
export const MSG_ERROR = "e";
/**
* Message to be displayed.
*
* Server-->Client-->Player
*/
export const MSG_MESSAGE = "m";
/**
* Message contains the player's password (or hash or whatever).
*
* Player-->Client-->Server
*/
export const MSG_PASSWORD = "pass";
/**
* Server-->Client-->Player
*
* Server tells the client to prompt the player for some info
*/
export const MSG_PROMPT = "ask";
/**
* Client sends the player's username to the server
*
* Player-->Client-->Server
*/
export const MSG_USERNAME = "user";
/**
* Player has entered a command, and wants to do something.
*
* Player-->Client-->Server
*/
export const MSG_COMMAND = "c";
/**
* Represents a message sent from client to server.
*/
export class ClientMessage {
/**
* @protected
* @type {any[]} _arr The array that contains the message data
*/
_arr;
/** The message type.
*
* One of the MSG_* constants from this document.
*
* @returns {string}
*/
get type() {
return this._arr[0];
}
/**
* @param {string} msgData the raw text data in the websocket message.
*/
constructor(msgData) {
if (typeof msgData !== "string") {
throw new Error("Could not create client message. Attempting to parse json, but data was not even a string, it was a " + typeof msgData);
return
}
try {
this._arr = JSON.parse(msgData);
} catch (_) {
throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`);
}
if (typeof this._arr !== "array") {
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._arr}`);
}
if (this._arr.length < 1) {
throw new Error("Could not create client message. Excpected an array with at least 1 element, but got an empty one");
}
this._arr = arr;
}
/** Does this message contain a message that should be displayed to the user the "normal" way? */
isMessage() {
return this._arr[0] === "m";
}
/** Does this message contain a username-response from the client? */
hasUsername() {
return this._arr[0] === MSG_USERNAME;
}
/** Does this message contain a password-response from the client? */
hasPassword() {
return this._arr[0] === MSG_PASSWORD;
}
/** @returns {string|false} Get the username stored in this message */
get username() {
return this.hasUsername() ? this._arr[1] : false;
}
/** @returns {string|false} Get the password stored in this message */
get password() {
return this.hasPassword() ? this._arr[1] : false;
}
get command() {
return this.isCommand() ? this._attr[1] : false;
}
isCommand() {
return this._raw[0] === MSG_COMMAND;
}
}