From 8c196bb6a14c79d4124b4c48d85486ab1e5c1f66 Mon Sep 17 00:00:00 2001 From: Kim Ravn Hansen Date: Wed, 10 Sep 2025 22:37:54 +0200 Subject: [PATCH] thinstuff --- ...s.html => find-element-width-in-chars.html | 0 server/ideas.md | 19 +- server/models/game.js | 19 ++ server/package.json | 1 + server/seeders/characerSeeder.js | 24 +- server/seeders/gameSeeder.js | 4 +- server/seeders/itemSeeder.js | 17 +- server/server.js | 310 +++++++++--------- server/states/justLoggedIn.js | 52 +-- server/utils/id.js | 27 +- server/utils/random.js | 80 +++++ server/utils/regex.js | 10 + server/tui.md => tui.md | 0 13 files changed, 350 insertions(+), 213 deletions(-) rename server/find-element-width-in-chars.html => find-element-width-in-chars.html (100%) mode change 100644 => 100755 create mode 100755 server/utils/random.js create mode 100644 server/utils/regex.js rename server/tui.md => tui.md (100%) mode change 100644 => 100755 diff --git a/server/find-element-width-in-chars.html b/find-element-width-in-chars.html old mode 100644 new mode 100755 similarity index 100% rename from server/find-element-width-in-chars.html rename to find-element-width-in-chars.html diff --git a/server/ideas.md b/server/ideas.md index b9c38e4..836ebbc 100755 --- a/server/ideas.md +++ b/server/ideas.md @@ -21,7 +21,7 @@ They never trigger quests or events where they go, but: - they can interact with adventurers (they are quite aggressive, and may attack unprovoked, maybe sneak past) - they can interact with each other, but mostly do so if there are PCs nearby. -# Attrition +# ATTRITION Even when a player is offline, their characters have to pay rent on their homes or room and board in an inn, or they can chance it in the wilderness. @@ -30,3 +30,20 @@ If they run out of money or rations, there is a small chance each day that the characters will be garbage collected. The sum that needs paying while offline not very large though. + +# CHAT SPELL + +You can buy a spell that lets you initiate a secure and encrypted group chat +with other players. + +The person who casts the spell generates a private key and sends the public key +to the others in the chat. Each recipient then generates a one-time symmetric +key and sends it securely (via the caster's public key) to the caster. The +caster then generates a "group chat key" and sends it to each recipient via +their one-time key. + +Any chats via the spell from then on is encrypted with the "group chat key". + +All parties throw away the group chat key when the spell ends. + +Each group chat has a name. diff --git a/server/models/game.js b/server/models/game.js index c1ed815..8ebc7a0 100755 --- a/server/models/game.js +++ b/server/models/game.js @@ -8,6 +8,7 @@ */ 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"; @@ -33,6 +34,24 @@ export class Game { */ _players = new Map(); + + /** @protected @type {Xorshift32} */ + _rng; + + /** @type {Xorshift32} */ + get rng() { + return this._rng; + } + + /** @param {number} rngSeed Seed number used for randomization */ + constructor(rngSeed) { + if (!Number.isInteger(rngSeed)) { + throw new Error("rngSeed must be an integer"); + } + + this._rng = new Xorshift32(rngSeed); + } + getPlayer(username) { return this._players.get(username); } diff --git a/server/package.json b/server/package.json index 4502a45..2e193d6 100755 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "prettier": { "tabWidth": 4, "printWidth": 120, + "quoteProps": "consistent", "singleQuote": false, "trailingComma": "all", "tabWidth": 4, diff --git a/server/seeders/characerSeeder.js b/server/seeders/characerSeeder.js index 4272fc8..6e13d11 100755 --- a/server/seeders/characerSeeder.js +++ b/server/seeders/characerSeeder.js @@ -73,6 +73,11 @@ export class CharacterSeeder { // Rolling skills + c.name = + this.game.rng.oneOf("sir", "madam", "mister", "miss", "", "", "") + + " random " + + this.game.rng.get().toString(); + c.awareness = roll.d6() + 2; c.grit = roll.d6() + 2; c.knowledge = roll.d6() + 2; @@ -126,6 +131,10 @@ export class CharacterSeeder { } this.applyFoundation(c); + + console.log(c); + + return c; } /** @@ -141,7 +150,9 @@ export class CharacterSeeder { createParty(player, partySize) { // for (let i = 0; i < partySize; i++) { - const character = this.createCharacter(player); + player.addCharacter( + this.createCharacter(player), // + ); } } @@ -149,9 +160,9 @@ export class CharacterSeeder { * @param {Character} c * @param {string|number} Foundation to add to character */ - applyFoundation(c, foundation = "random") { + applyFoundation(c, foundation = ":random") { switch (foundation) { - case "random": + case ":random": return this.applyFoundation(c, roll.dice(3)); break; @@ -193,12 +204,12 @@ export class CharacterSeeder { c, // ":armor.light.leather", ":weapon.light.sickle", - ":kits.poisoners_kit", - ":kits.healers_kit", + ":kit.poisoners_kit", + ":kit.healers_kit", ); this.addSkillsToCharacter( c, // - ":armor.light.leather", + ":armor.light.sleather", ":armor.light.hide", ":weapon.light.sickle", ); @@ -231,6 +242,7 @@ export class CharacterSeeder { ":weapon.light.rapier", ":weapon.light.dagger", ); + break; /* diff --git a/server/seeders/gameSeeder.js b/server/seeders/gameSeeder.js index 1b52a1c..8c56a0a 100755 --- a/server/seeders/gameSeeder.js +++ b/server/seeders/gameSeeder.js @@ -12,9 +12,9 @@ import { PlayerSeeder } from "./playerSeeder.js"; */ export class GameSeeder { /** @returns {Game} */ - createGame() { + createGame(rngSeed) { /** @protected @constant @readonly @type {Game} */ - this.game = new Game(); + this.game = new Game(rngSeed); this.work(); // Seeding may take a bit, so let's defer it so we can return early. diff --git a/server/seeders/itemSeeder.js b/server/seeders/itemSeeder.js index 55f2770..d56ed56 100755 --- a/server/seeders/itemSeeder.js +++ b/server/seeders/itemSeeder.js @@ -31,7 +31,6 @@ export class ItemSeeder { itemSlots: 0.5, damage: 3, melee: true, - skills: [":weapon.light"], ranged: true, specialEffect: ":effect.weapon.fast", }); @@ -41,7 +40,6 @@ export class ItemSeeder { description: "For cutting nuts, and branches", itemSlots: 1, damage: 4, - skills: [":weapon.light"], specialEffect: ":effect.weapon.sickle", }); @@ -50,11 +48,14 @@ export class ItemSeeder { description: "Spikes with gauntlets on them!", itemSlots: 1, damage: 5, - skills: [ - // Spiked gauntlets are :Weird so you must be specially trained to use them. - // This is done by having a skill that exactly matches the weapon's blueprintId - ":weapon.weird.spiked_gauntlets", - ], + specialEffect: "TBD", + }); + + this.game.addItemBlueprint(":weapon.light.rapier", { + name: "Rapier", + description: "Fancy musketeer sword", + itemSlots: 1, + damage: 5, specialEffect: "TBD", }); @@ -70,7 +71,6 @@ export class ItemSeeder { description: "Padded and hardened leather with metal stud reinforcement", itemSlots: 3, specialEffect: "TBD", - skills: [":armor.light"], armorHitPoints: 10, }); this.game.addItemBlueprint(":armor.light.leather", { @@ -78,7 +78,6 @@ export class ItemSeeder { description: "Padded and hardened leather", itemSlots: 2, specialEffect: "TBD", - skills: [":armor.light"], armorHitPoints: 6, }); diff --git a/server/server.js b/server/server.js index 3da963d..1e5ce57 100755 --- a/server/server.js +++ b/server/server.js @@ -9,191 +9,177 @@ import { AuthState } from "./states/authState.js"; import { GameSeeder } from "./seeders/gameSeeder.js"; import { Config } from "./config.js"; +// __ __ _ _ ____ ____ +// | \/ | | | | _ \ / ___| ___ _ ____ _____ _ __ +// | |\/| | | | | | | | \___ \ / _ \ '__\ \ / / _ \ '__| +// | | | | |_| | |_| | ___) | __/ | \ V / __/ | +// |_| |_|\___/|____/ |____/ \___|_| \_/ \___|_| +// ----------------------------------------------------- class MudServer { - constructor() { - /** @type {Game} */ - this.game = new GameSeeder().createGame(); - } + /** @type {Xorshift32} */ + rng; - // ____ ___ _ _ _ _ _____ ____ _____ _____ ____ - // / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \ - // | | | | | | \| | \| | _|| | | | | _| | | | | - // | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| | - // \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/ - //------------------------------------------------------ - // Handle New Socket Connections - //------------------------------ - /** @param {WebSocket} websocket */ - onConnectionEstabished(websocket) { - console.log("New connection established"); - const session = new Session(websocket, this.game); - session.sendSystemMessage("dev", true); + /** @param {number?} rngSeed seed for the pseudo-random number generator. */ + constructor(rngSeed = undefined) { + /** @type {Game} */ + this.game = new GameSeeder().createGame(rngSeed || Date.now()); + } - // ____ _ ___ ____ _____ - // / ___| | / _ \/ ___|| ____| - // | | | | | | | \___ \| _| - // | |___| |__| |_| |___) | |___ - // \____|_____\___/|____/|_____| - //------------------------------- - // Handle Socket Closing - //---------------------- - websocket.on("close", () => { - if (!session.player) { - console.info("A player without a session disconnected"); - return; - } - //------------- - // TODO - //------------- - // Handle player logout (move the or hide their characters) - // - // Maybe session.onConnectionClosed() that calls session._state.onConnectionClosed() - // Maybe this.setState(new ConnectionClosedState()); - // Maybe both ?? - console.log(`Player ${session.player.username} disconnected`); - }); + // ____ ___ _ _ _ _ _____ ____ _____ _____ ____ + // / ___/ _ \| \ | | \ | | ____/ ___|_ _| ____| _ \ + // | | | | | | \| | \| | _|| | | | | _| | | | | + // | |__| |_| | |\ | |\ | |__| |___ | | | |___| |_| | + // \____\___/|_| \_|_| \_|_____\____| |_| |_____|____/ + //------------------------------------------------------ + // Handle New Socket Connections + //------------------------------ + /** @param {WebSocket} websocket */ + onConnectionEstabished(websocket) { + console.log("New connection established"); + const session = new Session(websocket, this.game); + session.sendSystemMessage("dev", true); - // __ __ _____ ____ ____ _ ____ _____ - // | \/ | ____/ ___/ ___| / \ / ___| ____| - // | |\/| | _| \___ \___ \ / _ \| | _| _| - // | | | | |___ ___) |__) / ___ \ |_| | |___ - // |_| |_|_____|____/____/_/ \_\____|_____| - //-------------------------------------------- - // HANDLE INCOMING MESSAGES - //------------------------- - websocket.on("message", (data) => { - try { - console.debug("incoming websocket message %s", data); + // ____ _ ___ ____ _____ + // / ___| | / _ \/ ___|| ____| + // | | | | | | | \___ \| _| + // | |___| |__| |_| |___) | |___ + // \____|_____\___/|____/|_____| + //------------------------------- + // Handle Socket Closing + //---------------------- + websocket.on("close", () => { + if (!session.player) { + console.info("A player without a session disconnected"); + return; + } + //------------- + // TODO + //------------- + // Handle player logout (move the or hide their characters) + // + // Maybe session.onConnectionClosed() that calls session._state.onConnectionClosed() + // Maybe this.setState(new ConnectionClosedState()); + // Maybe both ?? + console.log(`Player ${session.player.username} disconnected`); + }); - if (!session.state) { - console.error( - "we received a message, but don't even have a state. Zark!", - ); - websocket.send( - msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?"), - ); - return; - } + // __ __ _____ ____ ____ _ ____ _____ + // | \/ | ____/ ___/ ___| / \ / ___| ____| + // | |\/| | _| \___ \___ \ / _ \| | _| _| + // | | | | |___ ___) |__) / ___ \ |_| | |___ + // |_| |_|_____|____/____/_/ \_\____|_____| + //-------------------------------------------- + // HANDLE INCOMING MESSAGES + //------------------------- + websocket.on("message", (data) => { + try { + console.debug("incoming websocket message %s", data); - const msgObj = new msg.ClientMessage(data.toString()); + if (!session.state) { + console.error("we received a message, but don't even have a state. Zark!"); + websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?")); + return; + } - if (msgObj.isQuitCommand()) { - //--------------------- - // TODO TODO TODO TODO - //--------------------- - // Set state = QuitState - // - websocket.send( - msg.prepare( - msg.MESSAGE, - "The quitting quitter quits... Typical. Cya!", - ), - ); - websocket.close(); - return; - } + const msgObj = new msg.ClientMessage(data.toString()); - if (typeof session.state.onMessage !== "function") { - console.error( - "we received a message, but we're not i a State to receive it", - ); - websocket.send( - msg.prepare( - msg.ERROR, - "Oh no! I don't know what to do with that message.", - ), - ); - return; - } - session.state.onMessage(msgObj); - } catch (error) { - console.trace( - "received an invalid message (error: %s)", - error, - data.toString(), - data, - ); - websocket.send(msg.prepare(msg.CALAMITY, error)); - } - }); + if (msgObj.isQuitCommand()) { + //--------------------- + // TODO TODO TODO TODO + //--------------------- + // Set state = QuitState + // + websocket.send(msg.prepare(msg.MESSAGE, "The quitting quitter quits... Typical. Cya!")); + websocket.close(); + return; + } - session.setState(new AuthState(session)); - } + if (typeof session.state.onMessage !== "function") { + console.error("we received a message, but we're not i a State to receive it"); + websocket.send(msg.prepare(msg.ERROR, "Oh no! I don't know what to do with that message.")); + return; + } + session.state.onMessage(msgObj); + } catch (error) { + console.trace("received an invalid message (error: %s)", error, data.toString(), data); + websocket.send(msg.prepare(msg.CALAMITY, error)); + } + }); - // ____ _____ _ ____ _____ - // / ___|_ _|/ \ | _ \_ _| - // \___ \ | | / _ \ | |_) || | - // ___) || |/ ___ \| _ < | | - // |____/ |_/_/ \_\_| \_\|_| - //----------------------------- - // Start the server - //----------------- - start() { - // - // The file types we allow to be served. - const contentTypes = { - ".js": "application/javascript", - ".css": "text/css", - ".html": "text/html", - }; + session.setState(new AuthState(session)); + } - // - // Create HTTP server for serving the client - Consider moving to own file - const httpServer = http.createServer((req, res) => { - let filePath = path.join( - "public", - req.url === "/" ? "index.html" : req.url, - ); - const ext = path.extname(filePath); - const contentType = contentTypes[ext]; + // ____ _____ _ ____ _____ + // / ___|_ _|/ \ | _ \_ _| + // \___ \ | | / _ \ | |_) || | + // ___) || |/ ___ \| _ < | | + // |____/ |_/_/ \_\_| \_\|_| + //----------------------------- + // Start the server + //----------------- + start() { + // + // The file types we allow to be served. + const contentTypes = { + ".js": "application/javascript", + ".css": "text/css", + ".html": "text/html", + }; - // - // Check if the requested file has a legal file type. - if (!contentType) { - // Invalid file, pretend it did not exist! - res.writeHead(404); - res.end(`File not found`); - console.log("Bad http request", req.url); - return; - } + // + // Create HTTP server for serving the client - Consider moving to own file + const httpServer = http.createServer((req, res) => { + let filePath = path.join("public", req.url === "/" ? "index.html" : req.url); + const ext = path.extname(filePath); + const contentType = contentTypes[ext]; - // - // Check if the file exists. - fs.readFile(filePath, (err, data) => { - if (err) { - res.writeHead(404); - res.end(`File not found`); - console.log("Bad http request", req.url); - return; - } - res.writeHead(200, { "Content-Type": contentType }); - res.end(data); - }); - }); + // + // Check if the requested file has a legal file type. + if (!contentType) { + // Invalid file, pretend it did not exist! + res.writeHead(404); + res.end(`File not found`); + console.log("Bad http request", req.url); + return; + } - // - // Create WebSocket server - const websocketServer = new WebSocketServer({ server: httpServer }); + // + // Check if the file exists. + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end(`File not found`); + console.log("Bad http request", req.url); + return; + } + res.writeHead(200, { "Content-Type": contentType }); + res.end(data); + }); + }); - websocketServer.on("connection", (ws) => { - this.onConnectionEstabished(ws); - }); + // + // Create WebSocket server + const websocketServer = new WebSocketServer({ server: httpServer }); - console.info(`running environment: ${Config.env}`); - httpServer.listen(Config.port, () => { - console.log(`NUUHD server running on port ${Config.port}`); - console.log(`WebSocket server ready for connections`); - }); - } + websocketServer.on("connection", (ws) => { + this.onConnectionEstabished(ws); + }); + + console.info(`running environment: ${Config.env}`); + httpServer.listen(Config.port, () => { + console.log(`NUUHD server running on port ${Config.port}`); + console.log(`WebSocket server ready for connections`); + }); + } } // __ __ _ ___ _ _ // | \/ | / \ |_ _| \ | | // | |\/| | / _ \ | || \| | // | | | |/ ___ \ | || |\ | -// |_| |_/_/ \_\___|_| \_| +// |_| |_/_/ \_\___|_| \_| A //--------------------------- // Code entry point //----------------- -const mudserver = new MudServer(); +const mudserver = new MudServer(/* location of crypto key for saving games */); mudserver.start(); diff --git a/server/states/justLoggedIn.js b/server/states/justLoggedIn.js index a037754..03979a5 100755 --- a/server/states/justLoggedIn.js +++ b/server/states/justLoggedIn.js @@ -4,32 +4,32 @@ import { AwaitCommandsState } from "./awaitCommands.js"; /** @interface */ export class JustLoggedInState { - /** @param {Session} session */ - constructor(session) { - /** @type {Session} */ - this.session = session; - } - - // Show welcome screen - onAttach() { - this.session.sendMessage([ - "", - "Welcome", - "", - "You can type “:quit” at any time to quit the game", - "", - ]); - - // - // Check if we need to create characters for the player - if (this.session.player.characters.size === 0) { - this.session.sendMessage( - "You haven't got any characters, so let's make some\n\n", - ); - this.session.setState(new PartyCreationState(this.session)); - return; + /** @param {Session} session */ + constructor(session) { + /** @type {Session} */ + this.session = session; } - this.session.setState(new AwaitCommandsState(this.session)); - } + // Show welcome screen + onAttach() { + this.session.sendMessage(["", "Welcome", "", "You can type “:quit” at any time to quit the game", ""]); + + // + // Check if we need to create characters for the player + if (this.session.player.characters.size === 0) { + this.session.sendMessage("You haven't got any characters, so let's make some\n\n"); + this.session.setState(new PartyCreationState(this.session)); + return; + } + + const replacer = (key, value) => { + if (value instanceof Set) { + return [...value]; // turn Set into array + } + return value; + } + this.session.sendMessage(JSON.stringify(this.session.player.characters.entries(), replacer, "\t")); + + this.session.setState(new AwaitCommandsState(this.session)); + } } diff --git a/server/utils/id.js b/server/utils/id.js index 595f898..ffc1d41 100755 --- a/server/utils/id.js +++ b/server/utils/id.js @@ -1,6 +1,14 @@ -const UID_DIGITS = 12; -const MINI_UID_REGEX = /\.uid\.[a-z0-9]{6,}$/; -const ID_SANITY_REGEX = /^:([a-z0-9]+\.)*[a-z0-9_]+$/; +import * as regex from "./regex.js"; + +const MINI_UID_REGEX = regex.pretty( + "\.uid\.", // Mini-uids always begin with ".uid." + "[a-z0-9]{6,}$", // Terminated by 6 or more random numbers and lowercase letters. +); +const ID_SANITY_REGEX = regex.pretty( + "^:", // All ids start with a colon + "([a-z0-9]+\.)*?", // Middle -optional- part :myid.gogle.thing.thang.thong + "[a-z0-9_]+$", // The terminating part of the id is numbers, lowercase letters, and -notably- underscores. +); /** * Sanity check a string to see if it is a potential id. @@ -17,17 +25,22 @@ export function isIdSane(id) { return false; } - return ID_SANITY_REGEX.test(id); + if (!ID_SANITY_REGEX.test(id)) { + return false; + } + + return true; } /** - * @returns {string} crypto-unsafe pseudo random number. + * @returns {string} crypto-unsafe pseudo random numbe"r. * * Generate a random number, convert it to base36, and return it as a string with 7-8 characters. */ export function miniUid() { - // we use 12 digits, but we could go up to 16 - return Number(Math.random().toFixed(UID_DIGITS).substring(2)).toString(36); + // we use 12 digits, but we could go all the way to 16 + const digits = 12; + return Number(Math.random().toFixed(digits).substring(2)).toString(36); } /** diff --git a/server/utils/random.js b/server/utils/random.js new file mode 100755 index 0000000..d1a72d3 --- /dev/null +++ b/server/utils/random.js @@ -0,0 +1,80 @@ +/** + * Pseudo random number generator + * using the xorshift32 method. + */ +export class Xorshift32 { + /* @type {number} */ + state; + + /** @param {number} seed */ + constructor(seed) { + this.state = seed | 0; + } + + /** @protected Shuffle the internal state. */ + shuffle() { + // + // Run the actual xorshift32 algorithm + let x = this.state; + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + x = (x >>> 0) / 4294967296; + + this.state = x; + } + + /** + * Get a random number and shuffle the internal state. + * @returns {number} a pseudo-random positive integer. + */ + get() { + this.shuffle(); + return this.state; + } + + /** @param {number} x @returns {number} a positive integer lower than x */ + lowerThan(x) { + return this.get() % x; + } + + /** @param {number} x @reurns {number} a positive integer lower than or equal to x */ + lowerThanOrEqual(x) { + return this.get() % (x + 1); + } + + /** + * @param {[]} arr + * + * @return {} + */ + randomElement(arr) { + const idx = this.lowerThan(arr.length); + + return arr[idx]; + } + + /** + * @param {...} args + * @returns {} + */ + oneOf(...args) { + const idx = this.lowerThan(args.length); + + return args[idx]; + } + + /** + * @param {number} lowerThanOrEqual a positive integer + * @param {number} greaterThanOrEqual a positive integer greater than lowerThanOrEqual + * @returns {number} a pseudo-random integer + */ + within(greaterThanOrEqual, lowerThanOrEqual) { + const range = lowerThanOrEqual - greaterThanOrEqual; + + const num = this.lowerThanOrEqual(range); + + return num + greaterThanOrEqual; + } +} + diff --git a/server/utils/regex.js b/server/utils/regex.js new file mode 100644 index 0000000..a3bee62 --- /dev/null +++ b/server/utils/regex.js @@ -0,0 +1,10 @@ +/** + * Makes it easier to document regexes because you can break them up + * + * @param {...string} args + * @returns {Regexp} + */ +export function pretty(...args) { + const regexprStr = args.join(""); + return new RegExp(regexprStr); +} diff --git a/server/tui.md b/tui.md old mode 100644 new mode 100755 similarity index 100% rename from server/tui.md rename to tui.md