WIP on user achievements

+ Add MCI codes for points/count
+ Add docs for MCI codes
+ Record in stats, stat log, etc.
* Do not trigger more than once
* Code cleanup & organization, add classes, etc.
* Tweaks to DB table
This commit is contained in:
Bryan Ashby 2019-01-05 10:49:19 -07:00
parent 6410637359
commit c332b0f3ec
8 changed files with 242 additions and 89 deletions

View File

@ -5,58 +5,192 @@
const Events = require('./events.js'); const Events = require('./events.js');
const Config = require('./config.js').get; const Config = require('./config.js').get;
const UserDb = require('./database.js').dbs.user; const UserDb = require('./database.js').dbs.user;
const {
getISOTimestampString
} = require('./database.js');
const UserInterruptQueue = require('./user_interrupt_queue.js'); const UserInterruptQueue = require('./user_interrupt_queue.js');
const { const {
getConnectionByUserId getConnectionByUserId
} = require('./client_connections.js'); } = require('./client_connections.js');
const UserProps = require('./user_property.js'); const UserProps = require('./user_property.js');
const { Errors } = require('./enig_error.js'); const {
Errors,
ErrorReasons
} = require('./enig_error.js');
const { getThemeArt } = require('./theme.js'); const { getThemeArt } = require('./theme.js');
const { pipeToAnsi } = require('./color_codes.js'); const { pipeToAnsi } = require('./color_codes.js');
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const StatLog = require('./stat_log.js');
const Log = require('./logger.js').log;
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const async = require('async'); const async = require('async');
const moment = require('moment'); const moment = require('moment');
class Achievement {
constructor(data) {
this.data = data;
}
static factory(data) {
let achievement;
switch(data.type) {
case Achievement.Types.UserStat : achievement = new UserStatAchievement(data); break;
default : return;
}
if(achievement.isValid()) {
return achievement;
}
}
static get Types() {
return {
UserStat : 'userStat',
};
}
isValid() {
switch(this.data.type) {
case Achievement.Types.UserStat :
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(!_.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);
}
isValid() {
if(!super.isValid()) {
return false;
}
return !Object.keys(this.data.match).some(k => !parseInt(k));
}
getMatchDetails(matchValue) {
let matchField = Object.keys(this.data.match || {}).sort( (a, b) => b - a).find(v => matchValue >= v);
if(matchField) {
const match = this.data.match[matchField];
if(this.isValidMatchDetails(match)) {
return [ match, parseInt(matchField), matchValue ];
}
}
}
}
class Achievements { class Achievements {
constructor(events) { constructor(events) {
this.events = events; this.events = events;
} }
init(cb) { init(cb) {
// :TODO: if enabled/etc., load achievements.hjson -> if theme achievements.hjson{}, merge @ display time?
// merge for local vs global (per theme) clients
// ...only merge/override text
this.monitorUserStatUpdateEvents(); this.monitorUserStatUpdateEvents();
return cb(null); return cb(null);
} }
loadAchievementHitCount(user, achievementTag, field, value, cb) { loadAchievementHitCount(user, achievementTag, field, cb) {
UserDb.get( UserDb.get(
`SELECT COUNT() AS count `SELECT COUNT() AS count
FROM user_achievement FROM user_achievement
WHERE user_id = ? AND achievement_tag = ? AND match_field = ? AND match_value >= ?;`, WHERE user_id = ? AND achievement_tag = ? AND match_field = ?;`,
[ user.userId, achievementTag, field, value ], [ user.userId, achievementTag, field],
(err, row) => { (err, row) => {
return cb(err, row && row.count || 0); return cb(err, row && row.count || 0);
} }
); );
} }
record(info, cb) {
StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1);
StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points);
UserDb.run(
`INSERT INTO user_achievement (user_id, achievement_tag, timestamp, match_field, match_value)
VALUES (?, ?, ?, ?, ?);`,
[ info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField, info.matchValue ],
err => {
if(err) {
return cb(err);
}
Events.emit(
Events.getSystemEvents().UserAchievementEarned,
{
user : info.client.user,
achievementTag : info.achievementTag,
points : info.details.points,
}
);
return cb(null);
}
);
}
display(info, cb) {
this.createAchievementInterruptItems(info, (err, interruptItems) => {
if(err) {
return cb(err);
}
if(interruptItems.local) {
UserInterruptQueue.queue(interruptItems.local, { clients : info.client } );
}
if(interruptItems.global) {
UserInterruptQueue.queue(interruptItems.global, { omit : info.client } );
}
return cb(null);
});
}
monitorUserStatUpdateEvents() { monitorUserStatUpdateEvents() {
this.events.on(Events.getSystemEvents().UserStatUpdate, userStatEvent => { this.events.on(Events.getSystemEvents().UserStatUpdate, userStatEvent => {
if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) {
return;
}
const statValue = parseInt(userStatEvent.statValue, 10); const statValue = parseInt(userStatEvent.statValue, 10);
if(isNaN(statValue)) { if(isNaN(statValue)) {
return; return;
} }
const config = Config(); const config = Config();
// :TODO: Make this code generic - find + return factory created object
const achievementTag = _.findKey( const achievementTag = _.findKey(
_.get(config, 'userAchievements.achievements', {}), _.get(config, 'userAchievements.achievements', {}),
achievement => { achievement => {
if(false === achievement.enabled) { if(false === achievement.enabled) {
return false; return false;
} }
return 'userStat' === achievement.type && return Achievement.Types.UserStat === achievement.type &&
achievement.statName === userStatEvent.statName; achievement.statName === userStatEvent.statName;
} }
); );
@ -65,54 +199,60 @@ class Achievements {
return; return;
} }
const achievement = config.userAchievements.achievements[achievementTag]; const achievement = Achievement.factory(config.userAchievements.achievements[achievementTag]);
let matchValue = Object.keys(achievement.match || {}).sort( (a, b) => b - a).find(v => statValue >= v); if(!achievement) {
if(matchValue) { return;
const details = achievement.match[matchValue];
matchValue = parseInt(matchValue);
async.series(
[
(callback) => {
this.loadAchievementHitCount(userStatEvent.user, achievementTag, null, matchValue, (err, count) => {
if(err) {
return callback(err);
}
return callback(count > 0 ? Errors.General('Achievement already acquired') : null);
});
},
(callback) => {
const client = getConnectionByUserId(userStatEvent.user.userId);
if(!client) {
return callback(Errors.UnexpectedState('Failed to get client for user ID'));
}
const info = {
achievement,
details,
client,
value : matchValue,
user : userStatEvent.user,
timestamp : moment(),
};
this.createAchievementInterruptItems(info, (err, interruptItems) => {
if(err) {
return callback(err);
}
if(interruptItems.local) {
UserInterruptQueue.queue(interruptItems.local, { clients : client } );
}
if(interruptItems.global) {
UserInterruptQueue.queue(interruptItems.global, { omit : client } );
}
});
}
]
);
} }
const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue);
if(!details || _.isUndefined(matchField) || _.isUndefined(matchValue)) {
return;
}
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,
matchValue,
user : userStatEvent.user,
timestamp : moment(),
};
return callback(null, info);
},
(info, callback) => {
this.record(info, err => {
return callback(err, info);
});
},
(info, callback) => {
return this.display(info, callback);
}
],
err => {
if(err && ErrorReasons.TooMany !== err.reasonCode) {
Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event');
}
}
);
}); });
} }
@ -133,7 +273,8 @@ class Achievements {
title : info.details.title, title : info.details.title,
text : info.global ? info.details.globalText : info.details.text, text : info.global ? info.details.globalText : info.details.text,
points : info.details.points, points : info.details.points,
value : info.value, matchField : info.matchField,
matchValue : info.matchValue,
timestamp : moment(info.timestamp).format(dateTimeFormat), timestamp : moment(info.timestamp).format(dateTimeFormat),
boardName : config.general.boardName, boardName : config.general.boardName,
}; };
@ -175,12 +316,12 @@ class Achievements {
async.waterfall( async.waterfall(
[ [
(callback) => { (callback) => {
getArt('header', headerArt => { getArt(`${itemType}Header`, headerArt => {
return callback(null, headerArt); return callback(null, headerArt);
}); });
}, },
(headerArt, callback) => { (headerArt, callback) => {
getArt('footer', footerArt => { getArt(`${itemType}Footer`, footerArt => {
return callback(null, headerArt, footerArt); return callback(null, headerArt, footerArt);
}); });
}, },
@ -191,7 +332,7 @@ class Achievements {
pause : true, pause : true,
}; };
if(headerArt || footerArt) { if(headerArt || footerArt) {
interruptItems[itemType].contents = `${headerArt || ''}\r\n${pipeToAnsi(itemText)}\r\n${footerArt || ''}`; interruptItems[itemType].contents = `${headerArt || ''}\r\n${pipeToAnsi(title)}\r\n${pipeToAnsi(itemText)}\r\n${footerArt || ''}`;
} }
return callback(null); return callback(null);
} }

View File

@ -1009,8 +1009,10 @@ function getDefaultConfig() {
enabled : true, enabled : true,
art : { art : {
header : 'achievement_header', localHeader : 'achievement_local_header',
footer : 'achievement_footer', localFooter : 'achievement_local_footer',
globalHeader : 'achievement_global_header',
globalFooter : 'achievement_global_footer',
}, },
// :TODO: achievements should be a path/filename -> achievements.hjson & allow override/theming // :TODO: achievements should be a path/filename -> achievements.hjson & allow override/theming
@ -1023,20 +1025,20 @@ function getDefaultConfig() {
match : { match : {
10 : { 10 : {
title : 'Return Caller', title : 'Return Caller',
globalText : '{userName} has logged in {value} times!', globalText : '{userName} has logged in {matchValue} times!',
text : 'You\'ve logged in {value} times!', text : 'You\'ve logged in {matchValue} times!',
points : 5, points : 5,
}, },
25 : { 25 : {
title : 'Seems To Like It!', title : 'Seems To Like It!',
globalText : '{userName} has logged in {value} times!', globalText : '{userName} has logged in {matchValue} times!',
text : 'You\'ve logged in {value} times!', text : 'You\'ve logged in {matchValue} times!',
points : 10, points : 10,
}, },
100 : { 100 : {
title : '{boardName} Addict', title : '{boardName} Addict',
globalText : '{userName} the BBS {boardName} addict has logged in {value} times!', globalText : '{userName} the BBS {boardName} addict has logged in {matchValue} times!',
text : 'You\'re a {boardName} addict! You\'ve logged in {value} times!', text : 'You\'re a {boardName} addict! You\'ve logged in {matchValue} times!',
points : 10, points : 10,
} }
} }

View File

@ -196,7 +196,7 @@ const DB_INIT_TABLE = {
timestamp DATETIME NOT NULL, timestamp DATETIME NOT NULL,
match_field VARCHAR NOT NULL, match_field VARCHAR NOT NULL,
match_value VARCHAR NOT NULL, match_value VARCHAR NOT NULL,
UNIQUE(user_id, achievement_tag, match_field, match_value), UNIQUE(user_id, achievement_tag, match_field),
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);` );`
); );

View File

@ -90,7 +90,7 @@ const PREDEFINED_MCI_GENERATORS = {
return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat()); return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat());
}, },
US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); }, US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); },
UE : function emailAddres(client) { return userStatAsString(client, UserProps.EmailAddress, ''); }, UE : function emailAddress(client) { return userStatAsString(client, UserProps.EmailAddress, ''); },
UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); }, UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); },
UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); }, UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); },
UT : function themeName(client) { UT : function themeName(client) {
@ -122,7 +122,7 @@ const PREDEFINED_MCI_GENERATORS = {
return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes); return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes);
}, },
MS : function accountCreatedclient(client) { MS : function accountCreated(client) {
return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat()); return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat());
}, },
PS : function userPostCount(client) { return userStatAsString(client, UserProps.MessagePostCount, 0); }, PS : function userPostCount(client) { return userStatAsString(client, UserProps.MessagePostCount, 0); },
@ -152,6 +152,9 @@ const PREDEFINED_MCI_GENERATORS = {
SH : function termHeight(client) { return client.term.termHeight.toString(); }, SH : function termHeight(client) { return client.term.termHeight.toString(); },
SW : function termWidth(client) { return client.term.termWidth.toString(); }, SW : function termWidth(client) { return client.term.termWidth.toString(); },
AC : function achievementCount(client) { return userStatAsString(client, UserProps.AchievementTotalCount, 0); },
AP : function achievementPoints(client) { return userStatAsString(client, UserProps.AchievementTotalPoints, 0); },
// //
// Date/Time // Date/Time
// //
@ -166,7 +169,7 @@ const PREDEFINED_MCI_GENERATORS = {
OS : function operatingSystem() { OS : function operatingSystem() {
return { return {
linux : 'Linux', linux : 'Linux',
darwin : 'Mac OS X', darwin : 'OS X',
win32 : 'Windows', win32 : 'Windows',
sunos : 'SunOS', sunos : 'SunOS',
freebsd : 'FreeBSD', freebsd : 'FreeBSD',

View File

@ -359,6 +359,7 @@ class StatLog {
systemEvents.UserUpload, systemEvents.UserDownload, systemEvents.UserUpload, systemEvents.UserDownload,
systemEvents.UserPostMessage, systemEvents.UserSendMail, systemEvents.UserPostMessage, systemEvents.UserSendMail,
systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg, systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg,
systemEvents.UserAchievementEarned,
]; ];
Events.addListenerMultipleEvents(interestedEvents, (eventName, event) => { Events.addListenerMultipleEvents(interestedEvents, (eventName, event) => {

View File

@ -2,24 +2,25 @@
'use strict'; 'use strict';
module.exports = { module.exports = {
ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount } ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount }
ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount } ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount }
TermDetected : 'codes.l33t.enigma.system.term_detected', // { client } TermDetected : 'codes.l33t.enigma.system.term_detected', // { client }
ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId } ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId }
ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson) ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson)
MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson) MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson)
PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson) PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson)
// User - includes { user, ...} // User - includes { user, ...}
NewUser : 'codes.l33t.enigma.system.user_new', NewUser : 'codes.l33t.enigma.system.user_new',
UserLogin : 'codes.l33t.enigma.system.user_login', UserLogin : 'codes.l33t.enigma.system.user_login',
UserLogoff : 'codes.l33t.enigma.system.user_logoff', UserLogoff : 'codes.l33t.enigma.system.user_logoff',
UserUpload : 'codes.l33t.enigma.system.user_upload', // {..., files[ fileEntry, ...] } UserUpload : 'codes.l33t.enigma.system.user_upload', // {..., files[ fileEntry, ...] }
UserDownload : 'codes.l33t.enigma.system.user_download', // {..., files[ fileEntry, ...] } UserDownload : 'codes.l33t.enigma.system.user_download', // {..., files[ fileEntry, ...] }
UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { areaTag } UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { areaTag }
UserSendMail : 'codes.l33t.enigma.system.user_send_mail', UserSendMail : 'codes.l33t.enigma.system.user_send_mail',
UserRunDoor : 'codes.l33t.enigma.system.user_run_door', UserRunDoor : 'codes.l33t.enigma.system.user_run_door',
UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg',
UserStatUpdate : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } UserStatUpdate : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue }
UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // {..., achievementTag, points }
}; };

