Jump to content
RAGE Multiplayer Community

Snippet: commands&events decorators (now available as npm package)


Recommended Posts

Posted (edited)

The command&events decorators (@command, @commandable, @event, @eventable)

  1. Information
  2. Installation
  3. Disadvantages
  4. Examples
  5. Sources
  6. Event decorator

1. Information:

There is a snippet which helps to registry any commands/events to Rage API with the simple interface by using decorators.

Library: rage-decorators [github] [npm-package]

If you're using typescript, make sure there two options (experimentalDecorators, emitDecoratorMetadata) are true in your tsconfig.json:

{
    "compilerOptions": {
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    },
}

2. Installation:

Via npm: $ npm i --save rage-decorators

Via yarn: $ yarn add rage-decorators

3. Disadvantages:

Disadvantage 1: the snippet requires rage-decorators package

4. Examples:

Simple example:

Скрытый текст

Foo.ts

import { command, commandable } from 'rage-decorators'

// Tell the decorator that class should be resolved and register all their commands
@commandable()
class Foo {
  
  // Register a new command "foo"
  @command("foo")
  foo(player: PlayerMp, cmdDesc: string, bar?: string) {
    const text = "hello" + (bar || "")
    player.outputChatBox(text)
  }
  
  // Register two new commands "bar", "baz"
  @command(["bar", "baz"])
  bar(player: PlayerMp, cmdDesc: string, bar?: string) {
    player.outputChatBox("Invoked command bar, allows usage bar/baz")
  }
  
  // "Register new command "player" with custom description
  @command("player", { desc: "Usage /{{cmdName}} [id]"})
  playerExists(player: PlayerMp, cmdDesc: string, id: string) {
    if (typeof id === 'undefined') {
      player.outputChatBox(cmdDesc)
    } else {
      const has = !!mp.players.at(+id)
      player.outputChatBox('Player ' + (has ? 'exists': 'doesn\'t exists'))
    }
  }
  
  // Register new group command "/veh add"
  @command("add", "veh")
  vehAdd(): void {
    // code staff here...
  }

  // Throw an error 'Error: Duplicate command "veh"'
  @command("veh")
  veh(): void {
  }

  @command("remove", { group: "veh", desc: "Usage /{{groupName}} {{cmdName}} [id|code]" })
  vehDelete(player: PlayerMp, cmdDesc: string, ..args: any[]): void {
    player.outputChatBox(cmdDesc)
  }
}

App.ts

// we should import this package before our decorators
import 'reflect-metadata'

import { Foo } from './foo'

// When an instance is created our commands is registered
const foo = new Foo()

 

My example of command list:

Скрытый текст
import { command, commandable, registeredCommands } from "rage-decorators"

@commandable()
class CommandManager {
  static readonly PER_PAGE = 10

  private _list: string[] = []
  private _maxPages: number = 0

  constructor() {
    this.cmdlist = this.cmdlist.bind(this)
  }

  @command("cmdlist")
  cmdlist(player: PlayerMp, cmdDesc: string, pagination: string = "1"): void {
    const page = (+pagination || 1) > this.maxPages && this.maxPages || (+pagination || 1)

    const startIndex = (page - 1) * CommandManager.PER_PAGE
      , endIndex = page * CommandManager.PER_PAGE

    const cmds = this.list.slice(startIndex, endIndex)

    cmds.forEach((text, index) => player.outputChatBox(`[${startIndex+index+1}]: ` + text))

    player.outputChatBox(`Page: ${page}, showing commands [${startIndex+1}..${startIndex+cmds.length}] of ${this.list.length}`)
  }

  private makeList(): void {
    let texts: string[] = []

    registeredCommands.forEach((value, mainCmd) => {
      if (Array.isArray(value)) {
        let hasDescription = false
        const cmds = value.reduce((carry, { cmd, desc }) => {
          if (!hasDescription && desc) hasDescription = true
          return carry.concat(desc || cmd)
        }, [] as string[])

        texts = [...texts, ...(hasDescription && cmds || cmds.map(cmdName => `/${mainCmd} ${cmdName}`))]
      } else {
        const { cmd, desc } = value

        texts = [...texts, ...(desc || cmd.map(cmdName => `/${cmdName}`))]
      }
    })

    this._list = texts
  }

