This commit is contained in:
Kim Ravn Hansen
2025-10-23 09:37:39 +02:00
parent cda8392795
commit 4c2b2dcdfe
8 changed files with 281 additions and 263 deletions

View File

@@ -9,6 +9,7 @@
import { isIdSane, miniUid } from "../utils/id.js"; import { isIdSane, miniUid } from "../utils/id.js";
import { Xorshift32 } from "../utils/random.js"; import { Xorshift32 } from "../utils/random.js";
import { Security } from "../utils/security.js";
import { ItemBlueprint } from "./item.js"; import { ItemBlueprint } from "./item.js";
import { Player } from "./player.js"; import { Player } from "./player.js";
@@ -83,18 +84,17 @@ export class Game {
* @param {string?} passwordHash * @param {string?} passwordHash
* @param {string?} salt * @param {string?} salt
* *
* @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise. * @returns {Player|false} Returns the player if username wasn't already taken, or null otherwise.
*/ */
createPlayer(username, passwordHash = undefined, salt = undefined) { createPlayer(username, passwordHash = undefined, salt = undefined) {
if (this.#players.has(username)) { if (this.#players.has(username)) {
return false; return false;
} }
const player = new Player( passwordHash ??= "";
username, salt ??= Security.generateHash(miniUid());
typeof passwordHash === "string" ? passwordHash : "",
typeof salt === "string" && salt.length > 0 ? salt : miniUid(), const player = new Player(username, passwordHash, salt);
);
this.#players.set(username, player); this.#players.set(username, player);

View File

@@ -1,7 +1,7 @@
import { Player } from "./player.js"; import { Player } from "./player.js";
import { mustBeString, mustBe } from "../utils/mustbe.js"; import { mustBeString, mustBe } from "../utils/mustbe.js";
import { Scene } from "../scenes/scene.js"; import { Scene } from "../scenes/scene.js";
import { formatMessage, MessageType } from "../utils/messages.js"; import * as Messages from "../utils/messages.js";
/** @typedef {import("ws").WebSocket} WebSocket */ /** @typedef {import("ws").WebSocket} WebSocket */
@@ -42,7 +42,7 @@ export class Session {
* @param {Scene} scene * @param {Scene} scene
*/ */
setScene(scene) { setScene(scene) {
console.debug("changing scene", scene.constructor.name); console.debug("Changing scene", { scene: scene.constructor.name });
if (!(scene instanceof Scene)) { if (!(scene instanceof Scene)) {
throw new Error(`Expected instance of Scene, got a ${typeof scene}: >>${scene}<<`); throw new Error(`Expected instance of Scene, got a ${typeof scene}: >>${scene}<<`);
} }
@@ -91,7 +91,7 @@ export class Session {
console.error("Trying to send a message without a valid websocket", messageType, args); console.error("Trying to send a message without a valid websocket", messageType, args);
return; return;
} }
this._websocket.send(formatMessage(messageType, ...args)); this._websocket.send(Messages.formatMessage(messageType, ...args));
} }
/** /**
@@ -112,7 +112,7 @@ export class Session {
} }
this.send( this.send(
MessageType.PROMPT, // message type Messages.PROMPT, // message type
text, // TODO: prompt text must be string or an array of strings text, // TODO: prompt text must be string or an array of strings
mustBe(options, "object"), mustBe(options, "object"),
); );
@@ -125,12 +125,17 @@ export class Session {
* @param {object?} options message options for the client. * @param {object?} options message options for the client.
*/ */
sendText(text, options = {}) { sendText(text, options = {}) {
this.send(MessageType.TEXT, text, options); this.send(Messages.TEXT, text, options);
} }
/** @param {string|string[]} errorMessage */ /** @param {string|string[]} errorMessage */
sendError(errorMessage, options = { verbatim: true, error: true }) { sendError(errorMessage, options = { verbatim: true, error: true }) {
this.send(MessageType.ERROR, mustBeString(errorMessage), options); this.send(Messages.ERROR, mustBeString(errorMessage), options);
}
/** @param {string|string[]} debugMessage */
sendDebug(debugMessage, options = { verbatim: true, debug: true }) {
this.send(Messages.DEBUG, debugMessage, options);
} }
/** /**
@@ -141,7 +146,7 @@ export class Session {
// //
// The client should know not to format calamaties anyway, but we add “preformatted” anyway // The client should know not to format calamaties anyway, but we add “preformatted” anyway
console.info("CALAMITY", errorMessage); console.info("CALAMITY", errorMessage);
this.send(MessageType.CALAMITY, errorMessage, { verbatim: true, calamity: true }); this.send(Messages.CALAMITY, errorMessage, { verbatim: true, calamity: true });
this.close(); this.close();
} }
@@ -150,6 +155,6 @@ export class Session {
* @param {any?} value * @param {any?} value
*/ */
sendSystemMessage(systemMessageType, value = undefined) { sendSystemMessage(systemMessageType, value = undefined) {
this.send(MessageType.SYSTEM, mustBeString(systemMessageType), value); this.send(Messages.SYSTEM, mustBeString(systemMessageType), value);
} }
} }

