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
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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"
      ),
      "\\$&"
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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];
  }
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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!!"; });
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)