  get list(): string[] {
    if (!this._list.length) this.makeList()
    return this._list
  }

  get maxPages(): number {
    if (!this._maxPages) this._maxPages = Math.ceil(this.list.length / CommandManager.PER_PAGE)
    return this._maxPages
  }
}

export { CommandManager }

App.ts

// this is our entry script, so
// import reflect-metadat before decorators
import 'reflect-metadata'

import { CommandManager } from './CommandManager

// Our commands now are registered
const commandManager = new CommandManager()

Here is an example of /cmdlist

F9krWHZ.png

5. Source:

Sources is now allowed on github repository and as npm package:

https://github.com/READYTOMASSACRE/rage-decorators

the source of decorators.ts (server-side only, for supporting events, client-side usage npm package)

Скрытый текст
/**
 * An interface of command entity
 */
export interface ICommand {
  cmd: string[],
  desc: string[]
  callable: string,
  group?: string
}

/**
 * A collection of commands
 */
export type CommandCollection = Map<string, ICommand | ICommand[]>

/**
 * A storage of commands which is called by decorator command
 */
export const registeredCommands: CommandCollection = new Map<string, ICommand | ICommand[]>()


/**
 * Resolve any commands which passed to classes commandable
 */
export const commandable = (): any => {
  return function (target: any): any {
    return class extends target {
      constructor(...args: any[]) {
        // first we must call an inherited constructor
        super(...args)

        // then we start record our commands to Rage API
        // check if commands has already registered
        if (!Reflect.getMetadata("design:cmdlist:init", target.prototype)) {
          const commands: CommandCollection = Reflect.getMetadata("design:cmdlist", target.prototype) || []

          // register commands in Rage API 
          commands.forEach((command, mainCmd) => {
            // check if the command is a group command
            if (Array.isArray(command)) {
              // flat array of commands
              const cmdList = command.reduce((carry, { cmd }) => carry.concat(cmd), [] as string[])
              // flat array of descriptions
              const descList = command.reduce((carry, { desc }) => carry.concat(desc), [] as string[])
  
              // make a group command
              mp.events.addCommand(mainCmd, (player: PlayerMp, fullText: string, ...args: string[]) => {
                const cmdName = args.shift()
          
                // check if current subcommand in command group
                // otherwise send a message to a player
                if (!cmdName || cmdList.indexOf(cmdName) === -1) {
                  descList.forEach(text => player.outputChatBox(text))
                } else {
                  for (let { cmd, callable } of command) {
                    // check if command exists
                    if (cmd.indexOf(cmdName) !== -1) {
                      // call registered command with [this] context of current class
                      const descIndex = cmdList.findIndex(cmdname => cmdname === cmdName)
                      this[callable](player, descList[descIndex], ...args)
                      break
                    }
                  }
                }
              })
            } else {
              // make a common command
              // and then call the registered command with [this] context of current class
              const { cmd, callable, desc } = command
              cmd.forEach((cmdName, cmdIndex) => mp.events.addCommand(
                cmdName,
                (player: PlayerMp, fullText: string, ...args: any[]) => this[callable](player, desc[cmdIndex], ...args)
              ))
            }
          })
          // set flag to target.prototype that all their commands are registered
          Reflect.defineMetadata("design:cmdlist:init", true, target.prototype)
        }
      }
    }
  }
}

/**
 * Decorator for adding commands to RAGE API
 * 
 * Supports templates in the desc param:
 *  @template cmdName - name of command
 *  @template groupName - name of group (if added in the additional params)
 * 
 * @example {desc parameter}: `Usage: /{{cmdName}} id`
 * 
 * @param {string | string[]} cmd - command(s) name, which will be added to mp.events.addCommand
 * @param {string | { group?: string, desc?: string }} params - additional params, add to group or add to description
 */
