DEV Community

Cover image for Building A Modern MUSH With Typescript Part 5: Slaying the Databeast!
Lem Canady
Lem Canady

Posted on • Updated on

Building A Modern MUSH With Typescript Part 5: Slaying the Databeast!

Hello! Welcome back to my tutorial series where we're building a MUSH engine piece by piece. In Part 4 we covered creating the flag and file systems! In this installment, we're going to build an adapter for our database, and then implement it using NeDB.

Defining The Adapter

First things first, we need to define the database adapter. In the spirit of keeping UrsaMU extendable, I decided to go with an adapter pattern instead of just wiring the game up to a single database. We're going to define our adapter stub in src/api/mu.ts

export abstract class DbAdapter {
  abstract model(...args: any[]): any | Promise<any>;
  abstract get(...args: any[]): any | Promise<any>;
  abstract find(...args: any[]): any | Promise<any>;
  abstract create(...args: any[]): any | Promise<any>;
  abstract update(...args: any[]): any | Promise<any>;
  abstract delete(...args: any[]): any | Promise<any>;
}

Because it's an abstract class it's never meant to be called directly, instead any inheriting class will have to implement its methods.

Writing our Database Code - src/api/database.ts

Now we'll extend the adapter class and make a module for NeDB. In the future, we could swap the database out with anything we want, without affecting the game engine.

import DataStore from "nedb";
import { DbAdapter } from "./mu.ts";
import { resolve } from "path";

Here we're defining the shape of our NeDB data. We'll pass it as a type when we instantiate the game object database.

export interface DBObj {
  _id?: string;
  id: string;
  desc: string;
  name: string;
  type: "thing" | "player" | "room" | "exit";
  alias?: string;
  password?: string;
  attribites: Attribute[];
  flags: string[];
  location: string;
  contents: string[];
  exits?: string[];
  owner?: string;
}

Here, we use our first Generic! The T in NeDB<T> is a stand-in for whatever kind of type we want to check our typescript code against when manually entering new items into the database. You'll notice that T is used in places for our returns instead of a predefined type.

export class NeDB<T> implements DbAdapter {
  path?: string;
  db: DataStore | undefined;

  constructor(path?: string) {
    this.path = path || "";
  }

  /** create the database model  */
  model() {
    if (this.path) {
      this.db = new DataStore<T>({
        filename: this.path,
        autoload: true
      });
    } else {
      this.db = new DataStore<T>();
    }
  }

  /** Initialize the database */
  init() {
    this.model();
    console.log(`Database loaded: ${this.path}`);
  }

NeDB is callback-based, which is kind of a bummer - However! Making promises out of callbacks is easy! In fact, NodeJS has a way! But I thought for the sake of demonstration, I'd show how you go about converting your own functions. :)

/** Create a new DBObj */
  create(data: T): Promise<T> {
    return new Promise((resolve: any, reject: any) =>
      this.db?.insert(data, (err: Error, doc: T) => {
        if (err) reject(err);
        return resolve(doc);
      })
    );
  }

  /**
   * Get a single database document.
   * @param query The query object to search for.
   */
  get(query: any): Promise<T> {
    return new Promise((resolve: any, reject: any) =>
      this.db?.findOne<T>(query, (err: Error, doc: any) => {
        if (err) reject(err);
        return resolve(doc);
      })
    );
  }

  /**
   * Find an array of documents that match the query
   * @param query The query object.
   */
  find(query: any): Promise<T[]> {
    return new Promise((resolve: any, reject: any) =>
      this.db?.find<T>(query, (err: Error, docs: T[]) => {
        if (err) reject(err);
        return resolve(docs);
      })
    );
  }

  /**
   * Update fields of the NeDB database
   * @param query The NeDB query for the fields to be updated.
   * @param data The data to update with
   */
  update(query: any, data: T): Promise<T | T[]> {
    return new Promise((resolve: any, reject: any) =>
      this.db?.update(
        query,
        data,
        { returnUpdatedDocs: true },
        (err: Error, _, docs: T) => {
          if (err) return reject(err);
          return resolve(docs);
        }
      )
    );
  }

  /**
   * Delete a a field from the NeDB instance.
   * @param query The object to query against.
   */
  delete(query: any): Promise<number> {
    return new Promise((resolve: any, reject: any) =>
      this.db?.remove(query, {}, (err: Error, n: number) => {
        if (err) reject(resolve);
        return resolve(n);
      })
    );
  }
}

const db = new NeDB<DBObj>(resolve(__dirname, "../../data/ursa.db"));
db.init();
export default db;

Making the client!

I originally wrote this client as a stub in one file, before I had created (or needed) the static directories provided by ExpressJS. In the future, I want to make a MUCH more robust client using ReactJS. Perhaps that's another tutorial series in the making! But! For now! Here's what the client code looks like. First, our markup

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>UrsaMU Client</title>
    <link
      href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap"
      rel="stylesheet"
    />
  </head>
  <body>
    <div id="container">
      <div id="input" contenteditable></div>
      <ul id="feed"></ul>
    </div>
  </body>

Nothing too out of the ordinary there! Now for some basic styling:

<style>
    * {
      color: white;
      background-color: black;
      font-family: "Source Code Pro", monospace;
      font-size: 1rem;
      margin: 0;
      padding: 0;
      top: 0;
      left: 0;
    }

    #container {
      display: flex;
      height: 95vh;
      max-width: 800px;
      margin-left: auto;
      margin-right: auto;
      box-sizing: border-box;
      flex-direction: column-reverse;
    }

    #input {
      border: 1px;
      border-style: solid;
      border-color: rgba(255, 255, 255, 0.4);
      border-radius: 5px;
      margin-top: 16px;
      min-height: 48px;
      max-height: 112px;
      overflow-y: auto;
      overflow-x: hidden;
    }

    ul {
      padding: 0;
      margin: 0;
    }

    ul li {
      list-style-type: none;
      padding: 0;
      margin: 0;
    }

    img {
      width: 100%;
      object-fit: cover;
    }

    p {
      padding-top: 4px;
      padding-bottom: 4px;
      font-weight: lighter;
    }

    strong {
      font-weight: bold;
    }

    .item {
      width: 100%;
      word-wrap: break-word;
    }
  </style>

And finally, the JavaScript! A note, the first script tag that imports socket.io.js is provided by our socket.io instance.

  <script src="/socket.io/socket.io.js"></script>

  <script>
    // Declare our variables.
    const feed = document.getElementById("feed");
    const socket = io("http://localhost:8090/");
    const input = document.getElementById("input");

    // Monitor keypresses.  If the user hits enter, send
    // the message off to the server!
    input.addEventListener("keypress", ev => {
      if (ev.keyCode === 13 && !ev.shiftKey) {
        ev.preventDefault();
        socket.send(input.innerText);
        input.innerText = "";
      }
    });

    // When a new message comes in, add it to the feed.

    socket.on("message", res => {
      const li = document.createElement("li");
      li.className = "item";
      console.log(res);
      li.innerHTML = res.message;
      feed.appendChild(li);
    });
  </script>
</html>

And now the moment we've all been waiting for: A screen cap gif! :D
connect screen gif!
There we have it! I think that's a good wrapping up point for this article. In our next installment we're going to add a few more commands to the system: Specifically character creation and connection commands to test out the shiny new database!

Thanks for stopping in! Make sure to Follow to get notifications on my future articles. Feel free to leave a comment!

Top comments (0)