View File

@@ -147,9 +147,11 @@ class PasswordPrompt extends Prompt {
return; return;
} }
const player = this.scene.player;
// //
// Block users who enter bad passwords too many times. // Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) { if (player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = Date.now() + Config.accountLockoutSeconds * 1000; this.blockedUntil = Date.now() + Config.accountLockoutSeconds * 1000;
this.calamity("You have been locked out for too many failed password attempts, come back later"); this.calamity("You have been locked out for too many failed password attempts, come back later");
return; return;
@@ -158,7 +160,7 @@ class PasswordPrompt extends Prompt {
// //
// Handle blocked users. // Handle blocked users.
// They don't even get to have their password verified. // They don't even get to have their password verified.
if (this.player.blockedUntil > Date.now()) { if (player.blockedUntil > Date.now()) {
// //
// Try to re-login too soon, and your lockout lasts longer. // Try to re-login too soon, and your lockout lasts longer.
this.blockedUntil += Config.accountLockoutSeconds * 1000; this.blockedUntil += Config.accountLockoutSeconds * 1000;
@@ -168,23 +170,23 @@ class PasswordPrompt extends Prompt {
// //
// Verify the password against the hash we've stored. // Verify the password against the hash we've stored.
if (!Security.verifyPassword(text, this.player.passwordHash)) { if (!Security.verifyPassword(text, player.passwordHash)) {
this.sendError("Incorrect password!"); this.sendError("Incorrect password!");
this.player.failedPasswordsSinceLastLogin++; player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`); this.session.sendDebug(`Failed login attempt #${player.failedPasswordsSinceLastLogin}`);
this.execute(); this.execute();
return; return;
} }
this.player.lastSucessfulLoginAt = new Date(); player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0; player.failedPasswordsSinceLastLogin = 0;
// //
// We do not allow a player to be logged in more than once! // We do not allow a player to be logged in more than once!
if (this.player.loggedIn) { if (player.loggedIn) {
this.calamity("This player is already logged in"); this.calamity("player is already logged in");
return; return;
} }

View File

@@ -1,10 +1,3 @@
import figlet from "figlet";
import { Session } from "../models/session.js";
import { WebsocketMessage } from "../utils/messages.js";
import { frameText } from "../utils/tui.js";
import { Config } from "../config.js";
import { State } from "./state.js";
// _____ ___ ____ ___ ____ _ _____ // _____ ___ ____ ___ ____ _ _____
// |_ _/ _ \| _ \ / _ \ _ / ___|___ _ ____ _____ _ __| |_ |_ _|__ // |_ _/ _ \| _ \ / _ \ _ / ___|___ _ ____ _____ _ __| |_ |_ _|__
// | || | | | | | | | | (_) | | / _ \| '_ \ \ / / _ \ '__| __| | |/ _ \ // | || | | | | | | | | (_) | | / _ \| '_ \ \ / / _ \ '__| __| | |/ _ \
@@ -17,93 +10,93 @@ import { State } from "./state.js";
// ___) | (_| __/ | | | __/\__ \ // ___) | (_| __/ | | | __/\__ \
// |____/ \___\___|_| |_|\___||___/ // |____/ \___\___|_| |_|\___||___/
export class PartyCreationState extends State { // export class PartyCreationState extends State {
/** // /**
* @proteted // * @proteted
* @type {(msg: WebsocketMessage) => } // * @type {(msg: WebsocketMessage) => }
* // *
* NOTE: Should this be a stack? // * NOTE: Should this be a stack?
*/ // */
_dynamicMessageHandler; // _dynamicMessageHandler;
//
/** @param {Session} session */ // /** @param {Session} session */
constructor(session) { // constructor(session) {
super(); // super();
this.session = session; // this.session = session;
} // }
//
/** We attach (and execute) the next state */ // /** We attach (and execute) the next state */
onAttach() { // onAttach() {
const charCount = this.session.player.characters.size; // const charCount = this.session.player.characters.size;
//
//NOTE: could use async to optimize performance // //NOTE: could use async to optimize performance
const createPartyLogo = frameText(figlet.textSync("Create Your Party"), { // const createPartyLogo = frameText(figlet.textSync("Create Your Party"), {
vPadding: 0, // vPadding: 0,
frameChars: "§=§§§§§§", // frameChars: "§=§§§§§§",
}); // });
//
this.sendText(createPartyLogo, { preformatted: true }); // this.sendText(createPartyLogo, { preformatted: true });
//
this.session.sendText(["", `Current party size: ${charCount}`, `Max party size: ${Config.maxPartySize}`]); // this.session.sendText(["", `Current party size: ${charCount}`, `Max party size: ${Config.maxPartySize}`]);
const min = 1; // const min = 1;
const max = Config.maxPartySize - charCount; // const max = Config.maxPartySize - charCount;
const prompt = [ // const prompt = [
`Please enter an integer between ${min} - ${max}`, // `Please enter an integer between ${min} - ${max}`,
"((type *:help* to get more info about party size))", // "((type *:help* to get more info about party size))",
]; // ];
//
this.sendText(`You can create a party with ${min} - ${max} characters, how big should your party be?`); // this.sendText(`You can create a party with ${min} - ${max} characters, how big should your party be?`);
//
/** @param {WebsocketMessage} m */ // /** @param {WebsocketMessage} m */
this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); // this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
} // }
//
/** @param {WebsocketMessage} m */ // /** @param {WebsocketMessage} m */
receiveCharacterCount(m) { // receiveCharacterCount(m) {
if (m.isHelpRequest()) { // if (m.isHelpRequest()) {
return this.partySizeHelp(); // return this.partySizeHelp();
} // }
//
if (!m.isInteger()) { // if (!m.isInteger()) {
this.sendError("You didn't enter an integer"); // this.sendError("You didn't enter an integer");
this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); // this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
return; // return;
} // }
//
const numCharactersToCreate = Number(m.text); // const numCharactersToCreate = Number(m.text);
if (numCharactersToCreate > Config.maxPartySize) { // if (numCharactersToCreate > Config.maxPartySize) {
this.sendError("Number too high"); // this.sendError("Number too high");
this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); // this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
return; // return;
} // }
//
if (numCharactersToCreate < 1) { // if (numCharactersToCreate < 1) {
this.sendError("Number too low"); // this.sendError("Number too low");
this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m)); // this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
return; // return;
} // }
//
this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`); // this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`);
} // }
//
partySizeHelp() { // partySizeHelp() {
this.sendText([ // this.sendText([
`Your party can consist of 1 to ${Config.maxPartySize} characters.`, // `Your party can consist of 1 to ${Config.maxPartySize} characters.`,
"", // "",
"* Large parties tend live longer", // "* Large parties tend live longer",
`* If you have fewer than ${Config.maxPartySize} characters, you can`, // `* If you have fewer than ${Config.maxPartySize} characters, you can`,
" hire extra characters in your local inn.", // " hire extra characters in your local inn.",
"* large parties level slower because there are more", // "* large parties level slower because there are more",
" characters to share the Experience Points", // " characters to share the Experience Points",
"* The individual members of small parties get better", // "* The individual members of small parties get better",
" loot because they don't have to share, but it", // " loot because they don't have to share, but it",
" a lot of skill to accumulate loot as fast a larger", // " a lot of skill to accumulate loot as fast a larger",
" party can", // " party can",
]); // ]);
return; // return;
} // }
} // }
//
if (Math.PI < 0 && Session && WebsocketMessage) { // if (Math.PI < 0 && Session && WebsocketMessage) {
("STFU Linda"); // ("STFU Linda");
} // }

