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 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.

View File

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

View File

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

View File

@@ -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;
/*

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

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