rearrage_stuff

This commit is contained in:
Kim Ravn Hansen
2025-09-16 11:26:40 +02:00
parent 40e8c5e0ab
commit 3f11ebe6dc
4937 changed files with 1146031 additions and 134 deletions

View File

@@ -0,0 +1,50 @@
import { PasswordPrompt } from "./passwordPrompt.js";
import { Player } from "../../models/player.js";
import { Scene } from "../scene.js";
import { UsernamePrompt } from "./usernamePrompt.js";
import { PlayerCreationScene } from "../playerCreation/playerCreationSene.js";
/** @property {Session} session */
export class AuthenticationScene extends Scene {
introText = [
"= Welcome!", //
];
/** @type {Player} */
player;
onReady() {
// current prompt
this.show(UsernamePrompt);
}
/** @param {Player} player */
usernameAccepted(player) {
this.player = player;
this.session.sendSystemMessage("salt", player.salt);
this.show(PasswordPrompt);
}
passwordAccepted() {
this.player.loggedIn = true;
this.session.player = this.player;
this.session.sendText(["= Success!", "((but I don't know what to do now...))"]);
return;
if (this.player.admin) {
this.session.setScene("new AdminJustLoggedInScene");
} else {
this.session.setScene("new JustLoggedInScene");
}
}
/**
* User typed `:create`
*
* Create new player
*/
onColon__create() {
this.session.setScene(new PlayerCreationScene(this.session));
}
}

View File

@@ -0,0 +1,83 @@
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { Config } from "../../config.js";
import { AuthenticationScene } from "./authenticationScene.js";
export class PasswordPrompt extends Prompt {
//
promptText = "Please enter your password";
//
// Let the client know that we're asking for a password
// so it can set <input type="password">
promptOptions = { password: true };
get player() {
return this.scene.player;
}
/** @returns {AuthenticationScene} */
get scene() {
return this._scene;
}
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;
}
// Password was correct, go to main game
// this.scene.passwordAccepted();
this.scene.passwordAccepted();
}
}

View File

@@ -0,0 +1,65 @@
import { Player } from "../../models/player.js";
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { gGame } from "../../models/globals.js";
import { Config } from "../../config.js";
import { AuthenticationScene } from "./authenticationScene.js";
/**
* @class
*
* @property {AuthenticationScene} scene
*/
export class UsernamePrompt extends Prompt {
//
promptText = [
"Please enter your username:", //
"(((type *:create* if you want to create a new user)))", //
];
//
// When player types :help
helpText = [
"This is where you log in.",
"If you don't already have a player profile on this server, you can type *:create* to create one",
];
//
// Let the client know that we're asking for a username
promptOptions = { username: true };
/** @returns {AuthenticationScene} */
get scene() {
return this._scene;
}
//
// User replied to our prompt
onReply(text) {
//
// do basic syntax checks on usernames
if (!security.isUsernameSane(text)) {
console.info("Someone entered insane username: '%s'", text);
this.sendError("Incorrect username, try again");
this.execute();
return;
}
//
// try and fetch the player object from the game
const player = gGame.getPlayer(text);
//
// handle invalid username
if (!player) {
console.info("Someone entered incorrect username: '%s'", text);
this.sendError("Incorrect username, try again");
this.execute();
return;
}
//
// Tell daddy that we're done
this.scene.usernameAccepted(player);
}
}

26
scenes/gameLoop/gameScene.js Executable file
View File

@@ -0,0 +1,26 @@
import { Scene } from "../scene.js";
/**
* Main game state
*
* It's here we listen for player commands.
*/
export class GameScene extends Scene {
introText = "= Welcome";
onReady() {
//
// Find out which state the player and their characters are in
// Find out where we are
// Re-route to the relevant scene if necessary.
//
// IDEA:
// Does a player have a previous state?
// The state that was on the previous session?
//
// If player does not have a previous session
// then we start in the Adventurers Guild in the Hovedstad
//
this.doPrompt("new commandprompt or whatever");
}
}

13
scenes/interface.js Executable file
View File

@@ -0,0 +1,13 @@
import { WebsocketMessage } from "../utils/messages.js";
import { Session } from "../models/session.js";
/** @interface */
export class StateInterface {
/** @param {Session} session */
constructor(session) {}
onAttach() {}
/** @param {WebsocketMessage} message */
onMessage(message) {}
}

View File