View File

@@ -1,75 +0,0 @@
import { Prompt } from "../prompt.js";
import { Security } from "../../utils/security.js";
import { Config } from "../../config.js";
export class CreatePasswordPrompt extends Prompt {
//
message = ["Enter a password"];
//
// Let the client know that we're asking for a password
// so it can set <input type="password">
options = { password: true };
get player() {
return this.scene.player;
}
onReply(text) {
//
// 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(text)) {
this.sendError("Insane password");
this.execute();
return;
}
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = Date.now() + Config.accountLockoutSeconds;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}
//
// Handle blocked users.
// They don't even get to have their password verified.
if (this.player.blockedUntil > Date.now()) {
//
// Try to re-login too soon, and your lockout lasts longer.
this.blockedUntil += Config.accountLockoutSeconds;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}
//
// Verify the password against the hash we've stored.
if (!Security.verifyPassword(text, this.player.passwordHash)) {
this.sendError("Incorrect password!");
this.player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`);
this.execute();
return;
}
this.player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0;
//
// We do not allow a player to be logged in more than once!
if (this.player.loggedIn) {
this.calamity("This player is already logged in");
return;
}
this.scene.passwordAccepted();
}
}

View File

@@ -1,58 +0,0 @@
import { Prompt } from "../prompt.js";
import { Security } from "../../utils/security.js";
import { gGame } from "../../models/globals.js";
/** @typedef {import("./playerCreationScene.js").PlayerCreationScene} PlayerCreationScene */
export class CreateUsernamePrompt extends Prompt {
//
message = [
"Enter your username", //
"((type *:help* for more info))", //
];
//
// When player types :help
help = [
"Your username.",
"It's used, along with your password, when you log in.",
"Other players can see it.",
"Other players can use it to chat or trade with you",
"It may only consist of the letters _a-z_, _A-Z_, _0-9_, and _underscore_",
];
//
// Let the client know that we're asking for a username
options = { username: true };
/** @protected @type {PlayerCreationScene} */
_scene;
onReply(username) {
//
// do basic syntax checks on usernames
if (!Security.isUsernameSane(username)) {
console.info("Someone entered insane username: '%s'", username);
this.sendError("Incorrect username, try again.");
this.execute();
return;
}
//
// try and fetch the player object from the game
const player = gGame.getPlayerByUsername(username);
//
// handle invalid username
if (player) {
console.info("Someone tried to create a user with an occupied username: '%s'", username);
this.sendError("Occupied, try something else");
this.execute();
return;
}
//
// Tell owner that we're done
this._scene.usernameAccepted(username);
}
}

View File

@@ -1,8 +1,11 @@
import { Config } from "../../config.js"; import { Config } from "../../config.js";
import { gGame } from "../../models/globals.js"; import { Prompt } from "../prompt.js";
import { Security } from "../../utils/security.js";
import { Scene } from "../scene.js"; import { Scene } from "../scene.js";
import { CreateUsernamePrompt } from "./createUsernamePrompt.js"; import { Security } from "../../utils/security.js";
import { gGame } from "../../models/globals.js";
import { AuthenticationScene } from "../authentication/authenticationScene.js";
const MAX_PASSWORD_ATTEMPTS = 3;
export class PlayerCreationScene extends Scene { export class PlayerCreationScene extends Scene {
intro = "= Create Player"; intro = "= Create Player";
@@ -20,7 +23,7 @@ export class PlayerCreationScene extends Scene {
this.session.calamity("Server is full, no more players can be created"); this.session.calamity("Server is full, no more players can be created");
} }
this.showPrompt(new CreateUsernamePrompt(this)); this.showPrompt(new UsernamePrompt(this));
} }
/** /**
@@ -33,10 +36,10 @@ export class PlayerCreationScene extends Scene {
this.player = player; this.player = player;
this.session.sendSystemMessage("salt", player.salt); this.session.sendSystemMessage("salt", player.salt);
this.session.sendText(`Username _*${username}*_ is available, and I've reserved it for you :)`);
// this.session.sendText(`Username _*${username}*_ has been reserved for you`);
this.session.sendError("TODO: create a createPasswordPrompt and display it.");
this.show(PasswordPrompt);
} }
/** /**
@@ -47,7 +50,153 @@ export class PlayerCreationScene extends Scene {
*/ */
passwordAccepted(password) { passwordAccepted(password) {
this.password = password; this.password = password;
this.session.sendText("*_Success_* ✅ You will now be asked to log in again, sorry for that ;)");
this.player.setPasswordHash(Security.generateHash(this.password)); this.player.setPasswordHash(Security.generateHash(this.password));
this.session.sendText("*_Success_* ✅ You will now be asked to log in again, sorry about that ;)");
this.session.setScene(new AuthenticationScene(this.session));
}
}
// _ _
// | | | |___ ___ _ __ _ __ __ _ _ __ ___ ___
// | | | / __|/ _ \ '__| '_ \ / _` | '_ ` _ \ / _ \
// | |_| \__ \ __/ | | | | | (_| | | | | | | __/
// \___/|___/\___|_| |_| |_|\__,_|_| |_| |_|\___|
//
// ____ _
// | _ \ _ __ ___ _ __ ___ _ __ | |_
// | |_) | '__/ _ \| '_ ` _ \| '_ \| __|
// | __/| | | (_) | | | | | | |_) | |_
// |_| |_| \___/|_| |_| |_| .__/ \__|
// |_|
class UsernamePrompt extends Prompt {
//
message = [
"Enter your username", //
"((type *:help* for more info))", //
];
//
// When player types :help
help = [
"Your username.",
"It's used, along with your password, when you log in.",
"Other players can see it.",
"Other players can use it to chat or trade with you",
"It may only consist of the letters _a-z_, _A-Z_, _0-9_, and _underscore_",
];
//
// Let the client know that we're asking for a username
options = { username: true };
/** @type {PlayerCreationScene} */
get scene() {
return this._scene;
}
onReply(username) {
//
// do basic syntax checks on usernames
if (!Security.isUsernameSane(username)) {
console.info("Someone entered insane username: '%s'", username);
this.sendError("Incorrect username, try again.");
this.execute();
return;
}
//
// try and fetch the player object from the game
const player = gGame.getPlayerByUsername(username);
//
// handle invalid username
if (player) {
console.info("Someone tried to create a user with an occupied username: '%s'", username);
this.sendError("Occupied, try something else");
this.execute();
return;
}
this.scene.usernameAccepted(username);
}
}
// ____ _
// | _ \ __ _ ___ _____ _____ _ __ __| |
// | |_) / _` / __/ __\ \ /\ / / _ \| '__/ _` |
// | __/ (_| \__ \__ \\ V V / (_) | | | (_| |
// |_| \__,_|___/___/ \_/\_/ \___/|_| \__,_|
//
// ____ _
// | _ \ _ __ ___ _ __ ___ _ __ | |_
// | |_) | '__/ _ \| '_ ` _ \| '_ \| __|
// | __/| | | (_) | | | | | | |_) | |_
// |_| |_| \___/|_| |_| |_| .__/ \__|
// |_|
class PasswordPrompt extends Prompt {
//
message = "Enter a password";
//
// Let the client know that we're asking for a password
// so it can set <input type="password">
options = { password: true };
/** @type {string?} Password that was previously entered. */
firstPassword = undefined;
errorCount = 0;
/** @type {PlayerCreationScene} */
get scene() {
return this._scene;
}
beforeExecute() {
if (this.errorCount > MAX_PASSWORD_ATTEMPTS) {
this.firstPassword = false;
this.errorCount = 0;
this.message = ["Too many errors - starting over", "Enter password"];
return;
}
if (this.firstPassword && this.errorCount === 0) {
this.message = "Repeat the password";
return;
}
if (this.firstPassword && this.errorCount > 0) {
this.message = [
"Repeat the password",
`((attempt nr. ${this.errorCount + 1} of ${MAX_PASSWORD_ATTEMPTS + 1}))`,
];
return;
}
this.errorCount = 0;
this.message = "Enter a password";
}
onReply(str) {
if (!Security.isPasswordSane(str)) {
this.sendError("Invalid password format.");
this.errorCount++;
this.execute();
return;
}
if (!this.firstPassword) {
this.firstPassword = str;
this.execute();
return;
}
if (this.firstPassword !== str) {
this.errorCount++;
this.execute();
return;
}
this.scene.passwordAccepted(str);
} }
} }

