rearrage_stuff

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

99
models/character.js Executable file
View File

@@ -0,0 +1,99 @@
import * as roll from "../utils/dice.js";
import * as id from "../utils/id.js";
import { Item } from "./item.js";
/**
* A playable character.
*
* @class
*/
export class Character {
/** @type {string} character's name */
name;
/**
* @protected
* @type {number} The number of XP the character has.
*/
xp = 0;
/** @protected @type {number} The character's level. */
level = 1;
/** @type {number} Awareness Skill */
awareness;
/** @type {number} Grit Skill */
grit;
/** @type {number} Knowledge Skill */
knowledge;
/** @type {number} Magic Skill */
magic;
/** @type {number} Melee Attack Skill */
meleeCombat;
/** @type {number} Ranged Attack Skill */
rangedCombat;
/** @type {number} Skulduggery Skill */
skulduggery;
/** @type {string} Bloodline background */
ancestry;
/** @type {string} Foundational background */
foundation;
/** @type {string} Money */
silver;
/** @type {number} Current number of hit points */
currentHitPoints;
/** @type {number} Number of hit points when fully healed */
maxHitPoints;
/** @type {number} Number items you can carry */
itemSlots;
/** @type {Set<string>} Things the character is particularly proficient at. */
skills = new Set();
/** @type {Map<Item,number} Things the character is particularly proficient at. */
items = new Map();
/**
* @param {string} name The name of the character
*/
constructor(name, initialize) {
this.name = name;
}
/** 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");
}
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);
}
// todo removeItem(item, count)
}

127
models/game.js Executable file
View File

@@ -0,0 +1,127 @@
/*
* The game object holds everything.
* All the locations, players, characters, items, npcs, quests, loot, etc.
*
* It is a pseudo-singleton in that you should only ever create one.
*
* Serializing this object effectively saves the game.
*/
import { Config } from "../config.js";
import { isIdSane, miniUid } from "../utils/id.js";
import { Xorshift32 } from "../utils/random.js";
import { Character } from "./character.js";
import { ItemAttributes, ItemBlueprint } from "./item.js";
import { Player } from "./player.js";
export class Game {
_counter = 1_000_000;
/** @type {Map<string,ItemBlueprint>} List of all item blueprints in the game */
_itemBlueprints = new Map();
/** @type {Map<string,Location>} The list of locations in the game */
_locations = new Map();
/**
* The characters in the game.
*
* @protected
* @type {Map<string,Character>}
*/
_characters = new Map();
/*
* @protected
* @type {Map<string,Player>} Map of users in the game username->Player
*/
_players = new Map();
/** @protected @type {Xorshift32} */
_random;
/** @type {Xorshift32} */
get random() {
return this._random;
}
/** @param {number} rngSeed Seed number used for randomization */
constructor() {
this.rngSeed = Date.now();
}
set rngSeed(rngSeed) {
this._random = new Xorshift32(rngSeed);
}
getPlayer(username) {
return this._players.get(username);
}
/**
* Atomic player creation.
*
* @param {string} username
* @param {string?} passwordHash
* @param {string?} salt
*
* @returns {Player|null} Returns the player if username wasn't already taken, or null otherwise.
*/
createPlayer(username, passwordHash = undefined, salt = undefined) {
if (this._players.has(username)) {
return false;
}
const player = new Player(
username,
typeof passwordHash === "string" ? passwordHash : "",
typeof salt === "string" && salt.length > 0 ? salt : miniUid(),
);
this._players.set(username, player);
return player;
}
/**
* Create an ItemBlueprint with a given blueprintId
*
* @param {string} blueprintId
* @param {ItemAttributes} attributes
*
* @returns {ItemBlueprint|false}
*/
addItemBlueprint(blueprintId, attributes) {
if (typeof blueprintId !== "string" || !blueprintId) {
throw new Error("Invalid blueprintId!");
}
const existing = this._itemBlueprints.get(blueprintId);
if (existing) {
console.debug("we tried to create the same item blueprint more than once", blueprintId, attributes);
return existing;
}
attributes.blueprintId = blueprintId;
const result = new ItemBlueprint(attributes);
this._itemBlueprints.set(blueprintId, result);
return result;
}
/**
* @param {string} blueprintId
* @returns {ItemBlueprint?}
*/
getItemBlueprint(blueprintId) {
if (!isIdSane(blueprintId)) {
throw new Error(`blueprintId >>${blueprintId}<< is insane!`);
}
const tpl = this._itemBlueprints.get(blueprintId);
return tpl || undefined;
}
}

4
models/globals.js Executable file
View File

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

109
models/item.js Executable file
View File

@@ -0,0 +1,109 @@
/**
* Abstract class for documentation purposes.
* @abstract
*/
export class ItemAttributes {
/** @constant @readonly @type {string} Machine-friendly name for the blueprint */
blueprintId;
/** @constant @readonly @type {string} Item's human-friendly name */
name;
/** @constant @readonly @type {string} Item's Description */
description;
/** @constant @readonly @type {number} Number of Item Slots taken up by this item. */
itemSlots;
/** @constant @readonly @type {number?} How much damage (if any) does this item deal */
baseDamage;
/** @constant @readonly @type {string?} Which special effect is triggered when successful attacking with this item? */
specialEffect;
/** @constant @readonly @type {boolean?} Can this item be used as a melee weapon? */
melee;
/** @constant @readonly @type {boolean?} Can this item be used as a ranged weapon? */
ranged;
/** @readonly @type {number} How many extra HP do you have when oyu wear this armor. */
armorHitPoints;
/** @constant @readonly @type {string?} Type of ammo that this item is, or that this item uses */
ammoType;
/** @readonly @type {number} how much is left in this item. (Potions can have many doses and quivers many arrows) */
count;
/** @readonly @type {number} Some items (quivers) can be replenished, so how much can this quiver/potion/ration pack hold */
maxCount;
/** @constant @readonly @type {string[]} Type of ammo that this item is, or that this item uses */
skills = [];
}
/**
* Item blueprints are the built-in basic items of the game.
* A character cannot directly own one of these items,
* they can only own Items, and ItemBlueprints can be used to
* generate these Items.
*/
export class ItemBlueprint extends ItemAttributes {
/**
* Constructor
*
* @param {object} o Object whose attributes we copy
*/
constructor(o) {
super();
if (typeof o.blueprintId !== "string" || o.name.length < 1) {
throw new Error("blueprintId must be a string, but " + typeof o.blueprintId + " given.");
}
if (typeof o.name !== "string" || o.name.length < 1) {
throw new Error("Name must be a string, but " + typeof o.name + " given.");
}
if (!Number.isFinite(o.itemSlots)) {
throw new Error("itemSlots must be a finite number!");
}
o.itemSlots = Number(o.itemSlots);
for (const [key, _] of Object.entries(this)) {
if (o[key] !== "undefied") {
this[key] = o[key];
}
}
}
//
// Spawn a new non-unique item!
/** @returns {Item} */
createItem() {
const item = new Item();
for (const [key, value] of Object.entries(this)) {
item[key] = value;
}
item.blueprintId = this.blueprintId;
return item;
}
}
/**
* An object of this class represents a single instance
* of a given item in the game. It can be a shortsword, or a potion,
* or another, different shortsword that belongs to another character, etc.
*
* If a character has two identical potions of healing, they are each represented
* by an object of this class.
* The only notable tweak to this rule is collective items like quivers that have
* 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 {}

45
models/location.js Executable file
View File

@@ -0,0 +1,45 @@
import { Portal } from "./portal";
/**
* Location in the world.
*
* Can contain characters, quests, monsters, loot, NPCs and more.
*
* Can contain mundane portals (such as doors or pathways) to adjacent rooms/locations,
* or magical portals to distant locations.
*/
export class Location {
/** @protected @type string */
_id;
get id() {
return this._id;
}
/** @protected @type string */
_name;
get name() {
return this._name;
}
/** @protected @type string */
_description;
get description() {
return this._description;
}
/** @protected @type {Map<string,Portal>} */
_portals = new Map();
get portals() {
return this._portals;
}
/**
*/
constructor(id, name, description) {
this._id = id;
this._name = name;
this._description = description;
}
}
const l = new Location("foo", "bar", "baz");

98
models/player.js Executable file
View File

@@ -0,0 +1,98 @@
import WebSocket from "ws";
import { Character } from "./character.js";
import { Config } from "./../config.js";
import { Scene } from "../scenes/scene.js";
/**
* Player Account.
*
* Contain persistent player account info.
*/
export class Player {
/** @protected @type {string} unique username */
_username;
get username() {
return this._username;
}
/** @protected @type {string} */
_passwordHash;
get passwordHash() {
return this._passwordHash;
}
/** @protected @type {string} random salt used for hashing */
_salt;
get salt() {
return this._salt;
}
/** @protected @type {Date} */
_createdAt = new Date();
get createdAt() {
return this._createdAt;
}
/** @type {Date} */
blockedUntil;
/** @type {Date|null} Date of the player's last websocket message. */
lastActivityAt = null;
/** @type {Date|null} Date of the player's last login. */
lastSucessfulLoginAt = null;
/** @type {number} Number of successful logins on this character */
successfulLogins = 0;
/** @type {number} Number of failed login attempts since the last good login attempt */
failedPasswordsSinceLastLogin = 0;
/** @type {boolean} Is the player logged in right now? */
loggedIn = false;
/** @type {Scene} The scene the player was before they logged out */
latestScene;
/** @protected @type {Set<Character>} */
_characters = new Set(); // should this be a WeakSet? After all if the player is removed, their items might remain in the system, right?
get characters() {
return this._characters;
}
/**
* @param {string} username
* @param {string} passwordHash
* @param {string} salt
*/
constructor(username, passwordHash, salt) {
this._username = username;
this._passwordHash = passwordHash;
this._salt = salt;
this._createdAt = new Date();
}
setPasswordHash(hashedPassword) {
this._passwordHash = hashedPassword;
}
/**
* Add a character to the player's party
*
* @param {Character} character
* @returns {number|false} the new size of the players party if successful, or false if the character could not be added.
*/
addCharacter(character) {
if (this._characters.has(character)) {
return false;
}
if (this._characters.size >= Config.maxPartySize) {
return false;
}
this._characters.add(character);
return this._characters.size;
}
}

25
models/portal.js Executable file
View File

@@ -0,0 +1,25 @@
/**
* Connects two location ONE WAY.
*
* Example: two adjacent rooms connected by a door:
* Room A has a portal to Room B, and
* Room B has a portal to Room A.
*
* @todo Add encounters to portals
*/
export class Portal {
/**
* Target Location.
*/
_targetLocationId;
/**
* Description shown to the player when they inspect the portal from the source location.
*/
_description;
/**
* Description shown to the player when they traverse the portal.
*/
_traversalDescription;
}

143
models/session.js Executable file
View File

@@ -0,0 +1,143 @@
import WebSocket from "ws";
import { Player } from "./player.js";
import { mustBeString, mustBe } from "../utils/mustbe.js";
import { Scene } from "../scenes/scene.js";
import { gGame } from "./globals.js";
import { formatMessage, MessageType } from "../utils/messages.js";
export class Session {
/** @type {WebSocket} */
_websocket;
/** @protected @type {Scene} */
_scene;
/** @readonly @constant @type {Scene} */
get scene() {
return this._scene;
}
/** @type {Player} */
_player;
get player() {
return this._player;
}
/** @param {Player} player */
set player(player) {
if (player instanceof Player) {
this._player = player;
return;
}
if (player === null) {
this._player = null;
return;
}
throw Error(`Can only set player to null or instance of Player, but received ${typeof player}`);
}
/**
* @param {WebSocket} websocket
*/
constructor(websocket) {
this._websocket = websocket;
}
/**
* @param {Scene} scene
*/
setScene(scene) {
console.debug("changing scene", scene.constructor.name);
if (!(scene instanceof Scene)) {
throw new Error(`Expected instance of Scene, got a ${typeof scene}: >>${scene}<<`);
}
this._scene = scene;
scene.execute(this);
}
/** Close the session and websocket */
close() {
if (this._websocket) {
this._websocket.close();
this._websocket = null;
}
this._player = null;
this._scene = null;
}
/**
* Send a message via our websocket.
*
* @param {MessageType} messageType The message "header" (the first arg in the array sent to the client) holds the message type.
* @param {...any} args
*/
send(messageType, ...args) {
if (!this._websocket) {
console.error("Trying to send a message without a valid websocket", messageType, args);
return;
}
this._websocket.send(formatMessage(messageType, ...args));
}
/**
* @overload
* @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {string?} context
*/ /**
* @overload
* @param {string|string[]} text The prompt message (the request to get the user to enter some info).
* @param {object?} options Any options for the text (client side text formatting, color-, font-, or style info, etc.).
*/
sendPrompt(text, options) {
options = options || {};
if (typeof options === "string") {
// if options is just a string, assume we meant to apply a context to the prompt
options = { context: options };
}
this.send(
MessageType.PROMPT, // message type
text, // TODO: prompt text must be string or an array of strings
mustBe(options, "object"),
);
}
/**
* Send text to be displayed to the client
*
* @param {string|string[]} text Text to send. If array, each element will be displayed as its own line on the client side.
* @param {object?} options message options for the client.
*/
sendText(text, options = {}) {
this.send(MessageType.TEXT, text, options);
}
/** @param {string|string[]} errorMessage */
sendError(errorMessage, options = { verbatim: true, error: true }) {
this.send(MessageType.ERROR, mustBeString(errorMessage), options);
}
/**
* Send a calamity text and then close the connection.
* @param {string|string[]} errorMessage Text to send. If array, each element will be displayed as its own line on the client side.
*/
calamity(errorMessage) {
//
// 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.close();
}
/**
* @param {MessageType} systemMessageType
* @param {any?} value
*/
sendSystemMessage(systemMessageType, value = undefined) {
this.send(MessageType.SYSTEM, mustBeString(systemMessageType), value);
}
}