Merge branch '0.0.9-alpha' of github.com:NuSkooler/enigma-bbs into 0.0.9-alpha
This commit is contained in:
@@ -6,8 +6,11 @@ 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 {
|
||||
trackDoorRunBegin,
|
||||
trackDoorRunEnd
|
||||
} = require('./door_util.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
@@ -149,8 +152,6 @@ exports.getModule = class AbracadabraModule extends MenuModule {
|
||||
}
|
||||
|
||||
runDoor() {
|
||||
Events.emit(Events.getSystemEvents().UserRunDoor, { user : this.client.user } );
|
||||
|
||||
this.client.term.write(ansi.resetScreen());
|
||||
|
||||
const exeInfo = {
|
||||
@@ -164,7 +165,11 @@ exports.getModule = class AbracadabraModule extends MenuModule {
|
||||
node : this.client.node,
|
||||
};
|
||||
|
||||
const doorTracking = trackDoorRunBegin(this.client, this.config.name);
|
||||
|
||||
this.doorInstance.run(exeInfo, () => {
|
||||
trackDoorRunEnd(doorTracking);
|
||||
|
||||
//
|
||||
// Try to clean up various settings such as scroll regions that may
|
||||
// have been set within the door
|
||||
|
||||
724
core/achievement.js
Normal file
724
core/achievement.js
Normal file
@@ -0,0 +1,724 @@
|
||||
/* jslint node: true */
|
||||
'use strict';
|
||||
|
||||
// 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
|
||||
} = require('./database.js');
|
||||
const UserInterruptQueue = require('./user_interrupt_queue.js');
|
||||
const {
|
||||
getConnectionByUserId
|
||||
} = require('./client_connections.js');
|
||||
const UserProps = require('./user_property.js');
|
||||
const {
|
||||
Errors,
|
||||
ErrorReasons
|
||||
} = require('./enig_error.js');
|
||||
const { getThemeArt } = require('./theme.js');
|
||||
const {
|
||||
pipeToAnsi,
|
||||
stripMciColorCodes
|
||||
} = require('./color_codes.js');
|
||||
const stringFormat = require('./string_format.js');
|
||||
const StatLog = require('./stat_log.js');
|
||||
const Log = require('./logger.js').log;
|
||||
const ConfigCache = require('./config_cache.js');
|
||||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
const async = require('async');
|
||||
const moment = require('moment');
|
||||
const paths = require('path');
|
||||
|
||||
exports.getAchievementsEarnedByUser = getAchievementsEarnedByUser;
|
||||
|
||||
class Achievement {
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
|
||||
// achievements are retroactive by default
|
||||
this.data.retroactive = _.get(this.data, 'retroactive', true);
|
||||
}
|
||||
|
||||
static factory(data) {
|
||||
if(!data) {
|
||||
return;
|
||||
}
|
||||
let achievement;
|
||||
switch(data.type) {
|
||||
case Achievement.Types.UserStatSet :
|
||||
case Achievement.Types.UserStatInc :
|
||||
case Achievement.Types.UserStatIncNewVal :
|
||||
achievement = new UserStatAchievement(data);
|
||||
break;
|
||||
|
||||
default : return;
|
||||
}
|
||||
|
||||
if(achievement.isValid()) {
|
||||
return achievement;
|
||||
}
|
||||
}
|
||||
|
||||
static get Types() {
|
||||
return {
|
||||
UserStatSet : 'userStatSet',
|
||||
UserStatInc : 'userStatInc',
|
||||
UserStatIncNewVal : 'userStatIncNewVal',
|
||||
};
|
||||
}
|
||||
|
||||
isValid() {
|
||||
switch(this.data.type) {
|
||||
case Achievement.Types.UserStatSet :
|
||||
case Achievement.Types.UserStatInc :
|
||||
case Achievement.Types.UserStatIncNewVal :
|
||||
if(!_.isString(this.data.statName)) {
|
||||
return false;
|
||||
}
|
||||
if(!_.isObject(this.data.match)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
default : return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getMatchDetails(/*matchAgainst*/) {
|
||||
}
|
||||
|
||||
isValidMatchDetails(details) {
|
||||
if(!details || !_.isString(details.title) || !_.isString(details.text) || !_.isNumber(details.points)) {
|
||||
return false;
|
||||
}
|
||||
return (_.isString(details.globalText) || !details.globalText);
|
||||
}
|
||||
}
|
||||
|
||||
class UserStatAchievement extends Achievement {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
|
||||
// sort match keys for quick match lookup
|
||||
this.matchKeys = Object.keys(this.data.match || {}).map(k => parseInt(k)).sort( (a, b) => b - a);
|
||||
}
|
||||
|
||||
isValid() {
|
||||
if(!super.isValid()) {
|
||||
return false;
|
||||
}
|
||||
return !Object.keys(this.data.match).some(k => !parseInt(k));
|
||||
}
|
||||
|
||||
getMatchDetails(matchValue) {
|
||||
let ret = [];
|
||||
let matchField = this.matchKeys.find(v => matchValue >= v);
|
||||
if(matchField) {
|
||||
const match = this.data.match[matchField];
|
||||
matchField = parseInt(matchField);
|
||||
if(this.isValidMatchDetails(match) && !isNaN(matchField)) {
|
||||
ret = [ match, matchField, matchValue ];
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
class Achievements {
|
||||
constructor(events) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
getAchievementByTag(tag) {
|
||||
return this.achievementConfig.achievements[tag];
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return !_.isUndefined(this.achievementConfig);
|
||||
}
|
||||
|
||||
init(cb) {
|
||||
let achievementConfigPath = _.get(Config(), 'general.achievementFile');
|
||||
if(!achievementConfigPath) {
|
||||
Log.info('Achievements are not configured');
|
||||
return cb(null);
|
||||
}
|
||||
achievementConfigPath = getConfigPath(achievementConfigPath); // qualify
|
||||
|
||||
const configLoaded = (achievementConfig) => {
|
||||
if(true !== achievementConfig.enabled) {
|
||||
Log.info('Achievements are not enabled');
|
||||
this.stopMonitoringUserStatEvents();
|
||||
delete this.achievementConfig;
|
||||
} else {
|
||||
Log.info('Achievements are enabled');
|
||||
this.achievementConfig = achievementConfig;
|
||||
this.monitorUserStatEvents();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
loadAchievementHitCount(user, achievementTag, field, cb) {
|
||||
UserDb.get(
|
||||
`SELECT COUNT() AS count
|
||||
FROM user_achievement
|
||||
WHERE user_id = ? AND achievement_tag = ? AND match = ?;`,
|
||||
[ user.userId, achievementTag, field],
|
||||
(err, row) => {
|
||||
return cb(err, row ? row.count : 0);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
record(info, localInterruptItem, cb) {
|
||||
StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1);
|
||||
StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points);
|
||||
|
||||
const 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, title, text, points)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
recordData,
|
||||
err => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
this.events.emit(
|
||||
Events.getSystemEvents().UserAchievementEarned,
|
||||
{
|
||||
user : info.client.user,
|
||||
achievementTag : info.achievementTag,
|
||||
points : info.details.points,
|
||||
}
|
||||
);
|
||||
|
||||
return cb(null);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
display(info, interruptItems, cb) {
|
||||
if(interruptItems.local) {
|
||||
UserInterruptQueue.queue(interruptItems.local, { clients : info.client } );
|
||||
}
|
||||
|
||||
if(interruptItems.global) {
|
||||
UserInterruptQueue.queue(interruptItems.global, { omit : info.client } );
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
recordAndDisplayAchievement(info, cb) {
|
||||
async.waterfall(
|
||||
[
|
||||
(callback) => {
|
||||
return this.createAchievementInterruptItems(info, callback);
|
||||
},
|
||||
(interruptItems, callback) => {
|
||||
this.record(info, interruptItems.local, err => {
|
||||
return callback(err, interruptItems);
|
||||
});
|
||||
},
|
||||
(interruptItems, callback) => {
|
||||
return this.display(info, interruptItems, callback);
|
||||
}
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
monitorUserStatEvents() {
|
||||
if(this.userStatEventListeners) {
|
||||
return; // already listening
|
||||
}
|
||||
|
||||
const listenEvents = [
|
||||
Events.getSystemEvents().UserStatSet,
|
||||
Events.getSystemEvents().UserStatIncrement
|
||||
];
|
||||
|
||||
this.userStatEventListeners = this.events.addMultipleEventListener(listenEvents, userStatEvent => {
|
||||
if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(!_.isNumber(userStatEvent.statValue) && !_.isNumber(userStatEvent.statIncrementBy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// :TODO: Make this code generic - find + return factory created object
|
||||
const achievementTags = Object.keys(_.pickBy(
|
||||
_.get(this.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;
|
||||
}
|
||||
const acceptedTypes = [
|
||||
Achievement.Types.UserStatSet,
|
||||
Achievement.Types.UserStatInc,
|
||||
Achievement.Types.UserStatIncNewVal,
|
||||
];
|
||||
return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName;
|
||||
}
|
||||
);
|
||||
|
||||
if(!achievementTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const achievement = Achievement.factory(this.getAchievementByTag(achievementTag));
|
||||
if(!achievement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statValue = parseInt(
|
||||
[ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ?
|
||||
userStatEvent.statValue :
|
||||
userStatEvent.statIncrementBy
|
||||
);
|
||||
if(isNaN(statValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue);
|
||||
if(!details) {
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
);*/
|
||||
});
|
||||
}
|
||||
|
||||
stopMonitoringUserStatEvents() {
|
||||
if(this.userStatEventListeners) {
|
||||
this.events.removeMultipleEventListener(this.userStatEventListeners);
|
||||
delete this.userStatEventListeners;
|
||||
}
|
||||
}
|
||||
|
||||
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 textTypeSgr = themeDefaults[`${textType}SGR`] || defaultSgr;
|
||||
|
||||
const formatObj = this.getFormatObject(info);
|
||||
|
||||
const wrap = (input) => {
|
||||
const re = new RegExp(`{(${Object.keys(formatObj).join('|')})([^}]*)}`, 'g');
|
||||
return input.replace(re, (m, formatVar, formatOpts) => {
|
||||
const varSgr = themeDefaults[`${formatVar}SGR`] || textTypeSgr;
|
||||
let r = `${varSgr}{${formatVar}`;
|
||||
if(formatOpts) {
|
||||
r += formatOpts;
|
||||
}
|
||||
return `${r}}${textTypeSgr}`;
|
||||
});
|
||||
};
|
||||
|
||||
return stringFormat(`${textTypeSgr}${wrap(info.details[textType])}`, formatObj);
|
||||
}
|
||||
|
||||
createAchievementInterruptItems(info, cb) {
|
||||
info.dateTimeFormat =
|
||||
info.details.dateTimeFormat ||
|
||||
info.achievement.dateTimeFormat ||
|
||||
info.client.currentTheme.helpers.getDateTimeFormat();
|
||||
|
||||
const title = this.getFormattedTextFor(info, 'title');
|
||||
const text = this.getFormattedTextFor(info, 'text');
|
||||
|
||||
let globalText;
|
||||
if(info.details.globalText) {
|
||||
globalText = this.getFormattedTextFor(info, 'globalText');
|
||||
}
|
||||
|
||||
const getArt = (name, callback) => {
|
||||
const spec =
|
||||
_.get(info.details, `art.${name}`) ||
|
||||
_.get(info.achievement, `art.${name}`) ||
|
||||
_.get(this.achievementConfig, `art.${name}`);
|
||||
if(!spec) {
|
||||
return callback(null);
|
||||
}
|
||||
const getArtOpts = {
|
||||
name : spec,
|
||||
client : this.client,
|
||||
random : false,
|
||||
};
|
||||
getThemeArt(getArtOpts, (err, artInfo) => {
|
||||
// ignore errors
|
||||
return callback(artInfo ? artInfo.data : null);
|
||||
});
|
||||
};
|
||||
|
||||
const interruptItems = {};
|
||||
let itemTypes = [ 'local' ];
|
||||
if(globalText) {
|
||||
itemTypes.push('global');
|
||||
}
|
||||
|
||||
async.each(itemTypes, (itemType, nextItemType) => {
|
||||
async.waterfall(
|
||||
[
|
||||
(callback) => {
|
||||
getArt(`${itemType}Header`, headerArt => {
|
||||
return callback(null, headerArt);
|
||||
});
|
||||
},
|
||||
(headerArt, callback) => {
|
||||
getArt(`${itemType}Footer`, footerArt => {
|
||||
return callback(null, headerArt, footerArt);
|
||||
});
|
||||
},
|
||||
(headerArt, footerArt, callback) => {
|
||||
const itemText = 'global' === itemType ? globalText : text;
|
||||
interruptItems[itemType] = {
|
||||
title,
|
||||
achievText : itemText,
|
||||
text : `${title}\r\n${itemText}`,
|
||||
pause : true,
|
||||
};
|
||||
if(headerArt || footerArt) {
|
||||
const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {});
|
||||
const defaultContentsFormat = '{title}\r\n{message}';
|
||||
const contentsFormat = 'global' === itemType ?
|
||||
themeDefaults.globalFormat || defaultContentsFormat :
|
||||
themeDefaults.format || defaultContentsFormat;
|
||||
|
||||
const formatObj = Object.assign(this.getFormatObject(info), {
|
||||
title : this.getFormattedTextFor(info, 'title', ''), // ''=defaultSgr
|
||||
message : itemText,
|
||||
});
|
||||
|
||||
const contents = pipeToAnsi(stringFormat(contentsFormat, formatObj));
|
||||
|
||||
interruptItems[itemType].contents =
|
||||
`${headerArt || ''}\r\n${contents}\r\n${footerArt || ''}`;
|
||||
}
|
||||
return callback(null);
|
||||
}
|
||||
],
|
||||
err => {
|
||||
return nextItemType(err);
|
||||
}
|
||||
);
|
||||
},
|
||||
err => {
|
||||
return cb(err, interruptItems);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let achievementsInstance;
|
||||
|
||||
function getAchievementsEarnedByUser(userId, cb) {
|
||||
if(!achievementsInstance) {
|
||||
return cb(Errors.UnexpectedState('Achievements not initialized'));
|
||||
}
|
||||
|
||||
UserDb.all(
|
||||
`SELECT achievement_tag, timestamp, match, title, text, points
|
||||
FROM user_achievement
|
||||
WHERE user_id = ?
|
||||
ORDER BY DATETIME(timestamp);`,
|
||||
[ userId ],
|
||||
(err, rows) => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const earned = rows.map(row => {
|
||||
|
||||
const achievement = Achievement.factory(achievementsInstance.getAchievementByTag(row.achievement_tag));
|
||||
if(!achievement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const earnedInfo = {
|
||||
achievementTag : row.achievement_tag,
|
||||
type : achievement.data.type,
|
||||
retroactive : achievement.data.retroactive,
|
||||
title : row.title,
|
||||
text : row.text,
|
||||
points : row.points,
|
||||
timestamp : moment(row.timestamp),
|
||||
};
|
||||
|
||||
switch(earnedInfo.type) {
|
||||
case [ Achievement.Types.UserStatSet ] :
|
||||
case [ Achievement.Types.UserStatInc ] :
|
||||
case [ Achievement.Types.UserStatIncNewVal ] :
|
||||
earnedInfo.statName = achievement.data.statName;
|
||||
break;
|
||||
}
|
||||
|
||||
return earnedInfo;
|
||||
}).filter(a => a); // remove any empty records (ie: no achievement.hjson entry exists anymore).
|
||||
|
||||
return cb(null, earned);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
exports.moduleInitialize = (initInfo, cb) => {
|
||||
achievementsInstance = new Achievements(initInfo.events);
|
||||
achievementsInstance.init( err => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
});
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
const { MenuModule } = require('./menu_module.js');
|
||||
const { resetScreen } = require('./ansi_term.js');
|
||||
const { Errors } = require('./enig_error.js');
|
||||
const {
|
||||
trackDoorRunBegin,
|
||||
trackDoorRunEnd
|
||||
} = require('./door_util.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
@@ -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,19 @@ 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 doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`);
|
||||
|
||||
const bridgeConnection = net.createConnection(connectOpts, function connected() {
|
||||
self.client.log.info(connectOpts, 'BBSLink bridge connection established');
|
||||
|
||||
self.client.term.output.pipe(bridgeConnection);
|
||||
@@ -143,13 +149,15 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
var restorePipe = function() {
|
||||
const restorePipe = function() {
|
||||
self.client.term.output.unpipe(bridgeConnection);
|
||||
self.client.term.output.resume();
|
||||
|
||||
trackDoorRunEnd(doorTracking);
|
||||
};
|
||||
|
||||
bridgeConnection.on('data', function incomingData(data) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } );
|
||||
@@ -107,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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
const { MenuModule } = require('../core/menu_module.js');
|
||||
const { resetScreen } = require('../core/ansi_term.js');
|
||||
const { Errors } = require('./enig_error.js');
|
||||
const {
|
||||
trackDoorRunBegin,
|
||||
trackDoorRunEnd
|
||||
} = require('./door_util.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
@@ -46,9 +50,15 @@ exports.getModule = class CombatNetModule extends MenuModule {
|
||||
self.client.term.write(resetScreen());
|
||||
self.client.term.write('Connecting to CombatNet, please wait...\n');
|
||||
|
||||
let doorTracking;
|
||||
|
||||
const restorePipeToNormal = function() {
|
||||
if(self.client.term.output) {
|
||||
self.client.term.output.removeListener('data', sendToRloginBuffer);
|
||||
|
||||
if(doorTracking) {
|
||||
trackDoorRunEnd(doorTracking);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,6 +100,7 @@ exports.getModule = class CombatNetModule extends MenuModule {
|
||||
self.client.log.info('Connected to CombatNet');
|
||||
self.client.term.output.on('data', sendToRloginBuffer);
|
||||
|
||||
doorTracking = trackDoorRunBegin(self.client);
|
||||
} else {
|
||||
return callback(Errors.General('Failed to establish establish CombatNet connection'));
|
||||
}
|
||||
|
||||
@@ -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 : {
|
||||
@@ -1003,6 +1004,6 @@ function getDefaultConfig() {
|
||||
systemEvents : {
|
||||
loginHistoryMax: -1, // set to -1 for forever
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const paths = require('path');
|
||||
const async = require('async');
|
||||
|
||||
exports.init = init;
|
||||
exports.getConfigPath = getConfigPath;
|
||||
exports.getFullConfig = getFullConfig;
|
||||
|
||||
function getConfigPath(filePath) {
|
||||
|
||||
@@ -200,7 +200,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`
|
||||
);
|
||||
|
||||
@@ -189,6 +189,20 @@ 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 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
|
||||
);`
|
||||
);
|
||||
|
||||
return cb(null);
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
const { MenuModule } = require('./menu_module.js');
|
||||
const { resetScreen } = require('./ansi_term.js');
|
||||
const { Errors } = require('./enig_error.js');
|
||||
const {
|
||||
trackDoorRunBegin,
|
||||
trackDoorRunEnd
|
||||
} = require('./door_util.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
@@ -54,10 +58,16 @@ exports.getModule = class DoorPartyModule extends MenuModule {
|
||||
|
||||
let pipeRestored = false;
|
||||
let pipedStream;
|
||||
let doorTracking;
|
||||
|
||||
const restorePipe = function() {
|
||||
if(pipedStream && !pipeRestored && !clientTerminated) {
|
||||
self.client.term.output.unpipe(pipedStream);
|
||||
self.client.term.output.resume();
|
||||
|
||||
if(doorTracking) {
|
||||
trackDoorRunEnd(doorTracking);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,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.
|
||||
@@ -100,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', () => {
|
||||
@@ -122,7 +135,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();
|
||||
}
|
||||
|
||||
38
core/door_util.js
Normal file
38
core/door_util.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/* 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();
|
||||
return { startTime, client, doorTag };
|
||||
}
|
||||
|
||||
function trackDoorRunEnd(trackInfo) {
|
||||
const { startTime, client, doorTag } = trackInfo;
|
||||
|
||||
const diff = moment.duration(moment().diff(startTime));
|
||||
if(diff.asSeconds() >= 45) {
|
||||
StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalCount, 1);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
'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 {
|
||||
trackDoorRunBegin,
|
||||
trackDoorRunEnd
|
||||
} = require('./door_util.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
@@ -151,11 +157,16 @@ exports.getModule = class ExodusModule extends MenuModule {
|
||||
|
||||
let pipeRestored = false;
|
||||
let pipedStream;
|
||||
let doorTracking;
|
||||
|
||||
function restorePipe() {
|
||||
if(pipedStream && !pipeRestored && !clientTerminated) {
|
||||
self.client.term.output.unpipe(pipedStream);
|
||||
self.client.term.output.resume();
|
||||
|
||||
if(doorTracking) {
|
||||
trackDoorRunEnd(doorTracking);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +197,8 @@ exports.getModule = class ExodusModule extends MenuModule {
|
||||
});
|
||||
|
||||
sshClient.shell(window, options, (err, stream) => {
|
||||
doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`);
|
||||
|
||||
pipedStream = stream; // :TODO: ewwwwwwwww hack
|
||||
self.client.term.output.pipe(stream);
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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,21 +199,28 @@ 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
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -317,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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -90,14 +101,14 @@ 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) {
|
||||
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
|
||||
@@ -122,10 +133,10 @@ 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); },
|
||||
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,6 +163,19 @@ 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 userStatAsCountString(client, UserProps.AchievementTotalCount, 0); },
|
||||
AP : function achievementPoints(client) { return userStatAsCountString(client, UserProps.AchievementTotalPoints, 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();
|
||||
},
|
||||
TO : function friendlyTotalTimeOnSystem(client) {
|
||||
const minutes = client.user.properties[UserProps.MinutesOnlineTotalCount] || 0;
|
||||
return moment.duration(minutes, 'minutes').humanize();
|
||||
},
|
||||
|
||||
//
|
||||
// Date/Time
|
||||
//
|
||||
@@ -166,7 +190,7 @@ const PREDEFINED_MCI_GENERATORS = {
|
||||
OS : function operatingSystem() {
|
||||
return {
|
||||
linux : 'Linux',
|
||||
darwin : 'Mac OS X',
|
||||
darwin : 'OS X',
|
||||
win32 : 'Windows',
|
||||
sunos : 'SunOS',
|
||||
freebsd : 'FreeBSD',
|
||||
|
||||
@@ -120,11 +120,20 @@ 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) {
|
||||
setUserStatWithOptions(user, statName, statValue, options, cb) {
|
||||
// note: cb is optional in PersistUserProperty
|
||||
return user.persistProperty(statName, statValue, cb);
|
||||
user.persistProperty(statName, statValue, cb);
|
||||
|
||||
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) {
|
||||
@@ -138,18 +147,34 @@ class StatLog {
|
||||
incrementUserStat(user, statName, incrementBy, cb) {
|
||||
incrementBy = incrementBy || 1;
|
||||
|
||||
let newValue = parseInt(user.properties[statName]);
|
||||
if(newValue) {
|
||||
if(!_.isNumber(newValue)) {
|
||||
return cb(new Error(`Value for ${statName} is not a number!`));
|
||||
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,
|
||||
oldValue,
|
||||
statIncrementBy : incrementBy,
|
||||
statValue : newValue
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if(cb) {
|
||||
return cb(err);
|
||||
}
|
||||
}
|
||||
|
||||
newValue += incrementBy;
|
||||
} else {
|
||||
newValue = incrementBy;
|
||||
}
|
||||
|
||||
return this.setUserStat(user, statName, newValue, cb);
|
||||
);
|
||||
}
|
||||
|
||||
// the time "now" in the ISO format we use and love :)
|
||||
@@ -344,29 +369,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,
|
||||
];
|
||||
|
||||
Events.addListenerMultipleEvents(interestedEvents, (eventName, event) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
73
core/sys_event_user_log.js
Normal file
73
core/sys_event_user_log.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/* 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) => {
|
||||
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) => {
|
||||
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);
|
||||
},
|
||||
[ systemEvents.UserSendMail ] : (e) => {
|
||||
append(e, LogNames.SendMail, 1);
|
||||
},
|
||||
[ systemEvents.UserRunDoor ] : (e) => {
|
||||
append(e, LogNames.RunDoor, e.doorTag);
|
||||
append(e, LogNames.RunDoorMinutes, e.runTimeMinutes);
|
||||
},
|
||||
[ 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2,23 +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', // { ..., 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 }
|
||||
UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // { ..., achievementTag, points }
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
235
core/top_x.js
Normal file
235
core/top_x.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/* 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');
|
||||
|
||||
// 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);
|
||||
|
||||
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) => {
|
||||
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.value)) {
|
||||
return false;
|
||||
}
|
||||
// VM# must exist for this mci
|
||||
if(!_.isObject(mciData, [ 'menu', `VM${mci}` ])) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'userEventLog' :
|
||||
if(!userLogValues.includes(o.value)) {
|
||||
return false;
|
||||
}
|
||||
// VM# must exist for this mci
|
||||
if(!hasMci(mci, ['VM'])) {
|
||||
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) {
|
||||
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, { position : position++, value : row.value }));
|
||||
});
|
||||
},
|
||||
(err, items) => {
|
||||
return cb(err, items);
|
||||
});
|
||||
}
|
||||
|
||||
populateTopXUserEventLog(listView, mciCode, cb) {
|
||||
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')` : '';
|
||||
|
||||
SysDb.all(
|
||||
`SELECT user_id, ${valueSql} AS value
|
||||
FROM user_event_log
|
||||
WHERE log_name = ? ${dateSql}
|
||||
GROUP BY user_id
|
||||
ORDER BY value DESC
|
||||
LIMIT ${count};`,
|
||||
[ mciMap.value ],
|
||||
(err, rows) => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
this.rowsToItems(rows, (err, items) => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
listView.setItems(items);
|
||||
listView.redraw();
|
||||
return cb(null);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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].value ],
|
||||
(err, rows) => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
this.rowsToItems(rows, (err, items) => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
listView.setItems(items);
|
||||
listView.redraw();
|
||||
return cb(null);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
core/user.js
16
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];
|
||||
}
|
||||
|
||||
102
core/user_achievements_earned.js
Normal file
102
core/user_achievements_earned.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/* jslint node: true */
|
||||
'use strict';
|
||||
|
||||
// ENiGMA½
|
||||
const { MenuModule } = require('./menu_module.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),
|
||||
totalCount : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalCount),
|
||||
totalPoints : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalPoints),
|
||||
};
|
||||
}
|
||||
|
||||
selectionIndexUpdate(index) {
|
||||
const achiev = this.achievementsEarned[index];
|
||||
if(!achiev) {
|
||||
return;
|
||||
}
|
||||
this.updateCustomViewTextsWithFilter('achievements', MciViewIds.customRangeStart, achiev);
|
||||
}
|
||||
};
|
||||
@@ -76,23 +76,33 @@ 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');
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
22
core/user_log_name.js
Normal file
22
core/user_log_name.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/* 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', // 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
|
||||
};
|
||||
@@ -49,5 +49,13 @@ module.exports = {
|
||||
MessageConfTag : 'message_conf_tag',
|
||||
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',
|
||||
|
||||
MinutesOnlineTotalCount : 'minutes_online_total_count',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user