Compare commits

..

10 Commits

Author SHA1 Message Date
Kim Ravn Hansen
7ecb4f724b Stuff ad things 2026-02-12 16:54:27 +01:00
Kim Ravn Hansen
de96d45ade progress 2025-11-04 15:34:49 +01:00
Kim Ravn Hansen
87f8add864 refactor 2025-11-04 08:57:59 +01:00
Kim Ravn Hansen
4c2b2dcdfe Stuff 2025-10-23 09:37:39 +02:00
Kim Ravn Hansen
cda8392795 Bugfixes 2025-10-22 10:13:14 +02:00
Kim Ravn Hansen
6a25b15530 refactor 2025-10-22 10:09:50 +02:00
Kim Ravn Hansen
3ce96deeea Tweaks and fixes 2025-10-22 00:09:10 +02:00
Kim Ravn Hansen
9de5140e47 refactors 2025-10-21 23:55:52 +02:00
Kim Ravn Hansen
bebd4ce944 Tweaks 2025-10-21 23:11:46 +02:00
Kim Ravn Hansen
ccd0f248fc refactoring 2025-10-21 15:53:44 +02:00
31 changed files with 1108 additions and 1022 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
resources
# Logs
logs
*.log

View File

@@ -398,14 +398,14 @@ class MUDClient {
// prompted.
// In fact, we should ALWAYS be in a state of just-having-been-prompted.
handlePromptMessage(data) {
let [promptText, options = {}] = data;
let [prompt, options = {}] = data;
this.shouldReply = true;
this.promptOptions = { ...{ class: "prompt" }, ...options };
//
this.writeToOutput(promptText, this.promptOptions);
this.writeToOutput(prompt, this.promptOptions);
//
// The server has asked for a password, so we set the

View File

@@ -13,35 +13,47 @@ import { Orientation } from "./ascii_types";
/** Dungeon Generator - generates TileMaps populated with rooms, traps, encounters, etc. */
class DungeonFactory {
/** @type {number} */
roomCount;
#roomCount;
/** @type {RoomConfig[]} */
rooms;
#rooms;
/** @type {TileMap} */
map;
#map;
get roomCount() {
return this.#roomCount;
}
get rooms() {
return this.#rooms;
}
get map() {
return this.#map;
}
get width() {
return this.map.width;
return this.#map.width;
}
get height() {
return this.map.height;
return this.#map.height;
}
/**
* @param {number} width
* @param {number} height
* @param {number} roomCount
* @param {number} #roomCount
*/
constructor(width, height, roomCount) {
this.roomCount = roomCount | 0;
this.rooms = [];
this.#roomCount = roomCount | 0;
this.#rooms = [];
// 2d array of pure wall tiles
const tiles = new Array(height | 0).fill().map(() => Array(width | 0).fill(Tile.createWall()));
this.map = new TileMap(tiles);
this.#map = new TileMap(tiles);
}
generate() {
@@ -52,18 +64,18 @@ class DungeonFactory {
this.addFeatures();
this.addPlayerStart();
this.addPortals();
return this.map.toString(CharType.TYPE_ID);
return this.#map.toString(CharType.TYPE_ID);
}
generateRooms() {
this.rooms = [];
const maxAttempts = this.roomCount * 10;
this.#rooms = [];
const maxAttempts = this.#roomCount * 10;
let attempts = 0;
while (this.rooms.length < this.roomCount && attempts < maxAttempts) {
while (this.#rooms.length < this.#roomCount && attempts < maxAttempts) {
const room = this.generateRoom();
if (room && !this.roomOverlaps(room)) {
this.rooms.push(room);
this.#rooms.push(room);
this.carveRoom(room);
}
attempts++;
@@ -83,7 +95,7 @@ class DungeonFactory {
}
roomOverlaps(newRoom) {
return this.rooms.some(
return this.#rooms.some(
(room) =>
newRoom.x < room.x + room.width + 2 &&
newRoom.x + newRoom.width + 2 > room.x &&
@@ -95,26 +107,26 @@ class DungeonFactory {
carveRoom(room) {
for (let y = room.y; y < room.y + room.height; y++) {
for (let x = room.x; x < room.x + room.width; x++) {
this.map.tiles[y][x] = Tile.createFloor();
this.#map.tiles[y][x] = Tile.createFloor();
}
}
}
connectRooms() {
if (this.rooms.length < 2) return;
if (this.#rooms.length < 2) return;
// Connect each room to at least one other room
for (let i = 1; i < this.rooms.length >> 1; i++) {
const roomA = this.rooms[i - 1];
const roomB = this.rooms[i];
for (let i = 1; i < this.#rooms.length >> 1; i++) {
const roomA = this.#rooms[i - 1];
const roomB = this.#rooms[i];
this.createCorridor(roomA, roomB);
}
// Add some extra connections for more interesting layouts
const extraConnections = Math.floor(this.rooms.length / 3);
const extraConnections = Math.floor(this.#rooms.length / 3);
for (let i = 0; i < extraConnections; i++) {
const roomA = this.rooms[this.random(0, this.rooms.length - 1)];
const roomB = this.rooms[this.random(0, this.rooms.length - 1)];
const roomA = this.#rooms[this.random(0, this.#rooms.length - 1)];
const roomB = this.#rooms[this.random(0, this.#rooms.length - 1)];
if (roomA !== roomB) {
this.createCorridor(roomA, roomB);
}
@@ -138,7 +150,7 @@ class DungeonFactory {
for (let x = 0; x < this.width; x++) {
//
if (this.map.get(x, y).isWall()) {
if (this.#map.get(x, y).isWall()) {
continue;
}
@@ -180,7 +192,7 @@ class DungeonFactory {
row.push(Tile.createWall()); // Initial wall tile on this row
for (let x = dungeonStartX; x <= dungeonEndX; x++) {
/**/
const tile = this.map.get(x, y);
const tile = this.#map.get(x, y);
row.push(tile);
}
row.push(Tile.createWall()); // Final wall tile on this row
@@ -190,7 +202,7 @@ class DungeonFactory {
// Final row is all walls
newTiles.push(new Array(newWidth).fill(Tile.createWall()));
this.map = new TileMap(newTiles);
this.#map = new TileMap(newTiles);
}
createCorridor(roomA, roomB) {
@@ -220,7 +232,7 @@ class DungeonFactory {
while (x !== x2 || y !== y2) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.map.tiles[y][x] = Tile.createFloor();
this.#map.tiles[y][x] = Tile.createFloor();
}
if (x !== x2) x += dx;
@@ -229,7 +241,7 @@ class DungeonFactory {
// Ensure endpoint is carved
if (x2 >= 0 && x2 < this.width && y2 >= 0 && y2 < this.height) {
this.map.tiles[y2][x2] = Tile.createFloor();
this.#map.tiles[y2][x2] = Tile.createFloor();
}
}
@@ -238,14 +250,14 @@ class DungeonFactory {
for (let y = 1; y < this.height - 1; y++) {
//
for (let x = 1; x < this.width - 1; x++) {
const cell = this.map.get(x, y);
const cell = this.#map.get(x, y);
if (!cell) {
console.warn("out of bounds [%d, %d] (%s)", x, y, typeof cell);
continue;
}
if (this.map.get(x, y).isFloor()) {
if (this.#map.get(x, y).isFloor()) {
walkabilityCache.push([x, y]);
}
}
@@ -263,7 +275,7 @@ class DungeonFactory {
for (let [x, y] of walkabilityCache) {
//
const walkable = (offsetX, offsetY) => this.map.isFloorLike(x + offsetX, y + offsetY);
const walkable = (offsetX, offsetY) => this.#map.isFloorLike(x + offsetX, y + offsetY);
const surroundingFloorCount =
0 +
@@ -283,7 +295,7 @@ class DungeonFactory {
if (surroundingFloorCount >= 7) {
// MAGIC NUMBER 7
this.map.tiles[y][x] = Tile.createWall();
this.#map.tiles[y][x] = Tile.createWall();
}
}
}
@@ -293,14 +305,14 @@ class DungeonFactory {
for (let y = 1; y < this.height - 1; y++) {
//
for (let x = 1; x < this.width - 1; x++) {
const cell = this.map.get(x, y);
const cell = this.#map.get(x, y);
if (!cell) {
console.warn("out of bounds [%d, %d] (%s)", x, y, typeof cell);
continue;
}
if (this.map.isFloorLike(x, y)) {
if (this.#map.isFloorLike(x, y)) {
walkabilityCache.push([x, y]);
}
}
@@ -309,7 +321,7 @@ class DungeonFactory {
const idx = this.random(0, walkabilityCache.length - 1);
const [x, y] = walkabilityCache[idx];
const walkable = (offsetX, offsetY) => this.map.isFloorLike(x + offsetX, y + offsetY);
const walkable = (offsetX, offsetY) => this.#map.isFloorLike(x + offsetX, y + offsetY);
//
// When spawning in, which direction should the player be oriented?
@@ -324,23 +336,23 @@ class DungeonFactory {
// they don't face a wall upon spawning.
const dirIdx = this.random(0, directions.length - 1);
this.map.tiles[y][x] = Tile.createPlayerStart(directions[dirIdx]);
this.#map.tiles[y][x] = Tile.createPlayerStart(directions[dirIdx]);
}
// Add portals to isolated areas
addPortals() {
let traversableTileCount = this.map.getFloorlikeTileCount();
let traversableTileCount = this.#map.getFloorlikeTileCount();
//
// Find the player's start point, and let this be the
// bases of area 0
const [x, y] = this.map.forEach((tile, x, y) => {
const [x, y] = this.#map.forEach((tile, x, y) => {
if (tile.typeId === TileChars.PLAYER_START_POINT) {
return [x, y];
}
});
const result = this.map.getAllTraversableTilesConnectedTo(x, y);
const result = this.#map.getAllTraversableTilesConnectedTo(x, y);
if (result.size === traversableTileCount) {
// There are no isolated areas, return
@@ -385,7 +397,7 @@ class DungeonFactory {
const floorTiles = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.map.get(x, y).isFloor()) {
if (this.#map.get(x, y).isFloor()) {
floorTiles.push({ x, y });
}
}
@@ -405,11 +417,11 @@ class DungeonFactory {
// }
// Add monsters
const encouterCount = Math.min(5, this.rooms.length);
const encouterCount = Math.min(5, this.#rooms.length);
for (let i = 0; i < encouterCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.map.tiles[pos.y][pos.x].isFloor()) {
this.map.tiles[pos.y][pos.x] = Tile.createEncounterStartPoint("PLACEHOLDER_ENCOUNTER_ID");
if (this.#map.tiles[pos.y][pos.x].isFloor()) {
this.#map.tiles[pos.y][pos.x] = Tile.createEncounterStartPoint("PLACEHOLDER_ENCOUNTER_ID");
// TODO: Add encounter to the dungeon's "roaming entities" array.
}
}
@@ -468,15 +480,15 @@ export const downloadDungeon = () => {
URL.revokeObjectURL(url);
};
widthEl.addEventListener("input", function () {
widthEl.addEventListener("input", function() {
document.getElementById("widthValue").textContent = this.value;
});
heightEl.addEventListener("input", function () {
heightEl.addEventListener("input", function() {
document.getElementById("heightValue").textContent = this.value;
});
roomCountEl.addEventListener("input", function () {
roomCountEl.addEventListener("input", function() {
document.getElementById("roomCountValue").textContent = this.value;
});

View File

@@ -148,6 +148,6 @@ h2 {
}
.faint {
opacity: 0.42;
opacity: 0.6;
color: #44f;
}

View File

@@ -58,10 +58,13 @@ export class Character {
itemSlots;
/** @type {Set<string>} Things the character is particularly proficient at. */
skills = new Set();
proficiencies = new Set();
/** @type {Map<Item,number} Things the character is particularly proficient at. */
items = new Map();
/** @type {Set<Item} Things the character is particularly proficient at. */
items = new Set();
/** @type {string[]} */
freeSlots = [];
/**
* @param {string} name The name of the character
@@ -72,26 +75,18 @@ export class Character {
/** Add an item to the equipment list
* @param {Item} item
* @param {number} count
*
* Maybe return the accumulated ItemSlots used?
*/
addItem(item, count = 1) {
if (!Number.isInteger(count)) {
throw new Error("Number must be an integer");
}
addItem(item) {
if (!(item instanceof Item)) {
console.debug("bad item", item);
throw new Error("item must be an instance of Item!");
}
if (count <= 0) {
throw new Error("Number must be > 0");
}
const existingItemCount = this.items.get(item) || 0;
this.items.set(item, count + existingItemCount);
this.items.add(item)
}
// todo removeItem(item, count)
}

View File

@@ -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";
@@ -17,13 +18,22 @@ import { Player } from "./player.js";
/** @typedef {import("./item.js").ItemBlueprint} ItemBlueprint */
export class Game {
_counter = 1_000_000;
#counter = 1_000_000;
get counter() {
return this.#counter;
}
/** @type {Map<string,ItemBlueprint>} List of all item blueprints in the game */
_itemBlueprints = new Map();
#itemBlueprints = new Map();
get itemBlueprints() {
return this.#itemBlueprints;
}
/** @type {Map<string,Location>} The list of locations in the game */
_locations = new Map();
#locations = new Map();
get locations() {
return this.#locations;
}
/**
* The characters in the game.
@@ -31,34 +41,40 @@ export class Game {
* @protected
* @type {Map<string,Character>}
*/
_characters = new Map();
#characters = new Map();
get characters() {
return this.#characters;
}
/*
* @protected
* @type {Map<string,Player>} Map of users in the game username->Player
*/
_players = new Map();
#players = new Map();
get players() {
return this.#players;
}
/** @protected @type {Xorshift32} */
_random;
#random;
/** @type {Xorshift32} */
get random() {
return this._random;
return this.#random;
}
/** @param {number} rngSeed Seed number used for randomization */
constructor() {
this.rngSeed = Date.now();
constructor(rngSeed) {
this.seedRNG(rngSeed);
}
set rngSeed(rngSeed) {
this._random = new Xorshift32(rngSeed);
seedRNG(rngSeed) {
this.#random = new Xorshift32(rngSeed);
}
getPlayerByUsername(username) {
console.log("GETTING PLAYER: `%s`", username);
return this._players.get(username);
return this.#players.get(username);
}
/**
@@ -68,20 +84,19 @@ 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)) {
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());
this._players.set(username, player);
const player = new Player(username, passwordHash, salt);
this.#players.set(username, player);
return player;
}
@@ -99,7 +114,7 @@ export class Game {
throw new Error("Invalid blueprintId!");
}
const existing = this._itemBlueprints.get(blueprintId);
const existing = this.#itemBlueprints.get(blueprintId);
if (existing) {
console.warn("we tried to create the same item blueprint more than once", blueprintId, attributes);
@@ -110,7 +125,7 @@ export class Game {
const result = new ItemBlueprint(attributes);
this._itemBlueprints.set(blueprintId, result);
this.#itemBlueprints.set(blueprintId, result);
return result;
}
@@ -123,6 +138,6 @@ export class Game {
if (!isIdSane(blueprintId)) {
throw new Error(`blueprintId >>${blueprintId}<< is not a valid id`);
}
return this._itemBlueprints.get(blueprintId);
return this.#itemBlueprints.get(blueprintId);
}
}

View File

@@ -1,4 +1,5 @@
import { Config } from "../config.js";
import { Game } from "./game.js";
/** @constant @readonly @type {Game} Global instance of Game */
export const gGame = new Game();
export const gGame = new Game(Config.rngSeed);

View File

@@ -41,6 +41,9 @@ export class ItemAttributes {
/** @constant @readonly @type {string[]} Type of ammo that this item is, or that this item uses */
skills = [];
/** @constant @readonly @type {boolean} Can a person wearing this armor be stealthy? */
sneak;
}
/**
@@ -53,7 +56,7 @@ export class ItemBlueprint extends ItemAttributes {
/**
* Constructor
*
* @param {object} o Object whose attributes we copy
* @param {ItemAttributes} o Object whose attributes we copy
*/
constructor(o) {
super();
@@ -106,4 +109,4 @@ export class ItemBlueprint extends ItemAttributes {
* arrows that are consumed. In this case, each individual arrow is not tracked
* as its own entity, only the quiver is tracked.
*/
export class Item extends ItemAttributes {}
export class Item extends ItemAttributes { }

View File

@@ -9,28 +9,28 @@
* or magical portals to distant locations.
*/
export class Location {
/** @protected @type {string} */
_id;
/** @type {string} */
#id;
get id() {
return this._id;
return this.#id;
}
/** @protected @type {string} */
_name;
/** @type {string} */
#name;
get name() {
return this._name;
return this.#name;
}
/** @protected @type {string} */
_description;
/** @type {string} */
#description;
get description() {
return this._description;
return this.#description;
}
/** @protected @type {Map<string,Portal>} */
_portals = new Map();
/** @type {Map<string,Portal>} */
#portals = new Map();
get portals() {
return this._portals;
return this.#portals;
}
/**
@@ -39,8 +39,8 @@ export class Location {
* @param {string} description
*/
constructor(id, name, description) {
this._id = id;
this._name = name;
this._description = description;
this.#id = id;
this.#name = name;
this.#description = description;
}
}

View File

@@ -1,7 +1,8 @@
import WebSocket from "ws";
import { Character } from "./character.js";
import { Config } from "./../config.js";
import { Scene } from "../scenes/scene.js";
/** @typedef {import("../scenes/scene.js").Scene} Scene */
/** @typedef {import("./characer.js").Character} Character */
/** @typedef {import("ws").Websocket} Websocket */
/**
* Player Account.

View File

@@ -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,8 @@ export class Session {
* @param {Scene} scene
*/
setScene(scene) {
console.debug("changing scene", scene.constructor.name);
this.frankofil = stil;
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 +92,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));
}
/**
@@ -99,10 +100,10 @@ export class Session {
* @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.).
*/
* @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(text, options) {
options = options || {};
@@ -112,7 +113,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 +126,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 +147,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 +156,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);
}
}

View File

@@ -1,4 +1,4 @@
const [XSgetSeed, XSgetNext, XSrand] = (() => {
const [XSgetSeed, XSgetNext, XSrand] = (() => {
const m = 2 ** 32;
const XSgetSeed = () => Math.floor(Math.random() * (m - 1)) + 1;
const s = Uint32Array.of(XSgetSeed());

View File

@@ -1,23 +1,19 @@
import { Security } from "../../utils/security.js";
import { Config } from "../../config.js";
import { GameScene } from "../gameLoop/gameScene.js";
import { PlayerCreationScene } from "../playerCreation/playerCreationSene.js";
import { PlayerCreationScene } from "../playerCreation/playerCreationScene.js";
import { Prompt } from "../prompt.js";
import { Scene } from "../scene.js";
import { gGame } from "../../models/globals.js";
/** @typedef {import("../../models/player.js").Player} Player */
/** @property {Session} session */
export class AuthenticationScene extends Scene {
introText = [
"= Welcome!", //
];
/** @type {Player} */
player;
onReady() {
this.session.sendText("= Welcome");
this.show(UsernamePrompt);
}
@@ -57,25 +53,24 @@ export class AuthenticationScene extends Scene {
// | __/| | | (_) | | | | | | |_) | |_
// |_| |_| \___/|_| |_| |_| .__/ \__|
// |_|
/** @property {AuthenticationScene} scene */
class UsernamePrompt extends Prompt {
//
promptText = [
message = [
"Please enter your username:", //
"(((type *:create* if you want to create a new user)))", //
"((type _*:help*_ to see your other options))",
];
//
// 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",
help = [
"Enter your username to proceed with loggin in",
"Type _*:create*_ if you are not already registered, and want to create a new account",
"Only a username and password is required - not your email",
];
options = { username: true };
//
// Let the client know that we're asking for a username
promptOptions = { username: true };
/** @returns {AuthenticationScene} */
/** @returns {AuthenticationScene} workaround for proper type hinting */
get scene() {
return this._scene;
}
@@ -126,12 +121,8 @@ class UsernamePrompt extends Prompt {
// |_|
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 };
message = "Please enter your password";
options = { password: true };
get player() {
return this.scene.player;
@@ -156,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;
@@ -167,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;
@@ -177,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;
}

View File

@@ -1,3 +1,4 @@
import { Prompt } from "../prompt.js";
import { Scene } from "../scene.js";
/**
@@ -6,7 +7,38 @@ import { Scene } from "../scene.js";
* It's here we listen for player commands.
*/
export class GameScene extends Scene {
introText = `
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.
//
// IF player has stored state THEN
// restore it and resume [main flow]
// END
//
// IF player has no characters THEN
// go to createCharacterScene
// END
//
// set player's current location = Hovedstad
// display the welcome to Hovedstad stuff, and
// await the player's commands.
//
//
// 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.show(GameScenePlaceholderPrompt);
}
}
class GameScenePlaceholderPrompt extends Prompt {
message = `
█▐▀▀▀▌▄
█ ▐▀▀▀▌▌▓▌
@@ -49,33 +81,6 @@ export class GameScene extends Scene {
= Welcome to Hovedstad
((type :quit to quit))
`;
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.
//
// IF player has stored state THEN
// restore it and resume [main flow]
// END
//
// IF player has no characters THEN
// go to createCharacterScene
// END
//
// set player's current location = Hovedstad
// display the welcome to Hovedstad stuff, and
// await the player's commands.
//
//
// 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
//
}
}

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 {
/**
* @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");
// }

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 {
//
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();
}
}

View File

@@ -1,62 +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 {
//
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.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 daddy that we're done
this.scene.usernameAccepted(username);
}
}

View File

@@ -1,11 +1,14 @@
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 {
introText = "= Create Player";
intro = "= Create Player";
/** @protected @type {Player} */
player;
@@ -16,11 +19,11 @@ export class PlayerCreationScene extends Scene {
onReady() {
//
// If there are too many players, stop allowing new players in.
if (gGame._players.size >= Config.maxPlayers) {
if (gGame.players.size >= Config.maxPlayers) {
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 <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,53 +0,0 @@
import { Config } from "../../config.js";
import { gGame } from "../../models/globals.js";
import { Security } from "../../utils/security.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));
}
}

View File

@@ -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 */
@@ -16,7 +15,7 @@
* - onColon(...)
*/
export class Prompt {
/** @type {Scene} */
/** @protected @type {Scene} */
_scene;
/** @type {Scene} */
@@ -30,20 +29,25 @@ export class Prompt {
* Values: string containing the help text
*
* If you want truly custom help texts, you must override the onHelpFallback function,
* but overriding the onHelp() function gives you more control and skips this helpText
* but overriding the onHelp() function gives you more control and skips this help
* dictionary entirely.
*
* @constant
* @readonly
* @type {Record<string, string|string[]>}
*/
helpText = {
"": "Sorry, no help available. Figure it out yourself, adventurer", // default help text
};
help = {};
/** @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
/**
* Default prompt text to send if we don't want to send something in the execute() call.
*
* Array values will be converted to multiline strings with newlines between each string
* in the array.
*
* @type {string|string[]}
*/
message = [
"Please enter some very important info", // Silly placeholder text
"((or type :quit to run away))", // strings in double parentheses is rendered shaded/faintly
];
@@ -51,28 +55,21 @@ export class Prompt {
/* @example
*
* // if the prompt expects a username
* promptOptions = { username : true };
* options = { username : true };
*
* // if the prompt expects a password
* promptOptions = { password : true };
* options = { password : true };
*/
promptOptions = {};
options = {};
/** @type {Session} */
get session() {
return this.scene.session;
return this._scene.session;
}
/** @param {Scene} scene */
constructor(scene) {
this._scene = scene;
//
// Fix data formatting shorthand
// So lazy dev set property helpText = "fooo" instead of helpText = { "": "fooo" }.
if (typeof this.helpText === "string" || Array.isArray(this.helpText)) {
this.helpText = { "": this.helpText };
}
}
/**
@@ -81,13 +78,37 @@ export class Prompt {
* It's here you want to send the prompt text via the sendPrompt() method
*/
execute() {
this.sendPrompt(this.promptText, this.promptOptions);
this.prepareProperties();
this.beforeExecute();
this.sendPrompt(this.message, this.options);
}
/**
* Normalize / massage the properties of the Prompt.
*
* This function cannot be called from the Prompt base constructor, as the
* properties of the child class have not been set yet.
*/
prepareProperties() {
//
// Lazy dev set property help = "fooo" instead of help = { "": "fooo" }.
if (this.help && (typeof this.help === "string" || Array.isArray(this.help))) {
this.help = { "": this.help };
}
}
beforeExecute() {}
/** Triggered when user types `:help [some optional topic]` */
onHelp(topic) {
if (this.helpText[topic]) {
this.sendText(this.helpText[topic]);
if (!this.help) {
this.sendText("No help available at this moment - figure it out yourself, adventurer");
return;
}
if (this.help[topic]) {
this.sendText(this.help[topic]);
return;
}

View File

@@ -1,8 +1,10 @@
import { sprintf } from "sprintf-js";
import { Prompt } from "./prompt.js";
/** @typedef {import("../utils/messages.js").WebsocketMessage} WebsocketMessage */
/** @typedef {import("../models/session.js").Session} Session */
/** @typedef {import("./prompt.js").Prompt } Prompt */
/** @typedef {new (scene: Scene) => Prompt} PromptClassReference */
/**
* Scene - a class for showing one or more prompts in a row.
@@ -16,13 +18,8 @@ import { sprintf } from "sprintf-js";
* @abstract
*/
export class Scene {
/**
* @type {string|string[]} This text is shown when the scene begins
*/
introText = "";
/** @constant @readonly @type {Prompt?} */
introPrompt;
/** @constant @readonly @type {string|string[]|PromptClassReference} Text or prompt to show when this scene begins */
intro;
/** @readonly @constant @protected @type {Session} */
#session;
@@ -47,21 +44,15 @@ export class Scene {
/** @param {Session} session */
execute(session) {
this.#session = session;
if (this.introText) {
this.session.sendText(this.introText);
}
if (this.introPrompt) {
this.showPrompt(this.introPrompt);
} else {
this.onReady();
}
this.onReady();
}
/** @abstract */
onReady() {
throw new Error("Abstract method must be implemented by subclass");
if (!this.intro) {
return;
}
this.show(this.intro);
}
/** @param {Prompt} prompt */
@@ -70,9 +61,33 @@ export class Scene {
prompt.execute();
}
/** @param {new (scene: Scene) => Prompt} promptClassReference */
show(promptClassReference) {
this.showPrompt(new promptClassReference(this));
/** @param {string|string[]} text */
showText(text) {
this.session.sendText(text);
}
/** @param {PromptClassReference|string|string[]|Prompt} value */
show(value) {
if (value instanceof Prompt) {
this.showPrompt(value);
return;
}
if (typeof value === "string" || typeof value[0] === "string") {
this.showText(value);
return;
}
if (typeof value !== "function") {
throw new Error("Invalid type. Value must be string, string[], Prompt, or a class reference to Prompt");
}
const prompt = new value(this);
if (!(prompt instanceof Prompt)) {
throw new Error("Invalid class reference");
}
this.showPrompt(new value(this));
}
/**
@@ -91,6 +106,11 @@ export class Scene {
message,
type: typeof message,
});
if (!this.currentPrompt) {
throw new Error("LogicError: cannot get a reply when you have not prompted the player");
}
this.currentPrompt.onReply(message.text);
}
@@ -107,7 +127,7 @@ export class Scene {
* - call this method directly
*/
onQuit() {
this.currentPrompt.onQuit();
this.currentPrompt?.onQuit();
}
/**
@@ -127,7 +147,7 @@ export class Scene {
* @param {WebsocketMessage} message
*/
onHelp(message) {
this.currentPrompt.onHelp(message.text);
this.currentPrompt?.onHelp(message.text);
}
/**
@@ -185,7 +205,7 @@ export class Scene {
* @param {WebsocketMessage} message
*/
onColon(message) {
const handledByPrompt = this.currentPrompt.onColon(message.command, message.args);
const handledByPrompt = this.currentPrompt?.onColon(message.command, message.args);
if (!handledByPrompt) {
this.onColonFallback(message.command, message.args);
@@ -203,7 +223,7 @@ export class Scene {
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),
sprintf("%.2f centimeters is only %.2f inches. This is why american wands are so short!", n, n / 2.54),
);
}

View File

@@ -20,19 +20,11 @@ let roll = {};
export class CharacterSeeder {
constructor() {
// stupid convenience hack that only works if we only have a single Game in the system.
// stupid hack that ensures we populate roll AFTER gGame is available
// Which we easily could have.!!
roll = {
d: (max, min = 1) => {
return gGame.random.within(min, max);
},
d6: () => {
return gGame.random.within(1, 6);
},
d8: () => {
return gGame.random.within(1, 8);
},
};
roll.d = (max, min = 1) => gGame.random.within(min, max)
roll.d6 = () => roll.d(6)
roll.d8 = () => roll.d(8)
}
/**
@@ -52,14 +44,14 @@ export class CharacterSeeder {
/**
* @param {Character} character
* @param {...string} skills
* @param {...string} proficiencies
*/
addSkillsToCharacter(character, ...skills) {
for (const skill of skills) {
if (!isIdSane(skill)) {
throw new Error(`Skill id >>${skill}<< is insane!`);
addProficienciesToCharacter(character, ...proficiencies) {
for (const prof of proficiencies) {
if (!isIdSane(prof)) {
throw new Error(`Proficiency id >>${prof}<< is insane!`);
}
character.skills.add(skill);
character.proficiencies.add(prof);
}
}
@@ -72,17 +64,24 @@ export class CharacterSeeder {
createCharacter() {
const c = new Character();
//
// Initializing
//
// Rolling skills
this.generateName(c);
this.rollSkills(c);
this.applyAncestry(c);
this.applyFoundation(c);
return c;
}
generateName(c) {
/** @todo use actual random name generator */
c.name =
gGame.random.oneOf("sir ", "madam ", "mister ", "miss ", "", "", "") + // prefix
gGame.random.oneOf("sir ", "madam ", "mister ", "miss ", "", "", "") +
"random " + // name
gGame.random.get().toString(); // suffix
gGame.random.next().toString();
}
rollSkills(c) {
c.awareness = roll.d6() + 2;
c.grit = roll.d6() + 2;
c.knowledge = roll.d6() + 2;
@@ -90,11 +89,6 @@ export class CharacterSeeder {
c.meleeCombat = roll.d6() + 2;
c.rangedCombat = roll.d6() + 2;
c.skulduggery = roll.d6() + 2;
this.applyAncestry(c);
this.applyFoundation(c);
return c;
}
applyAncestry(c) {
@@ -168,430 +162,412 @@ export class CharacterSeeder {
* @param {string|number} Foundation to add to character
*/
applyFoundation(c, foundation = ":random") {
switch (foundation) {
case ":random":
return this.applyFoundation(c, roll.d(3));
//
// Brawler
// ------
case 1:
case ":brawler":
c.foundation = "Brawler";
c.skills.add(":armor.light");
c.silver = 40;
c.maxHitPoints = c.currentHitPoints = 15;
c.itemSlots = 7;
c.meleeCombat = Math.max(c.meleeCombat, 10);
c.knowledge = Math.min(c.knowledge, 10);
this.addItemsToCharacter(
c, //
":armor.light.studded_leather",
":weapon.weird.spiked_gauntlets",
);
this.addSkillsToCharacter(c, ":weapon.weird.spiked_gauntlets");
break;
//
// DRUID
// ------
case 2:
case ":druid":
c.foundation = "Druid";
c.silver = 40;
c.maxHitPoints = this.currentHitPoints = 15;
c.itemSlots = 7;
c.meleeCombat = Math.max(this.meleeCombat, 10);
c.knowledge = Math.min(this.knowledge, 10);
this.addItemsToCharacter(
c, //
":armor.light.leather",
":weapon.light.sickle",
":kit.poisoners_kit",
":kit.healers_kit",
);
this.addSkillsToCharacter(
c, //
":armor.light.sleather",
":armor.light.hide",
":weapon.light.sickle",
);
break;
case 3:
case ":fencer":
c.foundation = "Fencer";
//
// Stats
c.maxHitPoints = c.currentHitPoints = 15;
c.meleeCombat = Math.max(c.meleeCombat, 10);
c.magic = Math.min(c.magic, 10);
//
// Skills
this.addSkillsToCharacter(
c, //
":weapon.style.two_weapons",
":armor.light",
);
//
// Gear
c.silver = 40;
c.itemSlots = 5;
this.addItemsToCharacter(
c, //
":armor.light.leather",
":weapon.light.rapier",
":weapon.light.dagger",
);
break;
case 4:
case ":guard":
c.foundation = "Guard";
//
// Stats
c.maxHitPoints = c.currentHitPoints = 15;
c.meleeCombat = Math.max(c.meleeCombat, 10);
c.magic = Math.min(c.magic, 10);
//
// Skills
this.addSkillsToCharacter(
c, //
":armor.medium",
":weapon.weird.halberd",
);
//
// Gear
c.silver = 50;
c.itemSlots = 5;
this.addItemsToCharacter(
c, //
":armor.medium.breastplate",
":weapon.weird.halberd",
":lighting.bulls_eye_lantern",
":misc.signal_whistle",
":maps.area.hvedstad",
);
break;
/*
//
//---------------------------------------------------------------------------------------
//HEADLINE: GUARD
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Guard
|[unstyled]
* Medium Armor
|[unstyled]
* Halberd
* Bull's Eye Lantern
* Signal Whistle
* Map of Local Area
* 50 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Awareness raised to 10
* Melee Combat raised to 10
* Skulduggery limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: MAGICIAN
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Magician
|[unstyled]
* None
|[unstyled]
* Tier 2 Wand with random spell.
* Tier 1 Wand with random spell.
* 10 Silver Pieces
|[unstyled]
* 10 Hit Points
* 6 Item Slots
* Melee Combat limited to 10
* Ranged Combat limited to 5
* Magic raised to 10
* Grit limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: MEDIC
//---------------------------------------------------------------------------------------
| {counter:foundation}
|Medic
|[unstyled]
* Light Armor
* Medium Armor
|[unstyled]
* Club
* Sling
* 3 Daggers
* Healer's Kit
* 40 Silver Pieces
|[unstyled]
* 10 Hit Points
* 6 Item Slots
//
//---------------------------------------------------------------------------------------
//HEADLINE: RECKLESS
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Reckless
|[unstyled]
|[unstyled]
* Great Axe
* 50 Silver Pieces
|[unstyled]
* 20 Hit Points
* 7 Item Slots
* Melee Combat raised to 10
* Awareness raised to 10
* Grit raised to 10
* Magic limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: ROVER
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Rover
|[unstyled]
* Light Armor
|[unstyled]
* Leather Armor
* Short Sword
* Longbow
* Snare Maker's Kit
* 25 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Magic Reduced to 10
* Awareness raised to 10
* Ranged Combat raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SKIRMISHER
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Skirmisher
|[unstyled]
* Light Armor
* Shields
|[unstyled]
* Spear
* Small Shield
* 50 Silver Pieces
|[unstyled]
* 15 Hit Points
* 6 Item Slots
* Melee Combat raised to 10
* Awareness raised to 10
* Skulduggery raised to 10
* Grit raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SNEAK
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Sneak
|[unstyled]
* Light Armor
|[unstyled]
* 3 daggers
* Small Crossbow
* Poisoner's Kit
* 30 Silver Pieces
|[unstyled]
* 10 Hit Points
* 6 Item Slots
* Melee Combat raised to 10
* Awareness raised to 10
* Skulduggery raised to 10
* Grit raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SPELLSWORD
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Spellsword
|[unstyled]
|[unstyled]
* Tier 1 Wand with random spell.
* Longsword
* 30 Silver Pieces
|[unstyled]
* 12 Hit Points
* 5 Item Slots
* Melee Combat raised to 10
* Ranged Combat limited to 10
* Magic raised to 10
* Skulduggery limited to 10
* Grit raised to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SPELUNKER
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Spelunker
|[unstyled]
* None
|[unstyled]
* Spear
* Caltrops
* Bull's Eye Lantern
* Map Maker's Kit
* Chalk
* Caltrops
* 5 Silver Pieces
|[unstyled]
* 10 Hit Points
* 4 Item Slots
* Awareness raised to 10
* Melee Combat raised to 10
* Skulduggery raised to 10
* Magic limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: SPIT'N'POLISH
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Spit'n' Polish
|[unstyled]
* Heavy Armor
* Shield
|[unstyled]
* Half-Plate
* Large Shield
* Long Sword
* 10 Silver Pieces
|[unstyled]
* 10 Hit Points
* 2 Item Slots
* Melee Combat raised to 10
* Magic Reduced to 6
* Awareness Reduced to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: STILETTO
//---------------------------------------------------------------------------------------
| {counter:foundation}
| Stiletto
|[unstyled]
* Light Armor
|[unstyled]
* Padded Armor
* 3 Daggers
* Small Crossbow
* Poisoner's Kit
* 20 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Melee Combat raised to 10
* Ranged Combat raised to 10
* Awareness raised to 10
* Magic limited to 6
* Knowledge limited to 10
//
//---------------------------------------------------------------------------------------
//HEADLINE: Tinkerer
//---------------------------------------------------------------------------------------
| {counter:foundation}
|Tinkerer
|[unstyled]
* Light Armor
|[unstyled]
* Studded Leather
* Wrench (club)
* Tinkerer's Kit
* 30 Silver Pieces
|[unstyled]
* 10 Hit Points
* 5 Item Slots
* Awareness raised to 10
* Knowledge raised to 10
*/
//
// WTF ?!
// ------
default:
throw new Error(`Invalid foundation id ${foundation}`);
if (foundation == ":random") {
return this.applyFoundation(c, roll.d(20)); // according to the rulebook, roll a d20 and reroll any invalid results.
}
//
// Brawler
// ------
if (foundation === 1 || foundation === ":brawler") {
c.foundation = "Brawler";
c.maxHitPoints = c.currentHitPoints = 15;
c.itemSlots = 7;
c.silver = 40;
c.meleeCombat = Math.max(c.meleeCombat, 10);
c.knowledge = Math.min(c.knowledge, 10);
this.addProficienciesToCharacter(
c,
":armor.light",
":weapon.weird.spiked_gauntlets"
);
this.addItemsToCharacter(
c, //
":armor.light.studded_leather",
":weapon.weird.spiked_gauntlets",
);
return;
}
//
// DRUID
// ------
if (foundation === 2 || foundation === ":druid") {
c.foundation = "Druid";
c.silver = 10;
c.itemSlots = 5;
c.maxHitPoints = this.currentHitPoints = 10;
this.addProficienciesToCharacter(
c, //
":armor.light.cloth",
":armor.light.hide",
":armor.light.leather",
":kit.healers_kit",
":kit.poisoners_kit",
":weapon.light.sickle",
":weapon.light.quarterstaff",
":weapon.light.sling",
);
this.addItemsToCharacter(
c, //
":armor.light.leather",
":weapon.light.sickle",
":kit.poisoners_kit",
":kit.healers_kit",
);
return;
}
//
// FENCER
// -------
if (foundation === 3 || foundation === ":fencer") {
c.foundation = "Fencer";
c.silver = 40;
c.itemSlots = 5;
c.maxHitPoints = c.currentHitPoints = 15;
c.magic = Math.min(c.magic, 10)
c.meleeCombat = Math.max(c.meleeCombat, 10)
this.addProficienciesToCharacter(
c, //
":perk.riposte",
":armor.light",
":weapon.light",
);
this.addItemsToCharacter(
c, //
":armor.light.leather",
":weapon.basic.dagger",
":weapon.light.rapier",
);
}
if (foundation === 4 || foundation === ":guard") {
c.foundation = "Guard";
c.silver = 50;
c.itemSlots = 5;
c.maxHitPoints = c.currentHitPoints = 10
c.awareness = Math.max(c.awareness, 10)
c.meleeCombat = Math.max(c.meleeCombat, 10)
c.skulduggery = Math.min(c.skulduggery, 10)
this.addProficienciesToCharacter(
c, //
":armor.medium",
":weapon.heavy",
":weapon.specialist.halberd",
":wepaon.light",
);
this.addItemsToCharacter(
c, //
":armor.medium.breastplate",
":lighting.bulls_eye_lantern",
":map.city.hovedstad",
":misc.lamp_oil",
":misc.signal_whistle",
":weapon.specialist.halberd",
);
return
}
if (foundation === 5 || foundation === ":magician") {
c.foundation = "Magician"
c.silver = 10;
c.itemSlots = 6
c.maxHitPoints = c.currentHitPoints = 10
c.grit = Math.min(c.grit, 10)
c.magic = Math.max(c.magic, 10)
c.meleeCombat = Math.min(c.meleeCombat, 10)
c.rangedCombat = Math.min(c.rangedCombat, 10)
/* ---- NO PROFICIENCIES ---- */
this.addItemsToCharacter(
c, //
// "TODO: [TEIR 2 WAND WITH RANDOM SPELL]",
// "TODO: [TEIR 1 WAND WITH RANDOM SPELL]",
);
return
}
if (foundation === 6 || foundation === ":medic") {
c.foundation = "Medic";
c.silver = 40;
c.itemSlots = 7;
c.maxHitPoints = c.currentHitPoints = 10
c.awareness = Math.max(10, c.awareness)
this.addProficienciesToCharacter(
c, //
":armor.light",
":armor.medium",
":weapon.light",
);
this.addItemsToCharacter(
c, //
":armor.light.studded_leather",
":kit.healers_kit",
":weapon.basic.club",
":weapon.basic.dagger",
":weapon.light.sling",
);
return
}
if (foundation === 7 || foundation === ":reckless") {
c.foundation = "Reckless";
c.silver = 50;
c.itemSlots = 7;
c.maxHitPoints = c.currentHitPoints = 20
c.awareness = Math.max(10, c.awareness);
c.grit = Math.max(10, c.grit);
c.magic = Math.min(10, c.magic);
c.meleeCombat = Math.max(10, c.meleeCombat);
this.addProficienciesToCharacter(
c, //
":wepaon.heavy",
":wepaon.light",
);
this.addItemsToCharacter(
c, //
":weapon.heavy.great_axe",
);
return
}
if (foundation === 8 || foundation === ":rover") {
c.foundation = "Rover";
c.silver = 25;
c.itemSlots = 5;
c.maxHitPoints = c.currentHitPoints = 10
c.awareness = Math.max(10, c.awareness)
c.magic = Math.min(10, c.magic)
c.rangedCombat = Math.max(10, c.rangedCombat)
this.addProficienciesToCharacter(
c, //
":armor.light",
":weapon.light",
":weapon.heavy.longbow",
);
this.addItemsToCharacter(
c, //
":armor.light.leather",
":kit.snare_makers_kit",
":weapon.heavy.longbow",
":weapon.light.short_sword",
":map.shayland", // Shayland is the region around Hovedstad
);
return
}
if (foundation === 9 || foundation === ":skrimisher") {
c.foundation = "Skirmisher";
c.silver = 15;
c.itemSlots = 6;
c.maxHitPoints = c.currentHitPoints = 15
c.awareness = Math.max(10, c.awareness)
c.grit = Math.max(10, c.grit)
c.magic = Math.max(5, c.magic)
c.meleeCombat = Math.max(10, c.meleeCombat)
c.skulduggery = Math.max(10, c.skulduggery)
this.addProficienciesToCharacter(
c, //
":armor.light",
);
this.addItemsToCharacter(
c, //
":armor.light.small_shield",
":weapon.light.spear",
);
return
}
if (foundation === 10 || foundation === ":sneak") {
c.foundation = "Sneak";
c.silver = 30;
c.itemSlots = 6;
c.maxHitPoints = c.currentHitPoints = 10
c.awareness = Math.max(10, c.awareness)
c.meleeCombat = Math.max(10, c.meleeCombat)
c.skulduggery = Math.max(10, c.skulduggery)
this.addProficienciesToCharacter(
c, //
":armor.light",
);
this.addItemsToCharacter(
c, //
":weapon.basic.dagger",
":weapon.light.small_crossbow",
":kit.poisoners_kit",
);
return
}
if (foundation === 11 || foundation === ":spellsword") {
c.foundation = "Spellsword";
c.silver = 30;
c.itemSlots = 5;
c.maxHitPoints = c.currentHitPoints = 12;
c.grit = Math.max(10, c.grit)
c.magic = Math.max(10, c.magic)
c.meleeCombat = Math.max(10, c.meleeCombat)
c.rangedCombat = Math.min(10, c.rangedCombat)
c.skulduggery = Math.min(10, c.skulduggery)
this.addProficienciesToCharacter(
c, //
":weapon.light",
);
this.addItemsToCharacter(
c, //
":weapon.light.rapier",
// "[TODO TIER 1 WAND WITH RANDOM SPELL]",
);
return
}
if (foundation === 12 || foundation === ":spelunker") {
c.foundation = "Spelunker";
c.silver = 5;
c.itemSlots = 4;
c.maxHitPoints = c.currentHitPoints = 10;
c.awareness = Math.max(10, c.awareness)
c.magic = Math.min(10, c.magic)
c.meleeCombat = Math.max(10, c.meleeCombat)
c.skulduggery = Math.max(10, c.skulduggery)
this.addProficienciesToCharacter(
c, //
":weapon.light",
);
this.addItemsToCharacter(
c, //
":kit.map_makers_kit",
":lighting.bulls_eye_lantern",
":misc.caltrops",
":misc.chalk",
":misc.lamp_oil",
":weapon.light.spear",
);
return
}
if (foundation === 13 || foundation === ":spit_n_polish") {
c.foundation = "Spit'n'Polish";
c.silver = 10;
c.itemSlots = 2;
c.maxHitPoints = c.currentHitPoints = 10;
c.magic = Math.min(6, c.magic)
c.meleeCombat = Math.max(10, c.meleeCombat)
this.addProficienciesToCharacter(
c, //
":weapon.light",
":weapon.heavy",
":armor.heavy",
":armor.light",
);
this.addItemsToCharacter(
c, //
":weapon.heavy.longsword",
":armor.heavy.half_plate",
":armor.heavy.large_shield",
);
return
}
if (foundation === 14 || foundation === ":stiletto") {
c.foundation = "Stiletto";
c.silver = 10;
c.itemSlots = 5;
c.maxHitPoints = c.currentHitPoints = 10;
c.magic = Math.min(6, c.magic)
c.meleeCombat = Math.max(10, c.meleeCombat)
this.addProficienciesToCharacter(
c, //
":weapon.light",
":weapon.heavy",
":armor.heavy",
":armor.light",
);
this.addItemsToCharacter(
c, //
":weapon.heavy.longsword",
":armor.heavy.half_plate",
":armor.heavy.large_shield",
);
return
}
return this.applyFoundation(c, ":random")
}
}
if (Math.PI < 0 && Player) {
("STFU Linda");
}

View File

@@ -18,13 +18,23 @@ export class ItemSeeder {
// \_/\_/ \___|\__,_| .__/ \___/|_| |_|___/
// |_|
//-------------------------------------------------------
gGame.addItemBlueprint(":weapon.light.dagger", {
gGame.addItemBlueprint(":weapon.basic.club", {
name: "Club",
description: "A club, it's light, what's more to say?",
itemSlots: 1,
damage: 4,
specialEffect: "TBD",
});
gGame.addItemBlueprint(":weapon.basic.dagger", {
name: "Dagger",
description: "Small shady blady",
itemSlots: 0.5,
description: "Basic small shady blady",
itemSlots: 1,
damage: 3,
melee: true,
ranged: true,
ranged: true, //
count: 3, // basic daggers always come in a bundle of three
maxCount: 3,
specialEffect: ":effect.weapon.fast",
});
@@ -52,6 +62,31 @@ export class ItemSeeder {
specialEffect: "TBD",
});
gGame.addItemBlueprint(":weapon.light.small_crossbow", {
name: "Rapier",
description: "Small Crossbow",
itemSlots: 2,
damage: 8,
specialEffect: "TBD",
ammoType: "bolt",
});
gGame.addItemBlueprint(":weapon.heavy.longsword", {
name: "Rapier",
description: "Long one-handed sword",
itemSlots: 2,
damage: 8,
specialEffect: "TBD",
});
gGame.addItemBlueprint(":weapon.heavy.longbow", {
name: "Rapier",
description: "Longbow",
itemSlots: 3,
damage: 8,
specialEffect: "TBD",
});
// _
// / \ _ __ _ __ ___ ___ _ __ ___
// / _ \ | '__| '_ ` _ \ / _ \| '__/ __|
@@ -63,8 +98,10 @@ export class ItemSeeder {
description: "Padded and hardened leather with metal stud reinforcement",
itemSlots: 3,
specialEffect: "TBD",
sneak: false,
armorHitPoints: 10,
});
gGame.addItemBlueprint(":armor.light.leather", {
name: "Leather Armor",
description: "Padded and hardened leather",
@@ -72,6 +109,29 @@ export class ItemSeeder {
specialEffect: "TBD",
armorHitPoints: 6,
});
gGame.addItemBlueprint(":armor.medium.breastplate", {
name: "Breastplate",
description: "Plate that covers chest, cloth and leather covers the rest",
itemSlots: 3,
specialEffect: "TBD",
armorHitPoints: 10,
})
gGame.addItemBlueprint(":armor.heavy.half_plate", {
name: "Half-Plate",
description: "Platemail with near-total coverage",
itemSlots: 4,
specialEffect: "TBD",
armorHitPoints: 6,
});
gGame.addItemBlueprint(":armor.heavy.large_shield", {
name: "Large Shield",
description: "Platemail with near-total coverage",
itemSlots: 4,
specialEffect: "TBD",
armorHitPoints: 6,
});
// _ ___ _
// | |/ (_) |_ ___
@@ -96,5 +156,21 @@ export class ItemSeeder {
count: 20,
maxCount: 20,
});
gGame.addItemBlueprint(":kit.snare_makers_kit", {
name: "Healer's Kit",
description: "Allows you to create traps and snares",
itemSlots: 2,
specialEffect: "TBD",
count: 20,
maxCount: 20,
});
gGame.addItemBlueprint(":kit.map_makers_kit", {
name: "Healer's Kit",
description: "Allows you to create traps and snares",
itemSlots: 1,
specialEffect: "TBD",
})
}
}

View File

@@ -9,6 +9,7 @@ import { gGame } from "./models/globals.js";
import { AuthenticationScene } from "./scenes/authentication/authenticationScene.js";
import { MessageType, WebsocketMessage, formatMessage } from "./utils/messages.js";
// __ __ _ _ ____ ____
// | \/ | | | | _ \ / ___| ___ _ ____ _____ _ __
// | |\/| | | | | | | | \___ \ / _ \ '__\ \ / / _ \ '__|

2
test.js Normal file → Executable file
View File

@@ -22,4 +22,4 @@ class TestChild extends TestParent {
}
}
console.log(new TestChild());
console.log(Function.prototype.toString.call(TestChild));

View File

@@ -22,15 +22,17 @@ const htmlEscapeRegex = /[&<>"'`]/g; // used to escape html characters
* The order of the elements of this array matters.
*/
const opcodes = [
["(^|\\n)=", "($|\\n)", "$1<h1>$2</h1>$3"],
["(^|\\n)==", "($|\\n)", "$1<h2>$2</h2>$3"],
["---", "---", "<span class='strike'>$1</span>"],
["___", "___", "<span class='underline'>$1</span>"],
["(?:[,]{3})", "(?:[,]{3})", "<span class='undercurl'>$1</span>"],
["(?:[(]{2})", "(?:[)]{2})", "<span class='faint'>$1</span>"],
["_", "_", "<span class='italic'>$1</span>"],
["\\*", "\\*", "<span class='bold'>$1</span>"],
["\\[\\[([a-zA-Z0-9_ ]+)\\[\\[", "\\]\\]", "<span class='$1'>$2</span>"],
["(^|\\n)=", "($|\\n)", "$1<h1>$2</h1>$3"], // lines with large headline begins with =
["(^|\\n)==", "($|\\n)", "$1<h2>$2</h2>$3"], // lines with sub-headline begins with ==
["---", "---", "<span class='strike'>$1</span>"], // ---trike through---
["___", "___", "<span class='underline'>$1</span>"], // ___underline___
["(?:[,]{3})", "(?:[,]{3})", "<span class='undercurl'>$1</span>"], // ,,,undercurl,,,
["(?:[(]{2})", "(?:[)]{2})", "<span class='faint'>$1</span>"], // ((faint text))
["(?:_\\*)", "(?:\\*_)", "<span class='bold italic'>$1</span>"], // _*bold and italic*_
["(?:\\*_)", "(?:_\\*)", "<span class='bold italic'>$1</span>"], // *_bold and italic_*
["_", "_", "<span class='italic'>$1</span>"], // _italic_
["\\*", "\\*", "<span class='bold'>$1</span>"], // *bold*
["\\[\\[([a-zA-Z0-9_ ]+)\\[\\[", "\\]\\]", "<span class='$1'>$2</span>"], // [[custom_class[[text with custom class]]
];
/** @type{Array.Array.<Regexp,string>} */
const regexes = [];

View File

@@ -1,10 +1,10 @@
import * as regex from "./regex.js";
const MINI_UID_REGEX = regex.pretty(
const MINI_UID_REGEX = regex.compileMultilineRegex(
"\.uid\.", // Mini-uids always begin with ".uid."
"[a-z0-9]{6,}$", // Terminated by 6 or more random numbers and lowercase letters.
);
const ID_SANITY_REGEX = regex.pretty(
const ID_SANITY_REGEX = regex.compileMultilineRegex(
"^:", // All ids start with a colon
"([a-z0-9]+\.)*?", // Middle -optional- part :myid.gogle.thing.thang.thong
"[a-z0-9_]+$", // The terminating part of the id is numbers, lowercase letters, and -notably- underscores.
@@ -33,7 +33,7 @@ export function isIdSane(id) {
}
/**
* @returns {string} crypto-unsafe pseudo random numbe"r.
* @returns {string} crypto-unsafe pseudo random number.
*
* Generate a random number, convert it to base36, and return it as a string with 7-8 characters.
*/

View File

@@ -83,12 +83,15 @@ export const MessageType = Object.freeze({
* @property {any[]} args
*/
export class WebsocketMessage {
/** @protected @type {any[]} _arr The array that contains the message data */
/** @protected @type {any[]} The array that contains the message data */
_data;
/** @constant @readonly @type {string} _arr The array that contains the message data */
/** @constant @readonly @type {string} The array that contains the message data */
type;
/** @constant @readonly @type {string?} the text payload (if any) of the decoded message */
text;
/**
* @param {string} msgData the raw text data in the websocket message.
*/
@@ -98,13 +101,12 @@ export class WebsocketMessage {
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData,
);
return;
}
let data;
try {
data = JSON.parse(msgData);
} catch (_) {
} catch {
throw new Error(
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
);

View File

@@ -3,16 +3,16 @@
* using the xorshift32 method.
*/
export class Xorshift32 {
/* @type {number} */
initialSeed;
/**
* State holds a single uint32.
* It's useful for staying within modulo 2**32.
*
* @type {Uint32Array}
*/
state;
#state;
get state() {
return this.#state;
}
/** @param {number} seed */
constructor(seed) {
@@ -21,41 +21,44 @@ export class Xorshift32 {
seed = Math.floor(Math.random() * (maxInt32 - 1)) + 1;
}
seed = seed | 0;
this.state = Uint32Array.of(seed);
this.#state = Uint32Array.of(seed);
}
/** @protected Shuffle the internal state. */
shuffle() {
this.state[0] ^= this.state[0] << 13;
this.state[0] ^= this.state[0] >>> 17;
this.state[0] ^= this.state[0] << 5;
this.#state[0] ^= this.#state[0] << 13;
this.#state[0] ^= this.#state[0] >>> 17;
this.#state[0] ^= this.#state[0] << 5;
// We could also do something like this:
// x ^= x << 13;
// x ^= x >> 17;
// x ^= x >>> 17;
// x ^= x << 5;
// return x;
// But we'd have to xor the x with 2^32 after every op,
// we get that "for free" by using the uint32array
// And even though bitwise operations coerce numbers
// into int32 (except >>> which converts into uint32).
// But using Uint32Array ensures the number stays
// uint32 all the way through, thus avoiding the pitfalls
// of potentially dipping into negative number territory
}
/**
* Get a random number and shuffle the internal state.
* @returns {number} a pseudo-random positive integer.
*/
get() {
next() {
this.shuffle();
return this.state[0];
return this.#state[0];
}
/** @param {number} x @returns {number} a positive integer lower than x */
lowerThan(x) {
return this.get() % x;
return this.next() % x;
}
/** @param {number} x @returns {number} a positive integer lower than or equal to x */
lowerThanOrEqual(x) {
return this.get() % (x + 1);
return this.next() % (x + 1);
}
/**
@@ -73,13 +76,13 @@ export class Xorshift32 {
}
/**
* @param {...<T>} ... pick random function argument
* @returns {<T>}
* @method
* @template T
* @param {...T} args pick random function argument
* @returns {T}
*/
oneOf(...args) {
const idx = this.lowerThan(args.length);
return args[idx];
return this.randomElement(args)
}
/**

View File

@@ -4,7 +4,7 @@
* @param {...string} args
* @returns {Regexp}
*/
export function pretty(...args) {
export function compileMultilineRegex(...args) {
const regexprStr = args.join("");
return new RegExp(regexprStr);
}

View File

@@ -97,7 +97,7 @@ export class FramingOptions {
frameChars = FrameType.values.Double;
/**
* @param {object} o
* @param {FramingOptions} o
* @returns {FramingOptions}
*/
static fromObject(o) {