// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const Stats = require('./Stats.js');
const common = require('../common.js');
const crypto = require('crypto');
const fs = require('fs');
const rimraf = require('rimraf');
/**
* @description Metadata to store along with a {@link HungryGames~StatGroup}
* object. These values are user-defined and are not necessarily correct and are
* not trustworthy for any processing.
* @typedef {object} HGStatMetadata
*
* @property {string} [name] The user-defined name of this stats object.
* @property {Date} [startTime] The timestamp at which this stats object starts
* to include information.
* @property {Date} [endTime] The timestamp of the last time this object
* includes information for.
* @property {Date} createDate The timestamp at which this stats object was
* created.
* @property {Date} modifiedDate The timestamp at which this stats object was
* last modified.
*/
/**
* @description HG stats for a single timeframe.
* @memberof HungryGames
* @inner
*/
class StatGroup {
/**
* @description Create group.
* @param {GuildGame} parent The parent instance of this group.
* @param {HGStatMetadata|string} [metadata] Additional information to store
* with these stats, or ID if metadata should be read from file since this
* group already exists.
*/
constructor(parent, metadata) {
let id;
if (typeof metadata === 'string') {
id = metadata;
metadata = null;
}
// Ensure SQL connection is established.
common.connectSQL();
/**
* @description The ID of this current bot.
* @public
* @type {string}
* @constant
*/
this.bot = parent.bot;
/**
* @description The guild ID where this stat group resides.
* @public
* @type {string}
* @constant
*/
this.guild = parent.id;
/**
* @description The unique ID for this stat group. Unique per-guild.
* @public
* @type {string}
*/
this.id = id;
if (!this.id) this.id = StatGroup.createID(parent);
/**
* @description Queue of callbacks to fire once an object has been read from
* file. This is used to ensure that if multiple manipulations are requested
* on a single object at the same time, all modifications will take place on
* the same instance instead of overwriting eachother. Mapped by ID being
* fetched.
* @private
* @type {object.<Array.<Function>>}
* @default
*/
this._fetchQueue = {};
/**
* @description Cache of Stats objects that are to be saved to file, and the
* timeout until it will be saved. Prevents saving the same file multiple
* times at once.
* @private
* @type {object.<{data: HungryGames~Stats, timeout: Timeout}>}
* @default
*/
this._saveQueue = {};
const dir = `${common.guildSaveDir}${parent.id}/hg/stats/`;
/**
* @description The directory where all of this group's information is
* stored.
* @private
* @type {string}
* @constant
*/
this._dir = `${dir}${this.id}/`;
this._fetchUser = this._fetchUser.bind(this);
this.fetchUser = this.fetchUser.bind(this);
this.setValue = this.setValue.bind(this);
this.fetchValue = this.fetchValue.bind(this);
this._saveUser = this._saveUser.bind(this);
this.setMetaName = this.setMetaName.bind(this);
this.setMetaStart = this.setMetaStart.bind(this);
this.setMetaEnd = this.setMetaEnd.bind(this);
this._fetchMetadata = this._fetchMetadata.bind(this);
this._saveMetadata = this._saveMetadata.bind(this);
this.reset = this.reset.bind(this);
if (metadata) {
this._saveMetadata(this._parseMetadata(metadata));
} else {
this._fetchMetadata((err, meta) => {
if (err) {
console.error(err);
return;
}
this._saveMetadata(meta);
});
}
}
/**
* @description Fetch stats for a specific user in this group. Returned stats
* are modifiable, but changes will not persist unless saved to file.
* @private
* @param {string} uId The user ID of which to lookup.
* @param {Function} cb Callback with optional error as first argument,
* otherwise has stats as second argument.
*/
_fetchUser(uId, cb) {
if (typeof uId !== 'string' ||
(uId !== 'meta' && !uId.match(/^(\d{17,19}|NPC[A-F0-9]+)$/))) {
throw new TypeError('uId (' + uId + ') is not a valid ID.');
}
// Data is queued to be saved, and is still cached, return the cached
// version instead of reading the stale version from file.
if (this._saveQueue[uId]) {
cb(null, this._saveQueue[uId].data);
return;
}
if (!this._fetchQueue[uId]) {
this._fetchQueue[uId] = [cb];
} else {
this._fetchQueue[uId].push(cb);
return;
}
const self = this;
const done = function(err, data) {
self._fetchQueue[uId].forEach((el) => {
try {
el(err, data);
} catch (err) {
console.error(err);
}
});
delete self._fetchQueue[uId];
};
if (uId === 'meta') {
fs.readFile(`${this._dir}${uId}.json`, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
data = '{}';
} else {
done(err);
return;
}
}
try {
done(null, this._parseMetadata(JSON.parse(data)));
} catch (err) {
done(err);
}
});
} else {
const toSend = global.sqlCon.format(
'SELECT * FROM HGStats WHERE ' +
'botId=? AND guildId=? AND groupId=? AND userId=?',
[this.bot, this.guild, this.id, uId]);
global.sqlCon.query(toSend, (err, rows) => {
if (err) {
done(err);
return;
}
if (!rows || rows.length == 0) {
// Fallback to legacy filesytem.
fs.readFile(`${this._dir}${uId}.json`, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
data = '{}';
} else {
done(err);
return;
}
}
try {
const parsed = JSON.parse(data);
parsed.id = uId;
done(null, Stats.from(parsed));
} catch (err) {
done(err);
}
});
} else {
const data = rows[0] || {};
data.id = uId;
done(null, Stats.from(data));
}
});
}
}
/**
* @description Fetch stats for a specific user in this group. Modified values
* will not persist. Use functions to modify.
* @todo Return immutable/frozen copy to enforce no-modify rule.
* @public
* @param {string} uId The user ID of which to lookup.
* @param {Function} cb Callback with optional error as first argument,
* otherwise has stats as second argument.
*/
fetchUser(uId, cb) {
this._fetchUser(uId, (err, stats) => {
if (err) {
cb(err);
return;
}
// cb(null, common.deepFreeze(stats));
cb(null, stats);
});
}
/**
* @description Options for fetching a group of user stats.
* @typedef {object} HGStatGroupUserSelectOptions
*
* @property {string} [sort='wins'] Column to sort data by.
* @property {boolean} [ascending=false] Sort ascending or descending order.
* @property {number} [limit=10] Limit the number of fetched users.
* @property {number} [offset=0] Offset start index of found users.
*/
/**
* @description Fetch stats for a group of users. If array of IDs is given,
* data will not be sorted.
* @public
* @param {HGStatGroupUserSelectOptions|string[]} [opts] Options to specify
* which users are fetched, or array of user IDs to fetch.
* @param {Function} cb Callback with optional error as first argument,
* otherwise has stats as second argument as array of
* {@link HungryGames~Stats} objects.
*/
fetchUsers(opts, cb) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
if (typeof cb !== 'function') {
throw new TypeError('Callback must be a function');
}
if (!opts || typeof opts !== 'object') {
opts = {};
}
const onReply = function(err, rows) {
if (err) {
cb(err);
return;
}
try {
cb(null, rows.map((el) => {
el.id = el.userId;
return new Stats(el);
}));
} catch (err) {
cb(err);
}
};
if (Array.isArray(opts)) {
if (opts.length === 0) {
cb(null, []);
return;
}
const userList = opts.map(() => `userId=?`).join(' OR ');
const toSend = global.sqlCon.format(
'SELECT * FROM HGStats WHERE ' +
'botId=? AND guildId=? AND groupId=? AND (' + userList + ');',
[this.bot, this.guild, this.id].concat(opts));
global.sqlCon.query(toSend, onReply);
} else {
if (typeof opts.sort === 'undefined') {
opts.sort = 'wins';
} else if (typeof opts.sort !== 'string' || opts.sort.length == 0) {
opts.sort = null;
}
if (typeof opts.limit === 'undefined') opts.limit = 10;
if (!opts.offset || typeof opts.offset !== 'number' ||
isNaN(opts.offset)) {
opts.offset = 0;
}
const sort = (typeof opts.sort === 'string' ?
' ORDER BY ?? ' + (opts.ascending ? '' : 'DESC ') :
'') +
', `userId` ';
const limit = typeof opts.limit === 'number' && !isNaN(opts.limit) ?
`LIMIT ${opts.limit}` +
(opts.offset ? ` OFFSET ${opts.offset}` : '') :
'';
const toSend = global.sqlCon.format(
'SELECT * FROM HGStats WHERE ' +
'botId=? AND guildId=? AND groupId=?' + sort + limit + ';',
[this.bot, this.guild, this.id, opts.sort]);
global.sqlCon.query(toSend, onReply);
}
}
/**
* @description Set a stat value for a single user.
* @public
* @param {string} uId The user ID of which to change.
* @param {string} key The key of the value to change.
* @param {*} value The value to store.
* @param {Function} cb Callback with single optional error argument.
*/
setValue(uId, key, value, cb) {
this._fetchUser(uId, (err, data) => {
if (err && err.code !== 'ENOENT') {
cb(err);
return;
}
data.set(key, value);
this._saveUser(data);
cb();
});
}
/**
* @description Fetch a stat value for a single user. Immutable.
* @public
* @param {string} uId The user ID of which to fetch.
* @param {string} key The key of the value to fetch.
* @param {Function} cb Callback with optional error argument, and matched
* value.
*/
fetchValue(uId, key, cb) {
this._fetchUser(uId, (err, data) => {
if (err) {
cb(err);
return;
}
cb(null, data.get(key));
});
}
/**
* @description Increment a value by an amount.
* @public
* @param {string} uId The user ID of which to modify.
* @param {string} key The key of the value to modify.
* @param {number} [amount=1] Amount to increment by. Can be negative to
* decrement.
* @param {Function} [cb] Callback with single optional error argument.
*/
increment(uId, key, amount = 1, cb) {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new TypeError('Amount is not a number.');
}
this._fetchUser(uId, (err, data) => {
if (err) {
if (typeof cb !== 'function') {
console.error(err);
} else {
cb(err);
}
return;
}
if (!data.get(key)) data.set(key, 0);
if (typeof data.get(key) !== 'number') {
const err = new TypeError('Fetched value is not a number.');
if (typeof cb !== 'function') {
console.error(err);
} else {
cb(err);
}
return;
}
data.set(key, data.get(key) + amount);
this._saveUser(data);
if (typeof cb === 'function') cb();
});
}
/**
* @description Save a stats object to file.
* @private
* @param {HungryGames~Stats} data The stats object to save.
* @param {boolean} [immediate=false] Force saving to happen immediately
* instead of waiting until next event loop.
*/
_saveUser(data, immediate = false) {
if (this._saveQueue[data.id]) {
clearTimeout(this._saveQueue[data.id].timeout);
this._saveQueue[data.id].timeout = null;
}
if (!immediate) {
this._saveQueue[data.id] = {
data: data,
timeout: setTimeout(
() => this._saveUser(this._saveQueue[data.id].data, true), 1000),
};
return;
}
delete this._saveQueue[data.id];
const setList = 'botId=?,guildId=?,groupId=?,userId=?,' +
data.keys.map((el) => `${el}=?`).join(',');
const valueList = [this.bot, this.guild, this.id, data.id].concat(
Object.values(data.serializable));
const toSend = global.sqlCon.format(
'INSERT INTO HGStats SET ' + setList + ' ON DUPLICATE KEY UPDATE ' +
setList + ';',
valueList.concat(valueList));
global.sqlCon.query(toSend, (err) => {
if (err) {
console.error(err);
return;
}
const fn = `${this._dir}${data.id}.json`;
common.unlink(fn, (err) => {
if (err) {
console.error('Failed to remove legacy user stat file:', fn);
console.error(err);
}
});
});
// common.mkAndWrite(
// `${this._dir}${data.id}.json`, this._dir, data.serializable);
}
/**
* @description Set the metadata name.
* @public
* @param {string} name The new value.
*/
setMetaName(name) {
this.fetchMetadata((err, meta) => {
if (err) {
console.error(err);
return;
}
meta.name = name;
this._saveMetadata(meta);
});
}
/**
* @description Set the metadata start time.
* @public
* @param {Date|number|string} startTime Date parsable time.
*/
setMetaStart(startTime) {
this.fetchMetadata((err, meta) => {
if (err) {
console.error(err);
return;
}
meta.startTime = new Date(startTime);
this._saveMetadata(meta);
});
}
/**
* @description Set the metadata end time.
* @public
* @param {Date|number|string} endTime Date parsable time.
*/
setMetaEnd(endTime) {
this.fetchMetadata((err, meta) => {
if (err) {
console.error(err);
return;
}
meta.endTime = new Date(endTime);
this._saveMetadata(meta);
});
}
/**
* @description Fetch the metadata for this group from file.
* @private
* @param {Function} cb Callback with optional error argument, otherwise
* second argument is parsed {@link HGStatMetadata}.
*/
_fetchMetadata(cb) {
this._fetchUser('meta', cb);
}
/**
* @description Fetch the metadata for this group from file. Modified values
* will not persist. Use functions to modify.
* @private
* @param {Function} cb Callback with optional error argument, otherwise
* second argument is parsed {@link HGStatMetadata}.
*/
fetchMetadata(cb) {
this.fetchUser('meta', cb);
}
/**
* @description Parse the given object into a {@link HGStatMetadata} object.
* @private
* @param {object} data The data to parse.
* @returns {HGStatMetadata} The parsed object.
*/
_parseMetadata(data) {
const out = {};
if (!data) data = {};
if (data.name != null) out.name = data.name;
if (data.startTime != null) out.startTime = new Date(data.startTime);
if (data.endTime != null) out.endTime = new Date(data.endTime);
out.createDate = data.createDate ? new Date(data.createDate) : new Date();
out.modifiedDate =
data.modifiedDate ? new Date(data.modifiedDate) : new Date();
return out;
}
/**
* @description Save the current metadata to file.
* @private
* @param {HGStatMetadata} meta The data to save. Overwrites existing data.
* @param {boolean} [immediate=false] Force saving to perform immediately
* instead of delaying until next event loop.
*/
_saveMetadata(meta, immediate = false) {
if (this._saveQueue.meta) {
clearTimeout(this._saveQueue.meta.timeout);
this._saveQueue.meta.timeout = null;
}
if (!immediate) {
this._saveQueue.meta = {
data: meta,
timeout: setTimeout(
() => this._saveMetadata(this._saveQueue.meta.data, true), 1000),
};
return;
}
const data = {
name: meta.name,
startTime: meta.startTime && meta.startTime.getTime(),
endTime: meta.endTime && meta.endTime.getTime(),
createDate: meta.createDate.getTime(),
modifiedDate: Date.now(),
};
delete this._saveQueue.meta;
common.mkAndWrite(`${this._dir}meta.json`, this._dir, data);
}
/**
* @description Delete all data associated with this group.
* @public
*/
reset() {
const self = this;
const resetQueue = function() {
const keys = Object.keys(self._saveQueue);
for (const k of keys) {
clearTimeout(self._saveQueue[k].timeout);
delete self._saveQueue[k];
}
};
resetQueue();
const toSend = global.sqlCon.format(
'DELETE FROM HGStats WHERE botId=? AND guildId=? AND groupID=?;',
[this.bot, this.guild, this.id]);
global.sqlCon.query(toSend, (err) => {
if (err) console.error(err);
});
rimraf(this._dir, (err) => {
if (err) console.error(err);
resetQueue();
});
}
/**
* @description Check if a stat group with the given ID exists for the given
* game.
* @public
* @static
* @param {HungryGames~GuildGame} game The game of which the stats to look up.
* @param {string} id The group ID to check for.
* @returns {boolean} True if exists, false otherwise.
*/
static exists(game, id) {
const dir = `${common.guildSaveDir}${game.id}/hg/stats/`;
return fs.existsSync(`${dir}${id}/`);
}
/**
* @description Fetch list of IDs for all created groups.
* @public
* @static
* @param {HungryGames~GuildGame} game The game to get list for.
* @param {Function} cb Callback with optional error argument, otherwise
* second argument is array of IDs as strings.
*/
static fetchList(game, cb) {
fs.readdir(`${common.guildSaveDir}${game.id}/hg/stats/`, cb);
}
/**
* @description Create an ID for a new group.
* @todo Limit number of IDs to prevent infinite loop finding new ID.
* @public
* @static
* @param {HungryGames~GuildGame} game The game to create an ID for to ensure
* no collisions.
* @returns {string} Valid created ID.
*/
static createID(game) {
const dir = `${common.guildSaveDir}${game.id}/hg/stats/`;
let output;
do {
const id = crypto.randomBytes(2).toString('hex').toUpperCase();
output = `0000${id}`.slice(-4);
} while (fs.existsSync(`${dir}${output}`));
return output;
}
}
module.exports = StatGroup;