Source: pets.js

// Copyright 2019-2022 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const fs = require('fs');
const SubModule = require('./subModule.js');

delete require.cache[require.resolve('./locale/Strings.js')];
const Strings = require('./locale/Strings.js');

delete require.cache[require.resolve('./pets/Constants.js')];
delete require.cache[require.resolve('./pets/Pet.js')];
delete require.cache[require.resolve('./pets/BasePets.js')];
delete require.cache[require.resolve('./pets/BaseMoves.js')];
delete require.cache[require.resolve('./pets/BasePetClasses.js')];
const Constants = require('./pets/Constants.js');
const Pet = require('./pets/Pet.js');
const BasePets = require('./pets/BasePets.js');
const BaseMoves = require('./pets/BaseMoves.js');
const BasePetClasses = require('./pets/BasePetClasses.js');

const confirm = '✅';
const cancel = '❌';

/**
 * @description Manages pet related commands.
 * @listens Command#pet
 * @augments SubModule
 */
class Pets extends SubModule {
  /**
   * @description SubModule managing pet related commands.
   */
  constructor() {
    super();
    /** @inheritdoc */
    this.myName = 'Pets';
    /** @inheritdoc */
    this.postPrefix = 'pet ';

    /**
     * @description All pets currently cached. Mapped by user ID, then pet ID.
     * Only one pet is allowed per user at this time, but this future proofing
     * in case users will be able to have multiples in the future.
     * @private
     * @type {object.<object.<Pet>>}
     * @default
     */
    this._pets = {};

    /**
     * @description Cache of IDs that are currently being released to disk, but
     * are not loaded anymore. Used for {@link Pets._releasePet} to prevent
     * saving multiple times. If the ID exists, it will be true.
     * @private
     * @type {object.<boolean>}
     * @default
     */
    this._releasing = {};

    /**
     * @description Instance of {@link Pets~BasePets}.
     * @private
     * @type {Pets~BasePets}
     * @default
     * @constant
     */
    this._basePets = new BasePets();
    /**
     * @description Instance of {@link Pets~BaseMoves}.
     * @private
     * @type {Pets~BaseMoves}
     * @default
     * @constant
     */
    this._baseMoves = new BaseMoves();

    /**
     * @description Instance of locale strings helper.
     * @private
     * @type {Strings}
     * @default
     * @constant
     */
    this._strings = new Strings('pets');
    this._strings.purge();

    this._getAllPets = this._getAllPets.bind(this);
    this._getPet = this._getPet.bind(this);
    this._commandPet = this._commandPet.bind(this);
    this._commandAdopt = this._commandAdopt.bind(this);
    this._commandAbandon = this._commandAbandon.bind(this);
    this._releasePet = this._releasePet.bind(this);
    this._saveSingle = this._saveSingle.bind(this);
    this._checkPurge = this._checkPurge.bind(this);
  }
  /** @inheritdoc */
  initialize() {
    this.command.on(
        new this.command.SingleCommand(['pet'], this._commandPet, null, [
          new this.command.SingleCommand(['adopt', 'new'], this._commandAdopt),
          new this.command.SingleCommand(['abandon'], this._commandAbandon),
        ]));

    this._basePets.initialize();
    this._baseMoves.initialize();

    /**
     * @description Release pet from memory.
     * @see {@link Pet._releasePet}
     */
    this.client.releasePet = this._releasePet;
  }
  /** @inheritdoc */
  shutdown() {
    this.command.removeListener('pet');
    this.client.releasePet = null;
    this._basePets.shutdown();
    this._baseMoves.shutdown();
  }
  /** @inheritdoc */
  save(opt) {
    if (!this.initialized) return;

    const list = Object.values(this._pets).reduce((a, c) => {
      return a = a.concat(Object.values(c));
    }, []);

    list.forEach((obj) => {
      this._saveSingle(obj, opt);
    });
  }
  /**
   * @description Reply to msg with locale strings.
   * @private
   *
   * @param {Discord~Message} msg Message to reply to.
   * @param {?string} titleKey String key for the title, or null for default.
   * @param {string} bodyKey String key for the body message.
   * @param {string} [rep] Placeholder replacements for the body only.
   * @returns {Promise<Discord~Message>} Message send promise from
   * {@link Discord}.
   */
  _reply(msg, titleKey, bodyKey, ...rep) {
    return this.common.reply(
        msg, this._strings.get(titleKey, msg.locale),
        this._strings.get(bodyKey, msg.locale, ...rep));
  }