export const command = (
  cmd: string | string[],
  params?: string | { group?: string, desc?: string }
): MethodDecorator => {
  let group: string | undefined = undefined
  let desc: string | undefined = undefined

  // detect which params are passed to function
  if (typeof params === 'string') {
    group = params
  } else if (params) {
    group = params.group
    desc  = params.desc
  }

  // make sure we have an array in the cmd
  // and clean any duplicate cmds which passed into the params
  cmd = (Array.isArray(cmd) ? cmd : [cmd])
  const cmds = cmd.filter((item, index) => cmd.indexOf(item) === index)

  // get a main cmd
  const mainCmd = group || cmds[0]

  /** @todo seterror, setlocale */
  // throw erros if something has gone wrong
  if (!mainCmd) throw new Error("Wrong registry command")
  if (!group && registeredCommands.get(mainCmd)) throw new Error(`Duplicate command "${mainCmd}"`)

  if (group) {
    const command = registeredCommands.get(mainCmd)

    if (command) {
      if (Array.isArray(command)) {
        const flatCmds = command.reduce((carry, { cmd }) => carry.concat(cmd), [] as string[])
        const intersect = cmds.filter(value => flatCmds.includes(value))
  
        // make sure we won't add duplicate commands into group
        if (intersect.length) throw new Error(`Duplicate commands "${intersect.join(',')}" by group "${mainCmd}"`)
      } else {
        const { cmd } = command
        // make sure we won't add duplicate commands which intersect with group
        if (cmd.includes(mainCmd)) throw new Error(`Duplicate commands "${mainCmd}", trying to make a command group when a command has already existed`)
      }
    }
  } else {
    registeredCommands.forEach(value => {
      if (!Array.isArray(value)) {
        const { cmd } = value

        const intersect = cmds.filter(value => cmd.includes(value))
        // make sure we won't add duplicate commands
        if (intersect.length) throw new Error(`Duplicate commands "${intersect.join(',')}"`)
      }
    })
  }

  let description: string[] = []

  // make the description of commands
  if (desc) {
    description = cmds.map(cmdname => {
      let newDescription = desc!.replace(/{{cmdName}}/, cmdname)
      if (group) newDescription = newDescription.replace(/{{groupName}}/, mainCmd)

      return newDescription
    })
  } else {
    description = cmds.map(cmdname => `Usage /${group && (group + ' ' + cmdname) || cmdname}`)
  }

  // make a new object of command
  const newCommand: ICommand = {
    cmd: cmds,
    callable: "",
    desc: description,
  }

  // store in global storage our commands
  if (group) {
    const command = registeredCommands.get(mainCmd)
    registeredCommands.set(mainCmd, Array.isArray(command) ? [...command, newCommand] : [newCommand])
  } else {
    registeredCommands.set(mainCmd, newCommand)
  }

  return function(target: Object, callableMethod: string | symbol, descriptor: TypedPropertyDescriptor<any>) {
    const targetCommands: CommandCollection = Reflect.getMetadata("design:cmdlist", target) || new Map<string, ICommand>()

    // throw error if we're trying add command to not callable value
    if (!(descriptor.value instanceof Function)) throw new Error(`Command "${mainCmd}" should be callable`)

    newCommand.callable = callableMethod.toString()

    // store in target context storage our commands to pass them into constructor
    // where we will can register them in Rage API
    if (group) {
      const command = targetCommands.get(mainCmd)
      targetCommands.set(mainCmd, Array.isArray(command) ? [...command, newCommand] : [newCommand])
    } else {
      targetCommands.set(mainCmd, newCommand)
    }

    Reflect.defineMetadata("design:cmdlist", targetCommands, target)

    return descriptor
  }
}

 

the source of decorators.js (server-side only, for supporting events, client-side usage npm package)

