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