diff --git a/models/game.js b/models/game.js
index dfb4ca5..d19a101 100755
--- a/models/game.js
+++ b/models/game.js
@@ -9,6 +9,7 @@
import { isIdSane, miniUid } from "../utils/id.js";
import { Xorshift32 } from "../utils/random.js";
+import { Security } from "../utils/security.js";
import { ItemBlueprint } from "./item.js";
import { Player } from "./player.js";
@@ -83,18 +84,17 @@ export class Game {
* @param {string?} passwordHash
* @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) {
if (this.#players.has(username)) {
return false;
}
- const player = new Player(
- username,
- typeof passwordHash === "string" ? passwordHash : "",
- typeof salt === "string" && salt.length > 0 ? salt : miniUid(),
- );
+ passwordHash ??= "";
+ salt ??= Security.generateHash(miniUid());
+
+ const player = new Player(username, passwordHash, salt);
this.#players.set(username, player);
diff --git a/models/session.js b/models/session.js
index 86d9154..8165b89 100755
--- a/models/session.js
+++ b/models/session.js
@@ -1,7 +1,7 @@
import { Player } from "./player.js";
import { mustBeString, mustBe } from "../utils/mustbe.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 */
@@ -42,7 +42,7 @@ export class Session {
* @param {Scene} scene
*/
setScene(scene) {
- console.debug("changing scene", scene.constructor.name);
+ console.debug("Changing scene", { scene: scene.constructor.name });
if (!(scene instanceof 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);
return;
}
- this._websocket.send(formatMessage(messageType, ...args));
+ this._websocket.send(Messages.formatMessage(messageType, ...args));
}
/**
@@ -112,7 +112,7 @@ export class Session {
}
this.send(
- MessageType.PROMPT, // message type
+ Messages.PROMPT, // message type
text, // TODO: prompt text must be string or an array of strings
mustBe(options, "object"),
);
@@ -125,12 +125,17 @@ export class Session {
* @param {object?} options message options for the client.
*/
sendText(text, options = {}) {
- this.send(MessageType.TEXT, text, options);
+ this.send(Messages.TEXT, text, options);
}
/** @param {string|string[]} errorMessage */
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
console.info("CALAMITY", errorMessage);
- this.send(MessageType.CALAMITY, errorMessage, { verbatim: true, calamity: true });
+ this.send(Messages.CALAMITY, errorMessage, { verbatim: true, calamity: true });
this.close();
}
@@ -150,6 +155,6 @@ export class Session {
* @param {any?} value
*/
sendSystemMessage(systemMessageType, value = undefined) {
- this.send(MessageType.SYSTEM, mustBeString(systemMessageType), value);
+ this.send(Messages.SYSTEM, mustBeString(systemMessageType), value);
}
}
diff --git a/scenes/authentication/authenticationScene.js b/scenes/authentication/authenticationScene.js
index 30c3361..fa0c024 100755
--- a/scenes/authentication/authenticationScene.js
+++ b/scenes/authentication/authenticationScene.js
@@ -147,9 +147,11 @@ class PasswordPrompt extends Prompt {
return;
}
+ const player = this.scene.player;
+
//
// 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.calamity("You have been locked out for too many failed password attempts, come back later");
return;
@@ -158,7 +160,7 @@ class PasswordPrompt extends Prompt {
//
// Handle blocked users.
// 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.
this.blockedUntil += Config.accountLockoutSeconds * 1000;
@@ -168,23 +170,23 @@ class PasswordPrompt extends Prompt {
//
// 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.player.failedPasswordsSinceLastLogin++;
+ player.failedPasswordsSinceLastLogin++;
- this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`);
+ this.session.sendDebug(`Failed login attempt #${player.failedPasswordsSinceLastLogin}`);
this.execute();
return;
}
- this.player.lastSucessfulLoginAt = new Date();
- this.player.failedPasswordsSinceLastLogin = 0;
+ player.lastSucessfulLoginAt = new Date();
+ 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");
+ if (player.loggedIn) {
+ this.calamity("player is already logged in");
return;
}
diff --git a/scenes/partyCreation/partyCreationScene.js b/scenes/partyCreation/partyCreationScene.js
index c1ed327..f57c768 100755
--- a/scenes/partyCreation/partyCreationScene.js
+++ b/scenes/partyCreation/partyCreationScene.js
@@ -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 {
- /**
- * @proteted
- * @type {(msg: WebsocketMessage) => }
- *
- * NOTE: Should this be a stack?
- */
- _dynamicMessageHandler;
-
- /** @param {Session} session */
- constructor(session) {
- super();
- this.session = session;
- }
-
- /** We attach (and execute) the next state */
- onAttach() {
- const charCount = this.session.player.characters.size;
-
- //NOTE: could use async to optimize performance
- const createPartyLogo = frameText(figlet.textSync("Create Your Party"), {
- vPadding: 0,
- frameChars: "§=§§§§§§",
- });
-
- this.sendText(createPartyLogo, { preformatted: true });
-
- this.session.sendText(["", `Current party size: ${charCount}`, `Max party size: ${Config.maxPartySize}`]);
- const min = 1;
- const max = Config.maxPartySize - charCount;
- const prompt = [
- `Please enter an integer between ${min} - ${max}`,
- "((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?`);
-
- /** @param {WebsocketMessage} m */
- this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
- }
-
- /** @param {WebsocketMessage} m */
- receiveCharacterCount(m) {
- if (m.isHelpRequest()) {
- return this.partySizeHelp();
- }
-
- if (!m.isInteger()) {
- this.sendError("You didn't enter an integer");
- this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
- return;
- }
-
- const numCharactersToCreate = Number(m.text);
- if (numCharactersToCreate > Config.maxPartySize) {
- this.sendError("Number too high");
- this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
- return;
- }
-
- if (numCharactersToCreate < 1) {
- this.sendError("Number too low");
- this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
- return;
- }
-
- this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`);
- }
-
- partySizeHelp() {
- this.sendText([
- `Your party can consist of 1 to ${Config.maxPartySize} characters.`,
- "",
- "* Large parties tend live longer",
- `* If you have fewer than ${Config.maxPartySize} 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 (Math.PI < 0 && Session && WebsocketMessage) {
- ("STFU Linda");
-}
+// export class PartyCreationState extends State {
+// /**
+// * @proteted
+// * @type {(msg: WebsocketMessage) => }
+// *
+// * NOTE: Should this be a stack?
+// */
+// _dynamicMessageHandler;
+//
+// /** @param {Session} session */
+// constructor(session) {
+// super();
+// this.session = session;
+// }
+//
+// /** We attach (and execute) the next state */
+// onAttach() {
+// const charCount = this.session.player.characters.size;
+//
+// //NOTE: could use async to optimize performance
+// const createPartyLogo = frameText(figlet.textSync("Create Your Party"), {
+// vPadding: 0,
+// frameChars: "§=§§§§§§",
+// });
+//
+// this.sendText(createPartyLogo, { preformatted: true });
+//
+// this.session.sendText(["", `Current party size: ${charCount}`, `Max party size: ${Config.maxPartySize}`]);
+// const min = 1;
+// const max = Config.maxPartySize - charCount;
+// const prompt = [
+// `Please enter an integer between ${min} - ${max}`,
+// "((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?`);
+//
+// /** @param {WebsocketMessage} m */
+// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
+// }
+//
+// /** @param {WebsocketMessage} m */
+// receiveCharacterCount(m) {
+// if (m.isHelpRequest()) {
+// return this.partySizeHelp();
+// }
+//
+// if (!m.isInteger()) {
+// this.sendError("You didn't enter an integer");
+// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
+// return;
+// }
+//
+// const numCharactersToCreate = Number(m.text);
+// if (numCharactersToCreate > Config.maxPartySize) {
+// this.sendError("Number too high");
+// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
+// return;
+// }
+//
+// if (numCharactersToCreate < 1) {
+// this.sendError("Number too low");
+// this.sendPrompt(prompt, (m) => this.receiveCharacterCount(m));
+// return;
+// }
+//
+// this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`);
+// }
+//
+// partySizeHelp() {
+// this.sendText([
+// `Your party can consist of 1 to ${Config.maxPartySize} characters.`,
+// "",
+// "* Large parties tend live longer",
+// `* If you have fewer than ${Config.maxPartySize} 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 (Math.PI < 0 && Session && WebsocketMessage) {
+// ("STFU Linda");
+// }
diff --git a/scenes/playerCreation/createUasswprdPrompt.js b/scenes/playerCreation/createUasswprdPrompt.js
deleted file mode 100755
index 54f0134..0000000
--- a/scenes/playerCreation/createUasswprdPrompt.js
+++ /dev/null
@@ -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
- 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();
- }
-}
diff --git a/scenes/playerCreation/createUsernamePrompt.js b/scenes/playerCreation/createUsernamePrompt.js
deleted file mode 100755
index 2cb4da3..0000000
--- a/scenes/playerCreation/createUsernamePrompt.js
+++ /dev/null
@@ -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);
- }
-}
diff --git a/scenes/playerCreation/playerCreationScene.js b/scenes/playerCreation/playerCreationScene.js
index dffa5df..9c5bdf5 100755
--- a/scenes/playerCreation/playerCreationScene.js
+++ b/scenes/playerCreation/playerCreationScene.js
@@ -1,8 +1,11 @@
import { Config } from "../../config.js";
-import { gGame } from "../../models/globals.js";
-import { Security } from "../../utils/security.js";
+import { Prompt } from "../prompt.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 {
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.showPrompt(new CreateUsernamePrompt(this));
+ this.showPrompt(new UsernamePrompt(this));
}
/**
@@ -33,10 +36,10 @@ export class PlayerCreationScene extends Scene {
this.player = player;
this.session.sendSystemMessage("salt", player.salt);
- this.session.sendText(`Username _*${username}*_ is available, and I've reserved it for you :)`);
- //
- this.session.sendError("TODO: create a createPasswordPrompt and display it.");
+ this.session.sendText(`Username _*${username}*_ has been reserved for you`);
+
+ this.show(PasswordPrompt);
}
/**
@@ -47,7 +50,153 @@ export class PlayerCreationScene extends Scene {
*/
passwordAccepted(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.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
+ 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);
}
}
diff --git a/scenes/prompt.js b/scenes/prompt.js
index 7ff5f28..368bc90 100755
--- a/scenes/prompt.js
+++ b/scenes/prompt.js
@@ -1,5 +1,4 @@
/** @typedef {import("../models/session.js").Session} Session */
-/** @typedef {import("../utils/message.js").MessageType} MessageType */
/** @typedef {import("../utils/message.js").WebsocketMessage} WebsocketMessage */
/** @typedef {import("./scene.js").Scene} Scene */
@@ -65,7 +64,7 @@ export class Prompt {
/** @type {Session} */
get session() {
- return this.scene.session;
+ return this._scene.session;
}
/** @param {Scene} scene */
@@ -80,6 +79,7 @@ export class Prompt {
*/
execute() {
this.prepareProperties();
+ this.beforeExecute();
this.sendPrompt(this.message, this.options);
}
@@ -98,6 +98,8 @@ export class Prompt {
}
}
+ beforeExecute() {}
+
/** Triggered when user types `:help [some optional topic]` */
onHelp(topic) {
if (!this.help) {