DEV Community

Cover image for Writing a Modern MUSH server with Typescript Part 3: The Command Handler
Lem Canady
Lem Canady

Posted on • Edited on

Writing a Modern MUSH server with Typescript Part 3: The Command Handler

In Part 2 We set up socket.io on the server-side and created our basic input parsing middleware engine. In today's installment, we're going to create the command handling middleware and a couple of commands!

Before we begin we'll have to update our project structure just a bit. From your project root:

mkdir src/middleware
mkdir src/commands

Defining src/api/commands.ts, our In-Game Command Structure

The first thing we'll want to do is define the methods for handling the internals of a command. Note: We haven't defined flags yet, or utils we'll get to them soon!

import { types } from "util";
import { loadDir } from "./utils";
import mu from "./mu";
import { Marked } from "@ts-stack/markdown";

type Exec = (id: string, args: string[]) => Promise<string>;

export class MuCommand {
  private _pattern: RegExp | string;
  flags: string;
  name: string;
  exec: Exec;

  constructor({
    name,
    flags,
    pattern,
    exec
  }: {
    name: string;
    flags?: string;
    pattern: RegExp | string;
    exec: Exec;
  }) {
    this.name = name;
    this._pattern = pattern;
    this.flags = flags || "";
    this.exec = exec;
  }

Because I want users to be able to define in-game commands with both wildcard matching and regular expressions, I made a getter and setter for MuCommands. internally, the engine runs on regular expressions - so they need to be converted before being called by the command handling middleware.

  /**
   * Getter for the pattern.  Always return a regex string.
   */
  get pattern() {
    return types.isRegExp(this._pattern)
      ? this._pattern
      : this._globStringToRegex(this._pattern);
  }

  /**
   *  Set the pattern.
   */
  set pattern(str: string | RegExp) {
    this._pattern = str;
  }

Here's where the actual conversion process lives. It basically escapes out all special characters, before converting wildcard characters * and ? into regular expressions.

  /**
   * Convert a wildcard(glob) string to a regular expression.
   * @param str The string to convert to regex.
   */
  private _globStringToRegex(str: string) {
    return new RegExp(
      this._preg_quote(str)
        .replace(/\\\*/g, "(.*)")
        .replace(/\\\?/g, "(.)"),
      "gi"
    );
  }

  /**
   * Escape a string of characters to be Regex escaped.
   * @param str The string to convert to a regex statement.
   * @param delimiter The character to separate out words in
   * the string.
   */
  private _preg_quote(str: string, delimiter?: string) {
    // http://kevin.vanzonneveld.net
    // +   original by: booeyOH
    // +   improved by: Ates Goral (http://magnetiq.com)
    // +   improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
    // +   bugfixed by: Onno Marsman
    // +   improved by: Brett Zamir (http://brett-zamir.me)
    // *     example 1: preg_quote("$40");
    // *     returns 1: '\$40'
    // *     example 2: preg_quote("*RRRING* Hello?");
    // *     returns 2: '\*RRRING\* Hello\?'
    // *     example 3: preg_quote("\\.+*?[^]$(){}=!<>|:");
    // *     returns 3: '\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:'
    return (str + "").replace(
      new RegExp(
        "[.\\\\+*?\\[\\^\\]$(){}=!<>|:\\" + (delimiter || "") + "-]",
        "g"
      ),
      "\\$&"
    );
  }
}

Now we can define the command management system! This is going to be another singleton, as we only want it to be instantiated once.

export class Commands {
  cmds: MuCommand[];
  private static instance: Commands;

  private constructor() {
    this.cmds = [];
    this.init();
  }

When the class initializes it attempts to load all of the files
from the given directory.

  /**
   * initialize the object.
   */
  init() {
    loadDir("../commands/", (name: string) =>
      console.log(`Module loaded: ${name}`)
    );
  }

  /**
   * Add a new command to the system.
   * @param command The command object to be added to the system
   */
  add({
    name,
    flags,
    pattern,
    exec
  }: {
    name: string;
    flags?: string;
    pattern: RegExp | string;
    exec: Exec;
  }) {
    const command = new MuCommand({ name, flags, pattern, exec });
    this.cmds.push(command);
  }

match is a little intimidating at first, but I'm basically chaining array functions together. First I map through the commands and test against the command's pattern. If a match is found it returns an object, else it returns false. Then I filter through that map to take out the false entries. Finally, I return the first match, just in case there's more than one.

  /**
   * Match a string to a command pattern.
   * @param str The string to match the command against.
   */
  match(str: string) {
    return this.cmds
      .map(cmd => {
        const matched = str.match(cmd.pattern);
        if (matched) {
          return {
            args: matched,
            exec: cmd.exec,
            flags: cmd.flags
          };
        } else {
          return;
        }
      })
      .filter(Boolean)[0];
  }

The force method allows us to skip the command matching and fire a command directly from the command handler.

  async force(id: string, name: string, args: string[] = []) {
    const response = {
      id,
      payload: {
        command: name,
        message: await this.cmds
          .filter(cmd => 
            cmd.name.toLowerCase() === name.toLowerCase()
           )[0]
          .exec(id, args)
      }
    };

    if (response.payload.message)
      response.payload.message = Marked.parse(response.payload.message);
    mu.io?.to(id).send(response.payload);
  }

  static getInstance() {
    if (!Commands.instance) Commands.instance = new Commands();
    return Commands.instance;
  }
}

export default Commands.getInstance();

An example command src/commands/test.ts:

import cmds from '../api/commands'

export default () => {
  cmds.add({
    name: "Test",
    pattern: /^[+@]?test$/g,
    exec: async (id: string, args: any[]) => "Made it!!"; });
}

Creating src/middleware/commands.middleware.ts

Now we can edit our command middleware. Again, flags aren't defined yet, but we'll get to that soon!

import { MiddlewareNext, MuRequest } from "../api/parser";
import cmds from "../api/commands";
import flags from "../api/flags";
import mu from "../api/mu";

Since the middleware is just a function, we'll export default the module with the required arguments.

export default async (req: MuRequest, next: MiddlewareNext) => {
  const id = req.socket.id;
  const message = req.payload.message || "";
  let matched = cmds.match(message);
  let flgs: Boolean;

Here I made a helper function expression to encapsulate the logic of matching flags (coming soon!) without having to make my main logic too spammy. This basically just checks a few conditions to make sure the enactor has the right permissions to use the command.

  const _hasFlags = () => {
    if (matched && mu.connMap.has(id)) {
      const char = mu.connMap.get(id);
      return flags.hasFlags(char!, matched.flags);
    } else {
      return false;
    }
  };

  if (matched && (!matched.flags || _hasFlags())) {
    // Matching command found!
    // run the command and await results
    const results = await matched
      .exec(id, matched.args)
      .catch((err: Error) => next(err, req));

    req.payload.matched = matched ? true : false;
    req.payload.message = results;
    return next(null, req);
  } else if (!mu.connMap.has(id)) {
    req.payload.matched = matched ? true : false;
    req.payload.message = "";
    return next(null, req);
  }

  return next(null, req);
};

And with that, our command handling code is done! In our next installment, we'll try to cover flags, text files, and building a simple client so we can check out our work so far!

Thanks for stopping in for the read! Feel free to follow, leave a comment, or discuss!

Top comments (0)