Скрытый текст
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.command = exports.commandable = exports.registeredCommands = void 0;
exports.registeredCommands = new Map();
exports.commandable = () => {
    return function (target) {
        return class extends target {
            constructor(...args) {
                super(...args);
                if (!Reflect.getMetadata("design:cmdlist:init", target.prototype)) {
                    const commands = Reflect.getMetadata("design:cmdlist", target.prototype) || [];
                    commands.forEach((command, mainCmd) => {
                        if (Array.isArray(command)) {
                            const cmdList = command.reduce((carry, { cmd }) => carry.concat(cmd), []);
                            const descList = command.reduce((carry, { desc }) => carry.concat(desc), []);
                            mp.events.addCommand(mainCmd, (player, fullText, ...args) => {
                                const cmdName = args.shift();
                                if (!cmdName || cmdList.indexOf(cmdName) === -1) {
                                    descList.forEach(text => player.outputChatBox(text));
                                }
                                else {
                                    for (let { cmd, callable } of command) {
                                        if (cmd.indexOf(cmdName) !== -1) {
                                            const descIndex = cmdList.findIndex(cmdname => cmdname === cmdName);
                                            this[callable](player, descList[descIndex], ...args);
                                            break;
                                        }
                                    }
                                }
                            });
                        }
                        else {
                            const { cmd, callable, desc } = command;
                            cmd.forEach((cmdName, cmdIndex) => mp.events.addCommand(cmdName, (player, fullText, ...args) => this[callable](player, desc[cmdIndex], ...args)));
                        }
                    });
                    Reflect.defineMetadata("design:cmdlist:init", true, target.prototype);
                }
            }
        };
    };
};
exports.command = (cmd, params) => {
    let group = undefined;
    let desc = undefined;
    if (typeof params === 'string') {
        group = params;
    }
    else if (params) {
        group = params.group;
        desc = params.desc;
    }
    cmd = (Array.isArray(cmd) ? cmd : [cmd]);
    const cmds = cmd.filter((item, index) => cmd.indexOf(item) === index);
    const mainCmd = group || cmds[0];
    if (!mainCmd)
        throw new Error("Wrong registry command");
    if (!group && exports.registeredCommands.get(mainCmd))
        throw new Error(`Duplicate command "${mainCmd}"`);
    if (group) {
        const command = exports.registeredCommands.get(mainCmd);
        if (command) {
            if (Array.isArray(command)) {
                const flatCmds = command.reduce((carry, { cmd }) => carry.concat(cmd), []);
                const intersect = cmds.filter(value => flatCmds.includes(value));
                if (intersect.length)
                    throw new Error(`Duplicate commands "${intersect.join(',')}" by group "${mainCmd}"`);
            }
            else {
                const { cmd } = command;
                if (cmd.includes(mainCmd))
                    throw new Error(`Duplicate commands "${mainCmd}", trying to make a command group when a command has already existed`);
            }
        }
    }
    else {
        exports.registeredCommands.forEach(value => {
            if (!Array.isArray(value)) {
                const { cmd } = value;
                const intersect = cmds.filter(value => cmd.includes(value));
                if (intersect.length)
                    throw new Error(`Duplicate commands "${intersect.join(',')}"`);
            }
        });
    }
    let description = [];
    if (desc) {
        description = cmds.map(cmdname => {
            let newDescription = desc.replace(/{{cmdName}}/, cmdname);
            if (group)
                newDescription = newDescription.replace(/{{groupName}}/, mainCmd);
            return newDescription;
        });
    }
    else {
        description = cmds.map(cmdname => `Usage /${group && (group + ' ' + cmdname) || cmdname}`);
    }
    const newCommand = {
        cmd: cmds,
        callable: "",
        desc: description,
    };
    if (group) {
        const command = exports.registeredCommands.get(mainCmd);
        exports.registeredCommands.set(mainCmd, Array.isArray(command) ? [...command, newCommand] : [newCommand]);
    }
    else {
        exports.registeredCommands.set(mainCmd, newCommand);
    }
    return function (target, callableMethod, descriptor) {
        const targetCommands = Reflect.getMetadata("design:cmdlist", target) || new Map();
        if (!(descriptor.value instanceof Function))
            throw new Error(`Command "${mainCmd}" should be callable`);
        newCommand.callable = callableMethod.toString();
        if (group) {
            const command = targetCommands.get(mainCmd);
            targetCommands.set(mainCmd, Array.isArray(command) ? [...command, newCommand] : [newCommand]);
        }
        else {
            targetCommands.set(mainCmd, newCommand);
        }
        Reflect.defineMetadata("design:cmdlist", targetCommands, target);
        return descriptor;
    };
};

 

6. Event decorator:

Event decorator is now avaliable in npm package, here an usaging example:

import { eventable, event } from 'rage-decorators'

@eventable()
class Foo {

  @event("playerJoin")
  playerJoin(player: PlayerMp): void {
    console.log(`Player[${player.id}]${player.name} has joined to the server`)
  }
}

 

Edited by cacao
v. 1.1.4, Added clientside support
  • Like 3
Link to post
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    No registered users viewing this page.

×
×
  • Create New...