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
logs logs
*.log *.log

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
import { isIdSane, miniUid } from "../utils/id.js"; import { isIdSane, miniUid } from "../utils/id.js";
import { Xorshift32 } from "../utils/random.js"; import { Xorshift32 } from "../utils/random.js";
import { Security } from "../utils/security.js";
import { ItemBlueprint } from "./item.js"; import { ItemBlueprint } from "./item.js";
import { Player } from "./player.js"; import { Player } from "./player.js";
@@ -17,13 +18,22 @@ import { Player } from "./player.js";
/** @typedef {import("./item.js").ItemBlueprint} ItemBlueprint */ /** @typedef {import("./item.js").ItemBlueprint} ItemBlueprint */
export class Game { 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 */ /** @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 */ /** @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. * The characters in the game.
@@ -31,34 +41,40 @@ export class Game {
* @protected * @protected
* @type {Map<string,Character>} * @type {Map<string,Character>}
*/ */
_characters = new Map(); #characters = new Map();
get characters() {
return this.#characters;
}
/* /*
* @protected * @protected
* @type {Map<string,Player>} Map of users in the game username->Player * @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} */ /** @protected @type {Xorshift32} */
_random; #random;
/** @type {Xorshift32} */ /** @type {Xorshift32} */
get random() { get random() {
return this._random; return this.#random;
} }
/** @param {number} rngSeed Seed number used for randomization */ /** @param {number} rngSeed Seed number used for randomization */
constructor() { constructor(rngSeed) {
this.rngSeed = Date.now(); this.seedRNG(rngSeed);
} }
set rngSeed(rngSeed) { seedRNG(rngSeed) {
this._random = new Xorshift32(rngSeed); this.#random = new Xorshift32(rngSeed);
} }
getPlayerByUsername(username) { getPlayerByUsername(username) {
console.log("GETTING PLAYER: `%s`", 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?} passwordHash
* @param {string?} salt * @param {string?} salt
* *
* @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise. * @returns {Player|false} Returns the player if username wasn't already taken, or null otherwise.
*/ */
createPlayer(username, passwordHash = undefined, salt = undefined) { createPlayer(username, passwordHash = undefined, salt = undefined) {
if (this._players.has(username)) { if (this.#players.has(username)) {
return false; return false;
} }
const player = new Player( passwordHash ??= "";
username, salt ??= Security.generateHash(miniUid());
typeof passwordHash === "string" ? passwordHash : "",
typeof salt === "string" && salt.length > 0 ? salt : miniUid(),
);
this._players.set(username, player); const player = new Player(username, passwordHash, salt);
this.#players.set(username, player);
return player; return player;
} }
@@ -99,7 +114,7 @@ export class Game {
throw new Error("Invalid blueprintId!"); throw new Error("Invalid blueprintId!");
} }
const existing = this._itemBlueprints.get(blueprintId); const existing = this.#itemBlueprints.get(blueprintId);
if (existing) { if (existing) {
console.warn("we tried to create the same item blueprint more than once", blueprintId, attributes); 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); const result = new ItemBlueprint(attributes);
this._itemBlueprints.set(blueprintId, result); this.#itemBlueprints.set(blueprintId, result);
return result; return result;
} }
@@ -123,6 +138,6 @@ export class Game {
if (!isIdSane(blueprintId)) { if (!isIdSane(blueprintId)) {
throw new Error(`blueprintId >>${blueprintId}<< is not a valid id`); 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"; import { Game } from "./game.js";
/** @constant @readonly @type {Game} Global instance of Game */ /** @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 */ /** @constant @readonly @type {string[]} Type of ammo that this item is, or that this item uses */
skills = []; skills = [];
/** @constant @readonly @type {boolean} Can a person wearing this armor be stealthy? */
sneak;
} }
/** /**
@@ -53,7 +56,7 @@ export class ItemBlueprint extends ItemAttributes {
/** /**
* Constructor * Constructor
* *
* @param {object} o Object whose attributes we copy * @param {ItemAttributes} o Object whose attributes we copy
*/ */
constructor(o) { constructor(o) {
super(); super();
@@ -106,4 +109,4 @@ export class ItemBlueprint extends ItemAttributes {
* arrows that are consumed. In this case, each individual arrow is not tracked * arrows that are consumed. In this case, each individual arrow is not tracked
* as its own entity, only the quiver is 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. * or magical portals to distant locations.
*/ */
export class Location { export class Location {
/** @protected @type {string} */ /** @type {string} */
_id; #id;
get id() { get id() {
return this._id; return this.#id;
} }
/** @protected @type {string} */ /** @type {string} */
_name; #name;
get name() { get name() {
return this._name; return this.#name;
} }
/** @protected @type {string} */ /** @type {string} */
_description; #description;
get description() { get description() {
return this._description; return this.#description;
} }
/** @protected @type {Map<string,Portal>} */ /** @type {Map<string,Portal>} */
_portals = new Map(); #portals = new Map();
get portals() { get portals() {
return this._portals; return this.#portals;
} }
/** /**
@@ -39,8 +39,8 @@ export class Location {
* @param {string} description * @param {string} description
*/ */
constructor(id, name, description) { constructor(id, name, description) {
this._id = id; this.#id = id;
this._name = name; this.#name = name;
this._description = description; this.#description = description;
} }
} }

View File

@@ -1,7 +1,8 @@
import WebSocket from "ws";
import { Character } from "./character.js";
import { Config } from "./../config.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. * Player Account.

View File

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

View File

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

View File

@@ -1,23 +1,19 @@
import { Security } from "../../utils/security.js"; import { Security } from "../../utils/security.js";
import { Config } from "../../config.js"; import { Config } from "../../config.js";
import { GameScene } from "../gameLoop/gameScene.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 { Prompt } from "../prompt.js";
import { Scene } from "../scene.js"; import { Scene } from "../scene.js";
import { gGame } from "../../models/globals.js"; import { gGame } from "../../models/globals.js";
/** @typedef {import("../../models/player.js").Player} Player */ /** @typedef {import("../../models/player.js").Player} Player */
/** @property {Session} session */
export class AuthenticationScene extends Scene { export class AuthenticationScene extends Scene {
introText = [
"= Welcome!", //
];
/** @type {Player} */ /** @type {Player} */
player; player;
onReady() { onReady() {
this.session.sendText("= Welcome");
this.show(UsernamePrompt); this.show(UsernamePrompt);
} }
@@ -57,25 +53,24 @@ export class AuthenticationScene extends Scene {
// | __/| | | (_) | | | | | | |_) | |_ // | __/| | | (_) | | | | | | |_) | |_
// |_| |_| \___/|_| |_| |_| .__/ \__| // |_| |_| \___/|_| |_| |_| .__/ \__|
// |_| // |_|
/** @property {AuthenticationScene} scene */
class UsernamePrompt extends Prompt { class UsernamePrompt extends Prompt {
// //
promptText = [ message = [
"Please enter your username:", // "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 // When player types :help
helpText = [ help = [
"This is where you log in.", "Enter your username to proceed with loggin in",
"If you don't already have a player profile on this server, you can type *:create* to create one", "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 };
// /** @returns {AuthenticationScene} workaround for proper type hinting */
// Let the client know that we're asking for a username
promptOptions = { username: true };
/** @returns {AuthenticationScene} */
get scene() { get scene() {
return this._scene; return this._scene;
} }
@@ -126,12 +121,8 @@ class UsernamePrompt extends Prompt {
// |_| // |_|
class PasswordPrompt extends Prompt { class PasswordPrompt extends Prompt {
// //
promptText = "Please enter your password"; message = "Please enter your password";
options = { password: true };
//
// Let the client know that we're asking for a password
// so it can set <input type="password">
promptOptions = { password: true };
get player() { get player() {
return this.scene.player; return this.scene.player;
@@ -156,9 +147,11 @@ class PasswordPrompt extends Prompt {
return; return;
} }
const player = this.scene.player;
// //
// Block users who enter bad passwords too many times. // Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) { if (player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = Date.now() + Config.accountLockoutSeconds * 1000; this.blockedUntil = Date.now() + Config.accountLockoutSeconds * 1000;
this.calamity("You have been locked out for too many failed password attempts, come back later"); this.calamity("You have been locked out for too many failed password attempts, come back later");
return; return;
@@ -167,7 +160,7 @@ class PasswordPrompt extends Prompt {
// //
// Handle blocked users. // Handle blocked users.
// They don't even get to have their password verified. // They don't even get to have their password verified.
if (this.player.blockedUntil > Date.now()) { if (player.blockedUntil > Date.now()) {
// //
// Try to re-login too soon, and your lockout lasts longer. // Try to re-login too soon, and your lockout lasts longer.
this.blockedUntil += Config.accountLockoutSeconds * 1000; this.blockedUntil += Config.accountLockoutSeconds * 1000;
@@ -177,23 +170,23 @@ class PasswordPrompt extends Prompt {
// //
// Verify the password against the hash we've stored. // Verify the password against the hash we've stored.
if (!Security.verifyPassword(text, this.player.passwordHash)) { if (!Security.verifyPassword(text, player.passwordHash)) {
this.sendError("Incorrect password!"); this.sendError("Incorrect password!");
this.player.failedPasswordsSinceLastLogin++; player.failedPasswordsSinceLastLogin++;
this.session.sendDebug(`Failed login attempt #${this.player.failedPasswordsSinceLastLogin}`); this.session.sendDebug(`Failed login attempt #${player.failedPasswordsSinceLastLogin}`);
this.execute(); this.execute();
return; return;
} }
this.player.lastSucessfulLoginAt = new Date(); player.lastSucessfulLoginAt = new Date();
this.player.failedPasswordsSinceLastLogin = 0; player.failedPasswordsSinceLastLogin = 0;
// //
// We do not allow a player to be logged in more than once! // We do not allow a player to be logged in more than once!
if (this.player.loggedIn) { if (player.loggedIn) {
this.calamity("This player is already logged in"); this.calamity("player is already logged in");
return; return;
} }

View File

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

View File

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

View File

@@ -1,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("../models/session.js").Session} Session */
/** @typedef {import("../utils/message.js").MessageType} MessageType */
/** @typedef {import("../utils/message.js").WebsocketMessage} WebsocketMessage */ /** @typedef {import("../utils/message.js").WebsocketMessage} WebsocketMessage */
/** @typedef {import("./scene.js").Scene} Scene */ /** @typedef {import("./scene.js").Scene} Scene */
@@ -16,7 +15,7 @@
* - onColon(...) * - onColon(...)
*/ */
export class Prompt { export class Prompt {
/** @type {Scene} */ /** @protected @type {Scene} */
_scene; _scene;
/** @type {Scene} */ /** @type {Scene} */
@@ -30,20 +29,25 @@ export class Prompt {
* Values: string containing the help text * Values: string containing the help text
* *
* If you want truly custom help texts, you must override the onHelpFallback function, * 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. * dictionary entirely.
* *
* @constant * @constant
* @readonly * @readonly
* @type {Record<string, string|string[]>} * @type {Record<string, string|string[]>}
*/ */
helpText = { help = {};
"": "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 = [ * Default prompt text to send if we don't want to send something in the execute() call.
"Please enter some very important info", // Stupid placeholder text *
* 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 "((or type :quit to run away))", // strings in double parentheses is rendered shaded/faintly
]; ];
@@ -51,28 +55,21 @@ export class Prompt {
/* @example /* @example
* *
* // if the prompt expects a username * // if the prompt expects a username
* promptOptions = { username : true }; * options = { username : true };
* *
* // if the prompt expects a password * // if the prompt expects a password
* promptOptions = { password : true }; * options = { password : true };
*/ */
promptOptions = {}; options = {};
/** @type {Session} */ /** @type {Session} */
get session() { get session() {
return this.scene.session; return this._scene.session;
} }
/** @param {Scene} scene */ /** @param {Scene} scene */
constructor(scene) { constructor(scene) {
this._scene = 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 * It's here you want to send the prompt text via the sendPrompt() method
*/ */
execute() { 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]` */ /** Triggered when user types `:help [some optional topic]` */
onHelp(topic) { onHelp(topic) {
if (this.helpText[topic]) { if (!this.help) {
this.sendText(this.helpText[topic]); this.sendText("No help available at this moment - figure it out yourself, adventurer");
return;
}
if (this.help[topic]) {
this.sendText(this.help[topic]);
return; return;
} }

View File

@@ -1,8 +1,10 @@
import { sprintf } from "sprintf-js"; import { sprintf } from "sprintf-js";
import { Prompt } from "./prompt.js";
/** @typedef {import("../utils/messages.js").WebsocketMessage} WebsocketMessage */ /** @typedef {import("../utils/messages.js").WebsocketMessage} WebsocketMessage */
/** @typedef {import("../models/session.js").Session} Session */ /** @typedef {import("../models/session.js").Session} Session */
/** @typedef {import("./prompt.js").Prompt } Prompt */ /** @typedef {import("./prompt.js").Prompt } Prompt */
/** @typedef {new (scene: Scene) => Prompt} PromptClassReference */
/** /**
* Scene - a class for showing one or more prompts in a row. * Scene - a class for showing one or more prompts in a row.
@@ -16,13 +18,8 @@ import { sprintf } from "sprintf-js";
* @abstract * @abstract
*/ */
export class Scene { export class Scene {
/** /** @constant @readonly @type {string|string[]|PromptClassReference} Text or prompt to show when this scene begins */
* @type {string|string[]} This text is shown when the scene begins intro;
*/
introText = "";
/** @constant @readonly @type {Prompt?} */
introPrompt;
/** @readonly @constant @protected @type {Session} */ /** @readonly @constant @protected @type {Session} */
#session; #session;
@@ -47,21 +44,15 @@ export class Scene {
/** @param {Session} session */ /** @param {Session} session */
execute(session) { execute(session) {
this.#session = session; this.#session = session;
if (this.introText) {
this.session.sendText(this.introText);
}
if (this.introPrompt) {
this.showPrompt(this.introPrompt);
} else {
this.onReady(); this.onReady();
} }
onReady() {
if (!this.intro) {
return;
} }
/** @abstract */ this.show(this.intro);
onReady() {
throw new Error("Abstract method must be implemented by subclass");
} }
/** @param {Prompt} prompt */ /** @param {Prompt} prompt */
@@ -70,9 +61,33 @@ export class Scene {
prompt.execute(); prompt.execute();
} }
/** @param {new (scene: Scene) => Prompt} promptClassReference */ /** @param {string|string[]} text */
show(promptClassReference) { showText(text) {
this.showPrompt(new promptClassReference(this)); 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, message,
type: typeof 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); this.currentPrompt.onReply(message.text);
} }
@@ -107,7 +127,7 @@ export class Scene {
* - call this method directly * - call this method directly
*/ */
onQuit() { onQuit() {
this.currentPrompt.onQuit(); this.currentPrompt?.onQuit();
} }
/** /**
@@ -127,7 +147,7 @@ export class Scene {
* @param {WebsocketMessage} message * @param {WebsocketMessage} message
*/ */
onHelp(message) { onHelp(message) {
this.currentPrompt.onHelp(message.text); this.currentPrompt?.onHelp(message.text);
} }
/** /**
@@ -185,7 +205,7 @@ export class Scene {
* @param {WebsocketMessage} message * @param {WebsocketMessage} message
*/ */
onColon(message) { onColon(message) {
const handledByPrompt = this.currentPrompt.onColon(message.command, message.args); const handledByPrompt = this.currentPrompt?.onColon(message.command, message.args);
if (!handledByPrompt) { if (!handledByPrompt) {
this.onColonFallback(message.command, message.args); this.onColonFallback(message.command, message.args);
@@ -203,7 +223,7 @@ export class Scene {
const n = Number(args[0]); const n = Number(args[0]);
this.session.sendText( 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 { export class CharacterSeeder {
constructor() { 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.!! // Which we easily could have.!!
roll = { roll.d = (max, min = 1) => gGame.random.within(min, max)
d: (max, min = 1) => { roll.d6 = () => roll.d(6)
return gGame.random.within(min, max); roll.d8 = () => roll.d(8)
},
d6: () => {
return gGame.random.within(1, 6);
},
d8: () => {
return gGame.random.within(1, 8);
},
};
} }
/** /**
@@ -52,14 +44,14 @@ export class CharacterSeeder {
/** /**
* @param {Character} character * @param {Character} character
* @param {...string} skills * @param {...string} proficiencies
*/ */
addSkillsToCharacter(character, ...skills) { addProficienciesToCharacter(character, ...proficiencies) {
for (const skill of skills) { for (const prof of proficiencies) {
if (!isIdSane(skill)) { if (!isIdSane(prof)) {
throw new Error(`Skill id >>${skill}<< is insane!`); throw new Error(`Proficiency id >>${prof}<< is insane!`);
} }
character.skills.add(skill); character.proficiencies.add(prof);
} }
} }
@@ -72,17 +64,24 @@ export class CharacterSeeder {
createCharacter() { createCharacter() {
const c = new Character(); 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 = c.name =
gGame.random.oneOf("sir ", "madam ", "mister ", "miss ", "", "", "") + // prefix gGame.random.oneOf("sir ", "madam ", "mister ", "miss ", "", "", "") +
"random " + // name "random " + // name
gGame.random.get().toString(); // suffix gGame.random.next().toString();
}
rollSkills(c) {
c.awareness = roll.d6() + 2; c.awareness = roll.d6() + 2;
c.grit = roll.d6() + 2; c.grit = roll.d6() + 2;
c.knowledge = roll.d6() + 2; c.knowledge = roll.d6() + 2;
@@ -90,11 +89,6 @@ export class CharacterSeeder {
c.meleeCombat = roll.d6() + 2; c.meleeCombat = roll.d6() + 2;
c.rangedCombat = roll.d6() + 2; c.rangedCombat = roll.d6() + 2;
c.skulduggery = roll.d6() + 2; c.skulduggery = roll.d6() + 2;
this.applyAncestry(c);
this.applyFoundation(c);
return c;
} }
applyAncestry(c) { applyAncestry(c) {
@@ -168,44 +162,61 @@ export class CharacterSeeder {
* @param {string|number} Foundation to add to character * @param {string|number} Foundation to add to character
*/ */
applyFoundation(c, foundation = ":random") { applyFoundation(c, foundation = ":random") {
switch (foundation) { if (foundation == ":random") {
case ":random": return this.applyFoundation(c, roll.d(20)); // according to the rulebook, roll a d20 and reroll any invalid results.
return this.applyFoundation(c, roll.d(3)); }
// //
// Brawler // Brawler
// ------ // ------
case 1: if (foundation === 1 || foundation === ":brawler") {
case ":brawler":
c.foundation = "Brawler"; c.foundation = "Brawler";
c.skills.add(":armor.light");
c.silver = 40;
c.maxHitPoints = c.currentHitPoints = 15; c.maxHitPoints = c.currentHitPoints = 15;
c.itemSlots = 7; c.itemSlots = 7;
c.silver = 40;
c.meleeCombat = Math.max(c.meleeCombat, 10); c.meleeCombat = Math.max(c.meleeCombat, 10);
c.knowledge = Math.min(c.knowledge, 10); c.knowledge = Math.min(c.knowledge, 10);
this.addProficienciesToCharacter(
c,
":armor.light",
":weapon.weird.spiked_gauntlets"
);
this.addItemsToCharacter( this.addItemsToCharacter(
c, // c, //
":armor.light.studded_leather", ":armor.light.studded_leather",
":weapon.weird.spiked_gauntlets", ":weapon.weird.spiked_gauntlets",
); );
this.addSkillsToCharacter(c, ":weapon.weird.spiked_gauntlets"); return;
}
break;
// //
// DRUID // DRUID
// ------ // ------
case 2: if (foundation === 2 || foundation === ":druid") {
case ":druid":
c.foundation = "Druid"; c.foundation = "Druid";
c.silver = 40;
c.maxHitPoints = this.currentHitPoints = 15; c.silver = 10;
c.itemSlots = 7; c.itemSlots = 5;
c.meleeCombat = Math.max(this.meleeCombat, 10); c.maxHitPoints = this.currentHitPoints = 10;
c.knowledge = Math.min(this.knowledge, 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( this.addItemsToCharacter(
c, // c, //
":armor.light.leather", ":armor.light.leather",
@@ -213,385 +224,350 @@ export class CharacterSeeder {
":kit.poisoners_kit", ":kit.poisoners_kit",
":kit.healers_kit", ":kit.healers_kit",
); );
this.addSkillsToCharacter(
c, // return;
":armor.light.sleather", }
":armor.light.hide",
":weapon.light.sickle", //
); // FENCER
break; // -------
case 3: if (foundation === 3 || foundation === ":fencer") {
case ":fencer":
c.foundation = "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.silver = 40;
c.itemSlots = 5; 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( this.addItemsToCharacter(
c, // c, //
":armor.light.leather", ":armor.light.leather",
":weapon.basic.dagger",
":weapon.light.rapier", ":weapon.light.rapier",
":weapon.light.dagger",
); );
break; }
case 4:
case ":guard": if (foundation === 4 || foundation === ":guard") {
c.foundation = "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.silver = 50;
c.itemSlots = 5; 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( this.addItemsToCharacter(
c, // c, //
":armor.medium.breastplate", ":armor.medium.breastplate",
":weapon.weird.halberd",
":lighting.bulls_eye_lantern", ":lighting.bulls_eye_lantern",
":map.city.hovedstad",
":misc.lamp_oil",
":misc.signal_whistle", ":misc.signal_whistle",
":maps.area.hvedstad", ":weapon.specialist.halberd",
); );
break; return
/*
//
//---------------------------------------------------------------------------------------
//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 === 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", name: "Dagger",
description: "Small shady blady", description: "Basic small shady blady",
itemSlots: 0.5, itemSlots: 1,
damage: 3, damage: 3,
melee: true, melee: true,
ranged: true, ranged: true, //
count: 3, // basic daggers always come in a bundle of three
maxCount: 3,
specialEffect: ":effect.weapon.fast", specialEffect: ":effect.weapon.fast",
}); });
@@ -52,6 +62,31 @@ export class ItemSeeder {
specialEffect: "TBD", 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", description: "Padded and hardened leather with metal stud reinforcement",
itemSlots: 3, itemSlots: 3,
specialEffect: "TBD", specialEffect: "TBD",
sneak: false,
armorHitPoints: 10, armorHitPoints: 10,
}); });
gGame.addItemBlueprint(":armor.light.leather", { gGame.addItemBlueprint(":armor.light.leather", {
name: "Leather Armor", name: "Leather Armor",
description: "Padded and hardened leather", description: "Padded and hardened leather",
@@ -72,6 +109,29 @@ export class ItemSeeder {
specialEffect: "TBD", specialEffect: "TBD",
armorHitPoints: 6, 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, count: 20,
maxCount: 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 { AuthenticationScene } from "./scenes/authentication/authenticationScene.js";
import { MessageType, WebsocketMessage, formatMessage } from "./utils/messages.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. * The order of the elements of this array matters.
*/ */
const opcodes = [ const opcodes = [
["(^|\\n)=", "($|\\n)", "$1<h1>$2</h1>$3"], ["(^|\\n)=", "($|\\n)", "$1<h1>$2</h1>$3"], // lines with large headline begins with =
["(^|\\n)==", "($|\\n)", "$1<h2>$2</h2>$3"], ["(^|\\n)==", "($|\\n)", "$1<h2>$2</h2>$3"], // lines with sub-headline begins with ==
["---", "---", "<span class='strike'>$1</span>"], ["---", "---", "<span class='strike'>$1</span>"], // ---trike through---
["___", "___", "<span class='underline'>$1</span>"], ["___", "___", "<span class='underline'>$1</span>"], // ___underline___
["(?:[,]{3})", "(?:[,]{3})", "<span class='undercurl'>$1</span>"], ["(?:[,]{3})", "(?:[,]{3})", "<span class='undercurl'>$1</span>"], // ,,,undercurl,,,
["(?:[(]{2})", "(?:[)]{2})", "<span class='faint'>$1</span>"], ["(?:[(]{2})", "(?:[)]{2})", "<span class='faint'>$1</span>"], // ((faint text))
["_", "_", "<span class='italic'>$1</span>"], ["(?:_\\*)", "(?:\\*_)", "<span class='bold italic'>$1</span>"], // _*bold and italic*_
["\\*", "\\*", "<span class='bold'>$1</span>"], ["(?:\\*_)", "(?:_\\*)", "<span class='bold italic'>$1</span>"], // *_bold and italic_*
["\\[\\[([a-zA-Z0-9_ ]+)\\[\\[", "\\]\\]", "<span class='$1'>$2</span>"], ["_", "_", "<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>} */ /** @type{Array.Array.<Regexp,string>} */
const regexes = []; const regexes = [];

View File

@@ -1,10 +1,10 @@
import * as regex from "./regex.js"; 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." "\.uid\.", // Mini-uids always begin with ".uid."
"[a-z0-9]{6,}$", // Terminated by 6 or more random numbers and lowercase letters. "[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 "^:", // All ids start with a colon
"([a-z0-9]+\.)*?", // Middle -optional- part :myid.gogle.thing.thang.thong "([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. "[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. * 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 * @property {any[]} args
*/ */
export class WebsocketMessage { 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; _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; 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. * @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 " + "Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData, typeof msgData,
); );
return;
} }
let data; let data;
try { try {
data = JSON.parse(msgData); data = JSON.parse(msgData);
} catch (_) { } catch {
throw new Error( throw new Error(
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`, `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. * using the xorshift32 method.
*/ */
export class Xorshift32 { export class Xorshift32 {
/* @type {number} */
initialSeed;
/** /**
* State holds a single uint32. * State holds a single uint32.
* It's useful for staying within modulo 2**32. * It's useful for staying within modulo 2**32.
* *
* @type {Uint32Array} * @type {Uint32Array}
*/ */
state; #state;
get state() {
return this.#state;
}
/** @param {number} seed */ /** @param {number} seed */
constructor(seed) { constructor(seed) {
@@ -21,41 +21,44 @@ export class Xorshift32 {
seed = Math.floor(Math.random() * (maxInt32 - 1)) + 1; seed = Math.floor(Math.random() * (maxInt32 - 1)) + 1;
} }
seed = seed | 0; seed = seed | 0;
this.state = Uint32Array.of(seed); this.#state = Uint32Array.of(seed);
} }
/** @protected Shuffle the internal state. */ /** @protected Shuffle the internal state. */
shuffle() { shuffle() {
this.state[0] ^= this.state[0] << 13; this.#state[0] ^= this.#state[0] << 13;
this.state[0] ^= this.state[0] >>> 17; this.#state[0] ^= this.#state[0] >>> 17;
this.state[0] ^= this.state[0] << 5; this.#state[0] ^= this.#state[0] << 5;
// We could also do something like this: // We could also do something like this:
// x ^= x << 13; // x ^= x << 13;
// x ^= x >> 17; // x ^= x >>> 17;
// x ^= x << 5; // x ^= x << 5;
// return x; // return x;
// But we'd have to xor the x with 2^32 after every op, // And even though bitwise operations coerce numbers
// we get that "for free" by using the uint32array // 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. * Get a random number and shuffle the internal state.
* @returns {number} a pseudo-random positive integer. * @returns {number} a pseudo-random positive integer.
*/ */
get() { next() {
this.shuffle(); this.shuffle();
return this.state[0]; return this.#state[0];
} }
/** @param {number} x @returns {number} a positive integer lower than x */ /** @param {number} x @returns {number} a positive integer lower than x */
lowerThan(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 */ /** @param {number} x @returns {number} a positive integer lower than or equal to x */
lowerThanOrEqual(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 * @method
* @returns {<T>} * @template T
* @param {...T} args pick random function argument
* @returns {T}
*/ */
oneOf(...args) { oneOf(...args) {
const idx = this.lowerThan(args.length); return this.randomElement(args)
return args[idx];
} }
/** /**

View File

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

View File

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