stuffAndThings

This commit is contained in:
Kim Ravn Hansen
2025-09-09 12:55:50 +02:00
parent c8c7259574
commit 5d0cc61cf9
23 changed files with 823 additions and 358 deletions

View File

@@ -7,7 +7,7 @@
* Serializing this object effectively saves the game.
*/
import WebSocket from "ws";
import { miniUid } from "../utils/id.js";
import { Character } from "./character.js";
import { ItemTemplate } from "./item.js";
import { Player } from "./player.js";
@@ -28,43 +28,74 @@ export class Game {
*/
_characters = new Map();
/**
* All players ever registered, mapped by name => player.
*
* _____ _
* | ___(_)_ ___ __ ___ ___
* | |_ | \ \/ / '_ ` _ \ / _ \
* | _| | |> <| | | | | | __/
* |_| |_/_/\_\_| |_| |_|\___|
*
* 1. Add mutex on the players table to avoid race conditions during
* insert/delete/check_available_username
* 1.a ) add an "atomicInsert" that inserts a new player if the giver username
* is available.
* 2. Prune "dead" players (players with 0 logins) after a short while
*
*
/*
* @protected
* @type {Map<string,Player>} Map of users in the game username->Player
*/
_players = new Map();
hasPlayer(username) {
return this._players.has(username);
}
getPlayer(username) {
return this._players.get(username);
}
createPlayer(username, passwordHash=null) {
/**
* Atomic player creation.
*
* @param {string} username
* @param {string?} passwordHash
* @param {string?} salt
*
* @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise.
*/
createPlayer(username, passwordHash = undefined, salt = undefined) {
if (this._players.has(username)) {
return false;
}
const player = new Player(username, passwordHash);
const player = new Player(
username,
typeof passwordHash === "string" ? passwordHash : "",
typeof salt === "string" && salt.length > 0 ? salt : miniUid()
);
this._players.set(username, player);
return player;
}
/**
* Create an ItemTemplate with a given ID
*
* @param {string} id
* @param {object} attributes
*
* @returns {ItemTemplate|false}
*/
createItemTemplate(id, attributes) {
if (typeof id !== "string" || !id) {
throw new Error("Invalid id!");
}
if (this._itemTemplates.has(id)) {
return false;
}
/** @type {ItemTemplate} */
const result = new ItemTemplate(id, attributes.name, attributes.itemSlots);
for (const key of Object.keys(result)) {
if (key === "id") {
continue;
}
if (key in attributes) {
result[key] = attributes[key];
}
}
this._itemTemplates.set(id, result);
return result;
}
}

View File

@@ -1,5 +1,3 @@
import { cleanName } from "../utils/id.js";
/**
* Item templates are the built-in basic items of the game.
* A character cannot directly own one of these items,
@@ -7,74 +5,69 @@ import { cleanName } from "../utils/id.js";
* generate these CharacterItems.
*/
export class ItemTemplate {
_id;
_name;
_description;
_itemSlots;
/** @constant @readonly @type {string} Item's machine-friendly name */
id;
/** @type {string} Item's machine-friendly name */
get id() {
return this._id;
}
/** @constant @readonly @type {string} Item's human-friendly name */
name;
/** @type {string} Item's human-friendly name */
get name() {
return this._name;
}
/** @constant @readonly @type {string} Item's Description */
description;
/** @type {string} Item's Description */
get description() {
return this._description;
}
/** @constant @readonly @type {number} Number of Item Slots taken up by this item. */
itemSlots;
/** @type {number} Number of Item Slots taken up by this item. */
get itemSlots() {
return this._itemSlots;
}
/** @constant @readonly @type {number?} How much damage (if any) does this item deal */
damage;
/** @constant @readonly @type {string?} Which special effect is triggered when successfull attacking with this item? */
specialEffect;
/** @constant @readonly @type {boolean?} Can this item be used as a melee weapon? */
melee;
/** @constant @readonly @type {boolean?} Can this item be used as a ranged weapon? */
ranged;
/** @constant @readonly @type {string?} Type of ammo that this item is, or that this item uses */
ammoType;
/**
* Constructor
*
* @param {string=null} id Item's machine-friendly name.
* @param {string} name. The Item's Name.
* @param {number} itemSlots number of item slots the item takes up in a character's inventory.
* @param {string} description Item's detailed description.
* @param {string=} id Item's machine-friendly name.
*/
constructor(name, itemSlots, description, id) {
if (typeof name !== "string") {
throw new Error("Name must be a string, but " + typeof name + " given.");
}
if (typeof description === "undefined") {
description = "";
}
if (typeof description !== "string") {
throw new Error("Name must be a string, but " + typeof name + " given.");
}
if (!Number.isFinite(itemSlots)) {
throw new Error("itemSlots must be a finite number!");
}
if (typeof id === "undefined") {
id = cleanName(name);
}
if (typeof id !== "string") {
constructor(id, name, itemSlots) {
if (typeof id !== "string" || id.length < 1) {
throw new Error("id must be a string!");
}
this._name = name;
this._id = id;
this._itemSlots = Number(itemSlots);
this._description = "";
if (typeof name !== "string" || name.length < 1) {
throw new Error("Name must be a string, but " + typeof name + " given.");
}
if (!Number.isFinite(itemSlots)) {
throw new Error("itemSlots must be a finite number!");
}
this.name = name;
this.id = id;
this.itemSlots = Number(itemSlots);
}
//
// Spawn a new item!
/** @returns {Item} */
createItem() {
return new ChracterItem(this._id, this._name, this._description, this._itemSlots);
}
static getOrCreate(id, name, description, itemSlots) {
}
static seed() {
this
return new ChracterItem(
this.id,
this.name,
this.description,
this.itemSlots,
);
}
}
@@ -98,8 +91,8 @@ export class ItemTemplate {
* Another bonus is, that the game can spawn custom items that arent even in the ItemTemplate Set.
*/
export class CharacterItem {
/** @type {string?} The unique name if the ItemTemplate this item is based on. May be null. */
templateItemId; // We use the id instead of a pointer, could make garbage collection better.
/** @type {ItemTemplate|null} The template that created this item. Null if no such template exists [anymore]. */
itemTemplate; // We use the id instead of a pointer, could make garbage collection better.
/** @type {string} The player's name for this item. */
name;

View File

@@ -7,26 +7,34 @@ import { Character } from "./character.js";
* Contain persistent player account info.
*/
export class Player {
/**
* @protected
* @type {string} unique username
*/
/** @protected @type {string} unique username */
_username;
get username() {
return this._username;
}
/**
* @protected
* @type {string}
*/
/** @protected @type {string} */
_passwordHash;
get passwordHash() {
return this._passwordHash;
}
/** @type {Date} */
/** @protected @type {string} random salt used for hashing */
_salt;
get salt() {
return this._salt;
}
/** @protected @type {Date} */
_createdAt = new Date();
get createdAt() {
return this._createdAt;
}
/** @type {Date} */
blockedUntil;
/** @type {Date|null} Date of the player's last websocket message. */
lastActivityAt = null;
@@ -41,7 +49,7 @@ export class Player {
failedPasswordsSinceLastLogin = 0;
/** @protected @type {Set<Character>} */
_characters = new Set();
_characters = new Set(); // should this be a WeakSet? After all if the player is removed, their items might remain in the system, right?
get characters() {
return this._characters;
}
@@ -49,11 +57,12 @@ export class Player {
/**
* @param {string} username
* @param {string} passwordHash
* @param {string} salt
*/
constructor(username, passwordHash) {
constructor(username, passwordHash, salt) {
this._username = username;
this._passwordHash = passwordHash;
this._salt = salt;
this._createdAt = new Date();
}

View File

@@ -1,7 +1,7 @@
import WebSocket from 'ws';
import { Game } from './game.js';
import { Player } from './player.js';
import { StateInterface } from './states/interface.js';
import { StateInterface } from '../states/interface.js';
import * as msg from '../utils/messages.js';
import figlet from 'figlet';
@@ -20,20 +20,46 @@ export class Session {
}
/** @type {Player} */
player;
_player;
get player() {
return this._player;
}
/** @param {Player} player */
set player(player) {
if (player instanceof Player) {
this._player = player;
return;
}
if (player === null) {
this._player = null;
return;
}
throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`);
}
/** @type {WebSocket} */
websocket;
_websocket;
/**
* @param {WebSocket} websocket
* @param {Game} game
*/
constructor(websocket, game) {
this.websocket = websocket;
this._websocket = websocket;
this._game = game;
}
/** Close the session and websocket */
close() {
this._websocket.close();
this._player = null;
}
/**
* Send a message via our websocket.
*
@@ -41,7 +67,7 @@ export class Session {
* @param {...any} args
*/
send(messageType, ...args) {
this.websocket.send(JSON.stringify([messageType, ...args]));
this._websocket.send(JSON.stringify([messageType, ...args]));
}
sendFigletMessage(message) {
@@ -62,26 +88,32 @@ export class Session {
/**
* @param {string} type prompt type (username, password, character name, etc.)
* @param {string} message The prompting message (please enter your character's name)
* @param {string|string[]} message The prompting message (please enter your character's name)
* @param {string} tag helps with message routing and handling.
*/
sendPrompt(type, message,...args) {
sendPrompt(type, message, tag="default", ...args) {
if (Array.isArray(message)) {
message = message.join("\n");
}
this.send(msg.PROMPT, type, message,...args);
this.send(msg.PROMPT, type, message, tag, ...args);
}
/** @param {string} message The error message to display to player */
sendError(message,...args) {
sendError(message, ...args) {
this.send(msg.ERROR, message, ...args);
}
/** @param {string} message The error message to display to player */
sendDebug(message, ...args) {
this.send(msg.DEBUG, message, ...args);
}
/** @param {string} message The calamitous error to display to player */
sendCalamity(message,...args) {
sendCalamity(message, ...args) {
this.send(msg.CALAMITY, message, ...args);
}
sendSystemMessage(arg0,...rest) {
sendSystemMessage(arg0, ...rest) {
this.send(msg.SYSTEM, arg0, ...rest);
}

View File

@@ -1,158 +0,0 @@
import * as msg from "../../utils/messages.js";
import * as security from "../../utils/security.js";
import { CreatePlayerState } from "./createPlayer.js";
import { JustLoggedInState } from "./justLoggedIn.js";
import { Session } from "../session.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;
}
const player = this.session.game.getPlayer(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);
this.session.player.failedPasswordsSinceLastLogin++;
return;
}
this.session.player.lastSucessfulLoginAt = new Date();
this.session.player.failedPasswordsSinceLastLogin = 0;
//
// 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));
}
}

View File

@@ -1,50 +0,0 @@
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}`);
}
}
}

View File

@@ -1,103 +0,0 @@
import figlet from "figlet";
import { Session } from "../session.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);
}
}

View File

@@ -1,157 +0,0 @@
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 player = this.session.game.createPlayer(message.username);
//
// handle taken/occupied username
if (player === false) {
// Telling the user right away that the username is taken can
// lead to data leeching. But fukkit.
this.session.sendError(`Username _${message.username}_ was taken by another player.`);
this.session.sendPrompt("username", USERNAME_PROMPT);
return;
}
this._player = player;
this.session.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));
}
}

View File

@@ -1,13 +0,0 @@
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) {}
}

View File

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