thinstuff

This commit is contained in:
Kim Ravn Hansen
2025-09-10 22:37:54 +02:00
parent ba293d08b3
commit 8c196bb6a1
13 changed files with 350 additions and 213 deletions

View File

@@ -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 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. - 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 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. 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. characters will be garbage collected.
The sum that needs paying while offline not very large though. 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.

View File

@@ -8,6 +8,7 @@
*/ */
import { isIdSane, miniUid } from "../utils/id.js"; import { isIdSane, miniUid } from "../utils/id.js";
import { Xorshift32 } from "../utils/random.js";
import { Character } from "./character.js"; import { Character } from "./character.js";
import { ItemAttributes, ItemBlueprint } from "./item.js"; import { ItemAttributes, ItemBlueprint } from "./item.js";
import { Player } from "./player.js"; import { Player } from "./player.js";
@@ -33,6 +34,24 @@ export class Game {
*/ */
_players = new Map(); _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) { getPlayer(username) {
return this._players.get(username); return this._players.get(username);
} }

View File

@@ -30,6 +30,7 @@
"prettier": { "prettier": {
"tabWidth": 4, "tabWidth": 4,
"printWidth": 120, "printWidth": 120,
"quoteProps": "consistent",
"singleQuote": false, "singleQuote": false,
"trailingComma": "all", "trailingComma": "all",
"tabWidth": 4, "tabWidth": 4,

View File

@@ -73,6 +73,11 @@ export class CharacterSeeder {
// Rolling skills // Rolling skills
c.name =
this.game.rng.oneOf("sir", "madam", "mister", "miss", "", "", "") +
" random " +
this.game.rng.get().toString();
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;
@@ -126,6 +131,10 @@ export class CharacterSeeder {
} }
this.applyFoundation(c); this.applyFoundation(c);
console.log(c);
return c;
} }
/** /**
@@ -141,7 +150,9 @@ export class CharacterSeeder {
createParty(player, partySize) { createParty(player, partySize) {
// //
for (let i = 0; i < partySize; i++) { 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 {Character} c
* @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) { switch (foundation) {
case "random": case ":random":
return this.applyFoundation(c, roll.dice(3)); return this.applyFoundation(c, roll.dice(3));
break; break;
@@ -193,12 +204,12 @@ export class CharacterSeeder {
c, // c, //
":armor.light.leather", ":armor.light.leather",
":weapon.light.sickle", ":weapon.light.sickle",
":kits.poisoners_kit", ":kit.poisoners_kit",
":kits.healers_kit", ":kit.healers_kit",
); );
this.addSkillsToCharacter( this.addSkillsToCharacter(
c, // c, //
":armor.light.leather", ":armor.light.sleather",
":armor.light.hide", ":armor.light.hide",
":weapon.light.sickle", ":weapon.light.sickle",
); );
@@ -231,6 +242,7 @@ export class CharacterSeeder {
":weapon.light.rapier", ":weapon.light.rapier",
":weapon.light.dagger", ":weapon.light.dagger",
); );
break;
/* /*

View File

@@ -12,9 +12,9 @@ import { PlayerSeeder } from "./playerSeeder.js";
*/ */
export class GameSeeder { export class GameSeeder {
/** @returns {Game} */ /** @returns {Game} */
createGame() { createGame(rngSeed) {
/** @protected @constant @readonly @type {Game} */ /** @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. this.work(); // Seeding may take a bit, so let's defer it so we can return early.

View File

@@ -31,7 +31,6 @@ export class ItemSeeder {
itemSlots: 0.5, itemSlots: 0.5,
damage: 3, damage: 3,
melee: true, melee: true,
skills: [":weapon.light"],
ranged: true, ranged: true,
specialEffect: ":effect.weapon.fast", specialEffect: ":effect.weapon.fast",
}); });
@@ -41,7 +40,6 @@ export class ItemSeeder {
description: "For cutting nuts, and branches", description: "For cutting nuts, and branches",
itemSlots: 1, itemSlots: 1,
damage: 4, damage: 4,
skills: [":weapon.light"],
specialEffect: ":effect.weapon.sickle", specialEffect: ":effect.weapon.sickle",
}); });
@@ -50,11 +48,14 @@ export class ItemSeeder {
description: "Spikes with gauntlets on them!", description: "Spikes with gauntlets on them!",
itemSlots: 1, itemSlots: 1,
damage: 5, damage: 5,
skills: [ specialEffect: "TBD",
// 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", this.game.addItemBlueprint(":weapon.light.rapier", {
], name: "Rapier",
description: "Fancy musketeer sword",
itemSlots: 1,
damage: 5,
specialEffect: "TBD", specialEffect: "TBD",
}); });
@@ -70,7 +71,6 @@ 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",
skills: [":armor.light"],
armorHitPoints: 10, armorHitPoints: 10,
}); });
this.game.addItemBlueprint(":armor.light.leather", { this.game.addItemBlueprint(":armor.light.leather", {
@@ -78,7 +78,6 @@ export class ItemSeeder {
description: "Padded and hardened leather", description: "Padded and hardened leather",
itemSlots: 2, itemSlots: 2,
specialEffect: "TBD", specialEffect: "TBD",
skills: [":armor.light"],
armorHitPoints: 6, armorHitPoints: 6,
}); });

View File

@@ -9,10 +9,20 @@ import { AuthState } from "./states/authState.js";
import { GameSeeder } from "./seeders/gameSeeder.js"; import { GameSeeder } from "./seeders/gameSeeder.js";
import { Config } from "./config.js"; import { Config } from "./config.js";
// __ __ _ _ ____ ____
// | \/ | | | | _ \ / ___| ___ _ ____ _____ _ __
// | |\/| | | | | | | | \___ \ / _ \ '__\ \ / / _ \ '__|
// | | | | |_| | |_| | ___) | __/ | \ V / __/ |
// |_| |_|\___/|____/ |____/ \___|_| \_/ \___|_|
// -----------------------------------------------------
class MudServer { class MudServer {
constructor() { /** @type {Xorshift32} */
rng;
/** @param {number?} rngSeed seed for the pseudo-random number generator. */
constructor(rngSeed = undefined) {
/** @type {Game} */ /** @type {Game} */
this.game = new GameSeeder().createGame(); this.game = new GameSeeder().createGame(rngSeed || Date.now());
} }
// ____ ___ _ _ _ _ _____ ____ _____ _____ ____ // ____ ___ _ _ _ _ _____ ____ _____ _____ ____
@@ -66,12 +76,8 @@ class MudServer {
console.debug("incoming websocket message %s", data); console.debug("incoming websocket message %s", data);
if (!session.state) { if (!session.state) {
console.error( console.error("we received a message, but don't even have a state. Zark!");
"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!?"));
);
websocket.send(
msg.prepare(msg.ERROR, "Oh no! I don't know what to do!?"),
);
return; return;
} }
@@ -83,36 +89,19 @@ class MudServer {
//--------------------- //---------------------
// Set state = QuitState // Set state = QuitState
// //
websocket.send( websocket.send(msg.prepare(msg.MESSAGE, "The quitting quitter quits... Typical. Cya!"));
msg.prepare(
msg.MESSAGE,
"The quitting quitter quits... Typical. Cya!",
),
);
websocket.close(); websocket.close();
return; return;
} }
if (typeof session.state.onMessage !== "function") { if (typeof session.state.onMessage !== "function") {
console.error( console.error("we received a message, but we're not i a State to receive it");
"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."));
);
websocket.send(
msg.prepare(
msg.ERROR,
"Oh no! I don't know what to do with that message.",
),
);
return; return;
} }
session.state.onMessage(msgObj); session.state.onMessage(msgObj);
} catch (error) { } catch (error) {
console.trace( console.trace("received an invalid message (error: %s)", error, data.toString(), data);
"received an invalid message (error: %s)",
error,
data.toString(),
data,
);
websocket.send(msg.prepare(msg.CALAMITY, error)); websocket.send(msg.prepare(msg.CALAMITY, error));
} }
}); });
@@ -140,10 +129,7 @@ class MudServer {
// //
// Create HTTP server for serving the client - Consider moving to own file // Create HTTP server for serving the client - Consider moving to own file
const httpServer = http.createServer((req, res) => { const httpServer = http.createServer((req, res) => {
let filePath = path.join( let filePath = path.join("public", req.url === "/" ? "index.html" : req.url);
"public",
req.url === "/" ? "index.html" : req.url,
);
const ext = path.extname(filePath); const ext = path.extname(filePath);
const contentType = contentTypes[ext]; const contentType = contentTypes[ext];
@@ -191,9 +177,9 @@ class MudServer {
// | \/ | / \ |_ _| \ | | // | \/ | / \ |_ _| \ | |
// | |\/| | / _ \ | || \| | // | |\/| | / _ \ | || \| |
// | | | |/ ___ \ | || |\ | // | | | |/ ___ \ | || |\ |
// |_| |_/_/ \_\___|_| \_| // |_| |_/_/ \_\___|_| \_| A
//--------------------------- //---------------------------
// Code entry point // Code entry point
//----------------- //-----------------
const mudserver = new MudServer(); const mudserver = new MudServer(/* location of crypto key for saving games */);
mudserver.start(); mudserver.start();

View File

@@ -12,24 +12,24 @@ export class JustLoggedInState {
// Show welcome screen // Show welcome screen
onAttach() { onAttach() {
this.session.sendMessage([ this.session.sendMessage(["", "Welcome", "", "You can type “:quit” at any time to quit the game", ""]);
"",
"Welcome",
"",
"You can type “:quit” at any time to quit the game",
"",
]);
// //
// Check if we need to create characters for the player // Check if we need to create characters for the player
if (this.session.player.characters.size === 0) { if (this.session.player.characters.size === 0) {
this.session.sendMessage( this.session.sendMessage("You haven't got any characters, so let's make some\n\n");
"You haven't got any characters, so let's make some\n\n",
);
this.session.setState(new PartyCreationState(this.session)); this.session.setState(new PartyCreationState(this.session));
return; 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)); this.session.setState(new AwaitCommandsState(this.session));
} }
} }

View File

@@ -1,6 +1,14 @@
const UID_DIGITS = 12; import * as regex from "./regex.js";
const MINI_UID_REGEX = /\.uid\.[a-z0-9]{6,}$/;
const ID_SANITY_REGEX = /^:([a-z0-9]+\.)*[a-z0-9_]+$/; 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. * Sanity check a string to see if it is a potential id.
@@ -17,17 +25,22 @@ export function isIdSane(id) {
return false; 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. * Generate a random number, convert it to base36, and return it as a string with 7-8 characters.
*/ */
export function miniUid() { export function miniUid() {
// we use 12 digits, but we could go up to 16 // we use 12 digits, but we could go all the way to 16
return Number(Math.random().toFixed(UID_DIGITS).substring(2)).toString(36); const digits = 12;
return Number(Math.random().toFixed(digits).substring(2)).toString(36);
} }
/** /**

80
server/utils/random.js Executable file
View File

@@ -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 {<T>[]} arr
*
* @return {<T>}
*/
randomElement(arr) {
const idx = this.lowerThan(arr.length);
return arr[idx];
}
/**
* @param {...<T>} args
* @returns {<T>}
*/
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;
}
}

10
server/utils/regex.js Normal file
View File

@@ -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);
}

0
server/tui.md → tui.md Normal file → Executable file
View File