From a34dab6a73326c9241dbb702654a50c7497a0862 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 2 Jan 2019 22:13:42 -0700 Subject: [PATCH 01/63] WIP on user achievements * Hook up events for testing * Start to plug in experimental interrupt --- core/achievement.js | 103 ++++++++++++++++++++++++++++++++++++++++++ core/config.js | 35 ++++++++++++++ core/database.js | 12 +++++ core/stat_log.js | 7 ++- core/system_events.js | 1 + 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 core/achievement.js diff --git a/core/achievement.js b/core/achievement.js new file mode 100644 index 00000000..bb11e3c4 --- /dev/null +++ b/core/achievement.js @@ -0,0 +1,103 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Events = require('./events.js'); +const Config = require('./config.js').get; +const UserDb = require('./database.js').dbs.user; +const UserInterruptQueue = require('./user_interrupt_queue.js'); +const { + getConnectionByUserId +} = require('./client_connections.js'); + +// deps +const _ = require('lodash'); + +class Achievements { + constructor(events) { + this.events = events; + } + + init(cb) { + this.monitorUserStatUpdateEvents(); + return cb(null); + } + + loadAchievementHitCount(user, achievementTag, field, value, 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 ], + (err, row) => { + return cb(err, row && row.count || 0); + } + ); + } + + monitorUserStatUpdateEvents() { + this.events.on(Events.getSystemEvents().UserStatUpdate, userStatEvent => { + const statValue = parseInt(userStatEvent.statValue, 10); + if(isNaN(statValue)) { + return; + } + + const config = Config(); + const achievementTag = _.findKey( + _.get(config, 'userAchievements.achievements', {}), + achievement => { + if(false === achievement.enabled) { + return false; + } + return 'userStat' === achievement.type && + achievement.statName === userStatEvent.statName; + } + ); + + if(!achievementTag) { + 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 match = achievement.match[matchValue]; + + // + // Check if we've triggered this event before + // + this.loadAchievementHitCount(userStatEvent.user, achievementTag, null, matchValue, (err, count) => { + if(count > 0) { + return; + } + + const conn = getConnectionByUserId(userStatEvent.user.userId); + if(!conn) { + return; + } + + const interruptItem = { + text : match.text, + pause : true, + }; + + UserInterruptQueue.queue(interruptItem, { omit : conn} ); + }); + } + }); + } +} + +let achievements; + +exports.moduleInitialize = (initInfo, cb) => { + + if(false === _.get(Config(), 'userAchievements.enabled')) { + // :TODO: Log disabled + return cb(null); + } + + achievements = new Achievements(initInfo.events); + return achievements.init(cb); +}; + diff --git a/core/config.js b/core/config.js index 9c9c4cd4..f854e2f5 100644 --- a/core/config.js +++ b/core/config.js @@ -1003,6 +1003,41 @@ function getDefaultConfig() { systemEvents : { loginHistoryMax: -1, // set to -1 for forever } + }, + + userAchievements : { + enabled : true, + + artHeader : 'achievement_header', + artFooter : 'achievement_footer', + + achievements : { + user_login_count : { + type : 'userStat', + statName : 'login_count', + retroactive : true, + match : { + 10 : { + title : 'Return Caller', + globalText : '{userName} has logged in {statValue} times!', + text : 'You\'ve logged in {statValue} times!', + points : 5, + }, + 25 : { + title : 'Seems To Like It!', + globalText : '{userName} has logged in {statValue} times!', + text : 'You\'ve logged in {statValue} times!', + points : 10, + }, + 100 : { + title : '{boardName} Addict', + globalText : '{userName} the BBS {boardName} addict has logged in {statValue} times!', + text : 'You\'re a {boardName} addict! You\'ve logged in {statValue} times!', + points : 10, + } + } + } + } } }; } diff --git a/core/database.js b/core/database.js index 040cc1de..4cf2513c 100644 --- a/core/database.js +++ b/core/database.js @@ -189,6 +189,18 @@ const DB_INIT_TABLE = { );` ); + dbs.user.run( + `CREATE TABLE IF NOT EXISTS user_achievement ( + user_id INTEGER NOT NULL, + achievement_tag VARCHAR NOT NULL, + timestamp DATETIME NOT NULL, + match_field VARCHAR NOT NULL, + match_value VARCHAR NOT NULL, + UNIQUE(user_id, achievement_tag, match_field, match_value), + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE + );` + ); + return cb(null); }, diff --git a/core/stat_log.js b/core/stat_log.js index 6cf6198b..ffe099ae 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -120,11 +120,14 @@ class StatLog { // // User specific stats - // These are simply convience methods to the user's properties + // These are simply convenience methods to the user's properties // setUserStat(user, statName, statValue, cb) { // note: cb is optional in PersistUserProperty - return user.persistProperty(statName, statValue, cb); + user.persistProperty(statName, statValue, cb); + + const Events = require('./events.js'); // we need to late load currently + return Events.emit(Events.getSystemEvents().UserStatUpdate, { user, statName, statValue } ); } getUserStat(user, statName) { diff --git a/core/system_events.js b/core/system_events.js index 0f8118a2..80612195 100644 --- a/core/system_events.js +++ b/core/system_events.js @@ -21,4 +21,5 @@ module.exports = { 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 } }; From 9f728a2e94ba17a59063bf072e41833b2f81b909 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 3 Jan 2019 21:02:21 -0700 Subject: [PATCH 02/63] Fix longstanding bug with node IDs --- core/client_connections.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/core/client_connections.js b/core/client_connections.js index 558d0a0f..33f1df8a 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -60,12 +60,25 @@ function getActiveConnectionList(authUsersOnly) { } function addNewClient(client, clientSock) { - const id = client.session.id = clientConnections.push(client) - 1; - const remoteAddress = client.remoteAddress = clientSock.remoteAddress; + // + // Assign ID/client ID to next lowest & available # + // + let id = 0; + for(let i = 0; i < clientConnections.length; ++i) { + if(clientConnections[i].id > id) { + break; + } + id++; + } + client.session.id = id; + const remoteAddress = client.remoteAddress = clientSock.remoteAddress; // create a unique identifier one-time ID for this session client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, moment().valueOf() ]); + clientConnections.push(client); + clientConnections.sort( (c1, c2) => c1.session.id - c2.session.id); + // Create a client specific logger // Note that this will be updated @ login with additional information client.log = logger.log.child( { clientId : id, sessionId : client.session.uniqueId } ); From ea055ab58bf35994d20feeba098979d3659ad204 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 3 Jan 2019 21:02:42 -0700 Subject: [PATCH 03/63] Handle pause for text-only interruptions also --- core/user_interrupt_queue.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index 2e72bbd1..29a52685 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -81,18 +81,28 @@ module.exports = class UserInterruptQueue this.client.term.rawWrite('\r\n\r\n'); } + const maybePauseAndFinish = () => { + if(interruptItem.pause) { + this.client.currentMenuModule.pausePrompt( () => { + return cb(null); + }); + } else { + return cb(null); + } + }; + if(interruptItem.contents) { Art.display(this.client, interruptItem.contents, err => { if(err) { return cb(err); } //this.client.term.rawWrite('\r\n\r\n'); // :TODO: Prob optional based on contents vs text - this.client.currentMenuModule.pausePrompt( () => { - return cb(null); - }); + maybePauseAndFinish(); }); } else { - return this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), cb); + this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), true, () => { + maybePauseAndFinish(); + }); } } }; \ No newline at end of file From bd03d7a79bf45df0a3603659ebfdcb1f831b6be5 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 3 Jan 2019 21:02:57 -0700 Subject: [PATCH 04/63] Fix comment --- core/login_server_module.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/login_server_module.js b/core/login_server_module.js index e5fccb39..8ba1d978 100644 --- a/core/login_server_module.js +++ b/core/login_server_module.js @@ -37,8 +37,8 @@ module.exports = class LoginServerModule extends ServerModule { handleNewClient(client, clientSock, modInfo) { // - // Start tracking the client. We'll assign it an ID which is - // just the index in our connections array. + // Start tracking the client. A session ID aka client ID + // will be established in addNewClient() below. // if(_.isUndefined(client.session)) { client.session = {}; From 10517b1060073df954569e10ae62f983b99d31d0 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 3 Jan 2019 21:03:08 -0700 Subject: [PATCH 05/63] Progress on achivements * Fetch art if available * Queue local and/or global interrupts * Apply text formatting * Bug exists with interruptions in certain scenarios that needs worked out --- core/achievement.js | 155 +++++++++++++++++++++++++++++++++++++++----- core/config.js | 20 +++--- 2 files changed, 149 insertions(+), 26 deletions(-) diff --git a/core/achievement.js b/core/achievement.js index bb11e3c4..46f97e03 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -9,9 +9,16 @@ 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 { getThemeArt } = require('./theme.js'); +const { pipeToAnsi } = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); // deps const _ = require('lodash'); +const async = require('async'); +const moment = require('moment'); class Achievements { constructor(events) { @@ -61,31 +68,143 @@ class Achievements { const achievement = config.userAchievements.achievements[achievementTag]; let matchValue = Object.keys(achievement.match || {}).sort( (a, b) => b - a).find(v => statValue >= v); if(matchValue) { - const match = achievement.match[matchValue]; + const details = achievement.match[matchValue]; + matchValue = parseInt(matchValue); - // - // Check if we've triggered this event before - // - this.loadAchievementHitCount(userStatEvent.user, achievementTag, null, matchValue, (err, count) => { - if(count > 0) { - return; - } + 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 conn = getConnectionByUserId(userStatEvent.user.userId); - if(!conn) { - return; - } + const info = { + achievement, + details, + client, + value : matchValue, + user : userStatEvent.user, + timestamp : moment(), + }; - const interruptItem = { - text : match.text, - pause : true, - }; + this.createAchievementInterruptItems(info, (err, interruptItems) => { + if(err) { + return callback(err); + } - UserInterruptQueue.queue(interruptItem, { omit : conn} ); - }); + if(interruptItems.local) { + UserInterruptQueue.queue(interruptItems.local, { clients : client } ); + } + + if(interruptItems.global) { + UserInterruptQueue.queue(interruptItems.global, { omit : client } ); + } + }); + } + ] + ); } }); } + + createAchievementInterruptItems(info, cb) { + const dateTimeFormat = + info.details.dateTimeFormat || + info.achievement.dateTimeFormat || + info.client.currentTheme.helpers.getDateTimeFormat(); + + const config = Config(); + + const formatObj = { + userName : info.user.username, + userRealName : info.user.properties[UserProps.RealName], + userLocation : info.user.properties[UserProps.Location], + userAffils : info.user.properties[UserProps.Affiliations], + nodeId : info.client.node, + title : info.details.title, + text : info.global ? info.details.globalText : info.details.text, + points : info.details.points, + value : info.value, + timestamp : moment(info.timestamp).format(dateTimeFormat), + boardName : config.general.boardName, + }; + + const title = stringFormat(info.details.title, formatObj); + const text = stringFormat(info.details.text, formatObj); + + let globalText; + if(info.details.globalText) { + globalText = stringFormat(info.details.globalText, formatObj); + } + + const getArt = (name, callback) => { + const spec = + _.get(info.details, `art.${name}`) || + _.get(info.achievement, `art.${name}`) || + _.get(config, `userAchievements.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('header', headerArt => { + return callback(null, headerArt); + }); + }, + (headerArt, callback) => { + getArt('footer', footerArt => { + return callback(null, headerArt, footerArt); + }); + }, + (headerArt, footerArt, callback) => { + const itemText = 'global' === itemType ? globalText : text; + interruptItems[itemType] = { + text : `${title}\r\n${itemText}`, + pause : true, + }; + if(headerArt || footerArt) { + interruptItems[itemType].contents = `${headerArt || ''}\r\n${pipeToAnsi(itemText)}\r\n${footerArt || ''}`; + } + return callback(null); + } + ], + err => { + return nextItemType(err); + } + ); + }, + err => { + return cb(err, interruptItems); + }); + } } let achievements; diff --git a/core/config.js b/core/config.js index f854e2f5..b73490cc 100644 --- a/core/config.js +++ b/core/config.js @@ -1008,8 +1008,12 @@ function getDefaultConfig() { userAchievements : { enabled : true, - artHeader : 'achievement_header', - artFooter : 'achievement_footer', + art : { + header : 'achievement_header', + footer : 'achievement_footer', + }, + + // :TODO: achievements should be a path/filename -> achievements.hjson & allow override/theming achievements : { user_login_count : { @@ -1019,20 +1023,20 @@ function getDefaultConfig() { match : { 10 : { title : 'Return Caller', - globalText : '{userName} has logged in {statValue} times!', - text : 'You\'ve logged in {statValue} times!', + globalText : '{userName} has logged in {value} times!', + text : 'You\'ve logged in {value} times!', points : 5, }, 25 : { title : 'Seems To Like It!', - globalText : '{userName} has logged in {statValue} times!', - text : 'You\'ve logged in {statValue} times!', + globalText : '{userName} has logged in {value} times!', + text : 'You\'ve logged in {value} times!', points : 10, }, 100 : { title : '{boardName} Addict', - globalText : '{userName} the BBS {boardName} addict has logged in {statValue} times!', - text : 'You\'re a {boardName} addict! You\'ve logged in {statValue} times!', + globalText : '{userName} the BBS {boardName} addict has logged in {value} times!', + text : 'You\'re a {boardName} addict! You\'ve logged in {value} times!', points : 10, } } From 64106373593aabb818be1b7389b337bf52b5ee52 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 3 Jan 2019 22:03:00 -0700 Subject: [PATCH 06/63] Don't allow real time interrupt until ready --- core/menu_module.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/menu_module.js b/core/menu_module.js index bc631feb..15e7ebce 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -30,6 +30,10 @@ exports.MenuModule = class MenuModule extends PluginModule { this.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls); this.viewControllers = {}; this.interrupt = (_.get(this.menuConfig.config, 'interrupt', MenuModule.InterruptTypes.Queued)).toLowerCase(); + + if(MenuModule.InterruptTypes.Realtime === this.interrupt) { + this.realTimeInterrupt = 'blocked'; + } } static get InterruptTypes() { @@ -137,6 +141,7 @@ exports.MenuModule = class MenuModule extends PluginModule { }, function finishAndNext(callback) { self.finishedLoading(); + self.realTimeInterrupt = 'allowed'; return self.autoNextMenu(callback); } ], @@ -194,7 +199,7 @@ exports.MenuModule = class MenuModule extends PluginModule { } attemptInterruptNow(interruptItem, cb) { - if(MenuModule.InterruptTypes.Realtime !== this.interrupt) { + if(this.realTimeInterrupt !== 'allowed' || MenuModule.InterruptTypes.Realtime !== this.interrupt) { return cb(null, false); // don't eat up the item; queue for later } From c332b0f3ec6f2cc85b6b8d08eb425d71e3071f91 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 10:49:19 -0700 Subject: [PATCH 07/63] 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 From 2bd51c07250ac93807c9eccb5cf9b1e05c89b30f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 12:18:44 -0700 Subject: [PATCH 08/63] Achievements are now in 'achievements.hjson' + Config.general.achievementFile * Implement (re)caching (aka hot-reload) * Update values a bit --- config/achievements.hjson | 53 ++++++++++++++++++++++ core/achievement.js | 94 +++++++++++++++++++++++++++++++-------- core/config.js | 42 +---------------- core/config_util.js | 1 + 4 files changed, 130 insertions(+), 60 deletions(-) create mode 100644 config/achievements.hjson diff --git a/config/achievements.hjson b/config/achievements.hjson new file mode 100644 index 00000000..cb58ddd1 --- /dev/null +++ b/config/achievements.hjson @@ -0,0 +1,53 @@ +{ + enabled : true, + + art : { + 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 + + achievements : { + user_login_count : { + type : 'userStat', + statName : 'login_count', + retroactive : true, + + match : { + 2 : { + title : 'Return Caller', + globalText : '{userName} has returned to {boardName}!', + text : 'You\'ve returned to {boardName}!', + points : 5, + }, + 10 : { + title : '{achievedValue} Logins', + globalText : '{userName} has logged into {boardName} {achievedValue} times!', + text : 'You\'ve logged into {boardName} {achievedValue} times!', + points : 5, + }, + 25 : { + title : '{achievedValue} Logins', + globalText : '{userName} has logged into {boardName} {achievedValue} times!', + text : 'You\'ve logged into {boardName} {achievedValue} times!', + points : 10, + }, + 100 : { + title : '{boardName} Regular', + globalText : '{userName} has logged into {boardName} {achievedValue} times!', + text : 'You\'ve logged into {boardName} {achievedValue} times!', + points : 10, + }, + 500 : { + title : '{boardName} Addict', + globalText : '{userName} the BBS {boardName} addict has logged in {achievedValue} times!', + text : 'You\'re a {boardName} addict! You\'ve logged in {achievedValue} times!', + points : 25, + } + } + } + } +} \ No newline at end of file diff --git a/core/achievement.js b/core/achievement.js index 21cc7ae4..bcd6860f 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -4,6 +4,10 @@ // ENiGMA½ const Events = require('./events.js'); const Config = require('./config.js').get; +const { + getConfigPath, + getFullConfig, +} = require('./config_util.js'); const UserDb = require('./database.js').dbs.user; const { getISOTimestampString @@ -22,11 +26,13 @@ const { pipeToAnsi } = require('./color_codes.js'); const stringFormat = require('./string_format.js'); const StatLog = require('./stat_log.js'); const Log = require('./logger.js').log; +const ConfigCache = require('./config_cache.js'); // deps const _ = require('lodash'); const async = require('async'); const moment = require('moment'); +const paths = require('path'); class Achievement { constructor(data) { @@ -107,11 +113,56 @@ class Achievements { } init(cb) { + let achievementConfigPath = _.get(Config(), 'general.achievementFile'); + if(!achievementConfigPath) { + // :TODO: Log me + return cb(null); + } + achievementConfigPath = getConfigPath(achievementConfigPath); // qualify + + // :TODO: Log enabled + + const configLoaded = (achievementConfig) => { + if(true !== achievementConfig.enabled) { + this.stopMonitoringUserStatUpdateEvents(); + delete this.achievementConfig; + } else { + this.achievementConfig = achievementConfig; + this.monitorUserStatUpdateEvents(); + } + }; + + const changed = ( { fileName, fileRoot } ) => { + const reCachedPath = paths.join(fileRoot, fileName); + if(reCachedPath === achievementConfigPath) { + getFullConfig(achievementConfigPath, (err, achievementConfig) => { + if(err) { + return Log.error( { error : err.message }, 'Failed to reload achievement config from cache'); + } + configLoaded(achievementConfig); + }); + } + }; + + ConfigCache.getConfigWithOptions( + { + filePath : achievementConfigPath, + forceReCache : true, + callback : changed, + }, + (err, achievementConfig) => { + if(err) { + return cb(err); + } + + configLoaded(achievementConfig); + return cb(null); + } + ); + // :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); + // ...only merge/override text } loadAchievementHitCount(user, achievementTag, field, cb) { @@ -139,7 +190,7 @@ class Achievements { return cb(err); } - Events.emit( + this.events.emit( Events.getSystemEvents().UserAchievementEarned, { user : info.client.user, @@ -172,7 +223,11 @@ class Achievements { } monitorUserStatUpdateEvents() { - this.events.on(Events.getSystemEvents().UserStatUpdate, userStatEvent => { + if(this.userStatEventListener) { + return; // already listening + } + + this.userStatEventListener = this.events.on(Events.getSystemEvents().UserStatUpdate, userStatEvent => { if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) { return; } @@ -182,10 +237,9 @@ class Achievements { return; } - const config = Config(); // :TODO: Make this code generic - find + return factory created object const achievementTag = _.findKey( - _.get(config, 'userAchievements.achievements', {}), + _.get(this.achievementConfig, 'achievements', {}), achievement => { if(false === achievement.enabled) { return false; @@ -199,7 +253,7 @@ class Achievements { return; } - const achievement = Achievement.factory(config.userAchievements.achievements[achievementTag]); + const achievement = Achievement.factory(this.achievementConfig.achievements[achievementTag]); if(!achievement) { return; } @@ -230,10 +284,11 @@ class Achievements { achievement, details, client, - matchField, - matchValue, - user : userStatEvent.user, - timestamp : moment(), + matchField, // match - may be in odd format + matchValue, // actual value + achievedValue : matchField, // achievement value met + user : userStatEvent.user, + timestamp : moment(), }; return callback(null, info); @@ -256,6 +311,13 @@ class Achievements { }); } + stopMonitoringUserStatUpdateEvents() { + if(this.userStatEventListener) { + this.events.removeListener(Events.getSystemEvents().UserStatUpdate, this.userStatEventListener); + delete this.userStatEventListener; + } + } + createAchievementInterruptItems(info, cb) { const dateTimeFormat = info.details.dateTimeFormat || @@ -291,7 +353,7 @@ class Achievements { const spec = _.get(info.details, `art.${name}`) || _.get(info.achievement, `art.${name}`) || - _.get(config, `userAchievements.art.${name}`); + _.get(this.achievementConfig, `art.${name}`); if(!spec) { return callback(null); } @@ -351,12 +413,6 @@ class Achievements { let achievements; exports.moduleInitialize = (initInfo, cb) => { - - if(false === _.get(Config(), 'userAchievements.enabled')) { - // :TODO: Log disabled - return cb(null); - } - achievements = new Achievements(initInfo.events); return achievements.init(cb); }; diff --git a/core/config.js b/core/config.js index a74b124c..fa99d5da 100644 --- a/core/config.js +++ b/core/config.js @@ -175,6 +175,7 @@ function getDefaultConfig() { menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path + achievementFile : 'achievements.hjson', }, users : { @@ -1004,46 +1005,5 @@ function getDefaultConfig() { loginHistoryMax: -1, // set to -1 for forever } }, - - userAchievements : { - enabled : true, - - art : { - 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 - - achievements : { - user_login_count : { - type : 'userStat', - statName : 'login_count', - retroactive : true, - match : { - 10 : { - title : 'Return Caller', - 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 {matchValue} times!', - text : 'You\'ve logged in {matchValue} times!', - points : 10, - }, - 100 : { - title : '{boardName} Addict', - 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/config_util.js b/core/config_util.js index bda0e1bd..d64c7a24 100644 --- a/core/config_util.js +++ b/core/config_util.js @@ -10,6 +10,7 @@ const paths = require('path'); const async = require('async'); exports.init = init; +exports.getConfigPath = getConfigPath; exports.getFullConfig = getFullConfig; function getConfigPath(filePath) { From 3cc905ea84146fee919dbcbd27a5ec986ede1ef6 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 16:55:25 -0700 Subject: [PATCH 09/63] Notes on Gopher and NNTP --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 230878ed..a0bde9d0 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,13 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! * Telnet, **SSH**, and both secure and non-secure [WebSocket](https://en.wikipedia.org/wiki/WebSocket) access built in! Additional servers are easy to implement * [CP437](http://www.ascii-codes.com/) and UTF-8 output * [SyncTERM](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior. - * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support + * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support. * Renegade style [pipe color codes](/docs/configuration/colour-codes.md). * [SQLite](http://sqlite.org/) storage of users, message areas, etc. * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption. * [Door support](docs/modding/door-servers.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), [Exodus](https://oddnetwork.org/exodus/) and [CombatNet](http://combatnet.us/) support! * [Bunyan](https://github.com/trentm/node-bunyan) logging! - * [Message networks](docs/messageareas/message-networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export. Messages Bases can also be set to read-only viewable using a built in Gopher server! + * [Message networks](docs/messageareas/message-networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export. Messages Bases can also be exposed via [Gopher](docs/servers/gopher.md), or [NNTP](docs/servers/nntp.md)! * [Gazelle](https://github.com/WhatCD/Gazelle) inspired File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/servers/web-server.md). Legacy X/Y/Z modem also supported! * Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more! * ANSI support in the Full Screen Editor (FSE), file descriptions, etc. From f56a72e0c3aa76ccfa3bca2fd0a576f13ea31160 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 16:55:37 -0700 Subject: [PATCH 10/63] Start of theming of achievements + default text/SGR styles can now be set for quick customization of colors --- art/themes/luciano_blocktronics/theme.hjson | 23 ++++++++ core/achievement.js | 59 ++++++++++++--------- core/theme.js | 7 +-- 3 files changed, 61 insertions(+), 28 deletions(-) diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index b1fe8aec..d38137c8 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -980,5 +980,28 @@ } } } + + achievements: { + defaults: { + titleSGR: "|11" + textSGR: "|00|03" + globalTextSGR: "|03" + boardName: "|10" + userName: "|11" + achievedValue: "|15" + } + + overrides: { + user_login_count: { + match: { + 2: { + // + // You may override title, text, and globalText here + // + } + } + } + } + } } } \ No newline at end of file diff --git a/core/achievement.js b/core/achievement.js index bcd6860f..cc64779c 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -115,18 +115,18 @@ class Achievements { init(cb) { let achievementConfigPath = _.get(Config(), 'general.achievementFile'); if(!achievementConfigPath) { - // :TODO: Log me + Log.info('Achievements are not configured'); return cb(null); } achievementConfigPath = getConfigPath(achievementConfigPath); // qualify - // :TODO: Log enabled - const configLoaded = (achievementConfig) => { if(true !== achievementConfig.enabled) { + Log.info('Achievements are not enabled'); this.stopMonitoringUserStatUpdateEvents(); delete this.achievementConfig; } else { + Log.info('Achievements are enabled'); this.achievementConfig = achievementConfig; this.monitorUserStatUpdateEvents(); } @@ -318,35 +318,45 @@ class Achievements { } } + getFormattedTextFor(info, textType) { + const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); + const defSgr = themeDefaults[`${textType}SGR`] || '|07'; + + const wrap = (fieldName, value) => { + return `${themeDefaults[fieldName] || defSgr}${value}${defSgr}`; + }; + + const formatObj = { + userName : wrap('userName', info.user.username), + userRealName : wrap('userRealName', info.user.properties[UserProps.RealName]), + userLocation : wrap('userLocation', info.user.properties[UserProps.Location]), + userAffils : wrap('userAffils', info.user.properties[UserProps.Affiliations]), + nodeId : wrap('nodeId', info.client.node), + title : wrap('title', info.details.title), + text : wrap('text', info.global ? info.details.globalText : info.details.text), + points : wrap('points', info.details.points), + achievedValue : wrap('achievedValue', info.achievedValue), + matchField : wrap('matchField', info.matchField), + matchValue : wrap('matchValue', info.matchValue), + timestamp : wrap('timestamp', moment(info.timestamp).format(info.dateTimeFormat)), + boardName : wrap('boardName', Config().general.boardName), + }; + + return stringFormat(`${defSgr}${info.details[textType]}`, formatObj); + } + createAchievementInterruptItems(info, cb) { - const dateTimeFormat = + info.dateTimeFormat = info.details.dateTimeFormat || info.achievement.dateTimeFormat || info.client.currentTheme.helpers.getDateTimeFormat(); - const config = Config(); - - const formatObj = { - userName : info.user.username, - userRealName : info.user.properties[UserProps.RealName], - userLocation : info.user.properties[UserProps.Location], - userAffils : info.user.properties[UserProps.Affiliations], - nodeId : info.client.node, - title : info.details.title, - text : info.global ? info.details.globalText : info.details.text, - points : info.details.points, - matchField : info.matchField, - matchValue : info.matchValue, - timestamp : moment(info.timestamp).format(dateTimeFormat), - boardName : config.general.boardName, - }; - - const title = stringFormat(info.details.title, formatObj); - const text = stringFormat(info.details.text, formatObj); + const title = this.getFormattedTextFor(info, 'title'); + const text = this.getFormattedTextFor(info, 'text'); let globalText; if(info.details.globalText) { - globalText = stringFormat(info.details.globalText, formatObj); + globalText = this.getFormattedTextFor(info, 'globalText'); } const getArt = (name, callback) => { @@ -416,4 +426,3 @@ exports.moduleInitialize = (initInfo, cb) => { achievements = new Achievements(initInfo.events); return achievements.init(cb); }; - diff --git a/core/theme.js b/core/theme.js index 6dfee685..9978fde3 100644 --- a/core/theme.js +++ b/core/theme.js @@ -96,7 +96,7 @@ function loadTheme(themeId, cb) { } if(false === _.get(theme, 'info.enabled')) { - return cb(Errors.General('Theme is not enalbed', ErrorReasons.ErrNotEnabled)); + return cb(Errors.General('Theme is not enabled', ErrorReasons.ErrNotEnabled)); } refreshThemeHelpers(theme); @@ -131,8 +131,9 @@ function getMergedTheme(menuConfig, promptConfig, theme) { // // Add in data we won't be altering directly from the theme // - mergedTheme.info = theme.info; - mergedTheme.helpers = theme.helpers; + mergedTheme.info = theme.info; + mergedTheme.helpers = theme.helpers; + mergedTheme.achievements = _.get(theme, 'customization.achievements'); // // merge customizer to disallow immutable MCI properties From 43bbc3733cb334a87dc56f383430f95ca1692899 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 17:07:36 -0700 Subject: [PATCH 11/63] Tabs -> Spaces --- misc/menu_template.in.hjson | 8044 +++++++++++++++++------------------ 1 file changed, 4022 insertions(+), 4022 deletions(-) diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index 82a3893f..e3b76c21 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -1,16 +1,16 @@ { - /* - ./\/\.' ENiGMA½ Menu Configuration -/--/-------- - -- - + /* + ./\/\.' ENiGMA½ Menu Configuration -/--/-------- - -- - - _____________________ _____ ____________________ __________\_ / - \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! - // __|___// | \// |// | \// | | \// \ /___ /_____ - /____ _____| __________ ___|__| ____| \ / _____ \ - ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ - /__ _\ - <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ - *-----------------------------------------------------------------------------* + *-----------------------------------------------------------------------------* General Information ------------------------------- - - @@ -47,1713 +47,1713 @@ FTN : BBS Discussion on fsxNet IRC : #enigma-bbs / FreeNode Email : bryan@l33t.codes - */ - menus: { - // - // Send telnet connections to matrix where users can login, apply, etc. - // - telnetConnected: { - art: CONNECT - next: matrix - config: { nextTimeout: 1500 } - } - - // - // SSH connections are pre-authenticated via the SSH server itself. - // Jump directly to the login sequence - // - sshConnected: { - art: CONNECT - next: fullLoginSequenceLoginArt - config: { nextTimeout: 1500 } - } - - // - // Another SSH specialization: If the user logs in with a new user - // name (e.g. "new", "apply", ...) they will be directed to the - // application process. - // - sshConnectedNewUser: { - art: CONNECT - next: newUserApplicationPreSsh - config: { nextTimeout: 1500 } - } - - // Ye ol' standard matrix - matrix: { - art: matrix - form: { - 0: { - VM: { - mci: { - VM1: { - submit: true - focus: true - argName: navSelect - // - // To enable forgot password, you will need to have the web server - // enabled and mail/SMTP configured. Once that is in place, swap out - // the commented lines below as well as in the submit block - // - items: [ - { - text: login - data: login - } - { - text: apply - data: apply - } - { - text: forgot pass - data: forgot - } - { - text: log off - data: logoff - } - ] - } - } - submit: { - *: [ - { - value: { navSelect: "login" } - action: @menu:login - } - { - value: { navSelect: "apply" } - action: @menu:newUserApplicationPre - } - { - value: { navSelect: "forgot" } - action: @menu:forgotPassword - } - { - value: { navSelect: "logoff" } - action: @menu:logoff - } - ] - } - } - } - } - } - - login: { - art: USERLOG - next: fullLoginSequenceLoginArt - config: { - tooNodeMenu: loginAttemptTooNode - inactive: loginAttemptAccountInactive - disabled: loginAttemptAccountDisabled - locked: loginAttemptAccountLocked - } - form: { - 0: { - mci: { - ET1: { - maxLength: @config:users.usernameMax - argName: username - focus: true - } - ET2: { - password: true - maxLength: @config:users.passwordMax - argName: password - submit: true - } - } - submit: { - *: [ - { - value: { password: null } - action: @systemMethod:login - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - loginAttemptTooNode: { - art: TOONODE - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - loginAttemptAccountLocked: { - art: ACCOUNTLOCKED - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - loginAttemptAccountDisabled: { - art: ACCOUNTDISABLED - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - loginAttemptAccountInactive: { - art: ACCOUNTINACTIVE - config: { - cls: true - nextTimeout: 2000 - } - next: logoff - } - - forgotPassword: { - desc: Forgot password - prompt: forgotPasswordPrompt - submit: [ - { - value: { username: null } - action: @systemMethod:sendForgotPasswordEmail - extraArgs: { next: "forgotPasswordSubmitted" } - } - ] - } - - forgotPasswordSubmitted: { - desc: Forgot password - art: FORGOTPWSENT - config: { - cls: true - pause: true - } - next: @systemMethod:logoff - } - - // :TODO: Prompt Yes/No for logoff confirm - fullLogoffSequence: { - desc: Logging Off - prompt: logoffConfirmation - submit: [ - { - value: { promptValue: 0 } - action: @menu:fullLogoffSequencePreAd - } - { - value: { promptValue: 1 } - action: @systemMethod:prevMenu - } - ] - } - - fullLogoffSequencePreAd: { - art: PRELOGAD - desc: Logging Off - next: fullLogoffSequenceRandomBoardAd - config: { - cls: true - nextTimeout: 1500 - } - } - - fullLogoffSequenceRandomBoardAd: { - art: OTHRBBS - desc: Logging Off - next: logoff - config: { - baudRate: 57600 - pause: true - cls: true - } - } - - logoff: { - art: LOGOFF - desc: Logging Off - next: @systemMethod:logoff - } - - // A quick preamble - defaults to warning about broken terminals - newUserApplicationPre: { - art: NEWUSER1 - next: newUserApplication - desc: Applying - config: { - pause: true - cls: true - menuFlags: [ "noHistory" ] - } - } - - newUserApplication: { - module: nua - art: NUA - next: [ - { - // Initial SysOp does not send feedback to themselves - acs: ID1 - next: fullLoginSequenceLoginArt - } - { - // ...everyone else does - next: newUserFeedbackToSysOpPreamble - } - ] - form: { - 0: { - mci: { - ET1: { - focus: true - argName: username - maxLength: @config:users.usernameMax - validate: @systemMethod:validateUserNameAvail - } - ET2: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - } - MET3: { - argName: birthdate - maskPattern: "####/##/##" - validate: @systemMethod:validateBirthdate - } - ME4: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET6: { - argName: affils - maxLength: @config:users.affilsMax - } - ET7: { - argName: email - maxLength: @config:users.emailMax - validate: @systemMethod:validateEmailAvail - } - ET8: { - argName: web - maxLength: @config:users.webMax - } - ET9: { - argName: password - password: true - maxLength: @config:users.passwordMax - validate: @systemMethod:validatePasswordSpec - } - ET10: { - argName: passwordConfirm - password: true - maxLength: @config:users.passwordMax - validate: @method:validatePassConfirmMatch - } - TM12: { - argName: submission - items: [ "apply", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitApplication - extraArgs: { - inactive: userNeedsActivated - error: newUserCreateError - } - } - { - value: { "submission" : 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - // A quick preamble - defaults to warning about broken terminals (SSH version) - newUserApplicationPreSsh: { - art: NEWUSER1 - next: newUserApplicationSsh - desc: Applying - config: { - pause: true - cls: true - menuFlags: [ "noHistory" ] - } - } - - // - // SSH specialization of NUA - // Canceling this form logs off vs falling back to matrix - // - newUserApplicationSsh: { - module: nua - art: NUA - fallback: logoff - next: newUserFeedbackToSysOpPreamble - form: { - 0: { - mci: { - ET1: { - focus: true - argName: username - maxLength: @config:users.usernameMax - validate: @systemMethod:validateUserNameAvail - } - ET2: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - } - MET3: { - argName: birthdate - maskPattern: "####/##/##" - validate: @systemMethod:validateBirthdate - } - ME4: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET6: { - argName: affils - maxLength: @config:users.affilsMax - } - ET7: { - argName: email - maxLength: @config:users.emailMax - validate: @systemMethod:validateEmailAvail - } - ET8: { - argName: web - maxLength: @config:users.webMax - } - ET9: { - argName: password - password: true - maxLength: @config:users.passwordMax - validate: @systemMethod:validatePasswordSpec - } - ET10: { - argName: passwordConfirm - password: true - maxLength: @config:users.passwordMax - validate: @method:validatePassConfirmMatch - } - TM12: { - argName: submission - items: [ "apply", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitApplication - extraArgs: { - inactive: userNeedsActivated - error: newUserCreateError - } - } - { - value: { "submission" : 1 } - action: @systemMethod:logoff - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:logoff - } - ] - } - } - } - - newUserFeedbackToSysOpPreamble: { - art: LETTER - config: { pause: true } - next: newUserFeedbackToSysOp - } - - newUserFeedbackToSysOp: { - desc: Feedback to SysOp - module: msg_area_post_fse - next: [ - { - acs: AS2 - next: fullLoginSequenceLoginArt - } - { - next: newUserInactiveDone - } - ] - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - toUserId: 1 /* always to +op */ - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: @sysStat:sysop_username - // :TODO: readOnly: true - } - ET3: { - argName: subject - maxLength: 72 - submit: true - text: New user feedback - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - newUserInactiveDone: { - desc: Finished with NUA - art: DONE - config: { pause: true } - next: @menu:logoff - } - - fullLoginSequenceLoginArt: { - desc: Logging In - art: WELCOME - config: { pause: true } - next: fullLoginSequenceLastCallers - } - - fullLoginSequenceLastCallers: { - desc: Last Callers - module: last_callers - art: LASTCALL - config: { - pause: true - font: cp437 - } - next: fullLoginSequenceWhosOnline - } - fullLoginSequenceWhosOnline: { - desc: Who's Online - module: whos_online - art: WHOSON - config: { pause: true } - next: fullLoginSequenceOnelinerz - } - - fullLoginSequenceOnelinerz: { - desc: Viewing Onelinerz - module: onelinerz - next: [ - { - // calls >= 2 - acs: NC2 - next: fullLoginSequenceNewScanConfirm - } - { - // new users - skip new scan - next: fullLoginSequenceUserStats - } - ] - config: { - cls: true - art: { - view: ONELINER - add: ONEADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: oneliner - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - fullLoginSequenceNewScanConfirm: { - desc: Logging In - prompt: loginGlobalNewScan - submit: [ - { - value: { promptValue: 0 } - action: @menu:fullLoginSequenceNewScan - } - { - value: { promptValue: 1 } - action: @menu:fullLoginSequenceUserStats - } - ] - } - - fullLoginSequenceNewScan: { - desc: Performing New Scan - module: new_scan - art: NEWSCAN - next: fullLoginSequenceSysStats - config: { - messageListMenu: newScanMessageList - } - } - - fullLoginSequenceSysStats: { - desc: System Stats - art: SYSSTAT - config: { pause: true } - next: fullLoginSequenceUserStats - } - fullLoginSequenceUserStats: { - desc: User Stats - art: STATUS - config: { pause: true } - next: mainMenu - } - - newScanMessageList: { - desc: New Messages - module: msg_list - art: NEWMSGS - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - TL6: { - // theme me! - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "x", "shift + x" ] - action: @method:fullExit - } - { - keys: [ "m", "shift + m" ] - action: @method:markAllRead - } - ] - } - } - } - - newScanFileBaseList: { - module: file_area_list - desc: New Files - config: { - art: { - browse: FNEWBRWSE - details: FDETAIL - detailsGeneral: FDETGEN - detailsNfo: FDETNFO - detailsFileList: FDETLST - help: FBHELP - } - } - form: { - 0: { - mci: { - MT1: { - mode: preview - ansiView: true - } - - HM2: { - focus: true - submit: true - argName: navSelect - items: [ - "prev", "next", "details", "toggle queue", "rate", "help", "quit" - ] - focusItemIndex: 1 - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFile - } - { - value: { navSelect: 1 } - action: @method:nextFile - } - { - value: { navSelect: 2 } - action: @method:viewDetails - } - { - value: { navSelect: 3 } - action: @method:toggleQueue - } - { - value: { navSelect: 4 } - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - value: { navSelect: 5 } - action: @method:displayHelp - } - { - value: { navSelect: 6 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "w", "shift + w" ] - action: @method:showWebDownloadLink - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "t", "shift + t" ] - action: @method:toggleQueue - } - { - keys: [ "v", "shift + v" ] - action: @method:viewDetails - } - { - keys: [ "r", "shift + r" ] - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - keys: [ "?" ] - action: @method:displayHelp - } - ] - } - - 1: { - mci: { - HM1: { - focus: true - submit: true - argName: navSelect - items: [ - "general", "nfo/readme", "file listing" - ] - } - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @method:detailsQuit - } - ] - } - - 2: { - // details - general - mci: {} - } - - 3: { - // details - nfo/readme - mci: { - MT1: { - mode: preview - } - } - } - - 4: { - // details - file listing - mci: { - VM1: { - - } - } - } - } - } - - /////////////////////////////////////////////////////////////////////// - // Main Menu - /////////////////////////////////////////////////////////////////////// - mainMenu: { - art: MMENU - desc: Main Menu - prompt: menuCommand - config: { - font: cp437 - interrupt: realtime - } - submit: [ - { - value: { command: "MSG" } - action: @menu:nodeMessage - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: { command: "D" } - action: @menu:doorMenu - } - { - value: { command: "F" } - action: @menu:fileBase - } - { - value: { command: "U" } - action: @menu:mainMenuUserList - } - { - value: { command: "L" } - action: @menu:mainMenuLastCallers - } - { - value: { command: "W" } - action: @menu:mainMenuWhosOnline - } - { - value: { command: "Y" } - action: @menu:mainMenuUserStats - } - { - value: { command: "M" } - action: @menu:messageArea - } - { - value: { command: "E" } - action: @menu:mailMenu - } - { - value: { command: "C" } - action: @menu:mainMenuUserConfig - } - { - value: { command: "S" } - action: @menu:mainMenuSystemStats - } - { - value: { command: "!" } - action: @menu:mainMenuGlobalNewScan - } - { - value: { command: "K" } - action: @menu:mainMenuFeedbackToSysOp - } - { - value: { command: "O" } - action: @menu:mainMenuOnelinerz - } - { - value: { command: "R" } - action: @menu:mainMenuRumorz - } - { - value: { command: "BBS"} - action: @menu:bbsList - } - { - value: 1 - action: @menu:mainMenu - } - ] - } - - nodeMessage: { - desc: Node Messaging - module: node_msg - art: NODEMSG - config: { - cls: true - art: { - header: NODEMSGHDR - footer: NODEMSGFTR - } - } - form: { - 0: { - mci: { - SM1: { - argName: node - } - ET2: { - argName: message - submit: true - } - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - submit: { - *: [ - { - value: { message: null } - action: @method:sendMessage - } - ] - } - } - } - } - - mainMenuLastCallers: { - desc: Last Callers - module: last_callers - art: LASTCALL - config: { pause: true } - } - - mainMenuWhosOnline: { - desc: Who's Online - module: whos_online - art: WHOSON - config: { pause: true } - } - - mainMenuUserStats: { - desc: User Stats - art: STATUS - config: { pause: true } - } - - mainMenuSystemStats: { - desc: System Stats - art: SYSSTAT - config: { pause: true } - } - - mainMenuUserList: { - desc: User Listing - module: user_list - art: USERLST - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - } - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - mainMenuUserConfig: { - module: user_config - art: CONFSCR - form: { - 0: { - mci: { - ET1: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - focus: true - } - ME2: { - argName: birthdate - maskPattern: "####/##/##" - } - ME3: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET4: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: affils - maxLength: @config:users.affilsMax - } - ET6: { - argName: email - maxLength: @config:users.emailMax - validate: @method:validateEmailAvail - } - ET7: { - argName: web - maxLength: @config:users.webMax - } - ME8: { - maskPattern: "##" - argName: termHeight - validate: @systemMethod:validateNonEmpty - } - SM9: { - argName: theme - } - ET10: { - argName: password - maxLength: @config:users.passwordMax - password: true - validate: @method:validatePassword - } - ET11: { - argName: passwordConfirm - maxLength: @config:users.passwordMax - password: true - validate: @method:validatePassConfirmMatch - } - TM25: { - argName: submission - items: [ "save", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { submission: 0 } - action: @method:saveChanges - } - { - value: { submission: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - mainMenuGlobalNewScan: { - desc: Performing New Scan - module: new_scan - art: NEWSCAN - config: { - messageListMenu: newScanMessageList - } - } - - mainMenuFeedbackToSysOp: { - desc: Feedback to SysOp - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - toUserId: 1 /* always to +op */ - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: @sysStat:sysop_username - // :TODO: readOnly: true - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - mainMenuOnelinerz: { - desc: Viewing Onelinerz - module: onelinerz - config: { - cls: true - art: { - view: ONELINER - add: ONEADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: oneliner - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - mainMenuRumorz: { - desc: Rumorz - module: rumorz - config: { - cls: true - art: { - entries: RUMORS - add: RUMORADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: rumor - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - bbsList: { - desc: Viewing BBS List - module: bbs_list - config: { - cls: true - art: { - entries: BBSLIST - add: BBSADD - } - } - - form: { - 0: { - mci: { - VM1: { maxLength: 32 } - TL2: { maxLength: 32 } - TL3: { maxLength: 32 } - TL4: { maxLength: 32 } - TL5: { maxLength: 32 } - TL6: { maxLength: 32 } - TL7: { maxLength: 32 } - TL8: { maxLength: 32 } - TL9: { maxLength: 32 } - } - actionKeys: [ - { - keys: [ "a" ] - action: @method:addBBS - } - { - // :TODO: add delete key - keys: [ "d" ] - action: @method:deleteBBS - } - { - keys: [ "q", "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - ET1: { - argName: name - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET2: { - argName: sysop - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: telnet - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET4: { - argName: www - maxLength: 32 - } - ET5: { - argName: location - maxLength: 32 - } - ET6: { - argName: software - maxLength: 32 - } - ET7: { - argName: notes - maxLength: 32 - } - TM17: { - argName: submission - items: [ "save", "cancel" ] - submit: true - } - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelSubmit - } - ] - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitBBS - } - { - value: { "submission" : 1 } - action: @method:cancelSubmit - } - ] - } - } - } - } - - /////////////////////////////////////////////////////////////////////// - // Doors Menu - /////////////////////////////////////////////////////////////////////// - doorMenu: { - desc: Doors Menu - art: DOORMNU - prompt: menuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { command: "G" } - action: @menu:logoff - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - // - // The system supports many ways of launching doors including - // modules for DoorParty!, BBSLink, etc. - // - // Below are some examples. See the documentation for more info. - // - { - value: { command: "ABRACADABRA" } - action: @menu:doorAbracadabraExample - } - { - value: { command: "TWBBSLINK" } - action: @menu:doorTradeWars2002BBSLinkExample - } - { - value: { command: "DP" } - action: @menu:doorPartyExample - } - { + */ + menus: { + // + // Send telnet connections to matrix where users can login, apply, etc. + // + telnetConnected: { + art: CONNECT + next: matrix + config: { nextTimeout: 1500 } + } + + // + // SSH connections are pre-authenticated via the SSH server itself. + // Jump directly to the login sequence + // + sshConnected: { + art: CONNECT + next: fullLoginSequenceLoginArt + config: { nextTimeout: 1500 } + } + + // + // Another SSH specialization: If the user logs in with a new user + // name (e.g. "new", "apply", ...) they will be directed to the + // application process. + // + sshConnectedNewUser: { + art: CONNECT + next: newUserApplicationPreSsh + config: { nextTimeout: 1500 } + } + + // Ye ol' standard matrix + matrix: { + art: matrix + form: { + 0: { + VM: { + mci: { + VM1: { + submit: true + focus: true + argName: navSelect + // + // To enable forgot password, you will need to have the web server + // enabled and mail/SMTP configured. Once that is in place, swap out + // the commented lines below as well as in the submit block + // + items: [ + { + text: login + data: login + } + { + text: apply + data: apply + } + { + text: forgot pass + data: forgot + } + { + text: log off + data: logoff + } + ] + } + } + submit: { + *: [ + { + value: { navSelect: "login" } + action: @menu:login + } + { + value: { navSelect: "apply" } + action: @menu:newUserApplicationPre + } + { + value: { navSelect: "forgot" } + action: @menu:forgotPassword + } + { + value: { navSelect: "logoff" } + action: @menu:logoff + } + ] + } + } + } + } + } + + login: { + art: USERLOG + next: fullLoginSequenceLoginArt + config: { + tooNodeMenu: loginAttemptTooNode + inactive: loginAttemptAccountInactive + disabled: loginAttemptAccountDisabled + locked: loginAttemptAccountLocked + } + form: { + 0: { + mci: { + ET1: { + maxLength: @config:users.usernameMax + argName: username + focus: true + } + ET2: { + password: true + maxLength: @config:users.passwordMax + argName: password + submit: true + } + } + submit: { + *: [ + { + value: { password: null } + action: @systemMethod:login + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + loginAttemptTooNode: { + art: TOONODE + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountLocked: { + art: ACCOUNTLOCKED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountDisabled: { + art: ACCOUNTDISABLED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountInactive: { + art: ACCOUNTINACTIVE + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + forgotPassword: { + desc: Forgot password + prompt: forgotPasswordPrompt + submit: [ + { + value: { username: null } + action: @systemMethod:sendForgotPasswordEmail + extraArgs: { next: "forgotPasswordSubmitted" } + } + ] + } + + forgotPasswordSubmitted: { + desc: Forgot password + art: FORGOTPWSENT + config: { + cls: true + pause: true + } + next: @systemMethod:logoff + } + + // :TODO: Prompt Yes/No for logoff confirm + fullLogoffSequence: { + desc: Logging Off + prompt: logoffConfirmation + submit: [ + { + value: { promptValue: 0 } + action: @menu:fullLogoffSequencePreAd + } + { + value: { promptValue: 1 } + action: @systemMethod:prevMenu + } + ] + } + + fullLogoffSequencePreAd: { + art: PRELOGAD + desc: Logging Off + next: fullLogoffSequenceRandomBoardAd + config: { + cls: true + nextTimeout: 1500 + } + } + + fullLogoffSequenceRandomBoardAd: { + art: OTHRBBS + desc: Logging Off + next: logoff + config: { + baudRate: 57600 + pause: true + cls: true + } + } + + logoff: { + art: LOGOFF + desc: Logging Off + next: @systemMethod:logoff + } + + // A quick preamble - defaults to warning about broken terminals + newUserApplicationPre: { + art: NEWUSER1 + next: newUserApplication + desc: Applying + config: { + pause: true + cls: true + menuFlags: [ "noHistory" ] + } + } + + newUserApplication: { + module: nua + art: NUA + next: [ + { + // Initial SysOp does not send feedback to themselves + acs: ID1 + next: fullLoginSequenceLoginArt + } + { + // ...everyone else does + next: newUserFeedbackToSysOpPreamble + } + ] + form: { + 0: { + mci: { + ET1: { + focus: true + argName: username + maxLength: @config:users.usernameMax + validate: @systemMethod:validateUserNameAvail + } + ET2: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + } + MET3: { + argName: birthdate + maskPattern: "####/##/##" + validate: @systemMethod:validateBirthdate + } + ME4: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET6: { + argName: affils + maxLength: @config:users.affilsMax + } + ET7: { + argName: email + maxLength: @config:users.emailMax + validate: @systemMethod:validateEmailAvail + } + ET8: { + argName: web + maxLength: @config:users.webMax + } + ET9: { + argName: password + password: true + maxLength: @config:users.passwordMax + validate: @systemMethod:validatePasswordSpec + } + ET10: { + argName: passwordConfirm + password: true + maxLength: @config:users.passwordMax + validate: @method:validatePassConfirmMatch + } + TM12: { + argName: submission + items: [ "apply", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitApplication + extraArgs: { + inactive: userNeedsActivated + error: newUserCreateError + } + } + { + value: { "submission" : 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + // A quick preamble - defaults to warning about broken terminals (SSH version) + newUserApplicationPreSsh: { + art: NEWUSER1 + next: newUserApplicationSsh + desc: Applying + config: { + pause: true + cls: true + menuFlags: [ "noHistory" ] + } + } + + // + // SSH specialization of NUA + // Canceling this form logs off vs falling back to matrix + // + newUserApplicationSsh: { + module: nua + art: NUA + fallback: logoff + next: newUserFeedbackToSysOpPreamble + form: { + 0: { + mci: { + ET1: { + focus: true + argName: username + maxLength: @config:users.usernameMax + validate: @systemMethod:validateUserNameAvail + } + ET2: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + } + MET3: { + argName: birthdate + maskPattern: "####/##/##" + validate: @systemMethod:validateBirthdate + } + ME4: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET6: { + argName: affils + maxLength: @config:users.affilsMax + } + ET7: { + argName: email + maxLength: @config:users.emailMax + validate: @systemMethod:validateEmailAvail + } + ET8: { + argName: web + maxLength: @config:users.webMax + } + ET9: { + argName: password + password: true + maxLength: @config:users.passwordMax + validate: @systemMethod:validatePasswordSpec + } + ET10: { + argName: passwordConfirm + password: true + maxLength: @config:users.passwordMax + validate: @method:validatePassConfirmMatch + } + TM12: { + argName: submission + items: [ "apply", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitApplication + extraArgs: { + inactive: userNeedsActivated + error: newUserCreateError + } + } + { + value: { "submission" : 1 } + action: @systemMethod:logoff + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:logoff + } + ] + } + } + } + + newUserFeedbackToSysOpPreamble: { + art: LETTER + config: { pause: true } + next: newUserFeedbackToSysOp + } + + newUserFeedbackToSysOp: { + desc: Feedback to SysOp + module: msg_area_post_fse + next: [ + { + acs: AS2 + next: fullLoginSequenceLoginArt + } + { + next: newUserInactiveDone + } + ] + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + toUserId: 1 /* always to +op */ + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: @sysStat:sysop_username + // :TODO: readOnly: true + } + ET3: { + argName: subject + maxLength: 72 + submit: true + text: New user feedback + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + newUserInactiveDone: { + desc: Finished with NUA + art: DONE + config: { pause: true } + next: @menu:logoff + } + + fullLoginSequenceLoginArt: { + desc: Logging In + art: WELCOME + config: { pause: true } + next: fullLoginSequenceLastCallers + } + + fullLoginSequenceLastCallers: { + desc: Last Callers + module: last_callers + art: LASTCALL + config: { + pause: true + font: cp437 + } + next: fullLoginSequenceWhosOnline + } + fullLoginSequenceWhosOnline: { + desc: Who's Online + module: whos_online + art: WHOSON + config: { pause: true } + next: fullLoginSequenceOnelinerz + } + + fullLoginSequenceOnelinerz: { + desc: Viewing Onelinerz + module: onelinerz + next: [ + { + // calls >= 2 + acs: NC2 + next: fullLoginSequenceNewScanConfirm + } + { + // new users - skip new scan + next: fullLoginSequenceUserStats + } + ] + config: { + cls: true + art: { + view: ONELINER + add: ONEADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: oneliner + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + fullLoginSequenceNewScanConfirm: { + desc: Logging In + prompt: loginGlobalNewScan + submit: [ + { + value: { promptValue: 0 } + action: @menu:fullLoginSequenceNewScan + } + { + value: { promptValue: 1 } + action: @menu:fullLoginSequenceUserStats + } + ] + } + + fullLoginSequenceNewScan: { + desc: Performing New Scan + module: new_scan + art: NEWSCAN + next: fullLoginSequenceSysStats + config: { + messageListMenu: newScanMessageList + } + } + + fullLoginSequenceSysStats: { + desc: System Stats + art: SYSSTAT + config: { pause: true } + next: fullLoginSequenceUserStats + } + fullLoginSequenceUserStats: { + desc: User Stats + art: STATUS + config: { pause: true } + next: mainMenu + } + + newScanMessageList: { + desc: New Messages + module: msg_list + art: NEWMSGS + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + TL6: { + // theme me! + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "x", "shift + x" ] + action: @method:fullExit + } + { + keys: [ "m", "shift + m" ] + action: @method:markAllRead + } + ] + } + } + } + + newScanFileBaseList: { + module: file_area_list + desc: New Files + config: { + art: { + browse: FNEWBRWSE + details: FDETAIL + detailsGeneral: FDETGEN + detailsNfo: FDETNFO + detailsFileList: FDETLST + help: FBHELP + } + } + form: { + 0: { + mci: { + MT1: { + mode: preview + ansiView: true + } + + HM2: { + focus: true + submit: true + argName: navSelect + items: [ + "prev", "next", "details", "toggle queue", "rate", "help", "quit" + ] + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFile + } + { + value: { navSelect: 1 } + action: @method:nextFile + } + { + value: { navSelect: 2 } + action: @method:viewDetails + } + { + value: { navSelect: 3 } + action: @method:toggleQueue + } + { + value: { navSelect: 4 } + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + value: { navSelect: 5 } + action: @method:displayHelp + } + { + value: { navSelect: 6 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "w", "shift + w" ] + action: @method:showWebDownloadLink + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "t", "shift + t" ] + action: @method:toggleQueue + } + { + keys: [ "v", "shift + v" ] + action: @method:viewDetails + } + { + keys: [ "r", "shift + r" ] + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + keys: [ "?" ] + action: @method:displayHelp + } + ] + } + + 1: { + mci: { + HM1: { + focus: true + submit: true + argName: navSelect + items: [ + "general", "nfo/readme", "file listing" + ] + } + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @method:detailsQuit + } + ] + } + + 2: { + // details - general + mci: {} + } + + 3: { + // details - nfo/readme + mci: { + MT1: { + mode: preview + } + } + } + + 4: { + // details - file listing + mci: { + VM1: { + + } + } + } + } + } + + /////////////////////////////////////////////////////////////////////// + // Main Menu + /////////////////////////////////////////////////////////////////////// + mainMenu: { + art: MMENU + desc: Main Menu + prompt: menuCommand + config: { + font: cp437 + interrupt: realtime + } + submit: [ + { + value: { command: "MSG" } + action: @menu:nodeMessage + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: { command: "D" } + action: @menu:doorMenu + } + { + value: { command: "F" } + action: @menu:fileBase + } + { + value: { command: "U" } + action: @menu:mainMenuUserList + } + { + value: { command: "L" } + action: @menu:mainMenuLastCallers + } + { + value: { command: "W" } + action: @menu:mainMenuWhosOnline + } + { + value: { command: "Y" } + action: @menu:mainMenuUserStats + } + { + value: { command: "M" } + action: @menu:messageArea + } + { + value: { command: "E" } + action: @menu:mailMenu + } + { + value: { command: "C" } + action: @menu:mainMenuUserConfig + } + { + value: { command: "S" } + action: @menu:mainMenuSystemStats + } + { + value: { command: "!" } + action: @menu:mainMenuGlobalNewScan + } + { + value: { command: "K" } + action: @menu:mainMenuFeedbackToSysOp + } + { + value: { command: "O" } + action: @menu:mainMenuOnelinerz + } + { + value: { command: "R" } + action: @menu:mainMenuRumorz + } + { + value: { command: "BBS"} + action: @menu:bbsList + } + { + value: 1 + action: @menu:mainMenu + } + ] + } + + nodeMessage: { + desc: Node Messaging + module: node_msg + art: NODEMSG + config: { + cls: true + art: { + header: NODEMSGHDR + footer: NODEMSGFTR + } + } + form: { + 0: { + mci: { + SM1: { + argName: node + } + ET2: { + argName: message + submit: true + } + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + submit: { + *: [ + { + value: { message: null } + action: @method:sendMessage + } + ] + } + } + } + } + + mainMenuLastCallers: { + desc: Last Callers + module: last_callers + art: LASTCALL + config: { pause: true } + } + + mainMenuWhosOnline: { + desc: Who's Online + module: whos_online + art: WHOSON + config: { pause: true } + } + + mainMenuUserStats: { + desc: User Stats + art: STATUS + config: { pause: true } + } + + mainMenuSystemStats: { + desc: System Stats + art: SYSSTAT + config: { pause: true } + } + + mainMenuUserList: { + desc: User Listing + module: user_list + art: USERLST + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + } + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + mainMenuUserConfig: { + module: user_config + art: CONFSCR + form: { + 0: { + mci: { + ET1: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + focus: true + } + ME2: { + argName: birthdate + maskPattern: "####/##/##" + } + ME3: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET4: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: affils + maxLength: @config:users.affilsMax + } + ET6: { + argName: email + maxLength: @config:users.emailMax + validate: @method:validateEmailAvail + } + ET7: { + argName: web + maxLength: @config:users.webMax + } + ME8: { + maskPattern: "##" + argName: termHeight + validate: @systemMethod:validateNonEmpty + } + SM9: { + argName: theme + } + ET10: { + argName: password + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassword + } + ET11: { + argName: passwordConfirm + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassConfirmMatch + } + TM25: { + argName: submission + items: [ "save", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { submission: 0 } + action: @method:saveChanges + } + { + value: { submission: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + mainMenuGlobalNewScan: { + desc: Performing New Scan + module: new_scan + art: NEWSCAN + config: { + messageListMenu: newScanMessageList + } + } + + mainMenuFeedbackToSysOp: { + desc: Feedback to SysOp + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + toUserId: 1 /* always to +op */ + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: @sysStat:sysop_username + // :TODO: readOnly: true + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + mainMenuOnelinerz: { + desc: Viewing Onelinerz + module: onelinerz + config: { + cls: true + art: { + view: ONELINER + add: ONEADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: oneliner + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + mainMenuRumorz: { + desc: Rumorz + module: rumorz + config: { + cls: true + art: { + entries: RUMORS + add: RUMORADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: rumor + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + bbsList: { + desc: Viewing BBS List + module: bbs_list + config: { + cls: true + art: { + entries: BBSLIST + add: BBSADD + } + } + + form: { + 0: { + mci: { + VM1: { maxLength: 32 } + TL2: { maxLength: 32 } + TL3: { maxLength: 32 } + TL4: { maxLength: 32 } + TL5: { maxLength: 32 } + TL6: { maxLength: 32 } + TL7: { maxLength: 32 } + TL8: { maxLength: 32 } + TL9: { maxLength: 32 } + } + actionKeys: [ + { + keys: [ "a" ] + action: @method:addBBS + } + { + // :TODO: add delete key + keys: [ "d" ] + action: @method:deleteBBS + } + { + keys: [ "q", "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + ET1: { + argName: name + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET2: { + argName: sysop + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET3: { + argName: telnet + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET4: { + argName: www + maxLength: 32 + } + ET5: { + argName: location + maxLength: 32 + } + ET6: { + argName: software + maxLength: 32 + } + ET7: { + argName: notes + maxLength: 32 + } + TM17: { + argName: submission + items: [ "save", "cancel" ] + submit: true + } + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelSubmit + } + ] + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitBBS + } + { + value: { "submission" : 1 } + action: @method:cancelSubmit + } + ] + } + } + } + } + + /////////////////////////////////////////////////////////////////////// + // Doors Menu + /////////////////////////////////////////////////////////////////////// + doorMenu: { + desc: Doors Menu + art: DOORMNU + prompt: menuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "G" } + action: @menu:logoff + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + // + // The system supports many ways of launching doors including + // modules for DoorParty!, BBSLink, etc. + // + // Below are some examples. See the documentation for more info. + // + { + value: { command: "ABRACADABRA" } + action: @menu:doorAbracadabraExample + } + { + value: { command: "TWBBSLINK" } + action: @menu:doorTradeWars2002BBSLinkExample + } + { + value: { command: "DP" } + action: @menu:doorPartyExample + } + { value: { command: "CN" } action: @menu:doorCombatNetExample } - { - value: { command: "EXODUS" } - action: @menu:doorExodusCataclysm - } - ] - } + { + value: { command: "EXODUS" } + action: @menu:doorExodusCataclysm + } + ] + } - // - // Local Door Example via abracadabra module - // - // This example assumes launch_door.sh (which is passed args) - // launches the door. - // - doorAbracadabraExample: { - desc: Abracadabra Example - module: abracadabra - config: { - name: Example Door - dropFileType: DORINFO - cmd: /home/enigma/DOS/scripts/launch_door.sh - args: [ - "{node}", - "{dropFile}", - "{srvPort}", - ], - nodeMax: 1 - tooManyArt: DOORMANY - io: socket - } - } - - // - // BBSLink Example (TradeWars 2000) - // - // Register @ https://bbslink.net/ - // - doorTradeWars2002BBSLinkExample: { - desc: Playing TW 2002 (BBSLink) - module: bbs_link - config: { - sysCode: XXXXXXXX - authCode: XXXXXXXX - schemeCode: XXXXXXXX - door: tw - } - } - - // - // DoorParty! Example - // - // Register @ http://throwbackbbs.com/ - // - doorPartyExample: { - desc: Using DoorParty! - module: door_party - config: { - username: XXXXXXXX - password: XXXXXXXX - bbsTag: XX - } - } - - // - // CombatNet Example // - // Register @ http://combatnet.us/ + // Local Door Example via abracadabra module + // + // This example assumes launch_door.sh (which is passed args) + // launches the door. + // + doorAbracadabraExample: { + desc: Abracadabra Example + module: abracadabra + config: { + name: Example Door + dropFileType: DORINFO + cmd: /home/enigma/DOS/scripts/launch_door.sh + args: [ + "{node}", + "{dropFile}", + "{srvPort}", + ], + nodeMax: 1 + tooManyArt: DOORMANY + io: socket + } + } + + // + // BBSLink Example (TradeWars 2000) + // + // Register @ https://bbslink.net/ + // + doorTradeWars2002BBSLinkExample: { + desc: Playing TW 2002 (BBSLink) + module: bbs_link + config: { + sysCode: XXXXXXXX + authCode: XXXXXXXX + schemeCode: XXXXXXXX + door: tw + } + } + + // + // DoorParty! Example + // + // Register @ http://throwbackbbs.com/ + // + doorPartyExample: { + desc: Using DoorParty! + module: door_party + config: { + username: XXXXXXXX + password: XXXXXXXX + bbsTag: XX + } + } + + // + // CombatNet Example + // + // Register @ http://combatnet.us/ // doorCombatNetExample: { desc: Using CombatNet @@ -1765,2316 +1765,2316 @@ } // - // Exodus Example (cataclysm) - // Register @ https://oddnetwork.org/exodus/ + // Exodus Example (cataclysm) + // Register @ https://oddnetwork.org/exodus/ // doorExodusCataclysm: { - desc: Cataclysm - module: exodus - config: { - rejectUnauthorized: false - board: XXX - key: XXXXXXXX - door: cataclysm - } - } - - /////////////////////////////////////////////////////////////////////// - // Message Area Menu - /////////////////////////////////////////////////////////////////////// - messageArea: { - art: MSGMNU - desc: Message Area - prompt: messageMenuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { command: "P" } - action: @menu:messageAreaNewPost - } - { - value: { command: "J" } - action: @menu:messageAreaChangeCurrentConference - } - { - value: { command: "C" } - action: @menu:messageAreaChangeCurrentArea - } - { - value: { command: "L" } - action: @menu:messageAreaMessageList - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: { command: "<" } - action: @systemMethod:prevConf - } - { - value: { command: ">" } - action: @systemMethod:nextConf - } - { - value: { command: "[" } - action: @systemMethod:prevArea - } - { - value: { command: "]" } - action: @systemMethod:nextArea - } - { - value: { command: "D" } - action: @menu:messageAreaSetNewScanDate - } - { - value: { command: "S" } - action: @menu:messageSearch - } - { - value: 1 - action: @menu:messageArea - } - ] - } - - messageSearch: { - desc: Message Search - module: message_base_search - art: MSEARCH - config: { - messageListMenu: messageAreaSearchMessageList - } - form: { - 0: { - mci: { - ET1: { - focus: true - argName: searchTerms - } - BT2: { - argName: search - text: search - submit: true - } - SM3: { - argName: confTag - } - SM4: { - argName: areaTag - } - ET5: { - argName: toUserName - maxLength: @config:users.usernameMax - } - ET6: { - argName: fromUserName - maxLength: @config:users.usernameMax - } - BT7: { - argName: advancedSearch - text: advanced search - submit: true - } - } - - submit: { - *: [ - { - value: { search: null } - action: @method:search - } - { - value: { advancedSearch: null } - action: @method:search - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaSearchMessageList: { - desc: Message Search - module: msg_list - art: MSRCHLST - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - TL6: { - // theme me! - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageSearchNoResults: { - desc: Message Search - art: MSRCNORES - config: { - pause: true - } - } - - messageAreaChangeCurrentConference: { - art: CCHANGE - module: msg_conf_list - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: conf - } - } - submit: { - *: [ - { - value: { conf: null } - action: @method:changeConference - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaSetNewScanDate: { - module: set_newscan_date - desc: Message Base - art: SETMNSDATE - config: { - target: message - scanDateFormat: YYYYMMDD - } - form: { - 0: { - mci: { - ME1: { - focus: true - submit: true - argName: scanDate - maskPattern: "####/##/##" - } - SM2: { - argName: targetSelection - submit: false - } - } - submit: { - *: [ - { - value: { scanDate: null } - action: @method:scanDateSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - changeMessageConfPreArt: { - module: show_art - config: { - method: messageConf - key: confTag - pause: true - cls: true - menuFlags: [ "popParent", "noHistory" ] - } - } - - messageAreaChangeCurrentArea: { - // :TODO: rename this art to ACHANGE - art: CHANGE - module: msg_area_list - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: area - } - } - submit: { - *: [ - { - value: { area: null } - action: @method:changeArea - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - changeMessageAreaPreArt: { - module: show_art - config: { - method: messageArea - key: areaTag - pause: true - cls: true - menuFlags: [ "popParent", "noHistory" ] - } - } - - messageAreaMessageList: { - module: msg_list - art: MSGLIST - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaViewPost: { - module: msg_area_view_fse - config: { - art: { - header: MSGVHDR - body: MSGBODY - footerView: MSGVFTR - help: MSGVHLP - }, - editorMode: view - editorType: area - } - form: { - 0: { - mci: { - // :TODO: ensure this block isn't even req. for theme to apply... - } - } - 1: { - mci: { - MT1: { - width: 79 - mode: preview - } - } - submit: { - *: [ - { - value: message - action: @method:editModeEscPressed - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - } - 2: { - TLTL: { - mci: { - TL1: { width: 5 } - TL2: { width: 4 } - } - } - } - 4: { - mci: { - HM1: { - // :TODO: (#)Jump/(L)Index (msg list)/Last - items: [ "prev", "next", "reply", "quit", "help" ] - focusItemIndex: 1 - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:prevMessage - } - { - value: { 1: 1 } - action: @method:nextMessage - } - { - value: { 1: 2 } - action: @method:replyMessage - extraArgs: { - menu: messageAreaReplyPost - } - } - { - value: { 1: 3 } - action: @systemMethod:prevMenu - } - { - value: { 1: 4 } - action: @method:viewModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "p", "shift + p" ] - action: @method:prevMessage - } - { - keys: [ "n", "shift + n" ] - action: @method:nextMessage - } - { - keys: [ "r", "shift + r" ] - action: @method:replyMessage - extraArgs: { - menu: messageAreaReplyPost - } - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "?" ] - action: @method:viewModeMenuHelp - } - { - keys: [ "down arrow", "up arrow", "page up", "page down" ] - action: @method:movementKeyPressed - } - ] - } - } - } - - messageAreaReplyPost: { - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - quote: MSGQUOT - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - } - editorMode: edit - editorType: area - } - form: { - 0: { - mci: { - // :TODO: use appropriate system properties for max lengths - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateNonEmpty - } - TL4: { - // :TODO: this is for RE: line (NYI) - //width: 27 - //textOverflow: ... - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - height: 14 - argName: message - mode: edit - } - } - submit: { - *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ], - viewId: 1 - } - ] - } - - 3: { - mci: { - HM1: { - items: [ "save", "discard", "quote", "help" ] - } - } - - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 }, - action: @method:editModeMenuQuote - } - { - value: { 1: 3 } - action: @method:editModeMenuHelp - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "s", "shift + s" ] - action: @method:editModeMenuSave - } - { - keys: [ "d", "shift + d" ] - action: @systemMethod:prevMenu - } - { - keys: [ "q", "shift + q" ] - action: @method:editModeMenuQuote - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - - // Quote builder - 5: { - mci: { - MT1: { - width: 79 - height: 7 - } - VM3: { - width: 79 - height: 4 - argName: quote - } - } - - submit: { - *: [ - { - value: { quote: null } - action: @method:appendQuoteEntry - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:quoteBuilderEscPressed - } - ] - } - } - } - // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu - messageAreaNewPost: { - desc: Posting message, - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - } - editorMode: edit - editorType: area - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: All - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateNonEmpty - // :TODO: Validate -> close/cancel if empty - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - - 1: { - "mci" : { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - } - 2: { - TLTL: { - mci: { - TL1: { width: 5 } - TL2: { width: 4 } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - "items" : [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - // :TODO: something like the following for overriding keymap - // this should only override specified entries. others will default - /* - "keyMap" : { - "accept" : [ "return" ] - } - */ - } - } - } - } - - - // - // User to User mail aka Email Menu - // - mailMenu: { - art: MAILMNU - desc: Mail Menu - prompt: menuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { command: "C" } - action: @menu:mailMenuCreateMessage - } - { - value: { command: "I" } - action: @menu:mailMenuInbox - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: 1 - action: @menu:mailMenu - } - ] - } - - mailMenuCreateMessage: { - desc: Mailing Someone - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - validate: @systemMethod:validateGeneralMailAddressedTo - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - mailMenuInbox: { - module: msg_list - art: PRVMSGLIST - config: { - menuViewPost: messageAreaViewPost - messageAreaTag: private_mail - } - form: { - 0: { // main list - mci: { - VM1: { - focus: true - submit: true - argName: message - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "delete", "d", "shift + d" ] - action: @method:deleteSelected - } - ] - } - 1: { // delete prompt form - submit: { - *: [ - { - value: { promptValue: 0 } - action: @method:deleteMessageYes - } - { - value: { promptValue: 1 } - action: @method:deleteMessageNo - } - ] - } - } - } - } - - //////////////////////////////////////////////////////////////////////// - // File Base - //////////////////////////////////////////////////////////////////////// - - fileBase: { - desc: File Base - art: FMENU - prompt: fileMenuCommand - config: { - interrupt: realtime - } - submit: [ - { - value: { menuOption: "L" } - action: @menu:fileBaseListEntries - } - { - value: { menuOption: "B" } - action: @menu:fileBaseBrowseByAreaSelect - } - { - value: { menuOption: "F" } - action: @menu:fileAreaFilterEditor - } - { - value: { menuOption: "Q" } - action: @systemMethod:prevMenu - } - { - value: { menuOption: "G" } - action: @menu:fullLogoffSequence - } - { - value: { menuOption: "D" } - action: @menu:fileBaseDownloadManager - } - { - value: { menuOption: "W" } - action: @menu:fileBaseWebDownloadManager - } - { - value: { menuOption: "U" } - action: @menu:fileBaseUploadFiles - } - { - value: { menuOption: "S" } - action: @menu:fileBaseSearch - } - { - value: { menuOption: "P" } - action: @menu:fileBaseSetNewScanDate - } - { - value: { menuOption: "E" } - action: @menu:fileBaseExportListFilter - } - ] - } - - fileBaseExportListFilter: { - module: file_base_search - // :TODO: fixme: - art: FSEARCH - config: { - fileBaseListEntriesMenu: fileBaseExportList - } - form: { - 0: { - mci: { - ET1: { - focus: true - argName: searchTerms - } - BT2: { - argName: search - text: search - submit: true - } - ET3: { - maxLength: 64 - argName: tags - } - SM4: { - maxLength: 64 - argName: areaIndex - } - SM5: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - "filename" - ] - argName: sortByIndex - } - SM6: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - BT7: { - argName: advancedSearch - text: advanced search - submit: true - } - } - - submit: { - *: [ - { - value: { search: null } - action: @method:search - } - { - value: { advancedSearch: null } - action: @method:search - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseExportList: { - module: file_base_user_list_export - art: FBLISTEXP - config: { - pause: true - templates: { - entry: file_list_entry.asc - } - } - form: { - 0: { - mci: { - TL1: { } - TL2: { } - } - } - } - } - - fileBaseExportListNoResults: { - desc: Browsing Files - art: FBNORES - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - fileBaseSetNewScanDate: { - module: set_newscan_date - desc: File Base - art: SETFNSDATE - config: { - target: file - scanDateFormat: YYYYMMDD - } - form: { - 0: { - mci: { - ME1: { - focus: true - submit: true - argName: scanDate - maskPattern: "####/##/##" - } - } - submit: { - *: [ - { - value: { scanDate: null } - action: @method:scanDateSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseListEntries: { - module: file_area_list - desc: Browsing Files - config: { - art: { - browse: FBRWSE - details: FDETAIL - detailsGeneral: FDETGEN - detailsNfo: FDETNFO - detailsFileList: FDETLST - help: FBHELP - } - } - form: { - 0: { - mci: { - MT1: { - mode: preview - } - - HM2: { - focus: true - submit: true - argName: navSelect - items: [ - "prev", "next", "details", "toggle queue", "rate", "change filter", "help", "quit" - ] - focusItemIndex: 1 - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFile - } - { - value: { navSelect: 1 } - action: @method:nextFile - } - { - value: { navSelect: 2 } - action: @method:viewDetails - } - { - value: { navSelect: 3 } - action: @method:toggleQueue - } - { - value: { navSelect: 4 } - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - value: { navSelect: 5 } - action: @menu:fileAreaFilterEditor - } - { - value: { navSelect: 6 } - action: @method:displayHelp - } - { - value: { navSelect: 7 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "w", "shift + w" ] - action: @method:showWebDownloadLink - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "t", "shift + t" ] - action: @method:toggleQueue - } - { - keys: [ "f", "shift + f" ] - action: @menu:fileAreaFilterEditor - } - { - keys: [ "v", "shift + v" ] - action: @method:viewDetails - } - { - keys: [ "r", "shift + r" ] - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - keys: [ "?" ] - action: @method:displayHelp - } - ] - } - - 1: { - mci: { - HM1: { - focus: true - submit: true - argName: navSelect - items: [ - "general", "nfo/readme", "file listing" - ] - } - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @method:detailsQuit - } - ] - } - - 2: { - // details - general - mci: {} - } - - 3: { - // details - nfo/readme - mci: { - MT1: { - mode: preview - } - } - } - - 4: { - // details - file listing - mci: { - VM1: { - - } - } - } - } - } - - fileBaseBrowseByAreaSelect: { - desc: Browsing File Areas - module: file_base_area_select - art: FAREASEL - form: { - 0: { - mci: { - VM1: { - focus: true - argName: areaTag - } - } - - submit: { - *: [ - { - value: { areaTag: null } - action: @method:selectArea - } - ] - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseGetRatingForSelectedEntry: { - desc: Rating a File - prompt: fileBaseRateEntryPrompt - config: { - cls: true - } - submit: [ - // :TODO: handle esc/q - { - // pass data back to caller - value: { rating: null } - action: @systemMethod:prevMenu - } - ] - } - - fileBaseListEntriesNoResults: { - desc: Browsing Files - art: FBNORES - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - fileBaseSearch: { - module: file_base_search - desc: Searching Files - art: FSEARCH - form: { - 0: { - mci: { - ET1: { - focus: true - argName: searchTerms - } - BT2: { - argName: search - text: search - submit: true - } - ET3: { - maxLength: 64 - argName: tags - } - SM4: { - maxLength: 64 - argName: areaIndex - } - SM5: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - "filename", - ] - argName: sortByIndex - } - SM6: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - BT7: { - argName: advancedSearch - text: advanced search - submit: true - } - } - - submit: { - *: [ - { - value: { search: null } - action: @method:search - } - { - value: { advancedSearch: null } - action: @method:search - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileAreaFilterEditor: { - desc: File Filter Editor - module: file_area_filter_edit - art: FFILEDT - form: { - 0: { - mci: { - ET1: { - argName: searchTerms - } - ET2: { - maxLength: 64 - argName: tags - } - SM3: { - maxLength: 64 - argName: areaIndex - } - SM4: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - ] - argName: sortByIndex - } - SM5: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - ET6: { - maxLength: 64 - argName: name - validate: @systemMethod:validateNonEmpty - } - HM7: { - focus: true - items: [ - "prev", "next", "make active", "save", "new", "delete" - ] - argName: navSelect - focusItemIndex: 1 - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFilter - } - { - value: { navSelect: 1 } - action: @method:nextFilter - } - { - value: { navSelect: 2 } - action: @method:makeFilterActive - } - { - value: { navSelect: 3 } - action: @method:saveFilter - } - { - value: { navSelect: 4 } - action: @method:newFilter - } - { - value: { navSelect: 5 } - action: @method:deleteFilter - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseDownloadManager: { - desc: Download Manager - module: file_base_download_manager - config: { - art: { - queueManager: FDLMGR - /* - NYI - details: FDLDET - */ - } - emptyQueueMenu: fileBaseDownloadManagerEmptyQueue - } - form: { - 0: { - mci: { - VM1: { - argName: queueItem - } - HM2: { - focus: true - items: [ "download all", "quit" ] - argName: navSelect - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:downloadAll - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "a", "shift + a" ] - action: @method:downloadAll - } - { - keys: [ "delete", "r", "shift + r" ] - action: @method:removeItem - } - { - keys: [ "c", "shift + c" ] - action: @method:clearQueue - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseWebDownloadManager: { - desc: Web D/L Manager - module: file_base_web_download_manager - config: { - art: { - queueManager: FWDLMGR - batchList: BATDLINF - } - emptyQueueMenu: fileBaseDownloadManagerEmptyQueue - } - form: { - 0: { - mci: { - VM1: { - argName: queueItem - } - HM2: { - focus: true - items: [ "get batch link", "quit", "help" ] - argName: navSelect - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:getBatchLink - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "b", "shift + b" ] - action: @method:getBatchLink - } - { - keys: [ "delete", "r", "shift + r" ] - action: @method:removeItem - } - { - keys: [ "c", "shift + c" ] - action: @method:clearQueue - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseDownloadManagerEmptyQueue: { - desc: Empty Download Queue - art: FEMPTYQ - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - fileTransferProtocolSelection: { - desc: Protocol selection - module: file_transfer_protocol_select - art: FPROSEL - form: { - 0: { - mci: { - VM1: { - focus: true - argName: protocol - } - } - - submit: { - *: [ - { - value: { protocol: null } - action: @method:selectProtocol - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseUploadFiles: { - desc: Uploading - module: upload - config: { - art: { - options: ULOPTS - fileDetails: ULDETAIL - processing: ULCHECK - dupes: ULDUPES - } - } - - form: { - // options - 0: { - mci: { - SM1: { - argName: areaSelect - focus: true - } - TM2: { - argName: uploadType - items: [ "blind", "supply filename" ] - } - ET3: { - argName: fileName - maxLength: 255 - validate: @method:validateNonBlindFileName - } - HM4: { - argName: navSelect - items: [ "continue", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:optionsNavContinue - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - "actionKeys" : [ - { - "keys" : [ "escape" ], - action: @systemMethod:prevMenu - } - ] - } - - 1: { - mci: { } - } - - // file details entry - 2: { - mci: { - MT1: { - argName: shortDesc - tabSwitchesView: true - focus: true - } - - ET2: { - argName: tags - } - - ME3: { - argName: estYear - maskPattern: "####" - } - - BT4: { - argName: continue - text: continue - submit: true - } - } - - submit: { - *: [ - { - value: { continue: null } - action: @method:fileDetailsContinue - } - ] - } - } - - // dupes - 3: { - mci: { - VM1: { - /* - Use 'dupeInfoFormat' to custom format: - - areaDesc - areaName - areaTag - desc - descLong - fileId - fileName - fileSha256 - storageTag - uploadTimestamp - - */ - - mode: preview - } - } - } - } - } - - fileBaseNoUploadAreasAvail: { - desc: File Base - art: ULNOAREA - config: { - pause: true - menuFlags: [ "noHistory", "popParent" ] - } - } - - sendFilesToUser: { - desc: Downloading - module: file_transfer - config: { - // defaults - generally use extraArgs - protocol: zmodem8kSexyz - direction: send - } - } - - recvFilesFromUser: { - desc: Uploading - module: file_transfer - config: { - // defaults - generally use extraArgs - protocol: zmodem8kSexyz - direction: recv - } - } - - - //////////////////////////////////////////////////////////////////////// - // Required entries - //////////////////////////////////////////////////////////////////////// - idleLogoff: { - art: IDLELOG - next: @systemMethod:logoff - } - //////////////////////////////////////////////////////////////////////// - // Demo Section - // :TODO: This entire section needs updated!!! - //////////////////////////////////////////////////////////////////////// - "demoMain" : { - "art" : "demo_selection_vm.ans", - "form" : { - "0" : { - "VM" : { - "mci" : { - "VM1" : { - "items" : [ - "Single Line Text Editing Views", - "Spinner & Toggle Views", - "Mask Edit Views", - "Multi Line Text Editor", - "Vertical Menu Views", - "Horizontal Menu Views", - "Art Display", - "Full Screen Editor" - ], - "height" : 10, - "itemSpacing" : 1, - "justify" : "center", - "focusTextStyle" : "small i" - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@menu:demoEditTextView" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoSpinAndToggleView" - }, - { - "value" : { "1" : 2 }, - "action" : "@menu:demoMaskEditView" - }, - { - "value" : { "1" : 3 }, - "action" : "@menu:demoMultiLineEditTextView" - }, - { - "value" : { "1" : 4 }, - "action" : "@menu:demoVerticalMenuView" - }, - { - "value" : { "1" : 5 }, - "action" : "@menu:demoHorizontalMenuView" - }, - { - "value" : { "1" : 6 }, - "action" : "@menu:demoArtDisplay" - }, - { - "value" : { "1" : 7 }, - "action" : "@menu:demoFullScreenEditor" - } - ] - } - } - } - } - }, - "demoEditTextView" : { - "art" : "demo_edit_text_view1.ans", - "form" : { - "0" : { - "BTETETETET" : { - "mci" : { - "ET1" : { - "width" : 20, - "maxLength" : 20 - }, - "ET2" : { - "width" : 20, - "maxLength" : 40, - "textOverflow" : "..." - }, - "ET3" : { - "width" : 20, - "fillChar" : "-", - "styleSGR1" : "|00|36", - "maxLength" : 20 - }, - "ET4" : { - "width" : 20, - "maxLength" : 20, - "password" : true - }, - "BT5" : { - "width" : 8, - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoSpinAndToggleView" : { - "art" : "demo_spin_and_toggle.ans", - "form" : { - "0" : { - "BTSMSMTM" : { - "mci" : { - "SM1" : { - "items" : [ "Henry Morgan", "François l'Ollonais", "Roche Braziliano", "Black Bart", "Blackbeard" ] - }, - "SM2" : { - "items" : [ "Razor 1911", "DrinkOrDie", "TRSI" ] - }, - "TM3" : { - "items" : [ "Yarly", "Nowaii" ], - "styleSGR1" : "|00|30|01", - "hotKeys" : { "Y" : 0, "N" : 1 } - }, - "BT8" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 8, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 8 - } - ] - } - } - } - }, - "demoMaskEditView" : { - "art" : "demo_mask_edit_text_view1.ans", - "form" : { - "0" : { - "BTMEME" : { - "mci" : { - "ME1" : { - "maskPattern" : "##/##/##", - "styleSGR1" : "|00|30|01", - //"styleSGR2" : "|00|45|01", - "styleSGR3" : "|00|30|35", - "fillChar" : "#" - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoMultiLineEditTextView" : { - "art" : "demo_multi_line_edit_text_view1.ans", - "form" : { - "0" : { - "BTMT" : { - "mci" : { - "MT1" : { - "width" : 70, - "height" : 17, - //"text" : "@art:demo_multi_line_edit_text_view_text.txt", - // "text" : "@systemMethod:textFromFile" - text: "Hints:\n\t* Insert / CTRL-V toggles overtype mode\n\t* CTRL-Y deletes the current line\n\t* Try Page Up / Page Down\n\t* Home goes to the start of line text\n\t* End goes to the end of a line\n\n\nTab handling:\n-------------------------------------------------\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\nA0\tBB\t1\tCCC\t2\tDDD\t3EEEE\nW\t\tX\t\tY\t\tZ\n\nAn excerpt from A Clockwork Orange:\n\"What sloochatted then, of course, was that my cellmates woke up and started joining in, tolchocking a bit wild in the near-dark, and the shoom seemed to wake up the whole tier, so that you could slooshy a lot of creeching and banging about with tin mugs on the wall, as though all the plennies in all the cells thought a big break was about to commence, O my brothers.\n", - "focus" : true - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoHorizontalMenuView" : { - "art" : "demo_horizontal_menu_view1.ans", - "form" : { - "0" : { - "BTHMHM" : { - "mci" : { - "HM1" : { - "items" : [ "One", "Two", "Three" ], - "hotKeys" : { "1" : 0, "2" : 1, "3" : 2 } - }, - "HM2" : { - "items" : [ "Uno", "Dos", "Tres" ], - "hotKeys" : { "U" : 0, "D" : 1, "T" : 2 } - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoVerticalMenuView" : { - "art" : "demo_vertical_menu_view1.ans", - "form" : { - "0" : { - "BTVM" : { - "mci" : { - "VM1" : { - "items" : [ - "|33Oblivion/2", - "|33iNiQUiTY", - "|33ViSiON/X" - ], - "focusItems" : [ - "|33Oblivion|01/|00|332", - "|01|33i|00|33N|01i|00|33QU|01i|00|33TY", - "|33ViSiON/X" - ] - // - // :TODO: how to do the following: - // 1) Supply a view a string for a standard vs focused item - // "items" : [...], "focusItems" : [ ... ] ? - // "draw" : "@method:drawItemX", then items: [...] - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - - }, - "demoArtDisplay" : { - "art" : "demo_selection_vm.ans", - "form" : { - "0" : { - "VM" : { - "mci" : { - "VM1" : { - "items" : [ - "Defaults - DOS ANSI", - "bw_mindgames.ans - DOS", - "test.ans - DOS", - "Defaults - Amiga", - "Pause at Term Height" - ], - // :TODO: justify not working?? - "focusTextStyle" : "small i" - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@menu:demoDefaultsDosAnsi" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoDefaultsDosAnsi_bw_mindgames" - }, - { - "value" : { "1" : 2 }, - "action" : "@menu:demoDefaultsDosAnsi_test" - } - ] - } - } - } - } - }, - "demoDefaultsDosAnsi" : { - "art" : "DM-ENIG2.ANS" - }, - "demoDefaultsDosAnsi_bw_mindgames" : { - "art" : "bw_mindgames.ans" - }, - "demoDefaultsDosAnsi_test" : { - "art" : "test.ans" - }, - "demoFullScreenEditor" : { - "module" : "fse", - "config" : { - "editorType" : "netMail", - "art" : { - "header" : "demo_fse_netmail_header.ans", - "body" : "demo_fse_netmail_body.ans", - "footerEditor" : "demo_fse_netmail_footer_edit.ans", - "footerEditorMenu" : "demo_fse_netmail_footer_edit_menu.ans", - "footerView" : "demo_fse_netmail_footer_view.ans", - "help" : "demo_fse_netmail_help.ans" - } - }, - "form" : { - "0" : { - "ETETET" : { - "mci" : { - "ET1" : { - // :TODO: from/to may be set by args - // :TODO: focus may change dep on view vs edit - "width" : 36, - "focus" : true, - "argName" : "to" - }, - "ET2" : { - "width" : 36, - "argName" : "from" - }, - "ET3" : { - "width" : 65, - "maxLength" : 72, - "submit" : [ "enter" ], - "argName" : "subject" - } - }, - "submit" : { - "3" : [ - { - "value" : { "subject" : null }, - "action" : "@method:headerSubmit" - } - ] - } - } - }, - "1" : { - "MT" : { - "mci" : { - "MT1" : { - "width" : 79, - "height" : 17, - "text" : "", // :TODO: should not be req. - "argName" : "message" - } - }, - "submit" : { - "*" : [ - { - "value" : "message", - "action" : "@method:editModeEscPressed" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 1 - } - ] - } - }, - "2" : { - "TLTL" : { - "mci" : { - "TL1" : { - "width" : 5 - }, - "TL2" : { - "width" : 4 - } - } - } - }, - "3" : { - "HM" : { - "mci" : { - "HM1" : { - // :TODO: Continue, Save, Discard, Clear, Quote, Help - "items" : [ "Save", "Discard", "Quote", "Help" ] - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@method:editModeMenuSave" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoMain" - }, - { - "value" : { "1" : 2 }, - "action" : "@method:editModeMenuQuote" - }, - { - "value" : { "1" : 3 }, - "action" : "@method:editModeMenuHelp" - }, - { - "value" : 1, - "action" : "@method:editModeEscPressed" - } - ] - }, - "actionKeys" : [ // :TODO: Need better name - { - "keys" : [ "escape" ], - "action" : "@method:editModeEscPressed" - } - ] - } - } - } - } - } + desc: Cataclysm + module: exodus + config: { + rejectUnauthorized: false + board: XXX + key: XXXXXXXX + door: cataclysm + } + } + + /////////////////////////////////////////////////////////////////////// + // Message Area Menu + /////////////////////////////////////////////////////////////////////// + messageArea: { + art: MSGMNU + desc: Message Area + prompt: messageMenuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "P" } + action: @menu:messageAreaNewPost + } + { + value: { command: "J" } + action: @menu:messageAreaChangeCurrentConference + } + { + value: { command: "C" } + action: @menu:messageAreaChangeCurrentArea + } + { + value: { command: "L" } + action: @menu:messageAreaMessageList + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: { command: "<" } + action: @systemMethod:prevConf + } + { + value: { command: ">" } + action: @systemMethod:nextConf + } + { + value: { command: "[" } + action: @systemMethod:prevArea + } + { + value: { command: "]" } + action: @systemMethod:nextArea + } + { + value: { command: "D" } + action: @menu:messageAreaSetNewScanDate + } + { + value: { command: "S" } + action: @menu:messageSearch + } + { + value: 1 + action: @menu:messageArea + } + ] + } + + messageSearch: { + desc: Message Search + module: message_base_search + art: MSEARCH + config: { + messageListMenu: messageAreaSearchMessageList + } + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + SM3: { + argName: confTag + } + SM4: { + argName: areaTag + } + ET5: { + argName: toUserName + maxLength: @config:users.usernameMax + } + ET6: { + argName: fromUserName + maxLength: @config:users.usernameMax + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaSearchMessageList: { + desc: Message Search + module: msg_list + art: MSRCHLST + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + TL6: { + // theme me! + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageSearchNoResults: { + desc: Message Search + art: MSRCNORES + config: { + pause: true + } + } + + messageAreaChangeCurrentConference: { + art: CCHANGE + module: msg_conf_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: conf + } + } + submit: { + *: [ + { + value: { conf: null } + action: @method:changeConference + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaSetNewScanDate: { + module: set_newscan_date + desc: Message Base + art: SETMNSDATE + config: { + target: message + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + SM2: { + argName: targetSelection + submit: false + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + changeMessageConfPreArt: { + module: show_art + config: { + method: messageConf + key: confTag + pause: true + cls: true + menuFlags: [ "popParent", "noHistory" ] + } + } + + messageAreaChangeCurrentArea: { + // :TODO: rename this art to ACHANGE + art: CHANGE + module: msg_area_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: area + } + } + submit: { + *: [ + { + value: { area: null } + action: @method:changeArea + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + changeMessageAreaPreArt: { + module: show_art + config: { + method: messageArea + key: areaTag + pause: true + cls: true + menuFlags: [ "popParent", "noHistory" ] + } + } + + messageAreaMessageList: { + module: msg_list + art: MSGLIST + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaViewPost: { + module: msg_area_view_fse + config: { + art: { + header: MSGVHDR + body: MSGBODY + footerView: MSGVFTR + help: MSGVHLP + }, + editorMode: view + editorType: area + } + form: { + 0: { + mci: { + // :TODO: ensure this block isn't even req. for theme to apply... + } + } + 1: { + mci: { + MT1: { + width: 79 + mode: preview + } + } + submit: { + *: [ + { + value: message + action: @method:editModeEscPressed + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + } + 2: { + TLTL: { + mci: { + TL1: { width: 5 } + TL2: { width: 4 } + } + } + } + 4: { + mci: { + HM1: { + // :TODO: (#)Jump/(L)Index (msg list)/Last + items: [ "prev", "next", "reply", "quit", "help" ] + focusItemIndex: 1 + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:prevMessage + } + { + value: { 1: 1 } + action: @method:nextMessage + } + { + value: { 1: 2 } + action: @method:replyMessage + extraArgs: { + menu: messageAreaReplyPost + } + } + { + value: { 1: 3 } + action: @systemMethod:prevMenu + } + { + value: { 1: 4 } + action: @method:viewModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "p", "shift + p" ] + action: @method:prevMessage + } + { + keys: [ "n", "shift + n" ] + action: @method:nextMessage + } + { + keys: [ "r", "shift + r" ] + action: @method:replyMessage + extraArgs: { + menu: messageAreaReplyPost + } + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "?" ] + action: @method:viewModeMenuHelp + } + { + keys: [ "down arrow", "up arrow", "page up", "page down" ] + action: @method:movementKeyPressed + } + ] + } + } + } + + messageAreaReplyPost: { + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + quote: MSGQUOT + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + } + editorMode: edit + editorType: area + } + form: { + 0: { + mci: { + // :TODO: use appropriate system properties for max lengths + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + validate: @systemMethod:validateNonEmpty + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateNonEmpty + } + TL4: { + // :TODO: this is for RE: line (NYI) + //width: 27 + //textOverflow: ... + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + height: 14 + argName: message + mode: edit + } + } + submit: { + *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ], + viewId: 1 + } + ] + } + + 3: { + mci: { + HM1: { + items: [ "save", "discard", "quote", "help" ] + } + } + + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 }, + action: @method:editModeMenuQuote + } + { + value: { 1: 3 } + action: @method:editModeMenuHelp + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "s", "shift + s" ] + action: @method:editModeMenuSave + } + { + keys: [ "d", "shift + d" ] + action: @systemMethod:prevMenu + } + { + keys: [ "q", "shift + q" ] + action: @method:editModeMenuQuote + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + + // Quote builder + 5: { + mci: { + MT1: { + width: 79 + height: 7 + } + VM3: { + width: 79 + height: 4 + argName: quote + } + } + + submit: { + *: [ + { + value: { quote: null } + action: @method:appendQuoteEntry + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:quoteBuilderEscPressed + } + ] + } + } + } + // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu + messageAreaNewPost: { + desc: Posting message, + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + } + editorMode: edit + editorType: area + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: All + validate: @systemMethod:validateNonEmpty + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateNonEmpty + // :TODO: Validate -> close/cancel if empty + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + + 1: { + "mci" : { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + } + 2: { + TLTL: { + mci: { + TL1: { width: 5 } + TL2: { width: 4 } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + "items" : [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + // :TODO: something like the following for overriding keymap + // this should only override specified entries. others will default + /* + "keyMap" : { + "accept" : [ "return" ] + } + */ + } + } + } + } + + + // + // User to User mail aka Email Menu + // + mailMenu: { + art: MAILMNU + desc: Mail Menu + prompt: menuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "C" } + action: @menu:mailMenuCreateMessage + } + { + value: { command: "I" } + action: @menu:mailMenuInbox + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: 1 + action: @menu:mailMenu + } + ] + } + + mailMenuCreateMessage: { + desc: Mailing Someone + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + validate: @systemMethod:validateGeneralMailAddressedTo + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + mailMenuInbox: { + module: msg_list + art: PRVMSGLIST + config: { + menuViewPost: messageAreaViewPost + messageAreaTag: private_mail + } + form: { + 0: { // main list + mci: { + VM1: { + focus: true + submit: true + argName: message + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "delete", "d", "shift + d" ] + action: @method:deleteSelected + } + ] + } + 1: { // delete prompt form + submit: { + *: [ + { + value: { promptValue: 0 } + action: @method:deleteMessageYes + } + { + value: { promptValue: 1 } + action: @method:deleteMessageNo + } + ] + } + } + } + } + + //////////////////////////////////////////////////////////////////////// + // File Base + //////////////////////////////////////////////////////////////////////// + + fileBase: { + desc: File Base + art: FMENU + prompt: fileMenuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { menuOption: "L" } + action: @menu:fileBaseListEntries + } + { + value: { menuOption: "B" } + action: @menu:fileBaseBrowseByAreaSelect + } + { + value: { menuOption: "F" } + action: @menu:fileAreaFilterEditor + } + { + value: { menuOption: "Q" } + action: @systemMethod:prevMenu + } + { + value: { menuOption: "G" } + action: @menu:fullLogoffSequence + } + { + value: { menuOption: "D" } + action: @menu:fileBaseDownloadManager + } + { + value: { menuOption: "W" } + action: @menu:fileBaseWebDownloadManager + } + { + value: { menuOption: "U" } + action: @menu:fileBaseUploadFiles + } + { + value: { menuOption: "S" } + action: @menu:fileBaseSearch + } + { + value: { menuOption: "P" } + action: @menu:fileBaseSetNewScanDate + } + { + value: { menuOption: "E" } + action: @menu:fileBaseExportListFilter + } + ] + } + + fileBaseExportListFilter: { + module: file_base_search + // :TODO: fixme: + art: FSEARCH + config: { + fileBaseListEntriesMenu: fileBaseExportList + } + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + ET3: { + maxLength: 64 + argName: tags + } + SM4: { + maxLength: 64 + argName: areaIndex + } + SM5: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + "filename" + ] + argName: sortByIndex + } + SM6: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseExportList: { + module: file_base_user_list_export + art: FBLISTEXP + config: { + pause: true + templates: { + entry: file_list_entry.asc + } + } + form: { + 0: { + mci: { + TL1: { } + TL2: { } + } + } + } + } + + fileBaseExportListNoResults: { + desc: Browsing Files + art: FBNORES + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileBaseSetNewScanDate: { + module: set_newscan_date + desc: File Base + art: SETFNSDATE + config: { + target: file + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseListEntries: { + module: file_area_list + desc: Browsing Files + config: { + art: { + browse: FBRWSE + details: FDETAIL + detailsGeneral: FDETGEN + detailsNfo: FDETNFO + detailsFileList: FDETLST + help: FBHELP + } + } + form: { + 0: { + mci: { + MT1: { + mode: preview + } + + HM2: { + focus: true + submit: true + argName: navSelect + items: [ + "prev", "next", "details", "toggle queue", "rate", "change filter", "help", "quit" + ] + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFile + } + { + value: { navSelect: 1 } + action: @method:nextFile + } + { + value: { navSelect: 2 } + action: @method:viewDetails + } + { + value: { navSelect: 3 } + action: @method:toggleQueue + } + { + value: { navSelect: 4 } + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + value: { navSelect: 5 } + action: @menu:fileAreaFilterEditor + } + { + value: { navSelect: 6 } + action: @method:displayHelp + } + { + value: { navSelect: 7 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "w", "shift + w" ] + action: @method:showWebDownloadLink + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "t", "shift + t" ] + action: @method:toggleQueue + } + { + keys: [ "f", "shift + f" ] + action: @menu:fileAreaFilterEditor + } + { + keys: [ "v", "shift + v" ] + action: @method:viewDetails + } + { + keys: [ "r", "shift + r" ] + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + keys: [ "?" ] + action: @method:displayHelp + } + ] + } + + 1: { + mci: { + HM1: { + focus: true + submit: true + argName: navSelect + items: [ + "general", "nfo/readme", "file listing" + ] + } + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @method:detailsQuit + } + ] + } + + 2: { + // details - general + mci: {} + } + + 3: { + // details - nfo/readme + mci: { + MT1: { + mode: preview + } + } + } + + 4: { + // details - file listing + mci: { + VM1: { + + } + } + } + } + } + + fileBaseBrowseByAreaSelect: { + desc: Browsing File Areas + module: file_base_area_select + art: FAREASEL + form: { + 0: { + mci: { + VM1: { + focus: true + argName: areaTag + } + } + + submit: { + *: [ + { + value: { areaTag: null } + action: @method:selectArea + } + ] + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseGetRatingForSelectedEntry: { + desc: Rating a File + prompt: fileBaseRateEntryPrompt + config: { + cls: true + } + submit: [ + // :TODO: handle esc/q + { + // pass data back to caller + value: { rating: null } + action: @systemMethod:prevMenu + } + ] + } + + fileBaseListEntriesNoResults: { + desc: Browsing Files + art: FBNORES + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileBaseSearch: { + module: file_base_search + desc: Searching Files + art: FSEARCH + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + ET3: { + maxLength: 64 + argName: tags + } + SM4: { + maxLength: 64 + argName: areaIndex + } + SM5: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + "filename", + ] + argName: sortByIndex + } + SM6: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileAreaFilterEditor: { + desc: File Filter Editor + module: file_area_filter_edit + art: FFILEDT + form: { + 0: { + mci: { + ET1: { + argName: searchTerms + } + ET2: { + maxLength: 64 + argName: tags + } + SM3: { + maxLength: 64 + argName: areaIndex + } + SM4: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + ] + argName: sortByIndex + } + SM5: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + ET6: { + maxLength: 64 + argName: name + validate: @systemMethod:validateNonEmpty + } + HM7: { + focus: true + items: [ + "prev", "next", "make active", "save", "new", "delete" + ] + argName: navSelect + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFilter + } + { + value: { navSelect: 1 } + action: @method:nextFilter + } + { + value: { navSelect: 2 } + action: @method:makeFilterActive + } + { + value: { navSelect: 3 } + action: @method:saveFilter + } + { + value: { navSelect: 4 } + action: @method:newFilter + } + { + value: { navSelect: 5 } + action: @method:deleteFilter + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseDownloadManager: { + desc: Download Manager + module: file_base_download_manager + config: { + art: { + queueManager: FDLMGR + /* + NYI + details: FDLDET + */ + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "download all", "quit" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:downloadAll + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "a", "shift + a" ] + action: @method:downloadAll + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseWebDownloadManager: { + desc: Web D/L Manager + module: file_base_web_download_manager + config: { + art: { + queueManager: FWDLMGR + batchList: BATDLINF + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "get batch link", "quit", "help" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:getBatchLink + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "b", "shift + b" ] + action: @method:getBatchLink + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseDownloadManagerEmptyQueue: { + desc: Empty Download Queue + art: FEMPTYQ + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileTransferProtocolSelection: { + desc: Protocol selection + module: file_transfer_protocol_select + art: FPROSEL + form: { + 0: { + mci: { + VM1: { + focus: true + argName: protocol + } + } + + submit: { + *: [ + { + value: { protocol: null } + action: @method:selectProtocol + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseUploadFiles: { + desc: Uploading + module: upload + config: { + art: { + options: ULOPTS + fileDetails: ULDETAIL + processing: ULCHECK + dupes: ULDUPES + } + } + + form: { + // options + 0: { + mci: { + SM1: { + argName: areaSelect + focus: true + } + TM2: { + argName: uploadType + items: [ "blind", "supply filename" ] + } + ET3: { + argName: fileName + maxLength: 255 + validate: @method:validateNonBlindFileName + } + HM4: { + argName: navSelect + items: [ "continue", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:optionsNavContinue + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + "actionKeys" : [ + { + "keys" : [ "escape" ], + action: @systemMethod:prevMenu + } + ] + } + + 1: { + mci: { } + } + + // file details entry + 2: { + mci: { + MT1: { + argName: shortDesc + tabSwitchesView: true + focus: true + } + + ET2: { + argName: tags + } + + ME3: { + argName: estYear + maskPattern: "####" + } + + BT4: { + argName: continue + text: continue + submit: true + } + } + + submit: { + *: [ + { + value: { continue: null } + action: @method:fileDetailsContinue + } + ] + } + } + + // dupes + 3: { + mci: { + VM1: { + /* + Use 'dupeInfoFormat' to custom format: + + areaDesc + areaName + areaTag + desc + descLong + fileId + fileName + fileSha256 + storageTag + uploadTimestamp + + */ + + mode: preview + } + } + } + } + } + + fileBaseNoUploadAreasAvail: { + desc: File Base + art: ULNOAREA + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + sendFilesToUser: { + desc: Downloading + module: file_transfer + config: { + // defaults - generally use extraArgs + protocol: zmodem8kSexyz + direction: send + } + } + + recvFilesFromUser: { + desc: Uploading + module: file_transfer + config: { + // defaults - generally use extraArgs + protocol: zmodem8kSexyz + direction: recv + } + } + + + //////////////////////////////////////////////////////////////////////// + // Required entries + //////////////////////////////////////////////////////////////////////// + idleLogoff: { + art: IDLELOG + next: @systemMethod:logoff + } + //////////////////////////////////////////////////////////////////////// + // Demo Section + // :TODO: This entire section needs updated!!! + //////////////////////////////////////////////////////////////////////// + "demoMain" : { + "art" : "demo_selection_vm.ans", + "form" : { + "0" : { + "VM" : { + "mci" : { + "VM1" : { + "items" : [ + "Single Line Text Editing Views", + "Spinner & Toggle Views", + "Mask Edit Views", + "Multi Line Text Editor", + "Vertical Menu Views", + "Horizontal Menu Views", + "Art Display", + "Full Screen Editor" + ], + "height" : 10, + "itemSpacing" : 1, + "justify" : "center", + "focusTextStyle" : "small i" + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@menu:demoEditTextView" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoSpinAndToggleView" + }, + { + "value" : { "1" : 2 }, + "action" : "@menu:demoMaskEditView" + }, + { + "value" : { "1" : 3 }, + "action" : "@menu:demoMultiLineEditTextView" + }, + { + "value" : { "1" : 4 }, + "action" : "@menu:demoVerticalMenuView" + }, + { + "value" : { "1" : 5 }, + "action" : "@menu:demoHorizontalMenuView" + }, + { + "value" : { "1" : 6 }, + "action" : "@menu:demoArtDisplay" + }, + { + "value" : { "1" : 7 }, + "action" : "@menu:demoFullScreenEditor" + } + ] + } + } + } + } + }, + "demoEditTextView" : { + "art" : "demo_edit_text_view1.ans", + "form" : { + "0" : { + "BTETETETET" : { + "mci" : { + "ET1" : { + "width" : 20, + "maxLength" : 20 + }, + "ET2" : { + "width" : 20, + "maxLength" : 40, + "textOverflow" : "..." + }, + "ET3" : { + "width" : 20, + "fillChar" : "-", + "styleSGR1" : "|00|36", + "maxLength" : 20 + }, + "ET4" : { + "width" : 20, + "maxLength" : 20, + "password" : true + }, + "BT5" : { + "width" : 8, + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoSpinAndToggleView" : { + "art" : "demo_spin_and_toggle.ans", + "form" : { + "0" : { + "BTSMSMTM" : { + "mci" : { + "SM1" : { + "items" : [ "Henry Morgan", "François l'Ollonais", "Roche Braziliano", "Black Bart", "Blackbeard" ] + }, + "SM2" : { + "items" : [ "Razor 1911", "DrinkOrDie", "TRSI" ] + }, + "TM3" : { + "items" : [ "Yarly", "Nowaii" ], + "styleSGR1" : "|00|30|01", + "hotKeys" : { "Y" : 0, "N" : 1 } + }, + "BT8" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 8, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 8 + } + ] + } + } + } + }, + "demoMaskEditView" : { + "art" : "demo_mask_edit_text_view1.ans", + "form" : { + "0" : { + "BTMEME" : { + "mci" : { + "ME1" : { + "maskPattern" : "##/##/##", + "styleSGR1" : "|00|30|01", + //"styleSGR2" : "|00|45|01", + "styleSGR3" : "|00|30|35", + "fillChar" : "#" + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoMultiLineEditTextView" : { + "art" : "demo_multi_line_edit_text_view1.ans", + "form" : { + "0" : { + "BTMT" : { + "mci" : { + "MT1" : { + "width" : 70, + "height" : 17, + //"text" : "@art:demo_multi_line_edit_text_view_text.txt", + // "text" : "@systemMethod:textFromFile" + text: "Hints:\n\t* Insert / CTRL-V toggles overtype mode\n\t* CTRL-Y deletes the current line\n\t* Try Page Up / Page Down\n\t* Home goes to the start of line text\n\t* End goes to the end of a line\n\n\nTab handling:\n-------------------------------------------------\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\nA0\tBB\t1\tCCC\t2\tDDD\t3EEEE\nW\t\tX\t\tY\t\tZ\n\nAn excerpt from A Clockwork Orange:\n\"What sloochatted then, of course, was that my cellmates woke up and started joining in, tolchocking a bit wild in the near-dark, and the shoom seemed to wake up the whole tier, so that you could slooshy a lot of creeching and banging about with tin mugs on the wall, as though all the plennies in all the cells thought a big break was about to commence, O my brothers.\n", + "focus" : true + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoHorizontalMenuView" : { + "art" : "demo_horizontal_menu_view1.ans", + "form" : { + "0" : { + "BTHMHM" : { + "mci" : { + "HM1" : { + "items" : [ "One", "Two", "Three" ], + "hotKeys" : { "1" : 0, "2" : 1, "3" : 2 } + }, + "HM2" : { + "items" : [ "Uno", "Dos", "Tres" ], + "hotKeys" : { "U" : 0, "D" : 1, "T" : 2 } + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoVerticalMenuView" : { + "art" : "demo_vertical_menu_view1.ans", + "form" : { + "0" : { + "BTVM" : { + "mci" : { + "VM1" : { + "items" : [ + "|33Oblivion/2", + "|33iNiQUiTY", + "|33ViSiON/X" + ], + "focusItems" : [ + "|33Oblivion|01/|00|332", + "|01|33i|00|33N|01i|00|33QU|01i|00|33TY", + "|33ViSiON/X" + ] + // + // :TODO: how to do the following: + // 1) Supply a view a string for a standard vs focused item + // "items" : [...], "focusItems" : [ ... ] ? + // "draw" : "@method:drawItemX", then items: [...] + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + + }, + "demoArtDisplay" : { + "art" : "demo_selection_vm.ans", + "form" : { + "0" : { + "VM" : { + "mci" : { + "VM1" : { + "items" : [ + "Defaults - DOS ANSI", + "bw_mindgames.ans - DOS", + "test.ans - DOS", + "Defaults - Amiga", + "Pause at Term Height" + ], + // :TODO: justify not working?? + "focusTextStyle" : "small i" + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@menu:demoDefaultsDosAnsi" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoDefaultsDosAnsi_bw_mindgames" + }, + { + "value" : { "1" : 2 }, + "action" : "@menu:demoDefaultsDosAnsi_test" + } + ] + } + } + } + } + }, + "demoDefaultsDosAnsi" : { + "art" : "DM-ENIG2.ANS" + }, + "demoDefaultsDosAnsi_bw_mindgames" : { + "art" : "bw_mindgames.ans" + }, + "demoDefaultsDosAnsi_test" : { + "art" : "test.ans" + }, + "demoFullScreenEditor" : { + "module" : "fse", + "config" : { + "editorType" : "netMail", + "art" : { + "header" : "demo_fse_netmail_header.ans", + "body" : "demo_fse_netmail_body.ans", + "footerEditor" : "demo_fse_netmail_footer_edit.ans", + "footerEditorMenu" : "demo_fse_netmail_footer_edit_menu.ans", + "footerView" : "demo_fse_netmail_footer_view.ans", + "help" : "demo_fse_netmail_help.ans" + } + }, + "form" : { + "0" : { + "ETETET" : { + "mci" : { + "ET1" : { + // :TODO: from/to may be set by args + // :TODO: focus may change dep on view vs edit + "width" : 36, + "focus" : true, + "argName" : "to" + }, + "ET2" : { + "width" : 36, + "argName" : "from" + }, + "ET3" : { + "width" : 65, + "maxLength" : 72, + "submit" : [ "enter" ], + "argName" : "subject" + } + }, + "submit" : { + "3" : [ + { + "value" : { "subject" : null }, + "action" : "@method:headerSubmit" + } + ] + } + } + }, + "1" : { + "MT" : { + "mci" : { + "MT1" : { + "width" : 79, + "height" : 17, + "text" : "", // :TODO: should not be req. + "argName" : "message" + } + }, + "submit" : { + "*" : [ + { + "value" : "message", + "action" : "@method:editModeEscPressed" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 1 + } + ] + } + }, + "2" : { + "TLTL" : { + "mci" : { + "TL1" : { + "width" : 5 + }, + "TL2" : { + "width" : 4 + } + } + } + }, + "3" : { + "HM" : { + "mci" : { + "HM1" : { + // :TODO: Continue, Save, Discard, Clear, Quote, Help + "items" : [ "Save", "Discard", "Quote", "Help" ] + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@method:editModeMenuSave" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoMain" + }, + { + "value" : { "1" : 2 }, + "action" : "@method:editModeMenuQuote" + }, + { + "value" : { "1" : 3 }, + "action" : "@method:editModeMenuHelp" + }, + { + "value" : 1, + "action" : "@method:editModeEscPressed" + } + ] + }, + "actionKeys" : [ // :TODO: Need better name + { + "keys" : [ "escape" ], + "action" : "@method:editModeEscPressed" + } + ] + } + } + } + } + } } From 33478354488a3a42cfe31a57b53cd9f0633fff94 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 17:07:48 -0700 Subject: [PATCH 12/63] Header to achievements.hjson --- config/achievements.hjson | 42 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/config/achievements.hjson b/config/achievements.hjson index cb58ddd1..4100d2dd 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -1,3 +1,43 @@ + /* + ./\/\.' ENiGMA½ Achievement Configuration -/--/-------- - -- - + + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + + *-----------------------------------------------------------------------------* + + General Information + ------------------------------- - - + This configuration is in HJSON (http://hjson.org/) format. Strict to-spec + JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON. + + See http://hjson.org/ for more information and syntax. + + Various editors and IDEs such as Sublime Text 3, Visual Studio Code, and so + on have syntax highlighting for the HJSON format which are highly recommended. + + ------------------------------- -- - - + Achievement Configuration + ------------------------------- - - + Achievements are currently fairly limited in what can trigger them. This is + being expanded upon and more will be available in the near future. For now + you should mostly be interested in: + - Perhaps adding additional *levels* of triggers & points + - Applying customizations via the achievements section in theme.hjson + + Don't forget to RTFM ...er, uh... see the documentation for more information, and + don't be shy to ask for help: + + BBS : Xibalba @ xibalba.l33t.codes + FTN : BBS Discussion on fsxNet or ArakNet + IRC : #enigma-bbs / FreeNode + Email : bryan@l33t.codes +*/ { enabled : true, @@ -8,8 +48,6 @@ globalFooter : 'achievement_global_footer', }, - // :TODO: achievements should be a path/filename -> achievements.hjson & allow override/theming - achievements : { user_login_count : { type : 'userStat', From 3d07f763d1861c0821042c60ebe607c75ab6d95d Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 19:04:19 -0700 Subject: [PATCH 13/63] Achievement improvement & more achievements --- art/themes/luciano_blocktronics/STATUS.ANS | Bin 4517 -> 4686 bytes config/achievements.hjson | 203 ++++++++++++++++----- core/achievement.js | 7 +- 3 files changed, 159 insertions(+), 51 deletions(-) diff --git a/art/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS index b90ed2d9ffede5f3db62c67ff44131899d870bca..679e21d691e6973dfd63e41c32257b29735eff56 100644 GIT binary patch delta 526 zcmZ3gd`@M;5lIEdU)z8IDI@%ytI(p)9Ep=lD; zzzQf1RsnW}XMiEp8HNUv7x0Tr_GNA20b2|-1Eg~DBsQDLd~A&zK<68QgeF(8YfL`C zwwWDj{$yJYeMZB{3)$ySHsn;8oXT;O4{8@!InabRoLZA3IH$9g>gNFOd2V8IMrLYRYHn&?2}IlETfEOyK#m7m zo19;oR|0d16~rm3j^Ky@8h@DYrZz+j6y^mb#c-8ClMtcn7yvPN^Fsc6jEqkwHwdZ% E0Ky)RGynhq delta 335 zcmX@7vQ&A(5oYOV!-;pbj0{bkfh=odvs?w~Xaj3wBM@zDo(mK-fyfx--oDMHaQik; z8YF7ES&uQ9nFYu)oV=8!RZBYB0;mqCKQBKeRY3u$8)%wAt`*1_vs_g_7c;0v!^xSf z(*z;HnFUZwOr0kmWEW-e3@|jCypTg=vKw0yGf>H7ZZ@0AZ0t)}fouco$rso(7>y=B zVxK>GE4$9*6&(9m%z$=IuH#UiJe_kMW6I>eT+%Eq-iAh#L%Ak0OGlec*5^{=fZJ=y zy=d}3E+tlwo1G^s^Hehf-8uORx8CHX9Q>2l@=Or{83zso>1a@RfIMnB*_rnSNQ=4i gWE;L`%!U@ulim1lf<(-mH+u=(V`Q|MtSO`l0RH`7od5s; diff --git a/config/achievements.hjson b/config/achievements.hjson index 4100d2dd..8f1babfe 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -1,5 +1,5 @@ /* - ./\/\.' ENiGMA½ Achievement Configuration -/--/-------- - -- - + ./\/\." ENiGMA½ Achievement Configuration -/--/-------- - -- - _____________________ _____ ____________________ __________\_ / \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! @@ -14,11 +14,11 @@ General Information ------------------------------- - - This configuration is in HJSON (http://hjson.org/) format. Strict to-spec - JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON. + JSON is also perfectly valid. Use "hjson" from npm to convert to/from JSON. See http://hjson.org/ for more information and syntax. - Various editors and IDEs such as Sublime Text 3, Visual Studio Code, and so + Various editors and IDEs such as Sublime Text 3 Visual Studio Code and so on have syntax highlighting for the HJSON format which are highly recommended. ------------------------------- -- - - @@ -30,8 +30,11 @@ - Perhaps adding additional *levels* of triggers & points - Applying customizations via the achievements section in theme.hjson - Don't forget to RTFM ...er, uh... see the documentation for more information, and - don't be shy to ask for help: + Some tips: + - For 'userStat' types, see user_property.js + + Don"t forget to RTFM ...er uh... see the documentation for more information and + don"t be shy to ask for help: BBS : Xibalba @ xibalba.l33t.codes FTN : BBS Discussion on fsxNet or ArakNet @@ -39,51 +42,159 @@ Email : bryan@l33t.codes */ { - enabled : true, + enabled : true art : { - localHeader : 'achievement_local_header', - localFooter : 'achievement_local_footer', - globalHeader : 'achievement_global_header', - globalFooter : 'achievement_global_footer', - }, + localHeader: achievement_local_header + localFooter: achievement_local_footer + globalHeader: achievement_global_header + globalFooter: achievement_global_footer + } - achievements : { - user_login_count : { - type : 'userStat', - statName : 'login_count', - retroactive : true, + achievements: { + user_login_count: { + type: userStat + statName: login_count + retroactive: true + match: { + 2: { + title: "Return Caller" + globalText: "{userName} has returned to {boardName}!" + text: "You\"ve returned to {boardName}!" + points: 5 + } + 10: { + title: "{boardName} Curious" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 5 + } + 25: { + title: "{boardName} Inquisitive" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 10 + } + 100: { + title: "{boardName} Regular" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 10 + } + 500: { + title: "{boardName} Addict" + globalText: "{userName} the BBS {boardName} addict has logged in {achievedValue} times!" + text: "You're a {boardName} addict! You've logged in {achievedValue} times!" + points: 25 + } + } + } - match : { - 2 : { - title : 'Return Caller', - globalText : '{userName} has returned to {boardName}!', - text : 'You\'ve returned to {boardName}!', - points : 5, - }, - 10 : { - title : '{achievedValue} Logins', - globalText : '{userName} has logged into {boardName} {achievedValue} times!', - text : 'You\'ve logged into {boardName} {achievedValue} times!', - points : 5, - }, - 25 : { - title : '{achievedValue} Logins', - globalText : '{userName} has logged into {boardName} {achievedValue} times!', - text : 'You\'ve logged into {boardName} {achievedValue} times!', - points : 10, - }, - 100 : { - title : '{boardName} Regular', - globalText : '{userName} has logged into {boardName} {achievedValue} times!', - text : 'You\'ve logged into {boardName} {achievedValue} times!', - points : 10, - }, - 500 : { - title : '{boardName} Addict', - globalText : '{userName} the BBS {boardName} addict has logged in {achievedValue} times!', - text : 'You\'re a {boardName} addict! You\'ve logged in {achievedValue} times!', - points : 25, + user_post_count: { + type: userStat + statName: post_count + retroactive: true + match: { + 5: { + title: "Poster" + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 5 + } + 20: { + title: "Poster... again!", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 10 + } + 100: { + title: "Frequent Poster", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 15 + } + 500: { + title: "Scribe" + globalText: "{userName} the scribe has posted {achievedValue} messages!" + text: "Such a scribe! You've posted {achievedValue} messages!" + points: 25 + } + } + } + + user_upload_count: { + type: userStat + statName: ul_total_count + retroactive: true + match: { + 1: { + title: "Uploader" + globalText: "{userName} has uploaded a file!" + text: "You've uploaded somthing!" + points: 5 + } + 10: { + title: "Moar Uploads!" + globalText: "{userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 10 + } + 50: { + title: "Contributor" + globalText: "{userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 20 + + } + 100: { + title: "Courier" + globalText: "Courier {userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 25 + } + 200: { + title: "Must Be a Drop Site" + globalText: "{userName} has uploaded a whomping {achievedValue} files!" + text: "You've uploaded a whomping {achievedValue} files!" + points: 50 + } + } + } + + user_download_count: { + type: userStat + statName: dl_total_count + retroactive: true + match: { + 1: { + title: "Downloader" + globalText: "{userName} has downloaded a file!" + text: "You've downloaded somthing!" + points: 5 + } + 10: { + title: "Moar Downloads!" + globalText: "{userName} has downloaded {achievedValue} files!" + text: "You've downloaded {achievedValue} files!" + points: 10 + } + 50: { + title: "Leecher" + globalText: "{userName} has leeched {achievedValue} files!" + text: "You've leeched... er... downloaded {achievedValue} files!" + points: 15 + } + 100: { + title: "Hoarder" + globalText: "{userName} has downloaded {achievedValue} files!" + text: "Hoarding files? You've downloaded {achievedValue} files!" + points: 20 + } + 200: { + title: "Digital Archivist" + globalText: "{userName} the digital archivist has {achievedValue} files!" + text: "Building an archive? You've downloaded {achievedValue} files!" + points: 25 } } } diff --git a/core/achievement.js b/core/achievement.js index cc64779c..e5eecd87 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -159,10 +159,6 @@ class Achievements { return cb(null); } ); - - // :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 } loadAchievementHitCount(user, achievementTag, field, cb) { @@ -404,7 +400,8 @@ class Achievements { pause : true, }; if(headerArt || footerArt) { - interruptItems[itemType].contents = `${headerArt || ''}\r\n${pipeToAnsi(title)}\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); } From 209e3f1f1d6e6868a589461bde5df57d072c6a82 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 21:16:57 -0700 Subject: [PATCH 14/63] Update copyright --- LICENSE.TXT | 2 +- README.md | 3 ++- docs/installation/testing.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/LICENSE.TXT b/LICENSE.TXT index 8db0cf42..74697ba9 100644 --- a/LICENSE.TXT +++ b/LICENSE.TXT @@ -1,4 +1,4 @@ -Copyright (c) 2015-2018, Bryan D. Ashby +Copyright (c) 2015-2019, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index a0bde9d0..1d178892 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! * [Gazelle](https://github.com/WhatCD/Gazelle) inspired File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/servers/web-server.md). Legacy X/Y/Z modem also supported! * Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more! * ANSI support in the Full Screen Editor (FSE), file descriptions, etc. + * A built in achievement system. BBSing gamified! ## Documentation [Browse the docs online](https://nuskooler.github.io/enigma-bbs/). Be sure to checkout the [/docs/](/docs/) folder as well for the latest and greatest documentation. @@ -84,7 +85,7 @@ Please see [Installation Methods](https://nuskooler.github.io/enigma-bbs/install ## License Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license: -Copyright (c) 2015-2018, Bryan D. Ashby +Copyright (c) 2015-2019, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/docs/installation/testing.md b/docs/installation/testing.md index 9238fc61..b23616f2 100644 --- a/docs/installation/testing.md +++ b/docs/installation/testing.md @@ -13,7 +13,7 @@ _Note that if you've used the [Docker](docker) installation method, you've alrea If everything went OK: ```bash -ENiGMA½ Copyright (c) 2014-2018 Bryan Ashby +ENiGMA½ Copyright (c) 2014-2019 Bryan Ashby _____________________ _____ ____________________ __________\_ / \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! // __|___// | \// |// | \// | | \// \ /___ /_____ From 9d39e99c5a71fc3fda593b8a84bf7bb38b60b4ee Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 21:17:18 -0700 Subject: [PATCH 15/63] Update copyright --- core/connect.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/connect.js b/core/connect.js index b9316a3e..cda3fe8a 100644 --- a/core/connect.js +++ b/core/connect.js @@ -132,7 +132,7 @@ function displayBanner(term) { // note: intentional formatting: term.pipeWrite(` |06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN -|06Copyright (c) 2014-2018 Bryan Ashby |14- |12http://l33t.codes/ +|06Copyright (c) 2014-2019 Bryan Ashby |14- |12http://l33t.codes/ |06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/ |00` ); From 22b7fdd65c9107be8be3e3cbe2f94bb7dcc8adb1 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 21:17:53 -0700 Subject: [PATCH 16/63] Add door stats & new mini format styles + Door runs stat + Door run minutes stat + Door runs MCI + Door run friendly duration MCI + durationHours/Minutes/Seconds mini format styles --- config/achievements.hjson | 70 +++++++++++++++++++++++++++++++++++++++ core/abracadabra.js | 12 +++++++ core/predefined_mci.js | 6 ++++ core/string_format.js | 5 +++ core/user_property.js | 3 ++ docs/art/mci.md | 20 +++++++++++ 6 files changed, 116 insertions(+) diff --git a/config/achievements.hjson b/config/achievements.hjson index 8f1babfe..ce841e93 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -198,5 +198,75 @@ } } } + + user_door_runs: { + type: userStat + statName: door_run_total_count + retroactive: true + match: { + 1: { + title: "Nostalgia Toe Dip", + globalText: "{userName} ran a door!" + text: "You ran a door!" + points: 5 + }, + 10: { + title: "This is Kinda Fun" + globalText: "{userName} ran {achievedValue} doors!" + text: "You've run {achievedValue} doors!" + points: 10 + } + 50: { + title: "Gamer" + globalText: "{userName} ran {achievedValue} doors!" + text: "You've run {achievedValue} doors!" + points: 15 + } + 100: { + title: "Textmode is All You Need" + globalText: "{userName} must really like textmode and has run {achievedValue} doors!" + text: "You've run {achievedValue} doors! You must really like textmode!" + points: 25 + } + 200: { + title: "Dropfile Enthusiast" + globalText: "{userName} the dropfile enthusiast ran {achievedValue} doors!" + text: "You're a dropfile enthusiast! You've run {achievedValue} doors!" + points: 50 + } + } + } + + user_door_total_minutes: { + type: userStat + statName: door_run_total_minutes + retroactive: true + match: { + 1: { + title: "Nevermind!" + globalText: "{userName} ran a door for {achievedValue!durationSeconds}. Guess it's not their thing!" + text: "You ran a door for only {achievedValue!durationSeconds}. Not your thing?" + points: 5 + } + 10: { + title: "It's OK I Guess" + globalText: "{userName} ran a door for {achievedValue!durationSeconds}!" + text: "You ran a door for {achievedValue!durationSeconds}!" + points: 10 + } + 30: { + title: "Good Game" + globalText: "{userName} ran a door for {achievedValue!durationSeconds}!" + text: "You ran a door for {achievedValue!durationSeconds}!" + points: 20 + } + 60: { + title: "Textmode Dragon Slayer" + globalText: "{userName} has spent {achievedValue!durationSeconds} in a door!" + text: "You've spent {achievedValue!durationSeconds} in a door!" + points: 25 + } + } + } } } \ No newline at end of file diff --git a/core/abracadabra.js b/core/abracadabra.js index e448315b..42731ac0 100644 --- a/core/abracadabra.js +++ b/core/abracadabra.js @@ -8,12 +8,15 @@ const theme = require('./theme.js'); const ansi = require('./ansi_term.js'); const Events = require('./events.js'); const { Errors } = require('./enig_error.js'); +const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); const assert = require('assert'); const _ = require('lodash'); const paths = require('path'); +const moment = require('moment'); const activeDoorNodeInstances = {}; @@ -149,6 +152,7 @@ exports.getModule = class AbracadabraModule extends MenuModule { } runDoor() { + StatLog.incrementUserStat(this.client.user, UserProps.DoorRunTotalCount, 1); Events.emit(Events.getSystemEvents().UserRunDoor, { user : this.client.user } ); this.client.term.write(ansi.resetScreen()); @@ -164,7 +168,15 @@ exports.getModule = class AbracadabraModule extends MenuModule { node : this.client.node, }; + const startTime = moment(); + this.doorInstance.run(exeInfo, () => { + const endTime = moment(); + const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(this.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + } + // // Try to clean up various settings such as scroll regions that may // have been set within the door diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 01b1e285..39e339cc 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -155,6 +155,12 @@ const PREDEFINED_MCI_GENERATORS = { AC : function achievementCount(client) { return userStatAsString(client, UserProps.AchievementTotalCount, 0); }, AP : function achievementPoints(client) { return userStatAsString(client, UserProps.AchievementTotalPoints, 0); }, + DR : function doorRuns(client) { return userStatAsString(client, UserProps.DoorRunTotalCount, 0); }, + DM : function doorFriendlyRunTime(client) { + const minutes = client.user.properties[UserProps.DoorRunTotalMinutes] || 0; + return moment.duration(minutes, 'minutes').humanize(); + }, + // // Date/Time // diff --git a/core/string_format.js b/core/string_format.js index a756db72..4a5b110c 100644 --- a/core/string_format.js +++ b/core/string_format.js @@ -14,6 +14,7 @@ const { // deps const _ = require('lodash'); +const moment = require('moment'); /* String formatting HEAVILY inspired by David Chambers string-format library @@ -281,6 +282,10 @@ const transformers = { countWithAbbr : (n) => formatCount(n, true, 0), countWithoutAbbr : (n) => formatCount(n, false, 0), countAbbr : (n) => formatCountAbbr(n), + + durationHours : (h) => moment.duration(h, 'hours').humanize(), + durationMinutes : (m) => moment.duration(m, 'minutes').humanize(), + durationSeconds : (s) => moment.duration(s, 'seconds').humanize(), }; function transformValue(transformerName, value) { diff --git a/core/user_property.js b/core/user_property.js index dafd7170..a1489e82 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -50,6 +50,9 @@ module.exports = { MessageAreaTag : 'message_area_tag', MessagePostCount : 'post_count', + DoorRunTotalCount : 'door_run_total_count', + DoorRunTotalMinutes : 'door_run_total_minutes', + AchievementTotalCount : 'achievement_total_count', AchievementTotalPoints : 'achievement_total_points', }; diff --git a/docs/art/mci.md b/docs/art/mci.md index 5ec86805..3e5e18c5 100644 --- a/docs/art/mci.md +++ b/docs/art/mci.md @@ -60,6 +60,8 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et | `SW` | Current user's term width | | `AC` | Current user's total achievements | | `AP` | Current user's total achievement points | +| `DR` | Current user's number of door runs | +| `DM` | Current user's total amount of time spent in doors | | `DT` | Current date (using theme date format) | | `CT` | Current time (using theme time format) | | `OS` | System OS (Linux, Windows, etc.) | @@ -155,6 +157,21 @@ Standard style types available for `textStyle` and `focusTextStyle`: 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 +Some of the text styles mentioned above are also available in the mini format language: + +| Style | Description | +|-------|-------------| +| `normal` | Leaves text as-is. This is the default. | +| `toUpperCase` or `styleUpper` | ENIGMA BULLETIN BOARD SOFTWARE | +| `toLowerCase` or `styleLower` | enigma bulletin board software | +| `styleTitle` | Enigma Bulletin Board Software | +| `styleFirstLower` | eNIGMA bULLETIN bOARD sOFTWARE | +| `styleSmallVowels` | eNiGMa BuLLeTiN BoaRD SoFTWaRe | +| `styleBigVowels` | EniGMa bUllEtIn bOArd sOftwArE | +| `styleSmallI` | ENiGMA BULLETiN BOARD SOFTWARE | +| `styleMixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) | +| `styleL33t` | 3n1gm4 bull371n b04rd 50f7w4r3 | + Additional text styles are available for numbers: | Style | Description | @@ -165,6 +182,9 @@ Additional text styles are available for numbers: | `countWithAbbr` | Count with abbreviation such as `100 K`, `4.3 B`, etc. | | `countWithoutAbbr` | Just the count | | `countAbbr` | Just the abbreviation such as `M` for millions. | +| `durationHours` | Converts the provided *hours* value to something friendly such as `4 hours`, or `4 days`. | +| `durationMinutes` | Converts the provided *minutes* to something friendly such as `10 minutes` or `2 hours` | +| `durationSeconds` | Converts the provided *seconds* to something friendly such as `23 seconds` or `2 minutes` | #### Examples From 6496fd931aa5ed07ce5fdeea7fd989c47164105e Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 21:19:43 -0700 Subject: [PATCH 17/63] + Missing art for luciano_blocktronics - node messaging & new achievement stuff. Placeholder mostly for now. --- art/themes/luciano_blocktronics/NODEMSG.ANS | Bin 0 -> 1696 bytes art/themes/luciano_blocktronics/NODEMSGFTR.ANS | Bin 0 -> 221 bytes art/themes/luciano_blocktronics/NODEMSGHDR.ANS | Bin 0 -> 249 bytes .../achievement_global_footer.ans | Bin 0 -> 221 bytes .../achievement_global_header.ans | Bin 0 -> 247 bytes .../achievement_local_footer.ans | Bin 0 -> 221 bytes .../achievement_local_header.ans | Bin 0 -> 247 bytes 7 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 art/themes/luciano_blocktronics/NODEMSG.ANS create mode 100644 art/themes/luciano_blocktronics/NODEMSGFTR.ANS create mode 100644 art/themes/luciano_blocktronics/NODEMSGHDR.ANS create mode 100644 art/themes/luciano_blocktronics/achievement_global_footer.ans create mode 100644 art/themes/luciano_blocktronics/achievement_global_header.ans create mode 100644 art/themes/luciano_blocktronics/achievement_local_footer.ans create mode 100644 art/themes/luciano_blocktronics/achievement_local_header.ans diff --git a/art/themes/luciano_blocktronics/NODEMSG.ANS b/art/themes/luciano_blocktronics/NODEMSG.ANS new file mode 100644 index 0000000000000000000000000000000000000000..ebe742df8fce316add2404cc4ff8a93a102cfc65 GIT binary patch literal 1696 zcmb_c!EVz)5KS)}a^S)ZOR$$7k=E;ALoE(zi7F%lNC{kgszNA8-Et@&R`qvmY5xTA z-psDOiF(A5lI+gR+xOngy69}Xux-`&dC@f&MOW2+7zSfZ(UooGurRX3df|NE;|Ce| zww^gCm1Ws3>XedH1XfFW{0DN1Al(<;YY-@j&c$1@asIEQ)ZTph{C;KD zn@c?v0Wbz^10Ihu0gncR@hBrI;D)RMOSp_NihM*(EXic2l91AY0PzjQIjBfkFVvFB0aHkQ`Mskb;0qgpJAgOC9i`|w zs#tm+l}>6>zIIU}+V}`T)Cfa$P!)1nK_4nU#!Mm}9?}_8yO^)mPA5jOsiE=wycYk} zlWbPe_@lEYS7R4PXH=t+S&>N#Y(kpTwz3e1Pd`Wn+cyqTfbVE+(be%zWxR2|*i0fI zcz>UFRV03w<=72KW}%1lNZN2m-zLhP6Lq{v_^E}}XtYo#C+Xn&yLAqn6GkDEtZ!@xvyv>RytR36PtNzNt?VVED1kb$8q zIPK5x$kd!g&$!?C)6KdpI_DIm_Ag$lcNO0}A_cHGU9k9`^LN)Va0UeHPgh%q4p(D3 zvt)5>}p`3Ks{h&U}#}zXlNYEz`(%B7{I^?q=7)# O)5%vM%-s>fgOdPMvQ=aN literal 0 HcmV?d00001 diff --git a/art/themes/luciano_blocktronics/NODEMSGHDR.ANS b/art/themes/luciano_blocktronics/NODEMSGHDR.ANS new file mode 100644 index 0000000000000000000000000000000000000000..9e38285aaea354711e7f6bd00807e17cc7e35a76 GIT binary patch literal 249 zcmb1+Hn27^ur@Z&<>Hc#HncW2$W^#=|IY2(_Z2|2f;3Rx*eLfOkO7o5%k}ejaaHhj z4Gwm6cSVyl$h{BL0fM(N08kWe_EfNa_Fp literal 0 HcmV?d00001 diff --git a/art/themes/luciano_blocktronics/achievement_global_footer.ans b/art/themes/luciano_blocktronics/achievement_global_footer.ans new file mode 100644 index 0000000000000000000000000000000000000000..8cb30568bc48597a44aa443e7be088ff6e00d505 GIT binary patch literal 221 zcmb1+Hn29dHZia^Hpo@DLsh^f73>)5>}p`3Ks{h&U}#}zXlNYEz`(%B7{I^?q=7)# O)5%vM%-s>fgOdPMvQ=aN literal 0 HcmV?d00001 diff --git a/art/themes/luciano_blocktronics/achievement_global_header.ans b/art/themes/luciano_blocktronics/achievement_global_header.ans new file mode 100644 index 0000000000000000000000000000000000000000..87592fa3fcc8fda4bdcde2b176a922a0355a26b4 GIT binary patch literal 247 zcmb1+Hn27^ur@Z&<>Hc#HncW2$W^#=|IY2(_Z2|2f;3Rx*eLfOkO7o5%XM`2@C-uQo)X)&aMUq3e*Ee28I@fhK9zK3=9m6i~$VH TKpF^yJ)L|N!rUDpJU9seA0$YD literal 0 HcmV?d00001 diff --git a/art/themes/luciano_blocktronics/achievement_local_footer.ans b/art/themes/luciano_blocktronics/achievement_local_footer.ans new file mode 100644 index 0000000000000000000000000000000000000000..8cb30568bc48597a44aa443e7be088ff6e00d505 GIT binary patch literal 221 zcmb1+Hn29dHZia^Hpo@DLsh^f73>)5>}p`3Ks{h&U}#}zXlNYEz`(%B7{I^?q=7)# O)5%vM%-s>fgOdPMvQ=aN literal 0 HcmV?d00001 diff --git a/art/themes/luciano_blocktronics/achievement_local_header.ans b/art/themes/luciano_blocktronics/achievement_local_header.ans new file mode 100644 index 0000000000000000000000000000000000000000..87592fa3fcc8fda4bdcde2b176a922a0355a26b4 GIT binary patch literal 247 zcmb1+Hn27^ur@Z&<>Hc#HncW2$W^#=|IY2(_Z2|2f;3Rx*eLfOkO7o5%XM`2@C-uQo)X)&aMUq3e*Ee28I@fhK9zK3=9m6i~$VH TKpF^yJ)L|N!rUDpJU9seA0$YD literal 0 HcmV?d00001 From 2b802cb53439c2448b096cf105a2440fc4a9efce Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 5 Jan 2019 22:51:16 -0700 Subject: [PATCH 18/63] Better theming for achievements --- .../achievement_global_header.ans | Bin 247 -> 240 bytes .../achievement_local_header.ans | Bin 247 -> 240 bytes art/themes/luciano_blocktronics/theme.hjson | 5 +- config/achievements.hjson | 8 +-- core/achievement.js | 57 ++++++++++++------ 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/art/themes/luciano_blocktronics/achievement_global_header.ans b/art/themes/luciano_blocktronics/achievement_global_header.ans index 87592fa3fcc8fda4bdcde2b176a922a0355a26b4..6104c2caccd3734f4fe8705453da53311734ffab 100644 GIT binary patch delta 19 bcmey)_!UQz`BM@9zu delta 44 zcmeys_?>Zrxa{rQ_Z1ZG+`pqB9c^H3Y?S*C$S|}vHp_K%_VApjJD0I?;%QX?pYIVa diff --git a/art/themes/luciano_blocktronics/achievement_local_header.ans b/art/themes/luciano_blocktronics/achievement_local_header.ans index 87592fa3fcc8fda4bdcde2b176a922a0355a26b4..6104c2caccd3734f4fe8705453da53311734ffab 100644 GIT binary patch delta 19 bcmey)_!UQz`BM@9zu delta 44 zcmeys_?>Zrxa{rQ_Z1ZG+`pqB9c^H3Y?S*C$S|}vHp_K%_VApjJD0I?;%QX?pYIVa diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index d38137c8..28f17ff9 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -983,7 +983,10 @@ achievements: { defaults: { - titleSGR: "|11" + format: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + globalFformat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + titleSGR: "|10" + pointsSGR: "|12" textSGR: "|00|03" globalTextSGR: "|03" boardName: "|10" diff --git a/config/achievements.hjson b/config/achievements.hjson index ce841e93..41bfd4c1 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -64,25 +64,25 @@ points: 5 } 10: { - title: "{boardName} Curious" + title: "Curious Caller" globalText: "{userName} has logged into {boardName} {achievedValue} times!" text: "You've logged into {boardName} {achievedValue} times!" points: 5 } 25: { - title: "{boardName} Inquisitive" + title: "Inquisitive Caller" globalText: "{userName} has logged into {boardName} {achievedValue} times!" text: "You've logged into {boardName} {achievedValue} times!" points: 10 } 100: { - title: "{boardName} Regular" + title: "Regular Customer" globalText: "{userName} has logged into {boardName} {achievedValue} times!" text: "You've logged into {boardName} {achievedValue} times!" points: 10 } 500: { - title: "{boardName} Addict" + title: "System Addict" globalText: "{userName} the BBS {boardName} addict has logged in {achievedValue} times!" text: "You're a {boardName} addict! You've logged in {achievedValue} times!" points: 25 diff --git a/core/achievement.js b/core/achievement.js index e5eecd87..adfae332 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -314,29 +314,37 @@ class Achievements { } } - getFormattedTextFor(info, textType) { + getFormatObject(info) { + return { + userName : info.user.username, + userRealName : info.user.properties[UserProps.RealName], + userLocation : info.user.properties[UserProps.Location], + userAffils : info.user.properties[UserProps.Affiliations], + 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 defSgr = themeDefaults[`${textType}SGR`] || '|07'; + const defSgr = themeDefaults[`${textType}SGR`] || defaultSgr; const wrap = (fieldName, value) => { return `${themeDefaults[fieldName] || defSgr}${value}${defSgr}`; }; - const formatObj = { - userName : wrap('userName', info.user.username), - userRealName : wrap('userRealName', info.user.properties[UserProps.RealName]), - userLocation : wrap('userLocation', info.user.properties[UserProps.Location]), - userAffils : wrap('userAffils', info.user.properties[UserProps.Affiliations]), - nodeId : wrap('nodeId', info.client.node), - title : wrap('title', info.details.title), - text : wrap('text', info.global ? info.details.globalText : info.details.text), - points : wrap('points', info.details.points), - achievedValue : wrap('achievedValue', info.achievedValue), - matchField : wrap('matchField', info.matchField), - matchValue : wrap('matchValue', info.matchValue), - timestamp : wrap('timestamp', moment(info.timestamp).format(info.dateTimeFormat)), - boardName : wrap('boardName', Config().general.boardName), - }; + let formatObj = this.getFormatObject(info); + formatObj = _.reduce(formatObj, (out, v, k) => { + out[k] = wrap(k, v); + return out; + }, {}); return stringFormat(`${defSgr}${info.details[textType]}`, formatObj); } @@ -400,8 +408,21 @@ class Achievements { 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${pipeToAnsi(title)}\r\n${pipeToAnsi(itemText)}\r\n${footerArt || ''}`; + `${headerArt || ''}\r\n${contents}\r\n${footerArt || ''}`; } return callback(null); } From f653d83c1447c9aef69b3891bbc51072094484d9 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 6 Jan 2019 10:41:04 -0700 Subject: [PATCH 19/63] Implement retroactive achievements (for userStat types so far) --- art/themes/luciano_blocktronics/STATUS.ANS | Bin 4686 -> 4638 bytes art/themes/luciano_blocktronics/theme.hjson | 2 +- config/achievements.hjson | 8 +-- core/achievement.js | 56 ++++++++++++++++---- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/art/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS index 679e21d691e6973dfd63e41c32257b29735eff56..6871ff457b0b3d44ec533e785fac3b02aa03586e 100644 GIT binary patch delta 253 zcmX@7GEZf~5oYOV!-;q8r4^*3jm>iN@>5cQEW=z?KNmCUXoFk@AegMr=*$6@F4fPQ ze1S=ZH&->(&k!UHRBAMN31bnX!DMkJ!^w*o#U{rwWiT2}KF(yqXf*i`Q`6*nW-TCV z74!DV=`1>vC$X&Lwgy_1SpYQ9Ja@7Ss|rwcChLsJ+-!P4byjQ>CtqY!2eN*$ZDq8c zyop^O$bQ5=eR36t-sB%_ypy+bY-Ir&kTUr%m-OVxoU<9tC+l+^pZuLmYqA3OCPvfA nm$?l#Gw~=h34(1%OwPzmElbT!%_}M1Y{vJAnQ`l6O(9hPb2v~S delta 293 zcmbQIa!zH!5lIEdU)z8IDI@%ytI(p)9dtso6wK0fe;S3QpbDpfo z=*$VxRH~mh`5=?bB5#?RCg(A0PYz`j zpS*#2A*)d?Q1|2j7Tw8pEGsxc9H9M~1(OX~Re(CfSZ8nn?KA_48(2?%$EpW3LY8eJ z(72UsIzaX{wyi8+6MES7f!sap(I55a05Ty(KS!asYH|XH_~c~H zS&SBw|8X9le1uDT@&~SsjAoM;aT{#D%&o*E2y%gWZensqW@=e#Zfai1W;VW0%#2Sb I{}faO0Q$^X9{>OV diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index 28f17ff9..1dfeac55 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -984,7 +984,7 @@ achievements: { defaults: { format: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" - globalFformat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + globalformat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" titleSGR: "|10" pointsSGR: "|12" textSGR: "|00|03" diff --git a/config/achievements.hjson b/config/achievements.hjson index 41bfd4c1..a711bff4 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -55,12 +55,11 @@ user_login_count: { type: userStat statName: login_count - retroactive: true match: { 2: { title: "Return Caller" globalText: "{userName} has returned to {boardName}!" - text: "You\"ve returned to {boardName}!" + text: "You've returned to {boardName}!" points: 5 } 10: { @@ -93,7 +92,6 @@ user_post_count: { type: userStat statName: post_count - retroactive: true match: { 5: { title: "Poster" @@ -125,7 +123,6 @@ user_upload_count: { type: userStat statName: ul_total_count - retroactive: true match: { 1: { title: "Uploader" @@ -164,7 +161,6 @@ user_download_count: { type: userStat statName: dl_total_count - retroactive: true match: { 1: { title: "Downloader" @@ -202,7 +198,6 @@ user_door_runs: { type: userStat statName: door_run_total_count - retroactive: true match: { 1: { title: "Nostalgia Toe Dip", @@ -240,7 +235,6 @@ user_door_total_minutes: { type: userStat statName: door_run_total_minutes - retroactive: true match: { 1: { title: "Nevermind!" diff --git a/core/achievement.js b/core/achievement.js index adfae332..f3a04f1f 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -37,6 +37,9 @@ const paths = require('path'); class Achievement { constructor(data) { this.data = data; + + // achievements are retroactive by default + this.data.retroactive = _.get(this.data, 'retroactive', true); } static factory(data) { @@ -87,6 +90,9 @@ class Achievement { 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() { @@ -97,7 +103,7 @@ class UserStatAchievement extends Achievement { } getMatchDetails(matchValue) { - let matchField = Object.keys(this.data.match || {}).sort( (a, b) => b - a).find(v => matchValue >= v); + let matchField = this.matchKeys.find(v => matchValue >= v); if(matchField) { const match = this.data.match[matchField]; if(this.isValidMatchDetails(match)) { @@ -218,6 +224,22 @@ class Achievements { }); } + recordAndDisplayAchievement(info, cb) { + async.series( + [ + (callback) => { + return this.record(info, callback); + }, + (callback) => { + return this.display(info, callback); + } + ], + err => { + return cb(err); + } + ); + } + monitorUserStatUpdateEvents() { if(this.userStatEventListener) { return; // already listening @@ -287,15 +309,31 @@ class Achievements { timestamp : moment(), }; - return callback(null, info); - }, - (info, callback) => { - this.record(info, err => { - return callback(err, info); + const achievementsInfo = [ info ]; + if(true === achievement.data.retroactive) { + // For userStat, any lesser match keys(values) are also met. Example: + // matchKeys: [ 500, 200, 100, 20, 10, 2 ] + // ^---- we met here + // ^------------^ retroactive range + // + const index = achievement.matchKeys.findIndex(v => v < matchField); + if(index > -1) { + achievementsInfo.push(...achievement.matchKeys.slice(index).map(k => { + const [ d, f, v ] = achievement.getMatchDetails(k); + return Object.assign({}, info, { details : d, matchField : f, achievedValue : f, matchValue : v } ); + })); + } + } + + // reverse achievementsInfo so we display smallest > largest + achievementsInfo.reverse(); + + async.each(achievementsInfo, (achInfo, nextAchInfo) => { + return this.recordAndDisplayAchievement(achInfo, nextAchInfo); + }, + err => { + return callback(err); }); - }, - (info, callback) => { - return this.display(info, callback); } ], err => { From 8315b6219965b78960f1fa100d642a70479b23c6 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 6 Jan 2019 17:42:07 -0700 Subject: [PATCH 20/63] Door stats to BBSLink module --- core/bbs_link.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/core/bbs_link.js b/core/bbs_link.js index 71fa04c1..9144cf0a 100644 --- a/core/bbs_link.js +++ b/core/bbs_link.js @@ -4,12 +4,16 @@ const { MenuModule } = require('./menu_module.js'); const { resetScreen } = require('./ansi_term.js'); const { Errors } = require('./enig_error.js'); +const Events = require('./events.js'); +const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); const http = require('http'); const net = require('net'); const crypto = require('crypto'); +const moment = require('moment'); const packageJson = require('../package.json'); @@ -98,7 +102,7 @@ exports.getModule = class BBSLinkModule extends MenuModule { // // Authenticate the token we acquired previously // - var headers = { + const headers = { 'X-User' : self.client.user.userId.toString(), 'X-System' : self.config.sysCode, 'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'), @@ -125,17 +129,23 @@ exports.getModule = class BBSLinkModule extends MenuModule { // Authentication with BBSLink successful. Now, we need to create a telnet // bridge from us to them // - var connectOpts = { + const connectOpts = { port : self.config.port, host : self.config.host, }; - var clientTerminated; + let clientTerminated; self.client.term.write(resetScreen()); self.client.term.write(' Connecting to BBSLink.net, please wait...\n'); - var bridgeConnection = net.createConnection(connectOpts, function connected() { + const startTime = moment(); + + const bridgeConnection = net.createConnection(connectOpts, function connected() { + // bump stats, fire events, etc. + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); + Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); + self.client.log.info(connectOpts, 'BBSLink bridge connection established'); self.client.term.output.pipe(bridgeConnection); @@ -147,9 +157,15 @@ exports.getModule = class BBSLinkModule extends MenuModule { }); }); - var restorePipe = function() { + const restorePipe = function() { self.client.term.output.unpipe(bridgeConnection); self.client.term.output.resume(); + + const endTime = moment(); + const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + } }; bridgeConnection.on('data', function incomingData(data) { From 99a95e7648cf63acb32c3eda16074b41adeb8746 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 6 Jan 2019 17:50:22 -0700 Subject: [PATCH 21/63] Door stats to Exodus module --- core/exodus.js | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/core/exodus.js b/core/exodus.js index 0d439392..eaf4c9a5 100644 --- a/core/exodus.js +++ b/core/exodus.js @@ -2,12 +2,17 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('./menu_module.js').MenuModule; -const resetScreen = require('./ansi_term.js').resetScreen; -const Config = require('./config.js').get; -const Errors = require('./enig_error.js').Errors; -const Log = require('./logger.js').log; -const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent; +const { MenuModule } = require('./menu_module.js'); +const { resetScreen } = require('./ansi_term.js'); +const Config = require('./config.js').get; +const { Errors } = require('./enig_error.js'); +const Log = require('./logger.js').log; +const { + getEnigmaUserAgent +} = require('./misc_util.js'); +const Events = require('./events.js'); +const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -151,11 +156,18 @@ exports.getModule = class ExodusModule extends MenuModule { let pipeRestored = false; let pipedStream; + const startTime = moment(); function restorePipe() { if(pipedStream && !pipeRestored && !clientTerminated) { self.client.term.output.unpipe(pipedStream); self.client.term.output.resume(); + + const endTime = moment(); + const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + } } } @@ -186,6 +198,9 @@ exports.getModule = class ExodusModule extends MenuModule { }); sshClient.shell(window, options, (err, stream) => { + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); + Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); + pipedStream = stream; // :TODO: ewwwwwwwww hack self.client.term.output.pipe(stream); From 925ca134c6d83705bb5eacc7353e5a78044a1c6a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 6 Jan 2019 18:01:03 -0700 Subject: [PATCH 22/63] Door stats for CombatNet module --- core/combatnet.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/combatnet.js b/core/combatnet.js index abb9a889..bfd98103 100644 --- a/core/combatnet.js +++ b/core/combatnet.js @@ -5,10 +5,14 @@ const { MenuModule } = require('../core/menu_module.js'); const { resetScreen } = require('../core/ansi_term.js'); const { Errors } = require('./enig_error.js'); +const Events = require('./events.js'); +const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); const RLogin = require('rlogin'); +const moment = require('moment'); exports.moduleInfo = { name : 'CombatNet', @@ -46,9 +50,17 @@ exports.getModule = class CombatNetModule extends MenuModule { self.client.term.write(resetScreen()); self.client.term.write('Connecting to CombatNet, please wait...\n'); + const startTime = moment(); + const restorePipeToNormal = function() { if(self.client.term.output) { self.client.term.output.removeListener('data', sendToRloginBuffer); + + const endTime = moment(); + const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + } } }; @@ -90,6 +102,8 @@ exports.getModule = class CombatNetModule extends MenuModule { self.client.log.info('Connected to CombatNet'); self.client.term.output.on('data', sendToRloginBuffer); + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); + Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); } else { return callback(Errors.General('Failed to establish establish CombatNet connection')); } From 34c91780994eddabc9b93def65fe0faa42655b9b Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 6 Jan 2019 21:56:12 -0700 Subject: [PATCH 23/63] Achievement & Event improvements * User stat set vs user stat increment system events * Proper addMultipleEventListener() and removeMultipleEventListener() Events APIs * userStatSet vs userStatInc user stat achievement types. userStatInc for example can be used for door minutes used --- config/achievements.hjson | 14 ++++++------ core/achievement.js | 48 +++++++++++++++++++++++++++------------ core/door_party.js | 15 ++++++++++++ core/events.js | 31 +++++++++++++++++++++---- core/stat_log.js | 31 +++++++++++++++++++------ core/system_events.js | 3 ++- 6 files changed, 107 insertions(+), 35 deletions(-) diff --git a/config/achievements.hjson b/config/achievements.hjson index a711bff4..8dbcb637 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -31,7 +31,7 @@ - Applying customizations via the achievements section in theme.hjson Some tips: - - For 'userStat' types, see user_property.js + - For 'userStatSet' types, see user_property.js Don"t forget to RTFM ...er uh... see the documentation for more information and don"t be shy to ask for help: @@ -53,7 +53,7 @@ achievements: { user_login_count: { - type: userStat + type: userStatSet statName: login_count match: { 2: { @@ -90,7 +90,7 @@ } user_post_count: { - type: userStat + type: userStatSet statName: post_count match: { 5: { @@ -121,7 +121,7 @@ } user_upload_count: { - type: userStat + type: userStatSet statName: ul_total_count match: { 1: { @@ -159,7 +159,7 @@ } user_download_count: { - type: userStat + type: userStatSet statName: dl_total_count match: { 1: { @@ -196,7 +196,7 @@ } user_door_runs: { - type: userStat + type: userStatSet statName: door_run_total_count match: { 1: { @@ -233,7 +233,7 @@ } user_door_total_minutes: { - type: userStat + type: userStatInc statName: door_run_total_minutes match: { 1: { diff --git a/core/achievement.js b/core/achievement.js index f3a04f1f..96975628 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -45,7 +45,11 @@ class Achievement { static factory(data) { let achievement; switch(data.type) { - case Achievement.Types.UserStat : achievement = new UserStatAchievement(data); break; + case Achievement.Types.UserStatSet : + case Achievement.Types.UserStatInc : + achievement = new UserStatAchievement(data); + break; + default : return; } @@ -56,13 +60,15 @@ class Achievement { static get Types() { return { - UserStat : 'userStat', + UserStatSet : 'userStatSet', + UserStatInc : 'userStatInc', }; } isValid() { switch(this.data.type) { - case Achievement.Types.UserStat : + case Achievement.Types.UserStatSet : + case Achievement.Types.UserStatInc : if(!_.isString(this.data.statName)) { return false; } @@ -129,12 +135,12 @@ class Achievements { const configLoaded = (achievementConfig) => { if(true !== achievementConfig.enabled) { Log.info('Achievements are not enabled'); - this.stopMonitoringUserStatUpdateEvents(); + this.stopMonitoringUserStatEvents(); delete this.achievementConfig; } else { Log.info('Achievements are enabled'); this.achievementConfig = achievementConfig; - this.monitorUserStatUpdateEvents(); + this.monitorUserStatEvents(); } }; @@ -240,18 +246,22 @@ class Achievements { ); } - monitorUserStatUpdateEvents() { - if(this.userStatEventListener) { + monitorUserStatEvents() { + if(this.userStatEventListeners) { return; // already listening } - this.userStatEventListener = this.events.on(Events.getSystemEvents().UserStatUpdate, userStatEvent => { + const listenEvents = [ + Events.getSystemEvents().UserStatSet, + Events.getSystemEvents().UserStatIncrement + ]; + + this.userStatEventListeners = this.events.addMultipleEventListener(listenEvents, userStatEvent => { if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) { return; } - const statValue = parseInt(userStatEvent.statValue, 10); - if(isNaN(statValue)) { + if(!_.isNumber(userStatEvent.statValue) && !_.isNumber(userStatEvent.statIncrementBy)) { return; } @@ -262,7 +272,7 @@ class Achievements { if(false === achievement.enabled) { return false; } - return Achievement.Types.UserStat === achievement.type && + return [ Achievement.Types.UserStatSet, Achievement.Types.UserStatInc ].includes(achievement.type) && achievement.statName === userStatEvent.statName; } ); @@ -276,6 +286,14 @@ class Achievements { return; } + const statValue = parseInt( + Achievement.Types.UserStatSet === achievement.data.type ? userStatEvent.statValue : userStatEvent.statIncrementBy, + 10 + ); + if(isNaN(statValue)) { + return; + } + const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue); if(!details || _.isUndefined(matchField) || _.isUndefined(matchValue)) { return; @@ -345,10 +363,10 @@ class Achievements { }); } - stopMonitoringUserStatUpdateEvents() { - if(this.userStatEventListener) { - this.events.removeListener(Events.getSystemEvents().UserStatUpdate, this.userStatEventListener); - delete this.userStatEventListener; + stopMonitoringUserStatEvents() { + if(this.userStatEventListeners) { + this.events.removeMultipleEventListener(this.userStatEventListeners); + delete this.userStatEventListeners; } } diff --git a/core/door_party.js b/core/door_party.js index f6bc7be9..dcd6037e 100644 --- a/core/door_party.js +++ b/core/door_party.js @@ -5,10 +5,14 @@ const { MenuModule } = require('./menu_module.js'); const { resetScreen } = require('./ansi_term.js'); const { Errors } = require('./enig_error.js'); +const Events = require('./events.js'); +const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); const SSHClient = require('ssh2').Client; +const moment = require('moment'); exports.moduleInfo = { name : 'DoorParty', @@ -54,10 +58,18 @@ exports.getModule = class DoorPartyModule extends MenuModule { let pipeRestored = false; let pipedStream; + const startTime = moment(); + const restorePipe = function() { if(pipedStream && !pipeRestored && !clientTerminated) { self.client.term.output.unpipe(pipedStream); self.client.term.output.resume(); + + const endTime = moment(); + const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + } } }; @@ -83,6 +95,9 @@ exports.getModule = class DoorPartyModule extends MenuModule { const rlogin = `\x00${self.client.user.username}\x00[${self.config.bbsTag}]${self.client.user.username}\x00${self.client.term.termType}\x00`; stream.write(rlogin); + StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); + Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); + pipedStream = stream; // :TODO: this is hacky... self.client.term.output.pipe(stream); diff --git a/core/events.js b/core/events.js index 73253fe3..541a5cae 100644 --- a/core/events.js +++ b/core/events.js @@ -5,6 +5,9 @@ const events = require('events'); const Log = require('./logger.js').log; const SystemEvents = require('./system_events.js'); +// deps +const _ = require('lodash'); + module.exports = new class Events extends events.EventEmitter { constructor() { super(); @@ -35,12 +38,30 @@ module.exports = new class Events extends events.EventEmitter { return super.once(event, listener); } - addListenerMultipleEvents(events, listener) { - Log.trace( { events }, 'Registring event listeners'); + // + // Listen to multiple events for a single listener. + // Called with: listener(event, eventName) + // + // The returned object must be used with removeMultipleEventListener() + // + addMultipleEventListener(events, listener) { + Log.trace( { events }, 'Registering event listeners'); + + const listeners = []; + events.forEach(eventName => { - this.on(eventName, event => { - listener(eventName, event); - }); + const listenWrapper = _.partial(listener, _, eventName); + this.on(eventName, listenWrapper); + listeners.push( { eventName, listenWrapper } ); + }); + + return listeners; + } + + removeMultipleEventListener(listeners) { + Log.trace( { events }, 'Removing listeners'); + listeners.forEach(listener => { + this.removeListener(listener.eventName, listener.listenWrapper); }); } diff --git a/core/stat_log.js b/core/stat_log.js index 8627b6f2..0ff6aff6 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -122,12 +122,18 @@ class StatLog { // User specific stats // These are simply convenience methods to the user's properties // - setUserStat(user, statName, statValue, cb) { + setUserStatWithOptions(user, statName, statValue, options, cb) { // note: cb is optional in PersistUserProperty user.persistProperty(statName, statValue, cb); - const Events = require('./events.js'); // we need to late load currently - return Events.emit(Events.getSystemEvents().UserStatUpdate, { user, statName, statValue } ); + if(!options.noEvent) { + const Events = require('./events.js'); // we need to late load currently + Events.emit(Events.getSystemEvents().UserStatSet, { user, statName, statValue } ); + } + } + + setUserStat(user, statName, statValue, cb) { + return this.setUserStatWithOptions(user, statName, statValue, {}, cb); } getUserStat(user, statName) { @@ -143,16 +149,27 @@ class StatLog { let newValue = parseInt(user.properties[statName]); if(newValue) { - if(!_.isNumber(newValue)) { + if(!_.isNumber(newValue) && cb) { return cb(new Error(`Value for ${statName} is not a number!`)); } - newValue += incrementBy; } else { newValue = incrementBy; } - return this.setUserStat(user, statName, newValue, cb); + this.setUserStatWithOptions(user, statName, newValue, { noEvent : true }, err => { + if(!err) { + const Events = require('./events.js'); // we need to late load currently + Events.emit( + Events.getSystemEvents().UserStatIncrement, + { user, statName, statIncrementBy: incrementBy, statValue : newValue } + ); + } + + if(cb) { + return cb(err); + } + }); } // the time "now" in the ISO format we use and love :) @@ -362,7 +379,7 @@ class StatLog { systemEvents.UserAchievementEarned, ]; - Events.addListenerMultipleEvents(interestedEvents, (eventName, event) => { + Events.addMultipleEventListener(interestedEvents, (event, eventName) => { this.appendUserLogEntry( event.user, 'system_event', diff --git a/core/system_events.js b/core/system_events.js index 50a0c464..c0c09f35 100644 --- a/core/system_events.js +++ b/core/system_events.js @@ -21,6 +21,7 @@ module.exports = { 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 } + UserStatSet : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } + UserStatIncrement : 'codes.l33t.enigma.system.user_stat_increment', // {..., statName, statIncrementBy, statValue } UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // {..., achievementTag, points } }; From c9af0edef83d5a417e31f637f24733a69a2311d1 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 9 Jan 2019 20:06:30 -0700 Subject: [PATCH 24/63] resetScreen() vs clearScreen() --- core/user_interrupt_queue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index 29a52685..f1aee626 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -76,7 +76,7 @@ module.exports = class UserInterruptQueue displayWithItem(interruptItem, cb) { if(interruptItem.cls) { - this.client.term.rawWrite(ANSI.clearScreen()); + this.client.term.rawWrite(ANSI.resetScreen()); } else { this.client.term.rawWrite('\r\n\r\n'); } From 83c57926d346437d2ebfcea4d38b1e08f7ec758f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 9 Jan 2019 20:06:55 -0700 Subject: [PATCH 25/63] Never interrupt during upload --- core/upload.js | 2 ++ misc/menu_template.in.hjson | 1 + 2 files changed, 3 insertions(+) diff --git a/core/upload.js b/core/upload.js index a2f2c9ea..6eaff2ab 100644 --- a/core/upload.js +++ b/core/upload.js @@ -73,6 +73,8 @@ exports.getModule = class UploadModule extends MenuModule { constructor(options) { super(options); + this.interrupt = MenuModule.InterruptTypes.Never; + if(_.has(options, 'lastMenuResult.recvFilePaths')) { this.recvFilePaths = options.lastMenuResult.recvFilePaths; } diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index e3b76c21..60c57605 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -3440,6 +3440,7 @@ desc: Uploading module: upload config: { + interrupt: never art: { options: ULOPTS fileDetails: ULDETAIL From b96fa154c0fdf5006ffe5bcc97fe89ae84a823fb Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 9 Jan 2019 20:07:27 -0700 Subject: [PATCH 26/63] Spelling --- core/door_party.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/door_party.js b/core/door_party.js index dcd6037e..df3d189f 100644 --- a/core/door_party.js +++ b/core/door_party.js @@ -137,7 +137,7 @@ exports.getModule = class DoorPartyModule extends MenuModule { self.client.log.warn( { error : err.message }, 'DoorParty error'); } - // if the client is stil here, go to previous + // if the client is still here, go to previous if(!clientTerminated) { self.prevMenu(); } From 2726a7becc1dd6d8290d915f3011781a644d7262 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 9 Jan 2019 20:07:46 -0700 Subject: [PATCH 27/63] New achivements --- config/achievements.hjson | 71 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/config/achievements.hjson b/config/achievements.hjson index 8dbcb637..ba050673 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -42,6 +42,7 @@ Email : bryan@l33t.codes */ { + // Set to false to disable the achievement system enabled : true art : { @@ -158,6 +159,43 @@ } } + user_upload_bytes: { + type: userStatSet + statName: ul_total_bytes + match: { + 524288: { + title: "Kickstart" + globalText: "{userName} has uploaded 512KB, enough for a Kickstart!" + text: "You've uploaded 512KB, enough for a Kickstart!" + points: 10 + } + 1474560: { + title: "America Online 2.5?" + globalText: "{userName} has uploaded 1.44M worth of data. Hopefully it's not AOL 2.5." + title: "You've uploaded 1.44M worth of data. Hopefully it's not AOL 2.5." + points: 15 + } + 6291456: { + title: "A Quake of a Upload" + globalText: "{userName} has uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" + text: "You've uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" + points: 25 + } + 1073741824: { + title: "Gigabyte!" + globalText: "{userName} has uploaded a Gigabyte worth of data!" + text: "You've uploaded a Gigabyte worth of data!" + points: 50 + } + 3407872000: { + title: "Encarta" + globalText: "{userName} has uploaded 5 x CD-ROM disks worth of data. That's the size of Encarta!" + text: "You've uploaded 5 x CD-ROM disks worth of data. That's the size of Encarta!" + points: 100 + } + } + } + user_download_count: { type: userStatSet statName: dl_total_count @@ -195,6 +233,37 @@ } } + user_download_bytes: { + type: userStatSet + statName: dl_total_bytes + match: { + 655360: { + title: "Ought to be Enough" + globalText: "{userName} has downloaded 640K. Ought to be enough for anyone!" + text: "You've downloaded 640K. Ought to be enough for anyone!" + points: 5 + } + 1474560: { + title: "Fits on a Floppy" + globalText: "{userName} has downloaded 1.44MB worth of data!" + text: "You've downloaded 1.44MB of data!" + points: 10 + } + 104857600: { + title: "Click of Death" + globalText: "{userName} has downloaded 100MB... perhaps to a Zip Disk?" + text: "You've downloaded 100MB of data... perhaps to a Zip Disk?" + points: 15 + } + 681574400: { + title: "A CD-ROM Worth" + globalText: "{userName} has downloaded a CD-ROM's worth of data!" + text: "You've downloaded a CD-ROM's worth of data!" + points: 20 + } + } + } + user_door_runs: { type: userStatSet statName: door_run_total_count @@ -227,7 +296,7 @@ title: "Dropfile Enthusiast" globalText: "{userName} the dropfile enthusiast ran {achievedValue} doors!" text: "You're a dropfile enthusiast! You've run {achievedValue} doors!" - points: 50 + points: 100 } } } From 091a9ae2c7c8b6c4e9c76b47b17b2c06accee0a0 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 9 Jan 2019 20:07:59 -0700 Subject: [PATCH 28/63] Fix some bugs, clean up, etc. in achievements --- core/achievement.js | 48 ++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/core/achievement.js b/core/achievement.js index 96975628..ccd43a54 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -86,7 +86,7 @@ class Achievement { } isValidMatchDetails(details) { - if(!_.isString(details.title) || !_.isString(details.text) || !_.isNumber(details.points)) { + if(!details || !_.isString(details.title) || !_.isString(details.text) || !_.isNumber(details.points)) { return false; } return (_.isString(details.globalText) || !details.globalText); @@ -109,13 +109,16 @@ class UserStatAchievement extends Achievement { } getMatchDetails(matchValue) { + let ret = []; let matchField = this.matchKeys.find(v => matchValue >= v); if(matchField) { const match = this.data.match[matchField]; - if(this.isValidMatchDetails(match)) { - return [ match, parseInt(matchField), matchValue ]; + matchField = parseInt(matchField); + if(this.isValidMatchDetails(match) && !isNaN(matchField)) { + ret = [ match, matchField, matchValue ]; } } + return ret; } } @@ -180,7 +183,7 @@ class Achievements { WHERE user_id = ? AND achievement_tag = ? AND match_field = ?;`, [ user.userId, achievementTag, field], (err, row) => { - return cb(err, row && row.count || 0); + return cb(err, row ? row.count : 0); } ); } @@ -286,20 +289,20 @@ class Achievements { return; } - const statValue = parseInt( - Achievement.Types.UserStatSet === achievement.data.type ? userStatEvent.statValue : userStatEvent.statIncrementBy, - 10 + const statValue = parseInt(Achievement.Types.UserStatSet === achievement.data.type ? + userStatEvent.statValue : + userStatEvent.statIncrementBy ); if(isNaN(statValue)) { return; } const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue); - if(!details || _.isUndefined(matchField) || _.isUndefined(matchValue)) { + if(!details) { return; } - async.waterfall( + async.series( [ (callback) => { this.loadAchievementHitCount(userStatEvent.user, achievementTag, matchField, (err, count) => { @@ -335,19 +338,32 @@ class Achievements { // ^------------^ retroactive range // const index = achievement.matchKeys.findIndex(v => v < matchField); - if(index > -1) { - achievementsInfo.push(...achievement.matchKeys.slice(index).map(k => { - const [ d, f, v ] = achievement.getMatchDetails(k); - return Object.assign({}, info, { details : d, matchField : f, achievedValue : f, matchValue : v } ); - })); + if(index > -1 && Array.isArray(achievement.matchKeys)) { + achievement.matchKeys.slice(index).forEach(k => { + const [ det, fld, val ] = achievement.getMatchDetails(k); + if(det) { + achievementsInfo.push(Object.assign( + {}, + info, + { + details : det, + matchField : fld, + achievedValue : fld, + matchValue : val, + } + )); + } + }); } } // reverse achievementsInfo so we display smallest > largest achievementsInfo.reverse(); - async.each(achievementsInfo, (achInfo, nextAchInfo) => { - return this.recordAndDisplayAchievement(achInfo, nextAchInfo); + async.eachSeries(achievementsInfo, (achInfo, nextAchInfo) => { + return this.recordAndDisplayAchievement(achInfo, err => { + return nextAchInfo(err); + }); }, err => { return callback(err); From 2788c37492777c69b904d0380b493041261717f6 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 10 Jan 2019 20:34:52 -0700 Subject: [PATCH 29/63] + ACS: AC for achievement count check + ACS: AP for achievement point check + User minutes used on the system are now tracked + MCI: TO for total time spent online system (friendly format) * Fix up a couple ACS bugs with |value| * Fix formatting of achievement text + Add more achievements * Fix achievement duration formatting --- WHATSNEW.md | 1 + art/themes/luciano_blocktronics/theme.hjson | 6 +-- config/achievements.hjson | 47 +++++++++++++++++---- core/achievement.js | 24 ++++++----- core/acs_parser.js | 16 ++++++- core/ansi_term.js | 2 + core/client.js | 38 +++++++++++++++-- core/predefined_mci.js | 4 ++ core/user.js | 16 +++++++ core/user_property.js | 2 + docs/configuration/acs.md | 4 +- misc/acs_parser.pegjs | 16 ++++++- 12 files changed, 149 insertions(+), 27 deletions(-) diff --git a/WHATSNEW.md b/WHATSNEW.md index 01fc8edc..39dfca49 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -26,6 +26,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * `oputil.js user rm` and `oputil.js user info` are in! See [oputil CLI](/docs/admin/oputil.md). * Performing a file scan/import using `oputil.js fb scan` now recognizes various `FILES.BBS` formats. * Usernames found in the `config.users.badUserNames` are now not only disallowed from applying, but disconnected at any login attempt. +* Total minutes online is now tracked for users. Of course, it only starts after you get the update :) ## 0.0.8-alpha diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index 1dfeac55..99164a2b 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -989,9 +989,9 @@ pointsSGR: "|12" textSGR: "|00|03" globalTextSGR: "|03" - boardName: "|10" - userName: "|11" - achievedValue: "|15" + boardNameSGR: "|10" + userNameSGR: "|11" + achievedValueSGR: "|15" } overrides: { diff --git a/config/achievements.hjson b/config/achievements.hjson index ba050673..63ea5ee1 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -307,29 +307,60 @@ match: { 1: { title: "Nevermind!" - globalText: "{userName} ran a door for {achievedValue!durationSeconds}. Guess it's not their thing!" - text: "You ran a door for only {achievedValue!durationSeconds}. Not your thing?" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}. Guess it's not their thing!" + text: "You ran a door for only {achievedValue!durationMinutes}. Not your thing?" points: 5 } 10: { title: "It's OK I Guess" - globalText: "{userName} ran a door for {achievedValue!durationSeconds}!" - text: "You ran a door for {achievedValue!durationSeconds}!" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}!" + text: "You ran a door for {achievedValue!durationMinutes}!" points: 10 } 30: { title: "Good Game" - globalText: "{userName} ran a door for {achievedValue!durationSeconds}!" - text: "You ran a door for {achievedValue!durationSeconds}!" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}!" + text: "You ran a door for {achievedValue!durationMinutes}!" points: 20 } 60: { title: "Textmode Dragon Slayer" - globalText: "{userName} has spent {achievedValue!durationSeconds} in a door!" - text: "You've spent {achievedValue!durationSeconds} in a door!" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" points: 25 } } } + + user_total_system_online_minutes: { + type: userStatSet + statName: minutes_online_total_count + match: { + 30: { + title: "Just Poking Around" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 5 + } + 60: { + title: "Mildly Interesting" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 15 + } + 120: { + title: "Nothing Better to Do" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 25 + } + 1440: { + title: "Idle Bot" + globalText: "{userName} is probably a bot. They've spent {achievedValue!durationMinutes} on {boardName}!" + text: "You're a bot, aren't you? You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 50 + } + } + } } } \ No newline at end of file diff --git a/core/achievement.js b/core/achievement.js index ccd43a54..ad7644ba 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -406,19 +406,23 @@ class Achievements { getFormattedTextFor(info, textType, defaultSgr = '|07') { const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); - const defSgr = themeDefaults[`${textType}SGR`] || defaultSgr; + const textTypeSgr = themeDefaults[`${textType}SGR`] || defaultSgr; - const wrap = (fieldName, value) => { - return `${themeDefaults[fieldName] || defSgr}${value}${defSgr}`; + 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}`; + }); }; - let formatObj = this.getFormatObject(info); - formatObj = _.reduce(formatObj, (out, v, k) => { - out[k] = wrap(k, v); - return out; - }, {}); - - return stringFormat(`${defSgr}${info.details[textType]}`, formatObj); + return stringFormat(`${textTypeSgr}${wrap(info.details[textType])}`, formatObj); } createAchievementInterruptItems(info, cb) { diff --git a/core/acs_parser.js b/core/acs_parser.js index d6983b17..d4084b95 100644 --- a/core/acs_parser.js +++ b/core/acs_parser.js @@ -1004,7 +1004,7 @@ function peg$parse(input, options) { TW : function termWidth() { return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value; }, - ID : function isUserId(value) { + ID : function isUserId() { if(!user) { return false; } @@ -1024,6 +1024,20 @@ function peg$parse(input, options) { const midnight = now.clone().startOf('day') const minutesPastMidnight = now.diff(midnight, 'minutes'); return !isNaN(value) && minutesPastMidnight >= value; + }, + AC : function achievementCount() { + if(!user) { + return false; + } + const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0; + return !isNan(value) && points >= value; + }, + AP : function achievementPoints() { + if(!user) { + return false; + } + const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0; + return !isNan(value) && points >= value; } }[acsCode](value); } catch (e) { diff --git a/core/ansi_term.js b/core/ansi_term.js index f00fd011..353c46c8 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -23,6 +23,8 @@ // General // * http://en.wikipedia.org/wiki/ANSI_escape_code // * http://www.inwap.com/pdp10/ansicode.txt +// * Excellent information with many standards covered (for hterm): +// https://chromium.googlesource.com/apps/libapps/+/master/hterm/doc/ControlSequences.md // // Other Implementations // * https://github.com/chjj/term.js/blob/master/src/term.js diff --git a/core/client.js b/core/client.js index 300285a3..894119bf 100644 --- a/core/client.js +++ b/core/client.js @@ -40,6 +40,7 @@ const MenuStack = require('./menu_stack.js'); const ACS = require('./acs.js'); const Events = require('./events.js'); const UserInterruptQueue = require('./user_interrupt_queue.js'); +const UserProps = require('./user_property.js'); // deps const stream = require('stream'); @@ -442,13 +443,36 @@ Client.prototype.startIdleMonitor = function() { // // Every 1m, check for idle. + // We also update minutes spent online the system here, + // if we have a authenticated user. // this.idleCheck = setInterval( () => { const nowMs = Date.now(); - const idleLogoutSeconds = this.user.isAuthenticated() ? - Config().users.idleLogoutSeconds : - Config().users.preAuthIdleLogoutSeconds; + let idleLogoutSeconds; + if(this.user.isAuthenticated()) { + idleLogoutSeconds = Config().users.idleLogoutSeconds; + + // + // We don't really want to be firing off an event every 1m for + // every user, but want at least some updates for various things + // such as achievements. Send off every 5m. + // + const minOnline = this.user.incrementProperty(UserProps.MinutesOnlineTotalCount, 1); + if(0 === (minOnline % 5)) { + Events.emit( + Events.getSystemEvents().UserStatIncrement, + { + user : this.user, + statName : UserProps.MinutesOnlineTotalCount, + statIncrementBy : 1, + statValue : minOnline + } + ); + } + } else { + idleLogoutSeconds = Config().users.preAuthIdleLogoutSeconds; + } if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) { this.emit('idle timeout'); @@ -473,6 +497,14 @@ Client.prototype.end = function () { currentModule.leave(); } + // persist time online for authenticated users + if(this.user.isAuthenticated()) { + this.user.persistProperty( + UserProps.MinutesOnlineTotalCount, + this.user.getProperty(UserProps.MinutesOnlineTotalCount) + ); + } + this.stopIdleMonitor(); try { diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 39e339cc..2e7ed5ff 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -160,6 +160,10 @@ const PREDEFINED_MCI_GENERATORS = { const minutes = client.user.properties[UserProps.DoorRunTotalMinutes] || 0; return moment.duration(minutes, 'minutes').humanize(); }, + TO : function friendlyTotalTimeOnSystem(client) { + const minutes = client.user.properties[UserProps.MinutesOnlineTotalCount] || 0; + return moment.duration(minutes, 'minutes').humanize(); + }, // // Date/Time diff --git a/core/user.js b/core/user.js index ba89b387..3b261dc6 100644 --- a/core/user.js +++ b/core/user.js @@ -443,6 +443,22 @@ module.exports = class User { ); } + setProperty(propName, propValue) { + this.properties[propName] = propValue; + } + + incrementProperty(propName, incrementBy) { + incrementBy = incrementBy || 1; + let newValue = parseInt(this.getProperty(propName)); + if(newValue) { + newValue += incrementBy; + } else { + newValue = incrementBy; + } + this.setProperty(propName, newValue); + return newValue; + } + getProperty(propName) { return this.properties[propName]; } diff --git a/core/user_property.js b/core/user_property.js index a1489e82..56e47e66 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -55,5 +55,7 @@ module.exports = { AchievementTotalCount : 'achievement_total_count', AchievementTotalPoints : 'achievement_total_points', + + MinutesOnlineTotalCount : 'minutes_online_total_count', }; diff --git a/docs/configuration/acs.md b/docs/configuration/acs.md index 1ed83bb5..d0a45d06 100644 --- a/docs/configuration/acs.md +++ b/docs/configuration/acs.md @@ -34,7 +34,9 @@ The following are ACS codes available as of this writing: | NRratio | User has upload/download count ratio >= _ratio_ | | KRratio | User has a upload/download byte ratio >= _ratio_ | | PCratio | User has a post/call ratio >= _ratio_ | -| MMminutes | It is currently >= _minutes_ past midnight (system time) +| MMminutes | It is currently >= _minutes_ past midnight (system time) | +| ACachievementCount | User has >= _achievementCount_ achievements | +| APachievementPoints | User has >= _achievementPoints_ achievement points | \* Many more ACS codes are planned for the near future. diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs index bd6a8d96..8a39deea 100644 --- a/misc/acs_parser.pegjs +++ b/misc/acs_parser.pegjs @@ -160,7 +160,7 @@ TW : function termWidth() { return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value; }, - ID : function isUserId(value) { + ID : function isUserId() { if(!user) { return false; } @@ -180,6 +180,20 @@ const midnight = now.clone().startOf('day') const minutesPastMidnight = now.diff(midnight, 'minutes'); return !isNaN(value) && minutesPastMidnight >= value; + }, + AC : function achievementCount() { + if(!user) { + return false; + } + const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0; + return !isNan(value) && points >= value; + }, + AP : function achievementPoints() { + if(!user) { + return false; + } + const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0; + return !isNan(value) && points >= value; } }[acsCode](value); } catch (e) { From 3f2e836a83ae810840c13351ca08f7f016ca3b26 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 10 Jan 2019 21:41:32 -0700 Subject: [PATCH 30/63] Minor fixes --- art/themes/luciano_blocktronics/theme.hjson | 2 +- core/achievement.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index 99164a2b..f3871ce8 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -984,7 +984,7 @@ achievements: { defaults: { format: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" - globalformat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + globalFormat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" titleSGR: "|10" pointsSGR: "|12" textSGR: "|00|03" diff --git a/core/achievement.js b/core/achievement.js index ad7644ba..f57e2f06 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -485,7 +485,7 @@ class Achievements { }; if(headerArt || footerArt) { const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); - const defaultContentsFormat = '{title}\r\n${message}'; + const defaultContentsFormat = '{title}\r\n{message}'; const contentsFormat = 'global' === itemType ? themeDefaults.globalFormat || defaultContentsFormat : themeDefaults.format || defaultContentsFormat; From 403ee891d55eec802182d3215447e30e2b418f76 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 13 Jan 2019 18:19:00 -0700 Subject: [PATCH 31/63] Change column name, drop a useless one --- config/achievements.hjson | 2 +- core/achievement.js | 8 ++++---- core/database.js | 5 ++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/config/achievements.hjson b/config/achievements.hjson index 63ea5ee1..d5099f8c 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -70,7 +70,7 @@ points: 5 } 25: { - title: "Inquisitive Caller" + title: "Inquisitive" globalText: "{userName} has logged into {boardName} {achievedValue} times!" text: "You've logged into {boardName} {achievedValue} times!" points: 10 diff --git a/core/achievement.js b/core/achievement.js index f57e2f06..333b968c 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -180,7 +180,7 @@ class Achievements { UserDb.get( `SELECT COUNT() AS count FROM user_achievement - WHERE user_id = ? AND achievement_tag = ? AND match_field = ?;`, + WHERE user_id = ? AND achievement_tag = ? AND match = ?;`, [ user.userId, achievementTag, field], (err, row) => { return cb(err, row ? row.count : 0); @@ -193,9 +193,9 @@ class Achievements { 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 ], + `INSERT INTO user_achievement (user_id, achievement_tag, timestamp, match) + VALUES (?, ?, ?, ?);`, + [ info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField ], err => { if(err) { return cb(err); diff --git a/core/database.js b/core/database.js index 371af1ae..a6af1930 100644 --- a/core/database.js +++ b/core/database.js @@ -194,9 +194,8 @@ const DB_INIT_TABLE = { user_id INTEGER NOT NULL, achievement_tag VARCHAR NOT NULL, timestamp DATETIME NOT NULL, - match_field VARCHAR NOT NULL, - match_value VARCHAR NOT NULL, - UNIQUE(user_id, achievement_tag, match_field), + match VARCHAR NOT NULL, + UNIQUE(user_id, achievement_tag, match), FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE );` ); From 680898b56bf63f1b99a30e6cbad8ac0d35fddfd2 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 17 Jan 2019 20:13:49 -0700 Subject: [PATCH 32/63] Add minutes used to logoff event --- core/client_connections.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/client_connections.js b/core/client_connections.js index 33f1df8a..21aa5c1c 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -120,7 +120,8 @@ function removeClient(client) { ); if(client.user && client.user.isValid()) { - Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user } ); + const minutesOnline = moment().diff(moment(client.user.properties[UserProps.LastLoginTs]), 'minutes'); + Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user, minutesOnline } ); } Events.emit( From 483e7f4ee96c14cae48e16a0467ec157529ea2bf Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 17 Jan 2019 20:14:33 -0700 Subject: [PATCH 33/63] Add global boolean to node sent event --- core/node_msg.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/node_msg.js b/core/node_msg.js index bf22e24a..bb64757c 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -65,7 +65,7 @@ exports.getModule = class NodeMessageModule extends MenuModule { } } - Events.emit(Events.getSystemEvents().UserSendNodeMsg, { user : this.client.user } ); + Events.emit(Events.getSystemEvents().UserSendNodeMsg, { user : this.client.user, global : -1 === nodeId } ); return this.prevMenu(cb); }); From 7c6e3e3ad4499416f06ae763c9fc7f313728d483 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 17 Jan 2019 20:14:59 -0700 Subject: [PATCH 34/63] Cleanup, notes, etc. --- core/system_events.js | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/core/system_events.js b/core/system_events.js index c0c09f35..1bed2d13 100644 --- a/core/system_events.js +++ b/core/system_events.js @@ -2,26 +2,26 @@ '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', + 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', // { ..., global } UserStatSet : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } - UserStatIncrement : 'codes.l33t.enigma.system.user_stat_increment', // {..., statName, statIncrementBy, statValue } - UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // {..., achievementTag, points } + UserStatIncrement : 'codes.l33t.enigma.system.user_stat_increment', // { ..., statName, statIncrementBy, statValue } + UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // { ..., achievementTag, points } }; From dc7052105785c9508c3ceac0089adf5066768e9f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 17 Jan 2019 20:18:02 -0700 Subject: [PATCH 35/63] + New, more detailed user event log entries that can be summed/etc. * Last callers indicators now use new user event log entries --- core/last_callers.js | 2 +- core/stat_log.js | 26 ++------------ core/sys_event_user_log.js | 69 ++++++++++++++++++++++++++++++++++++ core/user_log_name.js | 21 +++++++++++ docs/modding/last-callers.md | 16 +++++---- 5 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 core/sys_event_user_log.js create mode 100644 core/user_log_name.js diff --git a/core/last_callers.js b/core/last_callers.js index f7c2552e..9d875b2e 100644 --- a/core/last_callers.js +++ b/core/last_callers.js @@ -174,7 +174,7 @@ exports.getModule = class LastCallersModule extends MenuModule { let indicatorSumsSql; if(actionIndicatorNames.length > 0) { indicatorSumsSql = actionIndicatorNames.map(i => { - return `SUM(CASE WHEN log_value='${_.snakeCase(i)}' THEN 1 ELSE 0 END) AS ${i}`; + return `SUM(CASE WHEN log_name='${_.snakeCase(i)}' THEN 1 ELSE 0 END) AS ${i}`; }); } diff --git a/core/stat_log.js b/core/stat_log.js index 0ff6aff6..f03319d0 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -364,30 +364,8 @@ class StatLog { } initUserEvents(cb) { - // - // We map some user events directly to user stat log entries such that they - // are persisted for a time. - // - const Events = require('./events.js'); - const systemEvents = Events.getSystemEvents(); - - const interestedEvents = [ - systemEvents.NewUser, - systemEvents.UserUpload, systemEvents.UserDownload, - systemEvents.UserPostMessage, systemEvents.UserSendMail, - systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg, - systemEvents.UserAchievementEarned, - ]; - - Events.addMultipleEventListener(interestedEvents, (event, eventName) => { - this.appendUserLogEntry( - event.user, - 'system_event', - eventName.replace(/^codes\.l33t\.enigma\.system\./, ''), // strip package name prefix - 90 - ); - }); - + const systemEventUserLogInit = require('./sys_event_user_log.js'); + systemEventUserLogInit(this); return cb(null); } } diff --git a/core/sys_event_user_log.js b/core/sys_event_user_log.js new file mode 100644 index 00000000..8b9b3f22 --- /dev/null +++ b/core/sys_event_user_log.js @@ -0,0 +1,69 @@ +/* jslint node: true */ +'use strict'; + +const Events = require('./events.js'); +const LogNames = require('./user_log_name.js'); + +const DefaultKeepForDays = 365; + +module.exports = function systemEventUserLogInit(statLog) { + const systemEvents = Events.getSystemEvents(); + + const interestedEvents = [ + systemEvents.NewUser, + systemEvents.UserLogin, systemEvents.UserLogoff, + systemEvents.UserUpload, systemEvents.UserDownload, + systemEvents.UserPostMessage, systemEvents.UserSendMail, + systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg, + systemEvents.UserAchievementEarned, + ]; + + const append = (e, n, v) => { + statLog.appendUserLogEntry(e.user, n, v, DefaultKeepForDays); + }; + + Events.addMultipleEventListener(interestedEvents, (event, eventName) => { + const detailHandler = { + [ systemEvents.NewUser ] : (e) => { + append(e, LogNames.NewUser, 1); + }, + [ systemEvents.UserLogin ] : (e) => { + append(e, LogNames.Login, 1); + }, + [ systemEvents.UserLogoff ] : (e) => { + append(e, LogNames.Logoff, e.minutesOnline); + }, + [ systemEvents.UserUpload ] : (e) => { + append(e, LogNames.UlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0); + append(e, LogNames.UlFileBytes, totalBytes); + }, + [ systemEvents.UserDownload ] : (e) => { + append(e, LogNames.DlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0); + append(e, LogNames.DlFileBytes, totalBytes); + }, + [ systemEvents.UserPostMessage ] : (e) => { + append(e, LogNames.PostMessage, e.areaTag); + }, + [ systemEvents.UserSendMail ] : (e) => { + append(e, LogNames.SendMail, 1); + }, + [ systemEvents.UserRunDoor ] : (e) => { + // :TODO: store door tag, else '-' ? + append(e, LogNames.RunDoor, 1); + }, + [ systemEvents.UserSendNodeMsg ] : (e) => { + append(e, LogNames.SendNodeMsg, e.global ? 'global' : 'direct'); + }, + [ systemEvents.UserAchievementEarned ] : (e) => { + append(e, LogNames.AchievementEarned, e.achievementTag); + append(e, LogNames.AchievementPointsEarned, e.points); + } + }[eventName]; + + if(detailHandler) { + detailHandler(event); + } + }); +}; diff --git a/core/user_log_name.js b/core/user_log_name.js new file mode 100644 index 00000000..6186d326 --- /dev/null +++ b/core/user_log_name.js @@ -0,0 +1,21 @@ +/* jslint node: true */ +'use strict'; + +// +// Common (but not all!) user log names +// +module.exports = { + NewUser : 'new_user', + Login : 'login', + Logoff : 'logoff', + UlFiles : 'ul_files', // value=count + UlFileBytes : 'ul_file_bytes', // value=total bytes + DlFiles : 'dl_files', // value=count + DlFileBytes : 'dl_file_bytes', // value=total bytes + PostMessage : 'post_msg', // value=areaTag + SendMail : 'send_mail', + RunDoor : 'run_door', + SendNodeMsg : 'send_node_msg', // value=global|direct + AchievementEarned : 'achievement_earned', // value=achievementTag + AchievementPointsEarned : 'achievement_pts_earned', // value=points earned +}; diff --git a/docs/modding/last-callers.md b/docs/modding/last-callers.md index 830244d7..0e15b4f8 100644 --- a/docs/modding/last-callers.md +++ b/docs/modding/last-callers.md @@ -14,13 +14,15 @@ Available `config` block entries: * `sysop`: Sysop options: * `collapse`: Collapse or roll up entries that fall within the period specified. May be a string in the form of `30 minutes`, `3 weeks`, `1 hour`, etc. * `hide`: Hide all +op logins -* `actionIndicators`: Maps user actions to indicators. For example: `userDownload` to "D". Available indicators: - * `userDownload` - * `userUpload` - * `userPostMsg` - * `userSendMail` - * `userRunDoor` - * `userSendNodeMsg` +* `actionIndicators`: Maps user events/actions to indicators. For example: `userDownload` to "D". Available indicators: + * `newUser`: User is new. + * `dlFiles`: User downloaded file(s). + * `ulFiles`: User uploaded file(s). + * `postMsg`: User posted message(s) to the message base, EchoMail, etc. + * `sendMail`: User sent _private_ mail. + * `runDoor`: User ran door(s). + * `sendNodeMsg`: User sent a node message(s). + * `achievementEarned`: User earned an achievement(s). * `actionIndicatorDefault`: Default indicator when an action is not set. Defaults to "-". Remember that entries such as `actionIndicators` and `actionIndicatorDefault` may contain pipe color codes! From 39e7fe5d69811473fb0ccd31190342dcb555e54d Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 17 Jan 2019 20:18:23 -0700 Subject: [PATCH 36/63] + WIP TopX module --- core/top_x.js | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 core/top_x.js diff --git a/core/top_x.js b/core/top_x.js new file mode 100644 index 00000000..7034a3d6 --- /dev/null +++ b/core/top_x.js @@ -0,0 +1,222 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const UserProps = require('./user_property.js'); +const UserLogNames = require('./user_log_name.js'); +const { Errors } = require('./enig_error.js'); +const UserDb = require('./database.js').dbs.user; +const SysDb = require('./database.js').dbs.system; +const User = require('./user.js'); +const stringFormat = require('./string_format.js'); + +// deps +const _ = require('lodash'); +const async = require('async'); + +exports.moduleInfo = { + name : 'TopX', + desc : 'Displays users top X stats', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.topx', +}; + +const FormIds = { + menu : 0, +}; + +exports.getModule = class TopXModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + const userPropValues = _.values(UserProps); + const userLogValues = _.values(UserLogNames); + + return this.validateConfigFields( + { + mciMap : (key, config) => { + const mciCodes = Object.keys(config.mciMap).map(mci => { + return parseInt(mci); + }).filter(mci => !isNaN(mci)); + if(0 === mciCodes.length) { + return false; + } + return mciCodes.every(mci => { + const o = config.mciMap[mci]; + if(!_.isObject(o)) { + return false; + } + const type = o.type; + switch(type) { + case 'userProp' : + if(!userPropValues.includes(o.propName)) { + return false; + } + // VM# must exist for this mci + if(!_.isObject(mciData, [ 'menu', `VM${mci}` ])) { + return false; + } + break; + + case 'userEventLog' : + if(!userLogValues.includes(o.logName)) { + return false; + } + // VM# must exist for this mci + if(!_.isObject(mciData, [ 'menu', `VM${mci}` ])) { + return false; + } + break; + + default : + return false; + } + return true; + }); + } + }, + callback + ); + }, + (callback) => { + return this.prepViewController('menu', FormIds.menu, mciData.menu, callback); + }, + (callback) => { + async.forEachSeries(Object.keys(this.config.mciMap), (mciCode, nextMciCode) => { + return this.populateTopXList(mciCode, nextMciCode); + }, + err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + }); + } + + populateTopXList(mciCode, cb) { + const listView = this.viewControllers.menu.getView(mciCode); + if(!listView) { + return cb(Errors.UnexpectedState(`Failed to get view for MCI ${mciCode}`)); + } + + const type = this.config.mciMap[mciCode].type; + switch(type) { + case 'userProp' : return this.populateTopXUserProp(listView, mciCode, cb); + case 'userEventLog' : return this.populateTopXUserEventLog(listView, mciCode, cb); + + // we should not hit here; validation happens up front + default : return cb(Errors.UnexpectedState(`Unexpected type: ${type}`)); + } + } + + rowsToItems(rows, cb) { + async.map(rows, (row, nextRow) => { + this.loadUserInfo(row.user_id, (err, userInfo) => { + if(err) { + return nextRow(err); + } + return nextRow(null, Object.assign(userInfo, { value : row.value })); + }); + }, + (err, items) => { + return cb(err, items); + }); + } + + populateTopXUserEventLog(listView, mciCode, cb) { + const count = listView.dimens.height || 1; + const daysBack = this.config.mciMap[mciCode].daysBack; + const whereDate = daysBack ? `AND DATETIME(timestamp) >= DATETIME('now', '-${daysBack} days')` : ''; + + SysDb.all( + `SELECT user_id, SUM(CASE WHEN typeof(log_value) IS 'text' THEN 1 ELSE CAST(log_value AS INTEGER) END) AS value + FROM user_event_log + WHERE log_name = ? ${whereDate} + GROUP BY user_id + ORDER BY value DESC + LIMIT ${count};`, + [ this.config.mciMap[mciCode].logName ], + (err, rows) => { + if(err) { + return cb(err); + } + + this.rowsToItems(rows, (err, items) => { + if(err) { + return cb(err); + } + listView.setItems(items); + listView.redraw(); + }); + } + ); + } + + populateTopXUserProp(listView, mciCode, cb) { + const count = listView.dimens.height || 1; + UserDb.all( + `SELECT user_id, CAST(prop_value AS INTEGER) AS value + FROM user_property + WHERE prop_name = ? + ORDER BY value DESC + LIMIT ${count};`, + [ this.config.mciMap[mciCode].propName ], + (err, rows) => { + if(err) { + return cb(err); + } + + this.rowsToItems(rows, (err, items) => { + if(err) { + return cb(err); + } + listView.setItems(items); + listView.redraw(); + }); + } + ); + } + + loadUserInfo(userId, cb) { + const getPropOpts = { + names : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations ] + }; + + const userInfo = { userId }; + User.getUserName(userId, (err, userName) => { + if(err) { + return cb(err); + } + + userInfo.userName = userName; + + User.loadProperties(userId, getPropOpts, (err, props) => { + if(err) { + return cb(err); + } + + userInfo.location = props[UserProps.Location] || ''; + userInfo.affils = userInfo.affiliation = props[UserProps.Affiliations] || ''; + userInfo.realName = props[UserProps.RealName] || ''; + + return cb(null, userInfo); + }); + }); + } +}; From 2a3271ef4e23f2a4345e49f56c25fe59690cd5e0 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 17 Jan 2019 21:27:25 -0700 Subject: [PATCH 37/63] Fix some events --- core/sys_event_user_log.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/core/sys_event_user_log.js b/core/sys_event_user_log.js index 8b9b3f22..39120987 100644 --- a/core/sys_event_user_log.js +++ b/core/sys_event_user_log.js @@ -34,14 +34,18 @@ module.exports = function systemEventUserLogInit(statLog) { append(e, LogNames.Logoff, e.minutesOnline); }, [ systemEvents.UserUpload ] : (e) => { - append(e, LogNames.UlFiles, e.files.length); - const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0); - append(e, LogNames.UlFileBytes, totalBytes); + if(e.files.length) { // we can get here for dupe uploads + append(e, LogNames.UlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0); + append(e, LogNames.UlFileBytes, totalBytes); + } }, [ systemEvents.UserDownload ] : (e) => { - append(e, LogNames.DlFiles, e.files.length); - const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0); - append(e, LogNames.DlFileBytes, totalBytes); + if(e.files.length) { + append(e, LogNames.DlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.byteSize, 0); + append(e, LogNames.DlFileBytes, totalBytes); + } }, [ systemEvents.UserPostMessage ] : (e) => { append(e, LogNames.PostMessage, e.areaTag); From 4e1997302e07f1bfce918e99d0aa7a1cf1f0b57a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 17 Jan 2019 21:27:37 -0700 Subject: [PATCH 38/63] Fairly functional --- core/top_x.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/core/top_x.js b/core/top_x.js index 7034a3d6..37b4f4bd 100644 --- a/core/top_x.js +++ b/core/top_x.js @@ -44,6 +44,13 @@ exports.getModule = class TopXModule extends MenuModule { const userPropValues = _.values(UserProps); const userLogValues = _.values(UserLogNames); + const hasMci = (c, t) => { + if(!Array.isArray(t)) { + t = [ t ]; + } + return t.some(t => _.isObject(mciData, [ 'menu', `${t}${c}` ])); + }; + return this.validateConfigFields( { mciMap : (key, config) => { @@ -75,7 +82,7 @@ exports.getModule = class TopXModule extends MenuModule { return false; } // VM# must exist for this mci - if(!_.isObject(mciData, [ 'menu', `VM${mci}` ])) { + if(!hasMci(mci, ['VM'])) { return false; } break; @@ -121,17 +128,18 @@ exports.getModule = class TopXModule extends MenuModule { case 'userEventLog' : return this.populateTopXUserEventLog(listView, mciCode, cb); // we should not hit here; validation happens up front - default : return cb(Errors.UnexpectedState(`Unexpected type: ${type}`)); + default : return cb(Errors.UnexpectedState(`Unexpected type: ${type}`)); } } rowsToItems(rows, cb) { - async.map(rows, (row, nextRow) => { + let position = 1; + async.mapSeries(rows, (row, nextRow) => { this.loadUserInfo(row.user_id, (err, userInfo) => { if(err) { return nextRow(err); } - return nextRow(null, Object.assign(userInfo, { value : row.value })); + return nextRow(null, Object.assign(userInfo, { position : position++, value : row.value })); }); }, (err, items) => { @@ -142,12 +150,15 @@ exports.getModule = class TopXModule extends MenuModule { populateTopXUserEventLog(listView, mciCode, cb) { const count = listView.dimens.height || 1; const daysBack = this.config.mciMap[mciCode].daysBack; - const whereDate = daysBack ? `AND DATETIME(timestamp) >= DATETIME('now', '-${daysBack} days')` : ''; + const shouldSum = _.get(this.config.mciMap[mciCode], 'sum', true); + + const valueSql = shouldSum ? 'SUM(CAST(log_value AS INTEGER))' : 'COUNT()'; + const dateSql = daysBack ? `AND DATETIME(timestamp) >= DATETIME('now', '-${daysBack} days')` : ''; SysDb.all( - `SELECT user_id, SUM(CASE WHEN typeof(log_value) IS 'text' THEN 1 ELSE CAST(log_value AS INTEGER) END) AS value + `SELECT user_id, ${valueSql} AS value FROM user_event_log - WHERE log_name = ? ${whereDate} + WHERE log_name = ? ${dateSql} GROUP BY user_id ORDER BY value DESC LIMIT ${count};`, @@ -163,6 +174,7 @@ exports.getModule = class TopXModule extends MenuModule { } listView.setItems(items); listView.redraw(); + return cb(null); }); } ); @@ -188,6 +200,7 @@ exports.getModule = class TopXModule extends MenuModule { } listView.setItems(items); listView.redraw(); + return cb(null); }); } ); From 77763911842043610de0875060fa43916b207542 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 18 Jan 2019 22:09:10 -0700 Subject: [PATCH 39/63] Door utility and door tracking * Require >= 45s of time in a door before it counts as "run" --- core/bbs_link.js | 22 +++++++--------------- core/combatnet.js | 19 ++++++++----------- core/door_party.js | 22 ++++++++++------------ core/door_util.js | 41 +++++++++++++++++++++++++++++++++++++++++ core/exodus.js | 18 ++++++++---------- 5 files changed, 74 insertions(+), 48 deletions(-) create mode 100644 core/door_util.js diff --git a/core/bbs_link.js b/core/bbs_link.js index 9144cf0a..01eb3bfe 100644 --- a/core/bbs_link.js +++ b/core/bbs_link.js @@ -4,16 +4,16 @@ const { MenuModule } = require('./menu_module.js'); const { resetScreen } = require('./ansi_term.js'); const { Errors } = require('./enig_error.js'); -const Events = require('./events.js'); -const StatLog = require('./stat_log.js'); -const UserProps = require('./user_property.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); const http = require('http'); const net = require('net'); const crypto = require('crypto'); -const moment = require('moment'); const packageJson = require('../package.json'); @@ -139,13 +139,9 @@ exports.getModule = class BBSLinkModule extends MenuModule { self.client.term.write(resetScreen()); self.client.term.write(' Connecting to BBSLink.net, please wait...\n'); - const startTime = moment(); + const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`); const bridgeConnection = net.createConnection(connectOpts, function connected() { - // bump stats, fire events, etc. - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); - Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); - self.client.log.info(connectOpts, 'BBSLink bridge connection established'); self.client.term.output.pipe(bridgeConnection); @@ -153,7 +149,7 @@ exports.getModule = class BBSLinkModule extends MenuModule { self.client.once('end', function clientEnd() { self.client.log.info('Connection ended. Terminating BBSLink connection'); clientTerminated = true; - bridgeConnection.end(); + bridgeConnection.end(); }); }); @@ -161,11 +157,7 @@ exports.getModule = class BBSLinkModule extends MenuModule { self.client.term.output.unpipe(bridgeConnection); self.client.term.output.resume(); - const endTime = moment(); - const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); - if(runTimeMinutes > 0) { - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); - } + trackDoorRunEnd(doorTracking); }; bridgeConnection.on('data', function incomingData(data) { diff --git a/core/combatnet.js b/core/combatnet.js index bfd98103..8f1a5623 100644 --- a/core/combatnet.js +++ b/core/combatnet.js @@ -5,14 +5,14 @@ const { MenuModule } = require('../core/menu_module.js'); const { resetScreen } = require('../core/ansi_term.js'); const { Errors } = require('./enig_error.js'); -const Events = require('./events.js'); -const StatLog = require('./stat_log.js'); -const UserProps = require('./user_property.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); const RLogin = require('rlogin'); -const moment = require('moment'); exports.moduleInfo = { name : 'CombatNet', @@ -50,16 +50,14 @@ exports.getModule = class CombatNetModule extends MenuModule { self.client.term.write(resetScreen()); self.client.term.write('Connecting to CombatNet, please wait...\n'); - const startTime = moment(); + let doorTracking; const restorePipeToNormal = function() { if(self.client.term.output) { self.client.term.output.removeListener('data', sendToRloginBuffer); - const endTime = moment(); - const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); - if(runTimeMinutes > 0) { - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + if(doorTracking) { + trackDoorRunEnd(doorTracking); } } }; @@ -102,8 +100,7 @@ exports.getModule = class CombatNetModule extends MenuModule { self.client.log.info('Connected to CombatNet'); self.client.term.output.on('data', sendToRloginBuffer); - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); - Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); + doorTracking = trackDoorRunBegin(self.client); } else { return callback(Errors.General('Failed to establish establish CombatNet connection')); } diff --git a/core/door_party.js b/core/door_party.js index df3d189f..184416f7 100644 --- a/core/door_party.js +++ b/core/door_party.js @@ -5,14 +5,14 @@ const { MenuModule } = require('./menu_module.js'); const { resetScreen } = require('./ansi_term.js'); const { Errors } = require('./enig_error.js'); -const Events = require('./events.js'); -const StatLog = require('./stat_log.js'); -const UserProps = require('./user_property.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); const SSHClient = require('ssh2').Client; -const moment = require('moment'); exports.moduleInfo = { name : 'DoorParty', @@ -58,17 +58,15 @@ exports.getModule = class DoorPartyModule extends MenuModule { let pipeRestored = false; let pipedStream; - const startTime = moment(); + let doorTracking; const restorePipe = function() { if(pipedStream && !pipeRestored && !clientTerminated) { self.client.term.output.unpipe(pipedStream); self.client.term.output.resume(); - const endTime = moment(); - const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); - if(runTimeMinutes > 0) { - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + if(doorTracking) { + trackDoorRunEnd(doorTracking); } } }; @@ -87,6 +85,8 @@ exports.getModule = class DoorPartyModule extends MenuModule { return callback(Errors.General('Failed to establish tunnel')); } + doorTracking = trackDoorRunBegin(self.client); + // // Send rlogin // DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g. @@ -95,9 +95,6 @@ exports.getModule = class DoorPartyModule extends MenuModule { const rlogin = `\x00${self.client.user.username}\x00[${self.config.bbsTag}]${self.client.user.username}\x00${self.client.term.termType}\x00`; stream.write(rlogin); - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); - Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); - pipedStream = stream; // :TODO: this is hacky... self.client.term.output.pipe(stream); @@ -115,6 +112,7 @@ exports.getModule = class DoorPartyModule extends MenuModule { sshClient.on('error', err => { self.client.log.info(`DoorParty SSH client error: ${err.message}`); + trackDoorRunEnd(doorTracking); }); sshClient.on('close', () => { diff --git a/core/door_util.js b/core/door_util.js new file mode 100644 index 00000000..c1681058 --- /dev/null +++ b/core/door_util.js @@ -0,0 +1,41 @@ +/* jslint node: true */ +'use strict'; + +const UserProps = require('./user_property.js'); +const Events = require('./events.js'); +const StatLog = require('./stat_log.js'); + +const moment = require('moment'); + +exports.trackDoorRunBegin = trackDoorRunBegin; +exports.trackDoorRunEnd = trackDoorRunEnd; + + +function trackDoorRunBegin(client, doorTag) { + const startTime = moment(); + + // door must be running for >= 45s for us to officially record it + const timeout = setTimeout( () => { + StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalCount, 1); + + const eventInfo = { user : client.user }; + if(doorTag) { + eventInfo.doorTag = doorTag; + } + Events.emit(Events.getSystemEvents().UserRunDoor, eventInfo); + }, 45 * 1000); + + return { startTime, timeout, client, doorTag }; +} + +function trackDoorRunEnd(trackInfo) { + const { startTime, timeout, client } = trackInfo; + + clearTimeout(timeout); + + const endTime = moment(); + const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + } +} \ No newline at end of file diff --git a/core/exodus.js b/core/exodus.js index eaf4c9a5..5ed29a4e 100644 --- a/core/exodus.js +++ b/core/exodus.js @@ -10,9 +10,10 @@ const Log = require('./logger.js').log; const { getEnigmaUserAgent } = require('./misc_util.js'); -const Events = require('./events.js'); -const StatLog = require('./stat_log.js'); -const UserProps = require('./user_property.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); @@ -156,17 +157,15 @@ exports.getModule = class ExodusModule extends MenuModule { let pipeRestored = false; let pipedStream; - const startTime = moment(); + let doorTracking; function restorePipe() { if(pipedStream && !pipeRestored && !clientTerminated) { self.client.term.output.unpipe(pipedStream); self.client.term.output.resume(); - const endTime = moment(); - const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); - if(runTimeMinutes > 0) { - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + if(doorTracking) { + trackDoorRunEnd(doorTracking); } } } @@ -198,8 +197,7 @@ exports.getModule = class ExodusModule extends MenuModule { }); sshClient.shell(window, options, (err, stream) => { - StatLog.incrementUserStat(self.client.user, UserProps.DoorRunTotalCount, 1); - Events.emit(Events.getSystemEvents().UserRunDoor, { user : self.client.user } ); + doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`); pipedStream = stream; // :TODO: ewwwwwwwww hack self.client.term.output.pipe(stream); From 0457a6601fd72ce60dba12c1c790bfbb75dd01b4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 18 Jan 2019 23:12:01 -0700 Subject: [PATCH 40/63] Better door tracking * Send event info with door run time & door tag * Only if >= 45s * Only log minutes if >= 1 * No timer required; track only @ door exit time --- core/abracadabra.js | 19 ++++++------------- core/door_util.js | 33 +++++++++++++++------------------ core/sys_event_user_log.js | 4 ++-- core/system_events.js | 2 +- core/user_log_name.js | 3 ++- 5 files changed, 26 insertions(+), 35 deletions(-) diff --git a/core/abracadabra.js b/core/abracadabra.js index 42731ac0..34374049 100644 --- a/core/abracadabra.js +++ b/core/abracadabra.js @@ -6,17 +6,17 @@ const DropFile = require('./dropfile.js'); const Door = require('./door.js'); const theme = require('./theme.js'); const ansi = require('./ansi_term.js'); -const Events = require('./events.js'); const { Errors } = require('./enig_error.js'); -const StatLog = require('./stat_log.js'); -const UserProps = require('./user_property.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); // deps const async = require('async'); const assert = require('assert'); const _ = require('lodash'); const paths = require('path'); -const moment = require('moment'); const activeDoorNodeInstances = {}; @@ -152,9 +152,6 @@ exports.getModule = class AbracadabraModule extends MenuModule { } runDoor() { - StatLog.incrementUserStat(this.client.user, UserProps.DoorRunTotalCount, 1); - Events.emit(Events.getSystemEvents().UserRunDoor, { user : this.client.user } ); - this.client.term.write(ansi.resetScreen()); const exeInfo = { @@ -168,14 +165,10 @@ exports.getModule = class AbracadabraModule extends MenuModule { node : this.client.node, }; - const startTime = moment(); + const doorTracking = trackDoorRunBegin(this.client, this.config.name); this.doorInstance.run(exeInfo, () => { - const endTime = moment(); - const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); - if(runTimeMinutes > 0) { - StatLog.incrementUserStat(this.client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); - } + trackDoorRunEnd(doorTracking); // // Try to clean up various settings such as scroll regions that may diff --git a/core/door_util.js b/core/door_util.js index c1681058..6517f1be 100644 --- a/core/door_util.js +++ b/core/door_util.js @@ -10,32 +10,29 @@ const moment = require('moment'); exports.trackDoorRunBegin = trackDoorRunBegin; exports.trackDoorRunEnd = trackDoorRunEnd; - function trackDoorRunBegin(client, doorTag) { const startTime = moment(); - - // door must be running for >= 45s for us to officially record it - const timeout = setTimeout( () => { - StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalCount, 1); - - const eventInfo = { user : client.user }; - if(doorTag) { - eventInfo.doorTag = doorTag; - } - Events.emit(Events.getSystemEvents().UserRunDoor, eventInfo); - }, 45 * 1000); - - return { startTime, timeout, client, doorTag }; + return { startTime, client, doorTag }; } function trackDoorRunEnd(trackInfo) { - const { startTime, timeout, client } = trackInfo; + const { startTime, client, doorTag } = trackInfo; - clearTimeout(timeout); + const diff = moment.duration(moment().diff(startTime)); + if(diff.asSeconds() >= 45) { + StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalCount, 1); + } - const endTime = moment(); - const runTimeMinutes = Math.floor(moment.duration(endTime.diff(startTime)).asMinutes()); + const runTimeMinutes = Math.floor(diff.asMinutes()); if(runTimeMinutes > 0) { StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + + const eventInfo = { + runTimeMinutes, + user : client.user, + doorTag : doorTag || 'unknown', + }; + + Events.emit(Events.getSystemEvents().UserRunDoor, eventInfo); } } \ No newline at end of file diff --git a/core/sys_event_user_log.js b/core/sys_event_user_log.js index 39120987..63ae0e55 100644 --- a/core/sys_event_user_log.js +++ b/core/sys_event_user_log.js @@ -54,8 +54,8 @@ module.exports = function systemEventUserLogInit(statLog) { append(e, LogNames.SendMail, 1); }, [ systemEvents.UserRunDoor ] : (e) => { - // :TODO: store door tag, else '-' ? - append(e, LogNames.RunDoor, 1); + append(e, LogNames.RunDoor, e.doorTag); + append(e, LogNames.RunDoorMinutes, e.runTimeMinutes); }, [ systemEvents.UserSendNodeMsg ] : (e) => { append(e, LogNames.SendNodeMsg, e.global ? 'global' : 'direct'); diff --git a/core/system_events.js b/core/system_events.js index 1bed2d13..173f753b 100644 --- a/core/system_events.js +++ b/core/system_events.js @@ -19,7 +19,7 @@ module.exports = { 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', // { ... } + UserRunDoor : 'codes.l33t.enigma.system.user_run_door', // { ..., runTimeMinutes, doorTag|unknown } UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', // { ..., global } UserStatSet : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } UserStatIncrement : 'codes.l33t.enigma.system.user_stat_increment', // { ..., statName, statIncrementBy, statValue } diff --git a/core/user_log_name.js b/core/user_log_name.js index 6186d326..77fa996c 100644 --- a/core/user_log_name.js +++ b/core/user_log_name.js @@ -14,7 +14,8 @@ module.exports = { DlFileBytes : 'dl_file_bytes', // value=total bytes PostMessage : 'post_msg', // value=areaTag SendMail : 'send_mail', - RunDoor : 'run_door', + RunDoor : 'run_door', // value=doorTag|unknown + RunDoorMinutes : 'run_door_minutes', // value=minutes ran SendNodeMsg : 'send_node_msg', // value=global|direct AchievementEarned : 'achievement_earned', // value=achievementTag AchievementPointsEarned : 'achievement_pts_earned', // value=points earned From 4173a2e6db9e55595b14845c20edc2f481b60fa1 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 18 Jan 2019 23:13:22 -0700 Subject: [PATCH 41/63] Add docs for TopX module --- docs/_includes/nav.md | 1 + docs/modding/top-x.md | 60 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 docs/modding/top-x.md diff --git a/docs/_includes/nav.md b/docs/_includes/nav.md index 79ce2f31..e67f1163 100644 --- a/docs/_includes/nav.md +++ b/docs/_includes/nav.md @@ -82,6 +82,7 @@ - [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %}) - [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %}) - [Node to Node Messaging]({{ site.baseurl }}{% link modding/node-msg.md %}) + - [Top X]({{ site.baseurl }}{% link modding/top-x.md %}) - Administration - [oputil]({{ site.baseurl }}{% link admin/oputil.md %}) diff --git a/docs/modding/top-x.md b/docs/modding/top-x.md new file mode 100644 index 00000000..6ab9c545 --- /dev/null +++ b/docs/modding/top-x.md @@ -0,0 +1,60 @@ +--- +layout: page +title: TopX +--- +## The TopX Module +The built in `top_x` module allows for displaying oldschool top user stats for the week, month, etc. Ops can configure what stat(s) are displayed and how far back in days the stats are considered. + +## Configuration +### Config Block +Available `config` block entries: +* `mciMap`: Supplies a mapping of MCI code to data source. See `mciMap` below. + +#### MCI Map (mciMap) +The `mciMap` `config` block configures MCI code mapping to data sources. Currently the following data sources (determined by `type`) are available: + +| Type | Description | +|-------------|-------------| +| `userEventLog` | Top counts or sum of values found in the User Event Log. | +| `userProp` | Top values (aka "scores") from user properties. | + +##### User Event Log (userEventLog) +When `type` is set to `userEventLog`, entries from the User Event Log can be counted (ie: individual instances of a particular log item) or summed in the case of log items that have numeric values. The default is to sum. + +Some current User Event Log `logName` examples include `ul_files`, `dl_file_bytes`, or `achievement_earned`. See [user_log_name.js](/core/user_log_name.js) for additional information. + +Example `userEventLog` entry: +```hjson +mciMap: { + 1: { // e.g.: %VM1 + type: userEventLog + logName: achievement_pts_earned // top achievement points earned + sum: true // this is the default + daysBack: 7 // omit daysBack for all-of-time + } +} +``` + +#### User Properties (userProp) +When `type` is set to `userProp`, data is collected from individual user's properties. For example a `propName` of `minutes_online_total_count`. See [user_property.js](/core/user_property.js) for more information. + +Example `userProp` entry: +```hjson +mciMap: { + 2: { // e.g.: %VM2 + type: userProp + propName: minutes_online_total_count // top users by minutes spent on the board + } +} +``` + +### Theming +Generally `mciMap` entries will point to a Vertical List View Menu (`%VM1`, `%VM2`, etc.). The following `itemFormat` object is provided: +* `value`: The value acquired from the supplied data source. +* `userName`: User's username. +* `realName`: User's real name. +* `location`: User's location. +* `affils` or `affiliation`: Users affiliations. +* `position`: Rank position (numeric). + +Remember that string format rules apply, so for example, if displaying top uploaded bytes (`ul_file_bytes`), a `itemFormat` may be `{userName} - {value!sizeWithAbbr}` yielding something like "TopDude - 4 GB". See [MCI](/docs/art/mci.md) for additional information. From 4696bd9ff27a9ab70a3a3ae913e65750bdd12a8f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 18 Jan 2019 23:46:15 -0700 Subject: [PATCH 42/63] Fix PCBoard/WildCat! color codes --- core/color_codes.js | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/core/color_codes.js b/core/color_codes.js index 4119a8ce..ff08275e 100644 --- a/core/color_codes.js +++ b/core/color_codes.js @@ -131,7 +131,7 @@ function renegadeToAnsi(s, client) { // // Supported control code formats: // * Renegade : |## -// * PCBoard : @X## where the first number/char is FG color, and second is BG +// * PCBoard : @X## where the first number/char is BG color, and second is FG // * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix // * WWIV : ^# // * CNET Y-Style : 0x19## where ## is a specific set of codes -- this is the older format @@ -179,26 +179,6 @@ function controlCodesToAnsi(s, client) { v = m[4]; } - fg = { - 0 : [ 'reset', 'black' ], - 1 : [ 'reset', 'blue' ], - 2 : [ 'reset', 'green' ], - 3 : [ 'reset', 'cyan' ], - 4 : [ 'reset', 'red' ], - 5 : [ 'reset', 'magenta' ], - 6 : [ 'reset', 'yellow' ], - 7 : [ 'reset', 'white' ], - - 8 : [ 'blink', 'black' ], - 9 : [ 'blink', 'blue' ], - A : [ 'blink', 'green' ], - B : [ 'blink', 'cyan' ], - C : [ 'blink', 'red' ], - D : [ 'blink', 'magenta' ], - E : [ 'blink', 'yellow' ], - F : [ 'blink', 'white' ], - }[v.charAt(0)] || ['normal']; - bg = { 0 : [ 'blackBG' ], 1 : [ 'blueBG' ], @@ -217,7 +197,27 @@ function controlCodesToAnsi(s, client) { D : [ 'bold', 'magentaBG' ], E : [ 'bold', 'yellowBG' ], F : [ 'bold', 'whiteBG' ], - }[v.charAt(1)] || [ 'normal' ]; + }[v.charAt(0)] || [ 'normal' ]; + + fg = { + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'blink', 'black' ], + 9 : [ 'blink', 'blue' ], + A : [ 'blink', 'green' ], + B : [ 'blink', 'cyan' ], + C : [ 'blink', 'red' ], + D : [ 'blink', 'magenta' ], + E : [ 'blink', 'yellow' ], + F : [ 'blink', 'white' ], + }[v.charAt(1)] || ['normal']; v = ANSI.sgr(fg.concat(bg)); result += s.substr(lastIndex, m.index - lastIndex) + v; From 9b7b5c6fffa9c1c6c260934a8313e4bbe87fc3b3 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 18 Jan 2019 23:47:00 -0700 Subject: [PATCH 43/63] Initial to_ansi util for color codes -> ANSI --- util/to_ansi.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100755 util/to_ansi.js diff --git a/util/to_ansi.js b/util/to_ansi.js new file mode 100755 index 00000000..72838493 --- /dev/null +++ b/util/to_ansi.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const { controlCodesToAnsi } = require('../core/color_codes.js'); + +const fs = require('graceful-fs'); +const iconv = require('iconv-lite'); + +const ToolVersion = '1.0.0'; + +function main() { + const argv = exports.argv = require('minimist')(process.argv.slice(2), { + alias : { + h : 'help', + v : 'version', + } + }); + + if(argv.version) { + console.info(ToolVersion); + return 0; + } + + if(0 === argv._.length || argv.help) { + console.info('usage: to_ansi.js [--version] [--help] PATH'); + return 0; + } + + const path = argv._[0]; + + fs.readFile(path, (err, data) => { + if(err) { + console.error(err.message); + return -1; + } + + data = iconv.decode(data, 'cp437'); + console.info(controlCodesToAnsi(data)); + return 0; + }); +} + +main(); From 34f0afc1752ac022f184e45c2b0b62483dfa9a13 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 20 Jan 2019 12:22:42 -0700 Subject: [PATCH 44/63] Fix INSERT clause for cases of overlap --- core/achievement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/achievement.js b/core/achievement.js index 333b968c..d2ac2508 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -193,7 +193,7 @@ class Achievements { StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points); UserDb.run( - `INSERT INTO user_achievement (user_id, achievement_tag, timestamp, match) + `INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match) VALUES (?, ?, ?, ?);`, [ info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField ], err => { From 18a7a79f14e264bc6826b36aa340efb254d1e3a2 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 20 Jan 2019 21:57:31 -0700 Subject: [PATCH 45/63] TopX mciMap standardized on "value" vs (propName, logName, etc.) --- core/top_x.js | 18 +++++++++--------- docs/modding/top-x.md | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/core/top_x.js b/core/top_x.js index 37b4f4bd..2403c380 100644 --- a/core/top_x.js +++ b/core/top_x.js @@ -9,7 +9,6 @@ const { Errors } = require('./enig_error.js'); const UserDb = require('./database.js').dbs.user; const SysDb = require('./database.js').dbs.system; const User = require('./user.js'); -const stringFormat = require('./string_format.js'); // deps const _ = require('lodash'); @@ -68,7 +67,7 @@ exports.getModule = class TopXModule extends MenuModule { const type = o.type; switch(type) { case 'userProp' : - if(!userPropValues.includes(o.propName)) { + if(!userPropValues.includes(o.value)) { return false; } // VM# must exist for this mci @@ -78,7 +77,7 @@ exports.getModule = class TopXModule extends MenuModule { break; case 'userEventLog' : - if(!userLogValues.includes(o.logName)) { + if(!userLogValues.includes(o.value)) { return false; } // VM# must exist for this mci @@ -122,7 +121,7 @@ exports.getModule = class TopXModule extends MenuModule { return cb(Errors.UnexpectedState(`Failed to get view for MCI ${mciCode}`)); } - const type = this.config.mciMap[mciCode].type; + const type = this.config.mciMap[mciCode].type; switch(type) { case 'userProp' : return this.populateTopXUserProp(listView, mciCode, cb); case 'userEventLog' : return this.populateTopXUserEventLog(listView, mciCode, cb); @@ -148,9 +147,10 @@ exports.getModule = class TopXModule extends MenuModule { } populateTopXUserEventLog(listView, mciCode, cb) { - const count = listView.dimens.height || 1; - const daysBack = this.config.mciMap[mciCode].daysBack; - const shouldSum = _.get(this.config.mciMap[mciCode], 'sum', true); + const mciMap = this.config.mciMap[mciCode]; + const count = listView.dimens.height || 1; + const daysBack = mciMap.daysBack; + const shouldSum = _.get(mciMap, 'sum', true); const valueSql = shouldSum ? 'SUM(CAST(log_value AS INTEGER))' : 'COUNT()'; const dateSql = daysBack ? `AND DATETIME(timestamp) >= DATETIME('now', '-${daysBack} days')` : ''; @@ -162,7 +162,7 @@ exports.getModule = class TopXModule extends MenuModule { GROUP BY user_id ORDER BY value DESC LIMIT ${count};`, - [ this.config.mciMap[mciCode].logName ], + [ mciMap.value ], (err, rows) => { if(err) { return cb(err); @@ -188,7 +188,7 @@ exports.getModule = class TopXModule extends MenuModule { WHERE prop_name = ? ORDER BY value DESC LIMIT ${count};`, - [ this.config.mciMap[mciCode].propName ], + [ this.config.mciMap[mciCode].value ], (err, rows) => { if(err) { return cb(err); diff --git a/docs/modding/top-x.md b/docs/modding/top-x.md index 6ab9c545..50d69bee 100644 --- a/docs/modding/top-x.md +++ b/docs/modding/top-x.md @@ -3,7 +3,7 @@ layout: page title: TopX --- ## The TopX Module -The built in `top_x` module allows for displaying oldschool top user stats for the week, month, etc. Ops can configure what stat(s) are displayed and how far back in days the stats are considered. +The built in `top_x` module allows for displaying oLDSKOOL (?!) top user stats for the week, month, etc. Ops can configure what stat(s) are displayed and how far back in days the stats are considered. ## Configuration ### Config Block @@ -21,14 +21,14 @@ The `mciMap` `config` block configures MCI code mapping to data sources. Current ##### User Event Log (userEventLog) When `type` is set to `userEventLog`, entries from the User Event Log can be counted (ie: individual instances of a particular log item) or summed in the case of log items that have numeric values. The default is to sum. -Some current User Event Log `logName` examples include `ul_files`, `dl_file_bytes`, or `achievement_earned`. See [user_log_name.js](/core/user_log_name.js) for additional information. +Some current User Event Log `value` examples include `ul_files`, `dl_file_bytes`, or `achievement_earned`. See [user_log_name.js](/core/user_log_name.js) for additional information. Example `userEventLog` entry: ```hjson mciMap: { 1: { // e.g.: %VM1 type: userEventLog - logName: achievement_pts_earned // top achievement points earned + value: achievement_pts_earned // top achievement points earned sum: true // this is the default daysBack: 7 // omit daysBack for all-of-time } @@ -36,14 +36,14 @@ mciMap: { ``` #### User Properties (userProp) -When `type` is set to `userProp`, data is collected from individual user's properties. For example a `propName` of `minutes_online_total_count`. See [user_property.js](/core/user_property.js) for more information. +When `type` is set to `userProp`, data is collected from individual user's properties. For example a `value` of `minutes_online_total_count`. See [user_property.js](/core/user_property.js) for more information. Example `userProp` entry: ```hjson mciMap: { 2: { // e.g.: %VM2 type: userProp - propName: minutes_online_total_count // top users by minutes spent on the board + value: minutes_online_total_count // top users by minutes spent on the board } } ``` From 16e903d4c67bee9222161ca3c844ae36cae1e2e7 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 20 Jan 2019 21:58:00 -0700 Subject: [PATCH 46/63] Achievements are now recorded in more detail such that they can be retrieved *as they were* at the time of earning --- core/achievement.js | 120 ++++++++++++++++++++++++++++++++++---------- core/database.js | 3 ++ 2 files changed, 96 insertions(+), 27 deletions(-) diff --git a/core/achievement.js b/core/achievement.js index d2ac2508..3c200c9b 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -22,7 +22,10 @@ const { ErrorReasons } = require('./enig_error.js'); const { getThemeArt } = require('./theme.js'); -const { pipeToAnsi } = require('./color_codes.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; @@ -127,6 +130,56 @@ class Achievements { this.events = events; } + getAchievementsEarnedByUser(userId, cb) { + if(!this.isEnabled()) { + return cb(Errors.General('Achievements not enabled', ErrorReasons.Disabled)); + } + + 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(this.achievementConfig.achievements[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, + }; + + switch(earnedInfo.type) { + case [ Achievement.Types.UserStatSet ] : + case [ Achievement.Types.UserStatInc ] : + 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); + } + ); + } + + isEnabled() { + return !_.isUndefined(this.achievementConfig); + } + init(cb) { let achievementConfigPath = _.get(Config(), 'general.achievementFile'); if(!achievementConfigPath) { @@ -188,14 +241,19 @@ class Achievements { ); } - record(info, cb) { + record(info, localInterruptItem, cb) { StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1); StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points); + const recordData = [ + info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField, + stripMciColorCodes(localInterruptItem.title), stripMciColorCodes(localInterruptItem.achievText), info.details.points, + ]; + UserDb.run( - `INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match) - VALUES (?, ?, ?, ?);`, - [ info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField ], + `INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match, title, text, points) + VALUES (?, ?, ?, ?, ?, ?, ?);`, + recordData, err => { if(err) { return cb(err); @@ -215,32 +273,31 @@ class Achievements { ); } - display(info, cb) { - this.createAchievementInterruptItems(info, (err, interruptItems) => { - if(err) { - return cb(err); - } + display(info, interruptItems, cb) { + if(interruptItems.local) { + UserInterruptQueue.queue(interruptItems.local, { clients : info.client } ); + } - if(interruptItems.local) { - UserInterruptQueue.queue(interruptItems.local, { clients : info.client } ); - } + if(interruptItems.global) { + UserInterruptQueue.queue(interruptItems.global, { omit : info.client } ); + } - if(interruptItems.global) { - UserInterruptQueue.queue(interruptItems.global, { omit : info.client } ); - } - - return cb(null); - }); + return cb(null); } recordAndDisplayAchievement(info, cb) { - async.series( + async.waterfall( [ (callback) => { - return this.record(info, callback); + return this.createAchievementInterruptItems(info, callback); }, - (callback) => { - return this.display(info, callback); + (interruptItems, callback) => { + this.record(info, interruptItems.local, err => { + return callback(err, interruptItems); + }); + }, + (interruptItems, callback) => { + return this.display(info, interruptItems, callback); } ], err => { @@ -394,7 +451,7 @@ class Achievements { userAffils : info.user.properties[UserProps.Affiliations], nodeId : info.client.node, 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, achievedValue : info.achievedValue, matchField : info.matchField, @@ -480,8 +537,10 @@ class Achievements { (headerArt, footerArt, callback) => { const itemText = 'global' === itemType ? globalText : text; interruptItems[itemType] = { - text : `${title}\r\n${itemText}`, - pause : true, + title, + achievText : itemText, + text : `${title}\r\n${itemText}`, + pause : true, }; if(headerArt || footerArt) { const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); @@ -518,5 +577,12 @@ let achievements; exports.moduleInitialize = (initInfo, cb) => { achievements = new Achievements(initInfo.events); - return achievements.init(cb); + achievements.init( err => { + if(err) { + return cb(err); + } + + exports.achievements = achievements; + return cb(null); + }); }; diff --git a/core/database.js b/core/database.js index a6af1930..91f56a04 100644 --- a/core/database.js +++ b/core/database.js @@ -195,6 +195,9 @@ const DB_INIT_TABLE = { achievement_tag VARCHAR NOT NULL, timestamp DATETIME NOT NULL, match VARCHAR NOT NULL, + title VARCHAR NOT NULL, + text VARCHAR NOT NULL, + points INTEGER NOT NULL, UNIQUE(user_id, achievement_tag, match), FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE );` From 94609eef3c1b4f490f7569d40bf18518c6b850b0 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 22 Jan 2019 20:18:38 -0700 Subject: [PATCH 47/63] Minor updates --- docs/installation/windows.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/installation/windows.md b/docs/installation/windows.md index a68afe97..a9fcf060 100644 --- a/docs/installation/windows.md +++ b/docs/installation/windows.md @@ -1,14 +1,14 @@ --- layout: page -title: Windows Full Install +title: Installation Under Windows --- +## Installation Under Windows -ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit doors natively then you should use a 32bit Windows. - +ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit doors natively then you should use a 32bit Windows. ### Basic Instructions -1. Download and Install [Node.JS](https://nodejs.org/en/download/). +1. Download and Install [Node.JS](https://nodejs.org/). 1. Upgrade NPM : At this time node comes with NPM 5.6 preinstalled. To upgrade to a newer version now or in the future on windows follow this method. `*Run PowerShell as Administrator` From b45cccaef711a29651671cd1337b8c8fb7275ff9 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 22 Jan 2019 20:19:05 -0700 Subject: [PATCH 48/63] Don't real-time interrupt while you interrupt... yo dawg. --- core/menu_module.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/core/menu_module.js b/core/menu_module.js index 15e7ebce..52784042 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -203,17 +203,24 @@ exports.MenuModule = class MenuModule extends PluginModule { return cb(null, false); // don't eat up the item; queue for later } + this.realTimeInterrupt = 'blocked'; + // // Default impl: clear screen -> standard display -> reload menu // + const done = (err, removeFromQueue) => { + this.realTimeInterrupt = 'allowed'; + return cb(err, removeFromQueue); + }; + this.client.interruptQueue.displayWithItem( Object.assign({}, interruptItem, { cls : true }), err => { if(err) { - return cb(err, false); + return done(err, false); } this.reload(err => { - return cb(err, err ? false : true); + return done(err, err ? false : true); }); }); } From 4b763cc3692803f284075f8794ae5cf0b51c3c88 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 22 Jan 2019 21:54:12 -0700 Subject: [PATCH 49/63] Spelling --- core/menu_module.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/menu_module.js b/core/menu_module.js index 52784042..06c0f6d7 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -329,7 +329,7 @@ exports.MenuModule = class MenuModule extends PluginModule { // A quick rundown: // * We may have mciData.menu, mciData.prompt, or both. // * Prompt form is favored over menu form if both are present. - // * Standard/prefdefined MCI entries must load both (e.g. %BN is expected to resolve) + // * Standard/predefined MCI entries must load both (e.g. %BN is expected to resolve) // const self = this; From 4f0ade6ce139d30007d7529842748033c9a90757 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 22 Jan 2019 21:54:37 -0700 Subject: [PATCH 50/63] * getAchievementsEarnedByUser() exported as standard method using global inst * Added timestamp info --- core/achievement.js | 105 +++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/core/achievement.js b/core/achievement.js index 3c200c9b..dc933151 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -37,6 +37,8 @@ const async = require('async'); const moment = require('moment'); const paths = require('path'); +exports.getAchievementsEarnedByUser = getAchievementsEarnedByUser; + class Achievement { constructor(data) { this.data = data; @@ -130,50 +132,8 @@ class Achievements { this.events = events; } - getAchievementsEarnedByUser(userId, cb) { - if(!this.isEnabled()) { - return cb(Errors.General('Achievements not enabled', ErrorReasons.Disabled)); - } - - 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(this.achievementConfig.achievements[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, - }; - - switch(earnedInfo.type) { - case [ Achievement.Types.UserStatSet ] : - case [ Achievement.Types.UserStatInc ] : - 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); - } - ); + getAchievementByTag(tag) { + return this.achievementConfig.achievements[tag]; } isEnabled() { @@ -341,7 +301,7 @@ class Achievements { return; } - const achievement = Achievement.factory(this.achievementConfig.achievements[achievementTag]); + const achievement = Achievement.factory(this.getAchievementByTag(achievementTag)); if(!achievement) { return; } @@ -573,16 +533,63 @@ class Achievements { } } -let achievements; +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 ] : + 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) => { - achievements = new Achievements(initInfo.events); - achievements.init( err => { + achievementsInstance = new Achievements(initInfo.events); + achievementsInstance.init( err => { if(err) { return cb(err); } - exports.achievements = achievements; return cb(null); }); }; From ae2a225e3a2c131e82cd5da5f6b8e44d136271bf Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 22 Jan 2019 21:55:28 -0700 Subject: [PATCH 51/63] Module for listing user achievements earned --- core/user_achievements_earned.js | 101 +++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 core/user_achievements_earned.js diff --git a/core/user_achievements_earned.js b/core/user_achievements_earned.js new file mode 100644 index 00000000..b6aee4f8 --- /dev/null +++ b/core/user_achievements_earned.js @@ -0,0 +1,101 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { Errors } = require('./enig_error.js'); +const { + getAchievementsEarnedByUser +} = require('./achievement.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'User Achievements Earned', + desc : 'Lists achievements earned by a user', + author : 'NuSkooler', +}; + +const MciViewIds = { + achievementList : 1, + customRangeStart : 10, // updated @ index update +}; + +exports.getModule = class UserAchievementsEarned extends MenuModule { + constructor(options) { + super(options); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.waterfall( + [ + (callback) => { + this.prepViewController('achievements', 0, mciData.menu, err => { + return callback(err); + }); + }, + (callback) => { + return this.validateMCIByViewIds('achievements', MciViewIds.achievementList, callback); + }, + (callback) => { + return getAchievementsEarnedByUser(this.client.user.userId, callback); + }, + (achievementsEarned, callback) => { + this.achievementsEarned = achievementsEarned; + + const achievementListView = this.viewControllers.achievements.getView(MciViewIds.achievementList); + + achievementListView.on('index update', idx => { + this.selectionIndexUpdate(idx); + }); + + const dateTimeFormat = _.get( + this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateFormat('short')); + + achievementListView.setItems(achievementsEarned.map(achiev => Object.assign( + achiev, + this.getUserInfo(), + { + ts : achiev.timestamp.format(dateTimeFormat), + } + ))); + achievementListView.redraw(); + this.selectionIndexUpdate(0); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + getUserInfo() { + // :TODO: allow args to pass in a different user - ie from user list -> press A for achievs, so on... + return { + userId : this.client.user.userId, + userName : this.client.user.username, + realName : this.client.user.getProperty(UserProps.RealName), + location : this.client.user.getProperty(UserProps.Location), + affils : this.client.user.getProperty(UserProps.Affiliations), + }; + } + + selectionIndexUpdate(index) { + const achiev = this.achievementsEarned[index]; + if(!achiev) { + return; + } + this.updateCustomViewTextsWithFilter('achievements', MciViewIds.customRangeStart, achiev); + } +}; From aa9cd8899c4bbea5fd3892cb8e6b87702fbe0264 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Jan 2019 21:53:31 -0700 Subject: [PATCH 52/63] New 'userStatIncNewVal' achievement type --- core/achievement.js | 142 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 134 insertions(+), 8 deletions(-) diff --git a/core/achievement.js b/core/achievement.js index dc933151..7b6a4b16 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -52,6 +52,7 @@ class Achievement { switch(data.type) { case Achievement.Types.UserStatSet : case Achievement.Types.UserStatInc : + case Achievement.Types.UserStatIncNewVal : achievement = new UserStatAchievement(data); break; @@ -65,8 +66,9 @@ class Achievement { static get Types() { return { - UserStatSet : 'userStatSet', - UserStatInc : 'userStatInc', + UserStatSet : 'userStatSet', + UserStatInc : 'userStatInc', + UserStatIncNewVal : 'userStatIncNewVal', }; } @@ -74,6 +76,7 @@ class Achievement { switch(this.data.type) { case Achievement.Types.UserStatSet : case Achievement.Types.UserStatInc : + case Achievement.Types.UserStatIncNewVal : if(!_.isString(this.data.statName)) { return false; } @@ -286,14 +289,135 @@ class Achievements { } // :TODO: Make this code generic - find + return factory created object + const achievementTags = Object.keys(_.pickBy( + _.get(this.achievementConfig, '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.series( + [ + (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 ]; + if(true === achievement.data.retroactive) { + // For userStat, any lesser match keys(values) are also met. Example: + // matchKeys: [ 500, 200, 100, 20, 10, 2 ] + // ^---- we met here + // ^------------^ retroactive range + // + const index = achievement.matchKeys.findIndex(v => v < matchField); + if(index > -1 && Array.isArray(achievement.matchKeys)) { + achievement.matchKeys.slice(index).forEach(k => { + const [ det, fld, val ] = achievement.getMatchDetails(k); + if(det) { + achievementsInfo.push(Object.assign( + {}, + info, + { + details : det, + matchField : fld, + achievedValue : fld, + matchValue : val, + } + )); + } + }); + } + } + + // 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 + } + ); + }); + + /* const achievementTag = _.findKey( _.get(this.achievementConfig, 'achievements', {}), achievement => { if(false === achievement.enabled) { return false; } - return [ Achievement.Types.UserStatSet, Achievement.Types.UserStatInc ].includes(achievement.type) && - achievement.statName === userStatEvent.statName; + const acceptedTypes = [ + Achievement.Types.UserStatSet, + Achievement.Types.UserStatInc, + Achievement.Types.UserStatIncNewVal, + ]; + return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName; } ); @@ -306,9 +430,10 @@ class Achievements { return; } - const statValue = parseInt(Achievement.Types.UserStatSet === achievement.data.type ? - userStatEvent.statValue : - userStatEvent.statIncrementBy + const statValue = parseInt( + [ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ? + userStatEvent.statValue : + userStatEvent.statIncrementBy ); if(isNaN(statValue)) { return; @@ -392,7 +517,7 @@ class Achievements { Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event'); } } - ); + );*/ }); } @@ -571,6 +696,7 @@ function getAchievementsEarnedByUser(userId, cb) { switch(earnedInfo.type) { case [ Achievement.Types.UserStatSet ] : case [ Achievement.Types.UserStatInc ] : + case [ Achievement.Types.UserStatIncNewVal ] : earnedInfo.statName = achievement.data.statName; break; } From eea9e7b5e68931403bde859f261414f92ad75706 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Jan 2019 21:53:45 -0700 Subject: [PATCH 53/63] Don't use Errors --- core/user_achievements_earned.js | 1 - 1 file changed, 1 deletion(-) diff --git a/core/user_achievements_earned.js b/core/user_achievements_earned.js index b6aee4f8..ef793023 100644 --- a/core/user_achievements_earned.js +++ b/core/user_achievements_earned.js @@ -3,7 +3,6 @@ // ENiGMA½ const { MenuModule } = require('./menu_module.js'); -const { Errors } = require('./enig_error.js'); const { getAchievementsEarnedByUser } = require('./achievement.js'); From 0efa148f63e52d6ca3a8cfd8c0d7ed0a12b8de44 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Jan 2019 21:54:16 -0700 Subject: [PATCH 54/63] Better incrementUserStat() --- core/stat_log.js | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/core/stat_log.js b/core/stat_log.js index f03319d0..af88ff57 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -147,29 +147,34 @@ class StatLog { incrementUserStat(user, statName, incrementBy, cb) { incrementBy = incrementBy || 1; - let newValue = parseInt(user.properties[statName]); - if(newValue) { - if(!_.isNumber(newValue) && cb) { - return cb(new Error(`Value for ${statName} is not a number!`)); - } - newValue += incrementBy; - } else { - newValue = incrementBy; - } + const oldValue = user.getPropertyAsNumber(statName) || 0; + const newValue = oldValue + incrementBy; - this.setUserStatWithOptions(user, statName, newValue, { noEvent : true }, err => { - if(!err) { - const Events = require('./events.js'); // we need to late load currently - Events.emit( - Events.getSystemEvents().UserStatIncrement, - { user, statName, statIncrementBy: incrementBy, statValue : newValue } - ); - } + this.setUserStatWithOptions( + user, + statName, + newValue, + { noEvent : true }, + err => { + if(!err) { + const Events = require('./events.js'); // we need to late load currently + Events.emit( + Events.getSystemEvents().UserStatIncrement, + { + user, + statName, + oldValue, + statIncrementBy : incrementBy, + statValue : newValue + } + ); + } - if(cb) { - return cb(err); + if(cb) { + return cb(err); + } } - }); + ); } // the time "now" in the ISO format we use and love :) From 69247eadf13153b4f1b812de2190cfdec13d1b5b Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Jan 2019 21:54:56 -0700 Subject: [PATCH 55/63] Minor adjust --- art/themes/luciano_blocktronics/STATUS.ANS | Bin 4638 -> 4639 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/art/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS index 6871ff457b0b3d44ec533e785fac3b02aa03586e..dc2b0ca88cc1f74e440a91bba90cf1758ad776d4 100644 GIT binary patch delta 47 zcmbQIGGArG2R;@BAej7xPie9!|6}HYlH$p6EMn}A0n*V1xeA*l1RgLlZkzm5P!#}o CLl15M delta 47 zcmbQQGEZg02R;_*XjA9OU-* Date: Thu, 24 Jan 2019 21:55:03 -0700 Subject: [PATCH 56/63] Many updates + user_door_run_total_minutes with new userStatIncNewVal type * Balance & add some new brackets to existing --- config/achievements.hjson | 144 ++++++++++++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 30 deletions(-) diff --git a/config/achievements.hjson b/config/achievements.hjson index d5099f8c..4ad30565 100644 --- a/config/achievements.hjson +++ b/config/achievements.hjson @@ -67,7 +67,7 @@ title: "Curious Caller" globalText: "{userName} has logged into {boardName} {achievedValue} times!" text: "You've logged into {boardName} {achievedValue} times!" - points: 5 + points: 10 } 25: { title: "Inquisitive" @@ -75,17 +75,29 @@ text: "You've logged into {boardName} {achievedValue} times!" points: 10 } + 75: { + title: "Still Interested!" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 15 + } 100: { title: "Regular Customer" globalText: "{userName} has logged into {boardName} {achievedValue} times!" text: "You've logged into {boardName} {achievedValue} times!" - points: 10 + points: 25 + } + 250: { + title: "Speed Dial", + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 50 } 500: { title: "System Addict" globalText: "{userName} the BBS {boardName} addict has logged in {achievedValue} times!" text: "You're a {boardName} addict! You've logged in {achievedValue} times!" - points: 25 + points: 50 } } } @@ -94,29 +106,41 @@ type: userStatSet statName: post_count match: { - 5: { + 2: { title: "Poster" globalText: "{userName} has posted {achievedValue} messages!" text: "You've posted {achievedValue} messages!" points: 5 } - 20: { + 5: { title: "Poster... again!", globalText: "{userName} has posted {achievedValue} messages!" text: "You've posted {achievedValue} messages!" + points: 5 + } + 20: { + title: "Just Want to Talk", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" points: 10 } 100: { - title: "Frequent Poster", + title: "Probably Just Spam", globalText: "{userName} has posted {achievedValue} messages!" text: "You've posted {achievedValue} messages!" - points: 15 + points: 25 } - 500: { + 250: { title: "Scribe" globalText: "{userName} the scribe has posted {achievedValue} messages!" text: "Such a scribe! You've posted {achievedValue} messages!" - points: 25 + points: 50 + } + 500: { + title: "Writing a Book" + globalText: "{userName} is writing a book and has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 50 } } } @@ -141,20 +165,20 @@ title: "Contributor" globalText: "{userName} has uploaded {achievedValue} files!" text: "You've uploaded {achievedValue} files!" - points: 20 + points: 25 } 100: { title: "Courier" globalText: "Courier {userName} has uploaded {achievedValue} files!" text: "You've uploaded {achievedValue} files!" - points: 25 + points: 50 } 200: { title: "Must Be a Drop Site" globalText: "{userName} has uploaded a whomping {achievedValue} files!" text: "You've uploaded a whomping {achievedValue} files!" - points: 50 + points: 55 } } } @@ -170,15 +194,21 @@ points: 10 } 1474560: { - title: "America Online 2.5?" - globalText: "{userName} has uploaded 1.44M worth of data. Hopefully it's not AOL 2.5." - title: "You've uploaded 1.44M worth of data. Hopefully it's not AOL 2.5." - points: 15 + title: "AOL Disk Anyone?" + globalText: "{userName} has uploaded 1.44M worth of data. Hopefully it's not AOL!" + title: "You've uploaded 1.44M worth of data. Hopefully it's not AOL!" + points: 10 } 6291456: { title: "A Quake of a Upload" globalText: "{userName} has uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" text: "You've uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" + points: 20 + } + 104857600: { + title: "Zip 100" + globalText: "{userName} has uploaded a Zip 100 disk's worth of data!" + text: "You've uploaded a Zip 100 disk's worth of data!" points: 25 } 1073741824: { @@ -189,8 +219,8 @@ } 3407872000: { title: "Encarta" - globalText: "{userName} has uploaded 5 x CD-ROM disks worth of data. That's the size of Encarta!" - text: "You've uploaded 5 x CD-ROM disks worth of data. That's the size of Encarta!" + globalText: "{userName} has uploaded 5xCD discs worth of data. That's the size of Encarta!" + text: "You've uploaded 5xCD discs worth of data. That's the size of Encarta!" points: 100 } } @@ -247,19 +277,30 @@ title: "Fits on a Floppy" globalText: "{userName} has downloaded 1.44MB worth of data!" text: "You've downloaded 1.44MB of data!" - points: 10 + points: 5 } 104857600: { title: "Click of Death" globalText: "{userName} has downloaded 100MB... perhaps to a Zip Disk?" text: "You've downloaded 100MB of data... perhaps to a Zip Disk?" - points: 15 + points: 10 } 681574400: { - title: "A CD-ROM Worth" + title: "CD Rip" globalText: "{userName} has downloaded a CD-ROM's worth of data!" text: "You've downloaded a CD-ROM's worth of data!" - points: 20 + points: 15 + } + 1073741824: { + title: "Like One Hundred Floppys, Man" + globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!" + text: "You've downloaded {achievedValue!sizeWithAbbr} of data!" + points: 25 + } + 5368709120: { + title: "That's a Lot of Bits!" + globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!" + text: "You've downloaded {achievedValue!sizeWithAbbr} of data!" } } } @@ -284,24 +325,24 @@ title: "Gamer" globalText: "{userName} ran {achievedValue} doors!" text: "You've run {achievedValue} doors!" - points: 15 + points: 20 } 100: { - title: "Textmode is All You Need" + title: "Trying Them All" globalText: "{userName} must really like textmode and has run {achievedValue} doors!" text: "You've run {achievedValue} doors! You must really like textmode!" - points: 25 + points: 50 } 200: { title: "Dropfile Enthusiast" globalText: "{userName} the dropfile enthusiast ran {achievedValue} doors!" text: "You're a dropfile enthusiast! You've run {achievedValue} doors!" - points: 100 + points: 55 } } } - user_door_total_minutes: { + user_individual_door_run_minutes: { type: userStatInc statName: door_run_total_minutes match: { @@ -324,11 +365,54 @@ points: 20 } 60: { - title: "Textmode Dragon Slayer" + title: "What? Limited Turns?!" globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" text: "You've spent {achievedValue!durationMinutes} in a door!" points: 25 } + 120: { + title: "It's the Only One I Know!" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 50 + } + 240: { + title: "Possible Addict" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 55 + } + } + } + + user_door_run_total_minutes: { + type: userStatIncNewVal + statName: door_run_total_minutes + match: { + 10: { + title: "Enough for the Instructions" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 10 + } + 30: { + title: "Probably Just L.O.R.D." + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 20 + } + 60: { + title: "Retro or Bust" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 25 + } + 240: { + title: "Textmode Dragon Slayer" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 50 + } } } @@ -358,9 +442,9 @@ title: "Idle Bot" globalText: "{userName} is probably a bot. They've spent {achievedValue!durationMinutes} on {boardName}!" text: "You're a bot, aren't you? You've been on {boardName} for a total of {achievedValue!durationMinutes}!" - points: 50 + points: 55 } } } } -} \ No newline at end of file +} From 289e49f0b986aada60ca212ef64e3b63b2a67012 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 24 Jan 2019 21:56:30 -0700 Subject: [PATCH 57/63] User achievements list --- art/themes/luciano_blocktronics/USERACHIEV.ANS | Bin 0 -> 492 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 art/themes/luciano_blocktronics/USERACHIEV.ANS diff --git a/art/themes/luciano_blocktronics/USERACHIEV.ANS b/art/themes/luciano_blocktronics/USERACHIEV.ANS new file mode 100644 index 0000000000000000000000000000000000000000..f061f04a5ee37b07fa8bd60bc7d131aeb63b642a GIT binary patch literal 492 zcmb_YJx{|h6iimH419R;(wS!`Gy+c{SWu~{Xj8IyMBRAmfW!~rZ#KgJ1aWpkg%Bfe zc(UJ}@9tT8vL)G~Vxgojh-0sKI7qK;iNhd$NjwPY6BdUSv@p`b5WoZh9XK9qTNwU~ zDs!%zhlT51>sH%Nxq7p5cM$;oTMjPB0c8Wnq%wwr%`{DZxKawLV@{+v3?D&Ew(t)s zh;L~~safA@@uQN+7$NqGXWONwv^m(v3EoJ5HE*VE&dz}l-=HTJzRNK0-*&*Uv+FNjzW~Hjbo~GT literal 0 HcmV?d00001 From 3450500d273c246d073b0b439a5c2e175d818acc Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 26 Jan 2019 12:04:59 -0700 Subject: [PATCH 58/63] factory() should not crash if data is null --- core/achievement.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/achievement.js b/core/achievement.js index 7b6a4b16..a5de541f 100644 --- a/core/achievement.js +++ b/core/achievement.js @@ -48,6 +48,9 @@ class Achievement { } static factory(data) { + if(!data) { + return; + } let achievement; switch(data.type) { case Achievement.Types.UserStatSet : From c98e1474d026ea864b813149703ce1b79431c38a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 26 Jan 2019 12:05:07 -0700 Subject: [PATCH 59/63] Add totalPoints, totalCount --- core/user_achievements_earned.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/user_achievements_earned.js b/core/user_achievements_earned.js index ef793023..4292cb91 100644 --- a/core/user_achievements_earned.js +++ b/core/user_achievements_earned.js @@ -87,6 +87,8 @@ exports.getModule = class UserAchievementsEarned extends MenuModule { realName : this.client.user.getProperty(UserProps.RealName), location : this.client.user.getProperty(UserProps.Location), affils : this.client.user.getProperty(UserProps.Affiliations), + totalCount : this.client.user.getProperty(UserProps.AchievementTotalCount), + totalPoints : this.client.user.getProperty(UserProps.AchievementTotalPoints), }; } From 301aacd9d8ba50b278f47ce72a000ebecc08c981 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 26 Jan 2019 12:55:25 -0700 Subject: [PATCH 60/63] Add mainMenuUserAchievementsEarned --- misc/menu_template.in.hjson | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index 60c57605..d303f452 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -1062,6 +1062,10 @@ value: { command: "BBS"} action: @menu:bbsList } + { + value: { command: "UA" } + action: @menu:mainMenuUserAchievementsEarned + } { value: 1 action: @menu:mainMenu @@ -1069,6 +1073,27 @@ ] } + mainMenuUserAchievementsEarned: { + desc: Achievements + module: user_achievements_earned + art: USERACHIEV + form: { + 0: { + mci: { + VM1: { + focus: true + } + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + nodeMessage: { desc: Node Messaging module: node_msg From 207711f1d144b15789d680ac97c4fca49782c9a5 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 26 Jan 2019 12:56:45 -0700 Subject: [PATCH 61/63] Achievements earned --- art/themes/luciano_blocktronics/MMENU.ANS | Bin 3574 -> 3610 bytes art/themes/luciano_blocktronics/theme.hjson | 22 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/art/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS index ad029e33f765113432e247acfd1b6a04a6e67e4d..bd8a483f56406abf49f17d727730aab0a4a157ff 100644 GIT binary patch delta 56 zcmew+JxgYT7_XY4fwOe9VQy)nf^@WjwXs=lVsb`iYFTP-YF Date: Sat, 26 Jan 2019 12:57:07 -0700 Subject: [PATCH 62/63] totalCount & totalPoints should be numbers --- core/user_achievements_earned.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/user_achievements_earned.js b/core/user_achievements_earned.js index 4292cb91..1004a9d0 100644 --- a/core/user_achievements_earned.js +++ b/core/user_achievements_earned.js @@ -87,8 +87,8 @@ exports.getModule = class UserAchievementsEarned extends MenuModule { realName : this.client.user.getProperty(UserProps.RealName), location : this.client.user.getProperty(UserProps.Location), affils : this.client.user.getProperty(UserProps.Affiliations), - totalCount : this.client.user.getProperty(UserProps.AchievementTotalCount), - totalPoints : this.client.user.getProperty(UserProps.AchievementTotalPoints), + totalCount : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalCount), + totalPoints : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalPoints), }; } From 6193dca58a712fa3ae5cb6fc7c24b1ee87a598f9 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 26 Jan 2019 12:57:29 -0700 Subject: [PATCH 63/63] Stats that are numbers should be formatted --- core/predefined_mci.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 2e7ed5ff..a1182a79 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -11,7 +11,9 @@ const { const clientConnections = require('./client_connections.js'); const StatLog = require('./stat_log.js'); const FileBaseFilters = require('./file_base_filter.js'); -const { formatByteSize } = require('./string_util.js'); +const { + formatByteSize, +} = require('./string_util.js'); const ANSI = require('./ansi_term.js'); const UserProps = require('./user_property.js'); const SysProps = require('./system_property.js'); @@ -54,6 +56,15 @@ function userStatAsString(client, statName, defaultValue) { return (StatLog.getUserStat(client.user, statName) || defaultValue).toLocaleString(); } +function toNumberWithCommas(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +function userStatAsCountString(client, statName, defaultValue) { + const value = StatLog.getUserStatNum(client.user, statName) || defaultValue; + return toNumberWithCommas(value); +} + function sysStatAsString(statName, defaultValue) { return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString(); } @@ -97,7 +108,7 @@ const PREDEFINED_MCI_GENERATORS = { return _.get(client, 'currentTheme.info.name', userStatAsString(client, UserProps.ThemeId, '')); }, UD : function themeId(client) { return userStatAsString(client, UserProps.ThemeId, ''); }, - UC : function loginCount(client) { return userStatAsString(client, UserProps.LoginCount, 0); }, + UC : function loginCount(client) { return userStatAsCountString(client, UserProps.LoginCount, 0); }, ND : function connectedNode(client) { return client.node.toString(); }, IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version ST : function serverName(client) { return client.session.serverName; }, @@ -105,12 +116,12 @@ const PREDEFINED_MCI_GENERATORS = { const activeFilter = FileBaseFilters.getActiveFilter(client); return activeFilter ? activeFilter.name : '(Unknown)'; }, - DN : function userNumDownloads(client) { return userStatAsString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2 + DN : function userNumDownloads(client) { return userStatAsCountString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2 DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes); return formatByteSize(byteSize, true); // true=withAbbr }, - UP : function userNumUploads(client) { return userStatAsString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2 + UP : function userNumUploads(client) { return userStatAsCountString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2 UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes); return formatByteSize(byteSize, true); // true=withAbbr @@ -125,7 +136,7 @@ const PREDEFINED_MCI_GENERATORS = { 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); }, + PS : function userPostCount(client) { return userStatAsCountString(client, UserProps.MessagePostCount, 0); }, PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); }, MD : function currentMenuDescription(client) { @@ -152,10 +163,10 @@ 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); }, + AC : function achievementCount(client) { return userStatAsCountString(client, UserProps.AchievementTotalCount, 0); }, + AP : function achievementPoints(client) { return userStatAsCountString(client, UserProps.AchievementTotalPoints, 0); }, - DR : function doorRuns(client) { return userStatAsString(client, UserProps.DoorRunTotalCount, 0); }, + DR : function doorRuns(client) { return userStatAsCountString(client, UserProps.DoorRunTotalCount, 0); }, DM : function doorFriendlyRunTime(client) { const minutes = client.user.properties[UserProps.DoorRunTotalMinutes] || 0; return moment.duration(minutes, 'minutes').humanize();