  /**
   * @description Save a single pet object to disk, and purge if stale.
   * @private
   * @param {Pet} obj The pet object to save.
   * @param {string} [opt='sync'] Either 'sync' or 'async'.
   * @param {Function} [cb] Optional callback that fires with no arguments on
   * completion.
   */
  _saveSingle(obj, opt = 'sync', cb) {
    const dir = `${this.common.userSaveDir}${obj.owner}/pets/`;
    const filename = `${dir}${obj.id}.json`;
    if (opt == 'async') {
      this.common.mkAndWrite(
          filename, dir, JSON.stringify(obj.serializable), () => {
            this._checkPurge(obj);
            if (typeof cb === 'function') cb();
          });
    } else {
      this.common.mkAndWriteSync(
          filename, dir, JSON.stringify(obj.serializable));
      this._checkPurge(obj);
      if (typeof cb === 'function') cb();
    }
  }

  /**
   * @description Check if pet is purgable from memory, and purges if possible.
   * @private
   * @param {Pet} obj Pet object to potentially purge.
   */
  _checkPurge(obj) {
    if (Date.now() - obj._lastInteractTime > 5 * 60 * 1000) {
      delete this._pets[obj.owner][obj.id];
    }
  }

  /**
   * @description User typed the pet command.
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#pet
   */
  _commandPet(msg) {
    this._getAllPets(msg.author, (err, pets) => {
      if (err) {
        this.common.reply(msg, err);
        return;
      }
      if (pets.length == 0) {
        this._reply(msg, 'title', 'noPet', `${msg.prefix}${this.postPrefix}`);
      } else {
        // Temporary.
        this.common.reply(
            msg, 'Pets',
            JSON.stringify(pets.map((el) => el.serializable), null, 2));
      }
    });
  }

  /**
   * @description User requested to adopt a new pet.
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#pet_adopt
   */
  _commandAdopt(msg) {
    this._getAllPets(msg.author, (err, pets) => {
      if (err) {
        this.common.reply(msg, err);
        return;
      }
      if (pets.length == 0) {
        const match = msg.text.match(/^\s*(\w+)\s+(\w{1,16})$/);
        const species = match && this._basePets.get(match[1]);
        if (!match) {
          this._reply(msg, 'noSpecies', 'availableSpeciesInfo');
          return;
        } else if (!species) {
          this._reply(msg, 'invalidSpecies', 'availableSpeciesInfo');
          return;
        } else if (!match[2] || match[2].length < 3) {
          this._reply(msg, 'invalidName', 'nameInstructions', 3, 16);
          return;
        }
        let reactMessage;
        this._reply(
            msg, 'title', 'confirmAdopt', match[1], match[2], confirm,
            cancel)
            .then((m) => {
              reactMessage = m;
              return m.react(confirm).then(() => m.react(cancel));
            })
            .then(() => {
              const filter = (reaction, user) => {
                return user.id == msg.author.id &&
                    (reaction.emoji.name == confirm ||
                     reaction.emoji.name == cancel);
              };
              return reactMessage.awaitReactions({filter, max: 1, time: 30000});
            })
            .then((reactions) => {
              reactMessage.reactions.removeAll().catch(() => {});
              if (reactions.size == 0) {
                reactMessage.edit({
                  content: this._strings.get('commandTimedOut', msg.locale),
                });
                return;
              } else if (reactions.first().emoji.name == cancel) {
                reactMessage.edit(
                    {content: this._strings.get('cancelled', msg.locale)});
                return;
              }
              reactMessage.edit(
                  {content: this._strings.get('confirmed', msg.locale)});
              const newPet = new Pet(msg.author.id, match[2], match[1]);
              this._saveSingle(newPet, 'async', () => {
                this._reply(
                    msg, 'title', 'adoptionConfirmed', species.name, match[2]);
              });
            });
      } else {
        this._reply(msg, 'title', 'alreadyHavePet');
      }
    });
  }