@@ -0,0 +1,72 @@
import { Session } from "../models/session.js";
const castle = `
█▐▀▀▀▌▄
█ ▐▀▀▀▌▌▓▌
█ ▄▄ ▄▄▀
█ ▐▀▀▀▀
▄█▄
▓▀ ▀▌
▓▀ ▓▄
▄▓ ▐▓
▄▓ ▀▌
▓▀▀▀▀▀▓ ▓▀▀▀▀▓ ▐█▀▀▀▀▓
█ █ █ ▓░ ▓▌ ▓░
█ ▀▀▀▀▀ ▀▀▀▀▀ ▓░
▓▒ ▓░
▀▓▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█
▐▌ █
▓▀▀▀▀█ ▐█▀▀▀█ ▐█▀▀▀▓▒ ▐▌ █ ▐▓▀▀▀▓▒ ▓▀▀▀▓▒ █▀▀▀▀▓
█ █ ▐▌ █ ▐▌ ▓▒ ▐▌ ▐██░ █ ▐█ ▓▄ █ ▐▌ █ ▐█
▓░ ▐▀▀▀ ▐▀▀▀ █░ ▐▌ ▓██▌ █ ▐█ ▀▀▀▀ ▀▀▀ ▐▌
▓▒ █ ▐▌ ▀██▌ █ █ ▐▌
▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▓▀ ▐▌ █ ▀▌▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▓
▐▌ █ ▐▌ █ █ ▐▌
▐▌ █ ▐▌ █ █ ▐▌
▐▌ ▓▌ █▀▀▀▀▀█ ▐▌▐▌▀▀▀▀█ ▓▀▀▀▀▓▄ █ █▀▀▀▀▀█ ▓▌ ▐▌
▓▌ ██▌ █ █ ▓▌▓▒ █ █ ▐▌ █ █ █ ▓██ ▐▓
▓▒ ▐██▌ █ █ ▓██░ ▐█
▓ ▐▐ █ █ ▐▐ █
█ █ █ █
█ █ ▄▄▄ █ █
█ █ ▄▀▀ ▀▀▓▄ █ █
█ █ ▄▌ ▀▓ █ █
▐█ █ ▓▀ ▐█ █ ▓▒
▐▌ █ ▐▓ ▐▌ ▐█ ▓▒
▐▌ █ █ █ ▐█ ▐▌
▐▌ ▓░ █ █ ▐▌ ▐▌
▓▒ ▓░ █ ▓▒ ▐▌ ▐▓
▓░ ▓░ ▐▌ ▀▌ ▐▌ ▐█
▀▌▄▄ ▓▄▄ ▐█ ▓▌ ▄▄▄▐▌ ▄▄▄▀
▐▐▐▀▀▀▀▐▐▐ ▐▐▀▀▀▀▀▀▐▐
`;
/** @interface */
export class JustLoggedInState {
/** @param {Session} session */
constructor(session) {
/** @type {Session} */
this.session = session;
}
// Show welcome screen
onAttach() {
this.session.sendText(castle);
this.session.sendText(["", "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.sendText("You haven't got any characters, so let's make some\n\n");
this.session.setState(new PartyCreationState(this.session));
return;
}
for (const character of this.session.player.characters) {
this.session.sendText(`Character: ${character.name} (${character.foundation})`);
}
this.session.setState(new AwaitCommandsState(this.session));
}
}

View File

@@ -0,0 +1,97 @@
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";
export class PartyCreationState extends State {
/**
* @proteted
* @type {(msg: WebsocketMessage) => }
*
* 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;
//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} message */
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
}
/** @param {WebsocketMessage} m */
receivePlayerCount(m) {
if (m.isHelpRequest()) {
return this.partySizeHelp();
}
if (!m.isInteger()) {
this.sendError("You didn't enter an integer");
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
return;
}
const numCharactersToCreate = Number(message.text);
if (numCharactersToCreate > max) {
this.sendError("Number too high");
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
return;
}
if (numCharactersToCreate < min) {
this.sendError("Number too low");
this.sendPrompt(prompt, (m) => this.receivePlayerCount(m));
return;
}
this.sendText(`Let's create ${numCharactersToCreate} character(s) for you :)`);
}
partySizeHelp() {
this.sendText([
`Your party can consist of 1 to ${mps} 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;
}
}

View File

@@ -0,0 +1,79 @@
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { Config } from "../../config.js";
export class CreatePasswordPrompt extends Prompt {
//
promptText = ["Enter a password"];
//
// Let the client know that we're asking for a password
// so it can set <input type="password">
promptOptions = { 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();
//
// Password was correct, go to main game
this.session.setState(new JustLoggedInState(this.session));
}
}

View File

@@ -0,0 +1,62 @@
import { Prompt } from "../prompt.js";
import * as security from "../../utils/security.js";
import { gGame } from "../../models/globals.js";
import { PlayerCreationScene } from "./playerCreationSene.js";
import { Config } from "../../config.js";
export class CreateUsernamePrompt extends Prompt {
//
promptText = [
"Enter your username", //
"((type *:help* for more info))", //
];
//
// When player types :help
helpText = [
"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
promptOptions = { username: true };
/**
* @returns {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.getPlayer(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 daddy that we're done
this.scene.usernameAccepted(username);
}
}

View File

@@ -0,0 +1,52 @@
import { Config } from "../../config.js";
import { gGame } from "../../models/globals.js";
import { Scene } from "../scene.js";
import { CreateUsernamePrompt } from "./createUsernamePrompt.js";
export class PlayerCreationScene extends Scene {
introText = "= Create Player";
/** @protected @type {Player} */
player;
/** @protected @type {string} */
password;
onReady() {
//
// If there are too many players, stop allowing new players in.
if (gGame._players.size >= Config.maxPlayers) {
this.session.calamity("Server is full, no more players can be created");
}
this.showPrompt(new CreateUsernamePrompt(this));
}
/**
* Called when the player has entered a valid and available username.
*
* @param {string} username
*/
usernameAccepted(username) {
const player = gGame.createPlayer(username);
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.");
}
/**
*
* Called when the player has entered a password and confirmed it.
*
* @param {string} password
*/
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));
}
}

212
scenes/prompt.js Executable file
View File

@@ -0,0 +1,212 @@
import figlet from "figlet";
import { gGame } from "../models/globals.js";
import { Session } from "../models/session.js";
import { Scene } from "./scene.js";
import { MessageType, WebsocketMessage } from "../utils/messages.js";
import { mustBe, mustBeString } from "../utils/mustbe.js";
import { sprintf } from "sprintf-js";
/**
* @typedef {object} PromptMethods
* @property {function(...any): any} [onColon_*] - Any method starting with "onColon_"
*/
/**
* @abstract
* @implements {PromptMethods}
* @class
* @dynamic Methods are dynamically created:
* - onColon(...)
*/
export class Prompt {
/** @private @readonly @type {Scene} */
_scene;
/** @type {Scene} */
get scene() {
return this._scene;
}
//
// Extra info about the prompt we send to the client.
promptOptions = undefined;
/**
* Dictionary of help topics.
* Keys: string matching /^[a-z]+$/ (topic name)
* Values: string containing the help text
*
* @constant
* @readonly
* @type {Record<string, string>}
*/
helpText = {
"": "Sorry, no help available. Figure it out yourself, adventurer", // default help text
};
/** @type {string|string[]} Default prompt text to send if we don't want to send something in the execute() call. */
promptText = [
"Please enter some very important info", // Stupid placeholder text
"((or type :quit to run away))", // strings in double parentheses is rendered shaded/faintly
];
/** @type {object|string} If string: the prompt's context (username, password, etc) of object, it's all the message's options */
promptOptions = {};
/** @type {Session} */
get session() {
return this.scene.session;
}
/** @param {Scene} scene */
constructor(scene) {
if (!(scene instanceof Scene)) {
throw new Error("Expected an instance of >>Scene<< but got " + typeof scene);
}
this._scene = scene;
}
/**
* Triggered when the prompt has been attached to a scene, and is ready to go.
*
* It's here you want to send the prompt text via the sendPrompt() method
*/
execute() {
this.sendPrompt(this.promptText, this.promptOptions);
}
/** Triggered when user types `:help` without any topic */
onHelp(topic) {
let h = this.helpText;
if (typeof h === "string" || Array.isArray(h)) {
h = { "": h };
}
//
// Fix data formatting shorthand
// So lazy dev set help = "fooo" instead of help = { "": "fooo" }.
if (h[topic]) {
this.sendText(h[topic]);
return;
}
this.onHelpFallback(topic);
}
/**
* Triggered when a user types a :command that begins with a colon
*
* @param {string} command
* @param {any[]} args
*/
onColon(command, args) {
const methodName = "onColon__" + command;
const property = this[methodName];
//
// Default: we have no handler for the Foo command,
// So let's see if daddy can handle it.
if (property === undefined) {
return this.scene.onColon(command, args);
}
//
// If the prompt has a method called onColon_foo() =>
if (typeof property === "function") {
property.call(this, args);
return;
}
//
// If the prompt has a _string_ called onColon_foo =>
if (typeof property === "string") {
this.sendText(property);
return;
}
//
// We found a property that has the right name but the wrong type.
throw new Error(
[
`Logic error. Prompt has a handler for a command called ${command}`,
`but it is neither a function or a string, but a ${typeof property}`,
].join(" "),
);
}
/**
* Triggered when the player asks for help on a topic, and we dont have an onHelp_thatParticularTopic method.
*
* @param {string} topic
*/
onHelpFallback(topic) {
this.sendError(`Sorry, no help available for topic “${topic}`);
}
/**
* Handle ":quit" messages
*
* The session will terminate no matter what. This just gives the State a chance to clean up before dying.
*
* @param {WebsocketMessage} message
*
*/
onQuit() {}
/**
* Triggered when the player replies to the prompt-message sent by this prompt-object.
*
* @param {WebsocketMessage} message The incoming reply
*/
onReply(message) {}
/**
* @overload
* @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {string} context
*/ /**
* @overload
* @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {object} options Any options for the text (client side text formatting, color-, font-, or style info, etc.).
*/
sendPrompt(...args) {
this.session.sendPrompt(...args);
}
/**
* Send text to be displayed to the client
*
* @param {string|string[]} text Text to send. If array, it will be joined/imploded with newline characters.
* @param {object?} options message options for the client.
*/
sendText(...args) {
this.session.sendText(...args);
}
/** @param {string} errorMessage */
sendError(...args) {
this.session.sendError(...args);
}
/**
* @param {string} systemMessageType The subtype of the system message (dev, salt, username, etc.)
* @param {any?} value
*/
sendSystemMessage(...args) {
this.session.sendSystemMessage(...args);
}
/**
* Send a calamity text and then close the connection.
* @param {string} errorMessage
*/
calamity(...args) {
this.session.calamity();
}
//
// Easter ægg
// Example of having a string as a colon-handler
onColon__pull_out_wand = "You cannot pull out your wand right now! But thanks for trying 😘🍌🍆";
}

128
scenes/scene.js Executable file
View File

@@ -0,0 +1,128 @@
import { sprintf } from "sprintf-js";
import { Session } from "../models/session.js";
import { Prompt } from "./prompt.js";
/**
* Scene - a class for showing one or more prompts in a row.
*
* Scenes are mostly there to keep track of which prompt to show,
* and to store data for subsequent prompts to access.
*
* The prompts themselves are responsible for data validation and
* interpretation.
*
* @abstract
*/
export class Scene {
/**
* @type {string|string[]} This text is shown when the scene begins
*/
introText = "";
/** @readonly @constant @type {Session} */
_session;
get session() {
return this._session;
}
/**
* The Prompt that is currently active.
* I.e. the handler for the latest question we asked.
*
* @readonly
* @type {Prompt}
*/
_prompt;
get prompt() {
return this._prompt;
}
constructor() {}
/** @param {Session} session */
execute(session) {
this._session = session;
if (this.introText) {
this.session.sendText(this.introText);
}
this.onReady();
}
/** @abstract */
onReady() {
throw new Error("Abstract method must be implemented by subclass");
}
/**
* @param {Prompt} prompt
*/
showPrompt(prompt) {
this._prompt = prompt;
prompt.execute();
}
/** @param {new (scene: Scene) => Prompt} promptClassReference */
show(promptClassReference) {
this.showPrompt(new promptClassReference(this));
}
/**
* Triggered when a user types a :command that begins with a colon
* and the current Prompt cannot handle that command.
*
* @param {string} command
* @param {any[]} args
*/
onColon(command, args) {
const propertyName = "onColon__" + command;
const property = this[propertyName];
//
// Default: we have no handler for the Foo command
if (property === undefined) {
this.session.sendError(`You cannot ${command.toUpperCase()} right now`); // :foo ==> you cannot FOO right now
return;
}
//
// If the prompt has a method called onColon_foo() =>
if (typeof property === "function") {
property.call(this, args);
return;
}
//
// If the prompt has a _string_ called onColon_foo =>
if (typeof property === "string") {
this.session.sendText(property);
return;
}
//
// We found a property that has the right name but the wrong type.
throw new Error(
[
`Logic error. Scene has a handler for a command called ${command}`,
`but it is neither a function or a string, but a ${typeof property}`,
].join(" "),
);
}
//
// Easter ægg
// Example dynamic colon handler
/** @param {any[]} args */
onColon__imperial(args) {
if (args.length === 0) {
this.session.sendText("The imperial system is the freeest system ever. Also the least good");
}
const n = Number(args[0]);
this.session.sendText(
sprintf("%.2f centimeters is only %.2f inches. This is american wands are so short!", n, n / 2.54),
);
}
onColon__hi = "Hoe";
}