722 lines
27 KiB
JavaScript
722 lines
27 KiB
JavaScript
/* jslint node: true */
|
|
'use strict';
|
|
|
|
// ENiGMA½
|
|
const Events = require('./events.js');
|
|
const Config = require('./config.js').get;
|
|
const ConfigLoader = require('./config_loader');
|
|
const { getConfigPath } = require('./config_util');
|
|
const UserDb = require('./database.js').dbs.user;
|
|
const { getISOTimestampString } = require('./database.js');
|
|
const UserInterruptQueue = require('./user_interrupt_queue.js');
|
|
const { getConnectionByUserId } = require('./client_connections.js');
|
|
const UserProps = require('./user_property.js');
|
|
const { Errors, ErrorReasons } = require('./enig_error.js');
|
|
const { getThemeArt } = require('./theme.js');
|
|
const { pipeToAnsi, stripMciColorCodes } = require('./color_codes.js');
|
|
const stringFormat = require('./string_format.js');
|
|
const StatLog = require('./stat_log.js');
|
|
const Log = require('./logger.js').log;
|
|
|
|
// deps
|
|
const _ = require('lodash');
|
|
const async = require('async');
|
|
const moment = require('moment');
|
|
|
|
exports.getAchievementsEarnedByUser = getAchievementsEarnedByUser;
|
|
|
|
class Achievement {
|
|
constructor(data) {
|
|
this.data = data;
|
|
|
|
// achievements are retroactive by default
|
|
this.data.retroactive = _.get(this.data, 'retroactive', true);
|
|
}
|
|
|
|
static factory(data) {
|
|
if (!data) {
|
|
return;
|
|
}
|
|
let achievement;
|
|
switch (data.type) {
|
|
case Achievement.Types.UserStatSet:
|
|
case Achievement.Types.UserStatInc:
|
|
case Achievement.Types.UserStatIncNewVal:
|
|
achievement = new UserStatAchievement(data);
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
if (achievement.isValid()) {
|
|
return achievement;
|
|
}
|
|
}
|
|
|
|
static get Types() {
|
|
return {
|
|
UserStatSet: 'userStatSet',
|
|
UserStatInc: 'userStatInc',
|
|
UserStatIncNewVal: 'userStatIncNewVal',
|
|
};
|
|
}
|
|
|
|
isValid() {
|
|
switch (this.data.type) {
|
|
case Achievement.Types.UserStatSet:
|
|
case Achievement.Types.UserStatInc:
|
|
case Achievement.Types.UserStatIncNewVal:
|
|
if (!_.isString(this.data.statName)) {
|
|
return false;
|
|
}
|
|
if (!_.isObject(this.data.match)) {
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
getMatchDetails(/*matchAgainst*/) {}
|
|
|
|
isValidMatchDetails(details) {
|
|
if (
|
|
!details ||
|
|
!_.isString(details.title) ||
|
|
!_.isString(details.text) ||
|
|
!_.isNumber(details.points)
|
|
) {
|
|
return false;
|
|
}
|
|
return _.isString(details.globalText) || !details.globalText;
|
|
}
|
|
}
|
|
|
|
class UserStatAchievement extends Achievement {
|
|
constructor(data) {
|
|
super(data);
|
|
|
|
// sort match keys for quick match lookup
|
|
this.matchKeys = Object.keys(this.data.match || {})
|
|
.map(k => parseInt(k))
|
|
.sort((a, b) => b - a);
|
|
}
|
|
|
|
isValid() {
|
|
if (!super.isValid()) {
|
|
return false;
|
|
}
|
|
return !Object.keys(this.data.match).some(k => !parseInt(k));
|
|
}
|
|
|
|
getMatchDetails(matchValue) {
|
|
let ret = [];
|
|
let matchField = this.matchKeys.find(v => matchValue >= v);
|
|
if (matchField) {
|
|
const match = this.data.match[matchField];
|
|
matchField = parseInt(matchField);
|
|
if (this.isValidMatchDetails(match) && !isNaN(matchField)) {
|
|
ret = [match, matchField, matchValue];
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
class Achievements {
|
|
constructor(events) {
|
|
this.events = events;
|
|
this.enabled = false;
|
|
}
|
|
|
|
getAchievementByTag(tag) {
|
|
return this.config.get().achievements[tag];
|
|
}
|
|
|
|
isEnabled() {
|
|
return this.enabled;
|
|
}
|
|
|
|
init(cb) {
|
|
const configPath = this._getConfigPath();
|
|
if (!configPath) {
|
|
Log.info('Achievements are not configured');
|
|
return cb(null);
|
|
}
|
|
|
|
const configLoaded = () => {
|
|
if (true !== this.config.get().enabled) {
|
|
Log.info('Achievements are not enabled');
|
|
this.enabled = false;
|
|
this.stopMonitoringUserStatEvents();
|
|
} else {
|
|
Log.info('Achievements are enabled');
|
|
this.enabled = true;
|
|
this.monitorUserStatEvents();
|
|
}
|
|
};
|
|
|
|
this.config = new ConfigLoader({
|
|
onReload: err => {
|
|
if (!err) {
|
|
configLoaded();
|
|
}
|
|
},
|
|
});
|
|
|
|
this.config.init(configPath, err => {
|
|
if (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
configLoaded();
|
|
return cb(null);
|
|
});
|
|
}
|
|
|
|
_getConfigPath() {
|
|
const path = _.get(Config(), 'general.achievementFile');
|
|
if (!path) {
|
|
return;
|
|
}
|
|
return getConfigPath(path); // qualify
|
|
}
|
|
|
|
loadAchievementHitCount(user, achievementTag, field, cb) {
|
|
UserDb.get(
|
|
`SELECT COUNT() AS count
|
|
FROM user_achievement
|
|
WHERE user_id = ? AND achievement_tag = ? AND match = ?;`,
|
|
[user.userId, achievementTag, field],
|
|
(err, row) => {
|
|
return cb(err, row ? row.count : 0);
|
|
}
|
|
);
|
|
}
|
|
|
|
record(info, localInterruptItem, cb) {
|
|
StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1);
|
|
StatLog.incrementUserStat(
|
|
info.client.user,
|
|
UserProps.AchievementTotalPoints,
|
|
info.details.points
|
|
);
|
|
|
|
const cleanTitle = stripMciColorCodes(localInterruptItem.title);
|
|
const cleanText = stripMciColorCodes(localInterruptItem.achievText);
|
|
|
|
const recordData = [
|
|
info.client.user.userId,
|
|
info.achievementTag,
|
|
getISOTimestampString(info.timestamp),
|
|
info.matchField,
|
|
cleanTitle,
|
|
cleanText,
|
|
info.details.points,
|
|
];
|
|
|
|
UserDb.run(
|
|
`INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match, title, text, points)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
|
recordData,
|
|
err => {
|
|
if (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
this.events.emit(Events.getSystemEvents().UserAchievementEarned, {
|
|
user: info.client.user,
|
|
achievementTag: info.achievementTag,
|
|
points: info.details.points,
|
|
title: cleanTitle,
|
|
text: cleanText,
|
|
});
|
|
|
|
return cb(null);
|
|
}
|
|
);
|
|
}
|
|
|
|
display(info, interruptItems, cb) {
|
|
if (interruptItems.local) {
|
|
UserInterruptQueue.queue(interruptItems.local, { clients: info.client });
|
|
}
|
|
|
|
if (interruptItems.global) {
|
|
UserInterruptQueue.queue(interruptItems.global, { omit: info.client });
|
|
}
|
|
|
|
return cb(null);
|
|
}
|
|
|
|
recordAndDisplayAchievement(info, cb) {
|
|
async.waterfall(
|
|
[
|
|
callback => {
|
|
return this.createAchievementInterruptItems(info, callback);
|
|
},
|
|
(interruptItems, callback) => {
|
|
this.record(info, interruptItems.local, err => {
|
|
return callback(err, interruptItems);
|
|
});
|
|
},
|
|
(interruptItems, callback) => {
|
|
return this.display(info, interruptItems, callback);
|
|
},
|
|
],
|
|
err => {
|
|
return cb(err);
|
|
}
|
|
);
|
|
}
|
|
|
|
monitorUserStatEvents() {
|
|
if (this.userStatEventListeners) {
|
|
return; // already listening
|
|
}
|
|
|
|
const listenEvents = [
|
|
Events.getSystemEvents().UserStatSet,
|
|
Events.getSystemEvents().UserStatIncrement,
|
|
];
|
|
|
|
this.userStatEventListeners = this.events.addMultipleEventListener(
|
|
listenEvents,
|
|
userStatEvent => {
|
|
if (
|
|
[
|
|
UserProps.AchievementTotalCount,
|
|
UserProps.AchievementTotalPoints,
|
|
].includes(userStatEvent.statName)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!_.isNumber(userStatEvent.statValue) &&
|
|
!_.isNumber(userStatEvent.statIncrementBy)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// :TODO: Make this code generic - find + return factory created object
|
|
const achievementTags = Object.keys(
|
|
_.pickBy(
|
|
_.get(this.config.get(), 'achievements', {}),
|
|
achievement => {
|
|
if (false === achievement.enabled) {
|
|
return false;
|
|
}
|
|
const acceptedTypes = [
|
|
Achievement.Types.UserStatSet,
|
|
Achievement.Types.UserStatInc,
|
|
Achievement.Types.UserStatIncNewVal,
|
|
];
|
|
return (
|
|
acceptedTypes.includes(achievement.type) &&
|
|
achievement.statName === userStatEvent.statName
|
|
);
|
|
}
|
|
)
|
|
);
|
|
|
|
if (0 === achievementTags.length) {
|
|
return;
|
|
}
|
|
|
|
async.eachSeries(
|
|
achievementTags,
|
|
(achievementTag, nextAchievementTag) => {
|
|
const achievement = Achievement.factory(
|
|
this.getAchievementByTag(achievementTag)
|
|
);
|
|
if (!achievement) {
|
|
return nextAchievementTag(null);
|
|
}
|
|
|
|
const statValue = parseInt(
|
|
[
|
|
Achievement.Types.UserStatSet,
|
|
Achievement.Types.UserStatIncNewVal,
|
|
].includes(achievement.data.type)
|
|
? userStatEvent.statValue
|
|
: userStatEvent.statIncrementBy
|
|
);
|
|
if (isNaN(statValue)) {
|
|
return nextAchievementTag(null);
|
|
}
|
|
|
|
const [details, matchField, matchValue] =
|
|
achievement.getMatchDetails(statValue);
|
|
if (!details) {
|
|
return nextAchievementTag(null);
|
|
}
|
|
|
|
async.waterfall(
|
|
[
|
|
callback => {
|
|
this.loadAchievementHitCount(
|
|
userStatEvent.user,
|
|
achievementTag,
|
|
matchField,
|
|
(err, count) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
return callback(
|
|
count > 0
|
|
? Errors.General(
|
|
'Achievement already acquired',
|
|
ErrorReasons.TooMany
|
|
)
|
|
: null
|
|
);
|
|
}
|
|
);
|
|
},
|
|
callback => {
|
|
const client = getConnectionByUserId(
|
|
userStatEvent.user.userId
|
|
);
|
|
if (!client) {
|
|
return callback(
|
|
Errors.UnexpectedState(
|
|
'Failed to get client for user ID'
|
|
)
|
|
);
|
|
}
|
|
|
|
const info = {
|
|
achievementTag,
|
|
achievement,
|
|
details,
|
|
client,
|
|
matchField, // match - may be in odd format
|
|
matchValue, // actual value
|
|
achievedValue: matchField, // achievement value met
|
|
user: userStatEvent.user,
|
|
timestamp: moment(),
|
|
};
|
|
|
|
const achievementsInfo = [info];
|
|
return callback(null, achievementsInfo, info);
|
|
},
|
|
(achievementsInfo, basicInfo, callback) => {
|
|
if (true !== achievement.data.retroactive) {
|
|
return callback(null, achievementsInfo);
|
|
}
|
|
|
|
const index = achievement.matchKeys.findIndex(
|
|
v => v < matchField
|
|
);
|
|
if (
|
|
-1 === index ||
|
|
!Array.isArray(achievement.matchKeys)
|
|
) {
|
|
return callback(null, achievementsInfo);
|
|
}
|
|
|
|
// For userStat, any lesser match keys(values) are also met. Example:
|
|
// matchKeys: [ 500, 200, 100, 20, 10, 2 ]
|
|
// ^---- we met here
|
|
// ^------------^ retroactive range
|
|
//
|
|
async.eachSeries(
|
|
achievement.matchKeys.slice(index),
|
|
(k, nextKey) => {
|
|
const [det, fld, val] =
|
|
achievement.getMatchDetails(k);
|
|
if (!det) {
|
|
return nextKey(null);
|
|
}
|
|
|
|
this.loadAchievementHitCount(
|
|
userStatEvent.user,
|
|
achievementTag,
|
|
fld,
|
|
(err, count) => {
|
|
if (!err || (count && 0 === count)) {
|
|
achievementsInfo.push(
|
|
Object.assign({}, basicInfo, {
|
|
details: det,
|
|
matchField: fld,
|
|
achievedValue: fld,
|
|
matchValue: val,
|
|
})
|
|
);
|
|
}
|
|
|
|
return nextKey(null);
|
|
}
|
|
);
|
|
},
|
|
() => {
|
|
return callback(null, achievementsInfo);
|
|
}
|
|
);
|
|
},
|
|
(achievementsInfo, callback) => {
|
|
// reverse achievementsInfo so we display smallest > largest
|
|
achievementsInfo.reverse();
|
|
|
|
async.eachSeries(
|
|
achievementsInfo,
|
|
(achInfo, nextAchInfo) => {
|
|
return this.recordAndDisplayAchievement(
|
|
achInfo,
|
|
err => {
|
|
return nextAchInfo(err);
|
|
}
|
|
);
|
|
},
|
|
err => {
|
|
return callback(err);
|
|
}
|
|
);
|
|
},
|
|
],
|
|
err => {
|
|
if (err && ErrorReasons.TooMany !== err.reasonCode) {
|
|
Log.warn(
|
|
{ error: err.message, userStatEvent },
|
|
'Error handling achievement for user stat event'
|
|
);
|
|
}
|
|
return nextAchievementTag(null); // always try the next, regardless
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
stopMonitoringUserStatEvents() {
|
|
if (this.userStatEventListeners) {
|
|
this.events.removeMultipleEventListener(this.userStatEventListeners);
|
|
delete this.userStatEventListeners;
|
|
}
|
|
}
|
|
|
|
getFormatObject(info) {
|
|
return {
|
|
userName: info.user.username,
|
|
userRealName: info.user.realName(false) || 'N/A',
|
|
userLocation: info.user.properties[UserProps.Location] || 'N/A',
|
|
userAffils: info.user.properties[UserProps.Affiliations] || 'N/A',
|
|
nodeId: info.client.node,
|
|
title: info.details.title,
|
|
//text : info.global ? info.details.globalText : info.details.text,
|
|
points: info.details.points,
|
|
achievedValue: info.achievedValue,
|
|
matchField: info.matchField,
|
|
matchValue: info.matchValue,
|
|
timestamp: moment(info.timestamp).format(info.dateTimeFormat),
|
|
boardName: Config().general.boardName,
|
|
};
|
|
}
|
|
|
|
getFormattedTextFor(info, textType, defaultSgr = '|07') {
|
|
const themeDefaults = _.get(
|
|
info.client.currentTheme,
|
|
'achievements.defaults',
|
|
{}
|
|
);
|
|
const textTypeSgr = themeDefaults[`${textType}SGR`] || defaultSgr;
|
|
|
|
const formatObj = this.getFormatObject(info);
|
|
|
|
const wrap = input => {
|
|
const re = new RegExp(`{(${Object.keys(formatObj).join('|')})([^}]*)}`, 'g');
|
|
return input.replace(re, (m, formatVar, formatOpts) => {
|
|
const varSgr = themeDefaults[`${formatVar}SGR`] || textTypeSgr;
|
|
let r = `${varSgr}{${formatVar}`;
|
|
if (formatOpts) {
|
|
r += formatOpts;
|
|
}
|
|
return `${r}}${textTypeSgr}`;
|
|
});
|
|
};
|
|
|
|
return stringFormat(`${textTypeSgr}${wrap(info.details[textType])}`, formatObj);
|
|
}
|
|
|
|
createAchievementInterruptItems(info, cb) {
|
|
info.dateTimeFormat =
|
|
info.details.dateTimeFormat ||
|
|
info.achievement.dateTimeFormat ||
|
|
info.client.currentTheme.helpers.getDateTimeFormat();
|
|
|
|
const title = this.getFormattedTextFor(info, 'title');
|
|
const text = this.getFormattedTextFor(info, 'text');
|
|
|
|
let globalText;
|
|
if (info.details.globalText) {
|
|
globalText = this.getFormattedTextFor(info, 'globalText');
|
|
}
|
|
|
|
const getArt = (name, callback) => {
|
|
const spec =
|
|
_.get(info.details, `art.${name}`) ||
|
|
_.get(info.achievement, `art.${name}`) ||
|
|
_.get(this.config.get(), `art.${name}`);
|
|
if (!spec) {
|
|
return callback(null);
|
|
}
|
|
const getArtOpts = {
|
|
name: spec,
|
|
client: this.client,
|
|
random: false,
|
|
};
|
|
getThemeArt(getArtOpts, (err, artInfo) => {
|
|
// ignore errors
|
|
return callback(artInfo ? artInfo.data : null);
|
|
});
|
|
};
|
|
|
|
const interruptItems = {};
|
|
let itemTypes = ['local'];
|
|
if (globalText) {
|
|
itemTypes.push('global');
|
|
}
|
|
|
|
async.each(
|
|
itemTypes,
|
|
(itemType, nextItemType) => {
|
|
async.waterfall(
|
|
[
|
|
callback => {
|
|
getArt(`${itemType}Header`, headerArt => {
|
|
return callback(null, headerArt);
|
|
});
|
|
},
|
|
(headerArt, callback) => {
|
|
getArt(`${itemType}Footer`, footerArt => {
|
|
return callback(null, headerArt, footerArt);
|
|
});
|
|
},
|
|
(headerArt, footerArt, callback) => {
|
|
const itemText = 'global' === itemType ? globalText : text;
|
|
interruptItems[itemType] = {
|
|
title,
|
|
achievText: itemText,
|
|
text: `${title}\r\n${itemText}`,
|
|
pause: true,
|
|
};
|
|
if (headerArt || footerArt) {
|
|
const themeDefaults = _.get(
|
|
info.client.currentTheme,
|
|
'achievements.defaults',
|
|
{}
|
|
);
|
|
const defaultContentsFormat = '{title}\r\n{message}';
|
|
const contentsFormat =
|
|
'global' === itemType
|
|
? themeDefaults.globalFormat ||
|
|
defaultContentsFormat
|
|
: themeDefaults.format || defaultContentsFormat;
|
|
|
|
const formatObj = Object.assign(
|
|
this.getFormatObject(info),
|
|
{
|
|
title: this.getFormattedTextFor(
|
|
info,
|
|
'title',
|
|
''
|
|
), // ''=defaultSgr
|
|
message: itemText,
|
|
}
|
|
);
|
|
|
|
const contents = pipeToAnsi(
|
|
stringFormat(contentsFormat, formatObj)
|
|
);
|
|
|
|
interruptItems[itemType].contents = `${
|
|
headerArt || ''
|
|
}\r\n${contents}\r\n${footerArt || ''}`;
|
|
}
|
|
return callback(null);
|
|
},
|
|
],
|
|
err => {
|
|
return nextItemType(err);
|
|
}
|
|
);
|
|
},
|
|
err => {
|
|
return cb(err, interruptItems);
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
let achievementsInstance;
|
|
|
|
function getAchievementsEarnedByUser(userId, cb) {
|
|
if (!achievementsInstance) {
|
|
return cb(Errors.UnexpectedState('Achievements not initialized'));
|
|
}
|
|
|
|
UserDb.all(
|
|
`SELECT achievement_tag, timestamp, match, title, text, points
|
|
FROM user_achievement
|
|
WHERE user_id = ?
|
|
ORDER BY DATETIME(timestamp);`,
|
|
[userId],
|
|
(err, rows) => {
|
|
if (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
const earned = rows
|
|
.map(row => {
|
|
const achievement = Achievement.factory(
|
|
achievementsInstance.getAchievementByTag(row.achievement_tag)
|
|
);
|
|
if (!achievement) {
|
|
return;
|
|
}
|
|
|
|
const earnedInfo = {
|
|
achievementTag: row.achievement_tag,
|
|
type: achievement.data.type,
|
|
retroactive: achievement.data.retroactive,
|
|
title: row.title,
|
|
text: row.text,
|
|
points: row.points,
|
|
timestamp: moment(row.timestamp),
|
|
};
|
|
|
|
switch (earnedInfo.type) {
|
|
case [Achievement.Types.UserStatSet]:
|
|
case [Achievement.Types.UserStatInc]:
|
|
case [Achievement.Types.UserStatIncNewVal]:
|
|
earnedInfo.statName = achievement.data.statName;
|
|
break;
|
|
}
|
|
|
|
return earnedInfo;
|
|
})
|
|
.filter(a => a); // remove any empty records (ie: no achievement.hjson entry exists anymore).
|
|
|
|
return cb(null, earned);
|
|
}
|
|
);
|
|
}
|
|
|
|
exports.moduleInitialize = (initInfo, cb) => {
|
|
achievementsInstance = new Achievements(initInfo.events);
|
|
achievementsInstance.init(err => {
|
|
if (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
return cb(null);
|
|
});
|
|
};
|