View File

@@ -1,5 +1,4 @@
/** @typedef {import("../models/session.js").Session} Session */ /** @typedef {import("../models/session.js").Session} Session */
/** @typedef {import("../utils/message.js").MessageType} MessageType */
/** @typedef {import("../utils/message.js").WebsocketMessage} WebsocketMessage */ /** @typedef {import("../utils/message.js").WebsocketMessage} WebsocketMessage */
/** @typedef {import("./scene.js").Scene} Scene */ /** @typedef {import("./scene.js").Scene} Scene */
@@ -65,7 +64,7 @@ export class Prompt {
/** @type {Session} */ /** @type {Session} */
get session() { get session() {
return this.scene.session; return this._scene.session;
} }
/** @param {Scene} scene */ /** @param {Scene} scene */
@@ -80,6 +79,7 @@ export class Prompt {
*/ */
execute() { execute() {
this.prepareProperties(); this.prepareProperties();
this.beforeExecute();
this.sendPrompt(this.message, this.options); this.sendPrompt(this.message, this.options);
} }
@@ -98,6 +98,8 @@ export class Prompt {
} }
} }
beforeExecute() {}
/** Triggered when user types `:help [some optional topic]` */ /** Triggered when user types `:help [some optional topic]` */
onHelp(topic) { onHelp(topic) {
if (!this.help) { if (!this.help) {