From c332b0f3ec6f2cc85b6b8d08eb425d71e3071f91 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 10:49:19 -0700 Subject: [PATCH] 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 --- core/achievement.js | 253 ++++++++++++++++++++++++++++++++--------- core/config.js | 18 +-- core/database.js | 2 +- core/predefined_mci.js | 9 +- core/stat_log.js | 1 + core/system_events.js | 35 +++--- core/user_property.js | 3 + docs/art/mci.md | 10 +- 8 files changed, 242 insertions(+), 89 deletions(-) diff --git a/core/achievement.js b/core/achievement.js index 46f97e03..21cc7ae4 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -5,58 +5,192 @@ const Events = require('./events.js'); const Config = require('./config.js').get; 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 } = require('./enig_error.js'); +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); const { getThemeArt } = require('./theme.js'); const { pipeToAnsi } = 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'); +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 { constructor(events) { this.events = events; } 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(); return cb(null); } - loadAchievementHitCount(user, achievementTag, field, value, cb) { + loadAchievementHitCount(user, achievementTag, field, cb) { UserDb.get( `SELECT COUNT() AS count FROM user_achievement - WHERE user_id = ? AND achievement_tag = ? AND match_field = ? AND match_value >= ?;`, - [ user.userId, achievementTag, field, value ], + WHERE user_id = ? AND achievement_tag = ? AND match_field = ?;`, + [ user.userId, achievementTag, field], (err, row) => { 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() { this.events.on(Events.getSystemEvents().UserStatUpdate, userStatEvent => { + if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) { + return; + } + const statValue = parseInt(userStatEvent.statValue, 10); if(isNaN(statValue)) { return; } const config = Config(); + // :TODO: Make this code generic - find + return factory created object const achievementTag = _.findKey( _.get(config, 'userAchievements.achievements', {}), achievement => { if(false === achievement.enabled) { return false; } - return 'userStat' === achievement.type && + return Achievement.Types.UserStat === achievement.type && achievement.statName === userStatEvent.statName; } ); @@ -65,54 +199,60 @@ class Achievements { return; } - const achievement = config.userAchievements.achievements[achievementTag]; - let matchValue = Object.keys(achievement.match || {}).sort( (a, b) => b - a).find(v => statValue >= v); - if(matchValue) { - 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 achievement = Achievement.factory(config.userAchievements.achievements[achievementTag]); + if(!achievement) { + return; } + + 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, text : info.global ? info.details.globalText : info.details.text, points : info.details.points, - value : info.value, + matchField : info.matchField, + matchValue : info.matchValue, timestamp : moment(info.timestamp).format(dateTimeFormat), boardName : config.general.boardName, }; @@ -175,12 +316,12 @@ class Achievements { async.waterfall( [ (callback) => { - getArt('header', headerArt => { + getArt(`${itemType}Header`, headerArt => { return callback(null, headerArt); }); }, (headerArt, callback) => { - getArt('footer', footerArt => { + getArt(`${itemType}Footer`, footerArt => { return callback(null, headerArt, footerArt); }); }, @@ -191,7 +332,7 @@ class Achievements { pause : true, }; 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); } diff --git a/core/config.js b/core/config.js index b73490cc..a74b124c 100644 --- a/core/config.js +++ b/core/config.js @@ -1009,8 +1009,10 @@ function getDefaultConfig() { enabled : true, art : { - header : 'achievement_header', - footer : 'achievement_footer', + localHeader : 'achievement_local_header', + localFooter : 'achievement_local_footer', + globalHeader : 'achievement_global_header', + globalFooter : 'achievement_global_footer', }, // :TODO: achievements should be a path/filename -> achievements.hjson & allow override/theming @@ -1023,20 +1025,20 @@ function getDefaultConfig() { match : { 10 : { title : 'Return Caller', - globalText : '{userName} has logged in {value} times!', - text : 'You\'ve logged in {value} times!', + globalText : '{userName} has logged in {matchValue} times!', + text : 'You\'ve logged in {matchValue} times!', points : 5, }, 25 : { title : 'Seems To Like It!', - globalText : '{userName} has logged in {value} times!', - text : 'You\'ve logged in {value} times!', + globalText : '{userName} has logged in {matchValue} times!', + text : 'You\'ve logged in {matchValue} times!', points : 10, }, 100 : { title : '{boardName} Addict', - globalText : '{userName} the BBS {boardName} addict has logged in {value} times!', - text : 'You\'re a {boardName} addict! You\'ve 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 {matchValue} times!', points : 10, } } diff --git a/core/database.js b/core/database.js index 4cf2513c..371af1ae 100644 --- a/core/database.js +++ b/core/database.js @@ -196,7 +196,7 @@ const DB_INIT_TABLE = { timestamp DATETIME NOT NULL, match_field 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 );` ); diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 76b34fd5..01b1e285 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -90,7 +90,7 @@ const PREDEFINED_MCI_GENERATORS = { return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat()); }, 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, ''); }, UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); }, UT : function themeName(client) { @@ -122,7 +122,7 @@ const PREDEFINED_MCI_GENERATORS = { 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()); }, 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(); }, 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 // @@ -166,7 +169,7 @@ const PREDEFINED_MCI_GENERATORS = { OS : function operatingSystem() { return { linux : 'Linux', - darwin : 'Mac OS X', + darwin : 'OS X', win32 : 'Windows', sunos : 'SunOS', freebsd : 'FreeBSD', diff --git a/core/stat_log.js b/core/stat_log.js index ffe099ae..8627b6f2 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -359,6 +359,7 @@ class StatLog { systemEvents.UserUpload, systemEvents.UserDownload, systemEvents.UserPostMessage, systemEvents.UserSendMail, systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg, + systemEvents.UserAchievementEarned, ]; Events.addListenerMultipleEvents(interestedEvents, (eventName, event) => { diff --git a/core/system_events.js b/core/system_events.js index 80612195..50a0c464 100644 --- a/core/system_events.js +++ b/core/system_events.js @@ -2,24 +2,25 @@ 'use strict'; module.exports = { - ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount } - ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount } - TermDetected : 'codes.l33t.enigma.system.term_detected', // { client } + ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount } + ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount } + TermDetected : 'codes.l33t.enigma.system.term_detected', // { client } - ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId } - ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson) - MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson) - PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson) + ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId } + ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson) + MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson) + PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson) // User - includes { user, ...} - NewUser : 'codes.l33t.enigma.system.user_new', - UserLogin : 'codes.l33t.enigma.system.user_login', - UserLogoff : 'codes.l33t.enigma.system.user_logoff', - UserUpload : 'codes.l33t.enigma.system.user_upload', // {..., files[ fileEntry, ...] } - UserDownload : 'codes.l33t.enigma.system.user_download', // {..., files[ fileEntry, ...] } - UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { areaTag } - UserSendMail : 'codes.l33t.enigma.system.user_send_mail', - UserRunDoor : 'codes.l33t.enigma.system.user_run_door', - UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', - UserStatUpdate : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } + NewUser : 'codes.l33t.enigma.system.user_new', + UserLogin : 'codes.l33t.enigma.system.user_login', + UserLogoff : 'codes.l33t.enigma.system.user_logoff', + UserUpload : 'codes.l33t.enigma.system.user_upload', // {..., files[ fileEntry, ...] } + UserDownload : 'codes.l33t.enigma.system.user_download', // {..., files[ fileEntry, ...] } + UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { areaTag } + UserSendMail : 'codes.l33t.enigma.system.user_send_mail', + UserRunDoor : 'codes.l33t.enigma.system.user_run_door', + UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', + UserStatUpdate : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } + UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // {..., achievementTag, points } }; diff --git a/core/user_property.js b/core/user_property.js index 7f2bf6c5..dafd7170 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -49,5 +49,8 @@ module.exports = { MessageConfTag : 'message_conf_tag', MessageAreaTag : 'message_area_tag', MessagePostCount : 'post_count', + + AchievementTotalCount : 'achievement_total_count', + AchievementTotalPoints : 'achievement_total_points', }; diff --git a/docs/art/mci.md b/docs/art/mci.md index e365f393..5ec86805 100644 --- a/docs/art/mci.md +++ b/docs/art/mci.md @@ -16,8 +16,8 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et | Code | Description | |------|--------------| | `BN` | Board Name | -| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.3-alpha" | -| `VN` | Version *number*, eg.. "0.0.3-alpha" | +| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.9-alpha" | +| `VN` | Version *number*, eg.. "0.0.9-alpha" | | `SN` | SysOp username | | `SR` | SysOp real name | | `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 | | `LO` | Current user's location | | `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 | | `UE` | Current user's email 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 | | `SH` | Current user's term height | | `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) | | `CT` | Current time (using theme time format) | | `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) | | `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). ### Additional Text Styles