Merge branch '0.0.9-alpha' of github.com:NuSkooler/enigma-bbs into 0.0.9-alpha

This commit is contained in:
Bryan Ashby 2019-01-26 15:30:41 -07:00
commit 1cd73ec78b
59 changed files with 6274 additions and 4173 deletions

View File

@ -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

View File

@ -13,16 +13,17 @@ 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.
* 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

View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -111,6 +111,28 @@
}
}
mainMenuUserAchievementsEarned: {
config: {
dateTimeFormat: MMM Do h:mma
achievementsInfoFormat10: "|00|07\"|11{title}|07\""
achievementsInfoFormat11: "|00|03{text}"
}
mci: {
VM1: {
height: 11
width: 76
itemFormat: "|00|15{ts} |07- |03{title:<47.46} |15{points:,}|07 pts"
focusItemFormat: "|00|19|15{ts} - {title:<47.46} {points:,} pts"
}
TL10: {
width: 76
}
TL11: {
width: 76
}
}
}
mainMenuUserStats: {
mci: {
UN1: { width: 15 }
@ -980,5 +1002,31 @@
}
}
}
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}"
titleSGR: "|10"
pointsSGR: "|12"
textSGR: "|00|03"
globalTextSGR: "|03"
boardNameSGR: "|10"
userNameSGR: "|11"
achievedValueSGR: "|15"
}
overrides: {
user_login_count: {
match: {
2: {
//
// You may override title, text, and globalText here
//
}
}
}
}
}
}
}

450
config/achievements.hjson Normal file
View File

@ -0,0 +1,450 @@
/*
./\/\." 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
Some tips:
- 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:
BBS : Xibalba @ xibalba.l33t.codes
FTN : BBS Discussion on fsxNet or ArakNet
IRC : #enigma-bbs / FreeNode
Email : bryan@l33t.codes
*/
{
// Set to false to disable the achievement system
enabled : true
art : {
localHeader: achievement_local_header
localFooter: achievement_local_footer
globalHeader: achievement_global_header
globalFooter: achievement_global_footer
}
achievements: {
user_login_count: {
type: userStatSet
statName: login_count
match: {
2: {
title: "Return Caller"
globalText: "{userName} has returned to {boardName}!"
text: "You've returned to {boardName}!"
points: 5
}
10: {
title: "Curious Caller"
globalText: "{userName} has logged into {boardName} {achievedValue} times!"
text: "You've logged into {boardName} {achievedValue} times!"
points: 10
}
25: {
title: "Inquisitive"
globalText: "{userName} has logged into {boardName} {achievedValue} times!"
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: 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: 50
}
}
}
user_post_count: {
type: userStatSet
statName: post_count
match: {
2: {
title: "Poster"
globalText: "{userName} has posted {achievedValue} messages!"
text: "You've posted {achievedValue} messages!"
points: 5
}
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: "Probably Just Spam",
globalText: "{userName} has posted {achievedValue} messages!"
text: "You've posted {achievedValue} messages!"
points: 25
}
250: {
title: "Scribe"
globalText: "{userName} the scribe has posted {achievedValue} messages!"
text: "Such a scribe! You've posted {achievedValue} messages!"
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
}
}
}
user_upload_count: {
type: userStatSet
statName: ul_total_count
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: 25
}
100: {
title: "Courier"
globalText: "Courier {userName} has uploaded {achievedValue} files!"
text: "You've uploaded {achievedValue} files!"
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: 55
}
}
}
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: "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: {
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 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
}
}
}
user_download_count: {
type: userStatSet
statName: dl_total_count
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
}
}
}
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: 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: 10
}
681574400: {
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: 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!"
}
}
}
user_door_runs: {
type: userStatSet
statName: door_run_total_count
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: 20
}
100: {
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: 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: 55
}
}
}
user_individual_door_run_minutes: {
type: userStatInc
statName: door_run_total_minutes
match: {
1: {
title: "Nevermind!"
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!durationMinutes}!"
text: "You ran a door for {achievedValue!durationMinutes}!"
points: 10
}
30: {
title: "Good Game"
globalText: "{userName} ran a door for {achievedValue!durationMinutes}!"
text: "You ran a door for {achievedValue!durationMinutes}!"
points: 20
}
60: {
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
}
}
}
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: 55
}
}
}
}
}