  /**
   * @description User requested to abandon a pet.
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#pet_abandon
   */
  _commandAbandon(msg) {
    this._getAllPets(msg.author, (err, pets) => {
      if (err) {
        this.common.reply(msg, err);
        return;
      }
      if (pets.length == 0) {
        this.common.reply(
            msg, 'Pets',
            'You don\'t have a pet that you can abandon.\nYou can adopt a new' +
                ' one with `' + msg.prefix + this.postPrefix + 'adopt`');
        return;
      }
      let reactMessage;
      this.common
          .reply(
              msg, 'Are you sure?',
              'This cannot be undone, your pet will never forgive you if you ' +
                  'abandon them.\n' + confirm + ': yes, ' + cancel + ': no')
          .then((m) => {
            reactMessage = m;
            m.react(confirm).then(() => m.react(cancel));
          })
          .then(() => {
            const filter = (reaction, user) => {
              return user.id == msg.author.id &&
                  (reaction.emoji.name == confirm ||
                   reaction.emoji.name == cancel);
            };
            return reactMessage.awaitReactions({filter, max: 1, time: 30000});
          })
          .then((reactions) => {
            reactMessage.reactions.removeAll().catch(() => {});
            if (reactions.size == 0) {
              reactMessage.edit({content: 'Timed out, enter command again.'});
              return;
            } else if (reactions.first().emoji.name == cancel) {
              reactMessage.edit({content: 'Cancelled'});
              return;
            }
            reactMessage.edit({content: 'Confirmed'});
            const uId = msg.author.id;
            const pId = pets[0].id;

            const fName = `${this.common.userSaveDir}${uId}/pets/${pId}.json`;
            this.common.unlink(fName, (err) => {
              if (err) {
                this.error(`Failed to delete pet file: ${fName}`);
                console.error(err);
                return;
              }
              const pet = this._pets[uId][pId];
              this.common.reply(
                  msg, 'Pet Abandoned', `${pet.name} (${pet.species})`);
              delete this._pets[uId][pId];
            });
          });
    });
  }

  /**
   * @description Get an array of all of a user's pets.
   * @private
   * @param {Discord~User} user Discord user to fetch all pets for.
   * @param {Function} cb Callback with first argument as optional error, and
   * second as array of Pet objects.
   */
  _getAllPets(user, cb) {
    const dir = `${this.common.userSaveDir}${user.id}/pets/`;

    fs.readdir(dir, (err, files) => {
      if (err) {
        if (err.code === 'ENOENT') {
          cb(null, []);
          return;
        }
        cb(err);
        return;
      }
      let numDone = 0;
      let numTotal = 0;
      const list = [];
      const done = function(err, pet) {
        numDone++;
        if (!err) list.push(pet);
        if (numDone >= numTotal) {
          cb(null, list);
        }
      };
      for (const file of files) {
        const filename = file.match(/^(.*)\.json$/);
        if (!filename) continue;
        numTotal++;
        this._getPet(user, filename[1], done);
      }
      if (numTotal === 0) {
        cb(null, []);
      }
    });
  }

  /**
   * @description Fetch a user's pet.
   * @private
   * @param {Discord~User} user A user of which to fetch the pet for.
   * @param {string} pId The pet ID to fetch.
   * @param {Function} cb Callback once complete. First argument is optional
   * error, second is parsed Pet object.
   */
  _getPet(user, pId, cb) {
    const uId = user.id;
    const obj = this._pets[uId] && this._pets[uId][pId];
    if (obj) {
      obj.touch();
      cb(null, obj);
      return;
    }

    const fname = `${this.common.userSaveDir}${uId}/pets/${pId}.json`;

    const self = this;

    const read = function() {
      self.common.readAndParse(fname, (err, parsed) => {
        if (err) {
          cb(err);
          return;
        }
        if (!self._pets[uId]) self._pets[uId] = {};
        self._pets[uId][pId] = parsed;
        cb(null, parsed);
      });
    };

    if (this.client.shard) {
      const toSend = `this.releasePet('${uId}', '${pId}')`;
      const release = function() {
        self.client.shard.broadcastEval(toSend)
            .then((res) => {
              const wait = res.find((el) => !el);
              if (wait) {
                setTimeout(release, 100);
              } else {
                read();
              }
            })
            .catch((err) => {
              self.error(
                  'Failed to release pet from other shards: ' + uId + ' ' +
                  pId);
              console.error(err);
              cb('Failed to release pet from other shards.');
            });
      };
      release();
    } else {
      read();
    }
  }

  /**
   * @description Force a pet to be saved to file and removed from memory
   * immediately. File IO is still asynchronous. This is used to release pets
   * from other shards, and returns true if pet has been completely released to
   * disk.
   * @private
   * @param {string} uId The ID of the user.
   * @param {string} pId THe ID of the pet.
   * @returns {boolean} True if fully released, false if not done yet.
   */
  _releasePet(uId, pId) {
    const releaseId = `${uId}_${pId}`;
    if (!this._pets[uId] || !this._pets[uId][pId]) {
      return !this._releasing[releaseId];
    }

    this._releasing[releaseId] = true;

    this._saveSingle(this._pets[uId][pId], 'async', () => {
      delete this._releasing[releaseId];
    });

    delete this._pets[uId][pId];

    return false;
  }
}

Pets.Pet = Pet;
Pets.BasePets = BasePets;
Pets.BaseMoves = BaseMoves;
Pets.BasePetClasses = BasePetClasses;
Pets.Constants = Constants;
Pets.Strings = Strings;

module.exports = new Pets();