View File

@ -49,5 +49,8 @@ module.exports = {
MessageConfTag : 'message_conf_tag', MessageConfTag : 'message_conf_tag',
MessageAreaTag : 'message_area_tag', MessageAreaTag : 'message_area_tag',
MessagePostCount : 'post_count', MessagePostCount : 'post_count',
AchievementTotalCount : 'achievement_total_count',
AchievementTotalPoints : 'achievement_total_points',
}; };

View File

@ -16,8 +16,8 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
| Code | Description | | Code | Description |
|------|--------------| |------|--------------|
| `BN` | Board Name | | `BN` | Board Name |
| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.3-alpha" | | `VL` | Version *label*, e.g. "ENiGMA½ v0.0.9-alpha" |
| `VN` | Version *number*, eg.. "0.0.3-alpha" | | `VN` | Version *number*, eg.. "0.0.9-alpha" |
| `SN` | SysOp username | | `SN` | SysOp username |
| `SR` | SysOp real name | | `SR` | SysOp real name |
| `SL` | SysOp location | | `SL` | SysOp location |
@ -30,7 +30,7 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
| `UR` | Current user's real name | | `UR` | Current user's real name |
| `LO` | Current user's location | | `LO` | Current user's location |
| `UA` | Current user's age | | `UA` | Current user's age |
| `BD` | Current user's birthdate (using theme date format) | | `BD` | Current user's birthday (using theme date format) |
| `US` | Current user's sex | | `US` | Current user's sex |
| `UE` | Current user's email address | | `UE` | Current user's email address |
| `UW` | Current user's web address | | `UW` | Current user's web address |
@ -58,6 +58,8 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
| `CM` | Current user's active message conference description | | `CM` | Current user's active message conference description |
| `SH` | Current user's term height | | `SH` | Current user's term height |
| `SW` | Current user's term width | | `SW` | Current user's term width |
| `AC` | Current user's total achievements |
| `AP` | Current user's total achievement points |
| `DT` | Current date (using theme date format) | | `DT` | Current date (using theme date format) |
| `CT` | Current time (using theme time format) | | `CT` | Current time (using theme time format) |
| `OS` | System OS (Linux, Windows, etc.) | | `OS` | System OS (Linux, Windows, etc.) |
@ -149,7 +151,7 @@ Standard style types available for `textStyle` and `focusTextStyle`:
| `mixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) | | `mixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) |
| `l33t` | 3n1gm4 bull371n b04rd 50f7w4r3 | | `l33t` | 3n1gm4 bull371n b04rd 50f7w4r3 |
### Entry Fromatting ### Entry Formatting
Various strings can be formatted using a syntax that allows width & precision specifiers, text styling, etc. Depending on the context, various elements can be referenced by `{name}`. Additional text styles can be supplied as well. The syntax is largely modeled after Python's [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language). Various strings can be formatted using a syntax that allows width & precision specifiers, text styling, etc. Depending on the context, various elements can be referenced by `{name}`. Additional text styles can be supplied as well. The syntax is largely modeled after Python's [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language).
### Additional Text Styles ### Additional Text Styles