View File

@ -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
View 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);
});
};

View File

@ -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) {

View File

@ -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

View File

@ -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);
@ -147,9 +153,11 @@ exports.getModule = class BBSLinkModule extends MenuModule {
});
});
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) {

View File

@ -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 {

View File

@ -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(

View File

@ -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,6 +197,26 @@ function controlCodesToAnsi(s, client) {
D : [ 'bold', 'magentaBG' ],
E : [ 'bold', 'yellowBG' ],
F : [ 'bold', 'whiteBG' ],
}[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));

View File

@ -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'));
}

View File

@ -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
}
}
},
};
}

View File

@ -10,6 +10,7 @@ const paths = require('path');
const async = require('async');
exports.init = init;
exports.getConfigPath = getConfigPath;
exports.getFullConfig = getFullConfig;
function getConfigPath(filePath) {

View File

@ -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`
);

View File

@ -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);
},

View File

@ -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
View 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);
}
}

View File

@ -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);
});
}

View File

@ -2,12 +2,18 @@
'use strict';
// ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule;
const resetScreen = require('./ansi_term.js').resetScreen;
const { MenuModule } = require('./menu_module.js');
const { resetScreen } = require('./ansi_term.js');
const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors;
const { Errors } = require('./enig_error.js');
const Log = require('./logger.js').log;
const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent;
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);

View File

@ -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}`;
});
}

View File

@ -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 = {};

View File

@ -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;

View File

@ -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);
});

View File

@ -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',

View File

@ -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
}
);
}
newValue += incrementBy;
} else {
newValue = incrementBy;
if(cb) {
return cb(err);
}
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);
}
}

View File

@ -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) {

View 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);
}
});
};

View File

@ -12,13 +12,16 @@ module.exports = {
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',
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',
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 }
};

View File

@ -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);
@ -133,6 +133,7 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
//
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
View 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);
});
});
}
};

View File

@ -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;
}

View File

@ -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];
}

View 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);
}
};

View File

@ -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
View 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
};

View File

@ -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',
};

View File

@ -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 %})

View File

@ -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,10 @@ 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 |
| `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.) |
@ -149,10 +153,25 @@ 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
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 |
@ -163,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

View File

@ -34,7 +34,9 @@ The following are ACS codes available as of this writing:
| NR<i>ratio</i> | User has upload/download count ratio >= _ratio_ |
| KR<i>ratio</i> | User has a upload/download byte ratio >= _ratio_ |
| PC<i>ratio</i> | User has a post/call ratio >= _ratio_ |
| MM<i>minutes</i> | It is currently >= _minutes_ past midnight (system time)
| MM<i>minutes</i> | It is currently >= _minutes_ past midnight (system time) |
| AC<i>achievementCount</i> | User has >= _achievementCount_ achievements |
| AP<i>achievementPoints</i> | User has >= _achievementPoints_ achievement points |
\* Many more ACS codes are planned for the near future.

View File

@ -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!
// __|___// | \// |// | \// | | \// \ /___ /_____

View File

@ -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.
### 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`

View File

@ -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!

60
docs/modding/top-x.md Normal file
View File

@ -0,0 +1,60 @@
---
layout: page
title: TopX
---
## The TopX Module
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
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 `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
value: 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 `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
value: 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.

View File

@ -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) {

View File

@ -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
@ -3440,6 +3465,7 @@
desc: Uploading
module: upload
config: {
interrupt: never
art: {
options: ULOPTS
fileDetails: ULDETAIL

46
util/to_ansi.js Executable file
View File

@ -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();