Merge pull request #229 from NuSkooler/achivements-0.0.9-alpha
Achivements 0.0.9 alpha
This commit is contained in:
commit
d662718016
|
@ -1,4 +1,4 @@
|
||||||
Copyright (c) 2015-2018, Bryan D. Ashby
|
Copyright (c) 2015-2019, Bryan D. Ashby
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
|
|
@ -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
|
* 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
|
* [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.
|
* [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).
|
* Renegade style [pipe color codes](/docs/configuration/colour-codes.md).
|
||||||
* [SQLite](http://sqlite.org/) storage of users, message areas, etc.
|
* [SQLite](http://sqlite.org/) storage of users, message areas, etc.
|
||||||
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption.
|
* 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!
|
* [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!
|
* [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!
|
* [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!
|
* 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.
|
* ANSI support in the Full Screen Editor (FSE), file descriptions, etc.
|
||||||
|
* A built in achievement system. BBSing gamified!
|
||||||
|
|
||||||
## Documentation
|
## 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.
|
[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
|
## License
|
||||||
Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) 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.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
|
|
@ -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).
|
* `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.
|
* 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.
|
* 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
|
## 0.0.8-alpha
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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: {
|
mainMenuUserStats: {
|
||||||
mci: {
|
mci: {
|
||||||
UN1: { width: 15 }
|
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
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,8 +6,11 @@ const DropFile = require('./dropfile.js');
|
||||||
const Door = require('./door.js');
|
const Door = require('./door.js');
|
||||||
const theme = require('./theme.js');
|
const theme = require('./theme.js');
|
||||||
const ansi = require('./ansi_term.js');
|
const ansi = require('./ansi_term.js');
|
||||||
const Events = require('./events.js');
|
|
||||||
const { Errors } = require('./enig_error.js');
|
const { Errors } = require('./enig_error.js');
|
||||||
|
const {
|
||||||
|
trackDoorRunBegin,
|
||||||
|
trackDoorRunEnd
|
||||||
|
} = require('./door_util.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
@ -149,8 +152,6 @@ exports.getModule = class AbracadabraModule extends MenuModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
runDoor() {
|
runDoor() {
|
||||||
Events.emit(Events.getSystemEvents().UserRunDoor, { user : this.client.user } );
|
|
||||||
|
|
||||||
this.client.term.write(ansi.resetScreen());
|
this.client.term.write(ansi.resetScreen());
|
||||||
|
|
||||||
const exeInfo = {
|
const exeInfo = {
|
||||||
|
@ -164,7 +165,11 @@ exports.getModule = class AbracadabraModule extends MenuModule {
|
||||||
node : this.client.node,
|
node : this.client.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doorTracking = trackDoorRunBegin(this.client, this.config.name);
|
||||||
|
|
||||||
this.doorInstance.run(exeInfo, () => {
|
this.doorInstance.run(exeInfo, () => {
|
||||||
|
trackDoorRunEnd(doorTracking);
|
||||||
|
|
||||||
//
|
//
|
||||||
// Try to clean up various settings such as scroll regions that may
|
// Try to clean up various settings such as scroll regions that may
|
||||||
// have been set within the door
|
// have been set within the door
|
||||||
|
|
|
@ -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() {
|
TW : function termWidth() {
|
||||||
return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value;
|
return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value;
|
||||||
},
|
},
|
||||||
ID : function isUserId(value) {
|
ID : function isUserId() {
|
||||||
if(!user) {
|
if(!user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -1024,6 +1024,20 @@ function peg$parse(input, options) {
|
||||||
const midnight = now.clone().startOf('day')
|
const midnight = now.clone().startOf('day')
|
||||||
const minutesPastMidnight = now.diff(midnight, 'minutes');
|
const minutesPastMidnight = now.diff(midnight, 'minutes');
|
||||||
return !isNaN(value) && minutesPastMidnight >= value;
|
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);
|
}[acsCode](value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -23,6 +23,8 @@
|
||||||
// General
|
// General
|
||||||
// * http://en.wikipedia.org/wiki/ANSI_escape_code
|
// * http://en.wikipedia.org/wiki/ANSI_escape_code
|
||||||
// * http://www.inwap.com/pdp10/ansicode.txt
|
// * 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
|
// Other Implementations
|
||||||
// * https://github.com/chjj/term.js/blob/master/src/term.js
|
// * https://github.com/chjj/term.js/blob/master/src/term.js
|
||||||
|
|
|
@ -4,6 +4,10 @@
|
||||||
const { MenuModule } = require('./menu_module.js');
|
const { MenuModule } = require('./menu_module.js');
|
||||||
const { resetScreen } = require('./ansi_term.js');
|
const { resetScreen } = require('./ansi_term.js');
|
||||||
const { Errors } = require('./enig_error.js');
|
const { Errors } = require('./enig_error.js');
|
||||||
|
const {
|
||||||
|
trackDoorRunBegin,
|
||||||
|
trackDoorRunEnd
|
||||||
|
} = require('./door_util.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
@ -98,7 +102,7 @@ exports.getModule = class BBSLinkModule extends MenuModule {
|
||||||
//
|
//
|
||||||
// Authenticate the token we acquired previously
|
// Authenticate the token we acquired previously
|
||||||
//
|
//
|
||||||
var headers = {
|
const headers = {
|
||||||
'X-User' : self.client.user.userId.toString(),
|
'X-User' : self.client.user.userId.toString(),
|
||||||
'X-System' : self.config.sysCode,
|
'X-System' : self.config.sysCode,
|
||||||
'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'),
|
'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
|
// Authentication with BBSLink successful. Now, we need to create a telnet
|
||||||
// bridge from us to them
|
// bridge from us to them
|
||||||
//
|
//
|
||||||
var connectOpts = {
|
const connectOpts = {
|
||||||
port : self.config.port,
|
port : self.config.port,
|
||||||
host : self.config.host,
|
host : self.config.host,
|
||||||
};
|
};
|
||||||
|
|
||||||
var clientTerminated;
|
let clientTerminated;
|
||||||
|
|
||||||
self.client.term.write(resetScreen());
|
self.client.term.write(resetScreen());
|
||||||
self.client.term.write(' Connecting to BBSLink.net, please wait...\n');
|
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.log.info(connectOpts, 'BBSLink bridge connection established');
|
||||||
|
|
||||||
self.client.term.output.pipe(bridgeConnection);
|
self.client.term.output.pipe(bridgeConnection);
|
||||||
|
@ -143,13 +149,15 @@ exports.getModule = class BBSLinkModule extends MenuModule {
|
||||||
self.client.once('end', function clientEnd() {
|
self.client.once('end', function clientEnd() {
|
||||||
self.client.log.info('Connection ended. Terminating BBSLink connection');
|
self.client.log.info('Connection ended. Terminating BBSLink connection');
|
||||||
clientTerminated = true;
|
clientTerminated = true;
|
||||||
bridgeConnection.end();
|
bridgeConnection.end();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
var restorePipe = function() {
|
const restorePipe = function() {
|
||||||
self.client.term.output.unpipe(bridgeConnection);
|
self.client.term.output.unpipe(bridgeConnection);
|
||||||
self.client.term.output.resume();
|
self.client.term.output.resume();
|
||||||
|
|
||||||
|
trackDoorRunEnd(doorTracking);
|
||||||
};
|
};
|
||||||
|
|
||||||
bridgeConnection.on('data', function incomingData(data) {
|
bridgeConnection.on('data', function incomingData(data) {
|
||||||
|
|
|
@ -40,6 +40,7 @@ const MenuStack = require('./menu_stack.js');
|
||||||
const ACS = require('./acs.js');
|
const ACS = require('./acs.js');
|
||||||
const Events = require('./events.js');
|
const Events = require('./events.js');
|
||||||
const UserInterruptQueue = require('./user_interrupt_queue.js');
|
const UserInterruptQueue = require('./user_interrupt_queue.js');
|
||||||
|
const UserProps = require('./user_property.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
@ -442,13 +443,36 @@ Client.prototype.startIdleMonitor = function() {
|
||||||
|
|
||||||
//
|
//
|
||||||
// Every 1m, check for idle.
|
// Every 1m, check for idle.
|
||||||
|
// We also update minutes spent online the system here,
|
||||||
|
// if we have a authenticated user.
|
||||||
//
|
//
|
||||||
this.idleCheck = setInterval( () => {
|
this.idleCheck = setInterval( () => {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
const idleLogoutSeconds = this.user.isAuthenticated() ?
|
let idleLogoutSeconds;
|
||||||
Config().users.idleLogoutSeconds :
|
if(this.user.isAuthenticated()) {
|
||||||
Config().users.preAuthIdleLogoutSeconds;
|
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)) {
|
if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
|
||||||
this.emit('idle timeout');
|
this.emit('idle timeout');
|
||||||
|
@ -473,6 +497,14 @@ Client.prototype.end = function () {
|
||||||
currentModule.leave();
|
currentModule.leave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// persist time online for authenticated users
|
||||||
|
if(this.user.isAuthenticated()) {
|
||||||
|
this.user.persistProperty(
|
||||||
|
UserProps.MinutesOnlineTotalCount,
|
||||||
|
this.user.getProperty(UserProps.MinutesOnlineTotalCount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.stopIdleMonitor();
|
this.stopIdleMonitor();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -60,12 +60,25 @@ function getActiveConnectionList(authUsersOnly) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNewClient(client, clientSock) {
|
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
|
// create a unique identifier one-time ID for this session
|
||||||
client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, moment().valueOf() ]);
|
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
|
// Create a client specific logger
|
||||||
// Note that this will be updated @ login with additional information
|
// Note that this will be updated @ login with additional information
|
||||||
client.log = logger.log.child( { clientId : id, sessionId : client.session.uniqueId } );
|
client.log = logger.log.child( { clientId : id, sessionId : client.session.uniqueId } );
|
||||||
|
@ -107,7 +120,8 @@ function removeClient(client) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if(client.user && client.user.isValid()) {
|
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(
|
Events.emit(
|
||||||
|
|
|
@ -131,7 +131,7 @@ function renegadeToAnsi(s, client) {
|
||||||
//
|
//
|
||||||
// Supported control code formats:
|
// Supported control code formats:
|
||||||
// * Renegade : |##
|
// * 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
|
// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix
|
||||||
// * WWIV : ^#
|
// * WWIV : ^#
|
||||||
// * CNET Y-Style : 0x19## where ## is a specific set of codes -- this is the older format
|
// * 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];
|
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 = {
|
bg = {
|
||||||
0 : [ 'blackBG' ],
|
0 : [ 'blackBG' ],
|
||||||
1 : [ 'blueBG' ],
|
1 : [ 'blueBG' ],
|
||||||
|
@ -217,7 +197,27 @@ function controlCodesToAnsi(s, client) {
|
||||||
D : [ 'bold', 'magentaBG' ],
|
D : [ 'bold', 'magentaBG' ],
|
||||||
E : [ 'bold', 'yellowBG' ],
|
E : [ 'bold', 'yellowBG' ],
|
||||||
F : [ 'bold', 'whiteBG' ],
|
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));
|
v = ANSI.sgr(fg.concat(bg));
|
||||||
result += s.substr(lastIndex, m.index - lastIndex) + v;
|
result += s.substr(lastIndex, m.index - lastIndex) + v;
|
||||||
|
|
|
@ -5,6 +5,10 @@
|
||||||
const { MenuModule } = require('../core/menu_module.js');
|
const { MenuModule } = require('../core/menu_module.js');
|
||||||
const { resetScreen } = require('../core/ansi_term.js');
|
const { resetScreen } = require('../core/ansi_term.js');
|
||||||
const { Errors } = require('./enig_error.js');
|
const { Errors } = require('./enig_error.js');
|
||||||
|
const {
|
||||||
|
trackDoorRunBegin,
|
||||||
|
trackDoorRunEnd
|
||||||
|
} = require('./door_util.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
@ -46,9 +50,15 @@ exports.getModule = class CombatNetModule extends MenuModule {
|
||||||
self.client.term.write(resetScreen());
|
self.client.term.write(resetScreen());
|
||||||
self.client.term.write('Connecting to CombatNet, please wait...\n');
|
self.client.term.write('Connecting to CombatNet, please wait...\n');
|
||||||
|
|
||||||
|
let doorTracking;
|
||||||
|
|
||||||
const restorePipeToNormal = function() {
|
const restorePipeToNormal = function() {
|
||||||
if(self.client.term.output) {
|
if(self.client.term.output) {
|
||||||
self.client.term.output.removeListener('data', sendToRloginBuffer);
|
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.log.info('Connected to CombatNet');
|
||||||
self.client.term.output.on('data', sendToRloginBuffer);
|
self.client.term.output.on('data', sendToRloginBuffer);
|
||||||
|
|
||||||
|
doorTracking = trackDoorRunBegin(self.client);
|
||||||
} else {
|
} else {
|
||||||
return callback(Errors.General('Failed to establish establish CombatNet connection'));
|
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
|
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
|
promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
|
||||||
|
achievementFile : 'achievements.hjson',
|
||||||
},
|
},
|
||||||
|
|
||||||
users : {
|
users : {
|
||||||
|
@ -1003,6 +1004,6 @@ function getDefaultConfig() {
|
||||||
systemEvents : {
|
systemEvents : {
|
||||||
loginHistoryMax: -1, // set to -1 for forever
|
loginHistoryMax: -1, // set to -1 for forever
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ const paths = require('path');
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
|
||||||
exports.init = init;
|
exports.init = init;
|
||||||
|
exports.getConfigPath = getConfigPath;
|
||||||
exports.getFullConfig = getFullConfig;
|
exports.getFullConfig = getFullConfig;
|
||||||
|
|
||||||
function getConfigPath(filePath) {
|
function getConfigPath(filePath) {
|
||||||
|
|
|
@ -200,7 +200,7 @@ function displayBanner(term) {
|
||||||
// note: intentional formatting:
|
// note: intentional formatting:
|
||||||
term.pipeWrite(`
|
term.pipeWrite(`
|
||||||
|06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN
|
|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/
|
|06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/
|
||||||
|00`
|
|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);
|
return cb(null);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,10 @@
|
||||||
const { MenuModule } = require('./menu_module.js');
|
const { MenuModule } = require('./menu_module.js');
|
||||||
const { resetScreen } = require('./ansi_term.js');
|
const { resetScreen } = require('./ansi_term.js');
|
||||||
const { Errors } = require('./enig_error.js');
|
const { Errors } = require('./enig_error.js');
|
||||||
|
const {
|
||||||
|
trackDoorRunBegin,
|
||||||
|
trackDoorRunEnd
|
||||||
|
} = require('./door_util.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
@ -54,10 +58,16 @@ exports.getModule = class DoorPartyModule extends MenuModule {
|
||||||
|
|
||||||
let pipeRestored = false;
|
let pipeRestored = false;
|
||||||
let pipedStream;
|
let pipedStream;
|
||||||
|
let doorTracking;
|
||||||
|
|
||||||
const restorePipe = function() {
|
const restorePipe = function() {
|
||||||
if(pipedStream && !pipeRestored && !clientTerminated) {
|
if(pipedStream && !pipeRestored && !clientTerminated) {
|
||||||
self.client.term.output.unpipe(pipedStream);
|
self.client.term.output.unpipe(pipedStream);
|
||||||
self.client.term.output.resume();
|
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'));
|
return callback(Errors.General('Failed to establish tunnel'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doorTracking = trackDoorRunBegin(self.client);
|
||||||
|
|
||||||
//
|
//
|
||||||
// Send rlogin
|
// Send rlogin
|
||||||
// DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g.
|
// 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 => {
|
sshClient.on('error', err => {
|
||||||
self.client.log.info(`DoorParty SSH client error: ${err.message}`);
|
self.client.log.info(`DoorParty SSH client error: ${err.message}`);
|
||||||
|
trackDoorRunEnd(doorTracking);
|
||||||
});
|
});
|
||||||
|
|
||||||
sshClient.on('close', () => {
|
sshClient.on('close', () => {
|
||||||
|
@ -122,7 +135,7 @@ exports.getModule = class DoorPartyModule extends MenuModule {
|
||||||
self.client.log.warn( { error : err.message }, 'DoorParty error');
|
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) {
|
if(!clientTerminated) {
|
||||||
self.prevMenu();
|
self.prevMenu();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 Log = require('./logger.js').log;
|
||||||
const SystemEvents = require('./system_events.js');
|
const SystemEvents = require('./system_events.js');
|
||||||
|
|
||||||
|
// deps
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
module.exports = new class Events extends events.EventEmitter {
|
module.exports = new class Events extends events.EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
@ -35,12 +38,30 @@ module.exports = new class Events extends events.EventEmitter {
|
||||||
return super.once(event, listener);
|
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 => {
|
events.forEach(eventName => {
|
||||||
this.on(eventName, event => {
|
const listenWrapper = _.partial(listener, _, eventName);
|
||||||
listener(eventName, event);
|
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';
|
'use strict';
|
||||||
|
|
||||||
// ENiGMA½
|
// ENiGMA½
|
||||||
const MenuModule = require('./menu_module.js').MenuModule;
|
const { MenuModule } = require('./menu_module.js');
|
||||||
const resetScreen = require('./ansi_term.js').resetScreen;
|
const { resetScreen } = require('./ansi_term.js');
|
||||||
const Config = require('./config.js').get;
|
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 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
|
// deps
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
@ -151,11 +157,16 @@ exports.getModule = class ExodusModule extends MenuModule {
|
||||||
|
|
||||||
let pipeRestored = false;
|
let pipeRestored = false;
|
||||||
let pipedStream;
|
let pipedStream;
|
||||||
|
let doorTracking;
|
||||||
|
|
||||||
function restorePipe() {
|
function restorePipe() {
|
||||||
if(pipedStream && !pipeRestored && !clientTerminated) {
|
if(pipedStream && !pipeRestored && !clientTerminated) {
|
||||||
self.client.term.output.unpipe(pipedStream);
|
self.client.term.output.unpipe(pipedStream);
|
||||||
self.client.term.output.resume();
|
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) => {
|
sshClient.shell(window, options, (err, stream) => {
|
||||||
|
doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`);
|
||||||
|
|
||||||
pipedStream = stream; // :TODO: ewwwwwwwww hack
|
pipedStream = stream; // :TODO: ewwwwwwwww hack
|
||||||
self.client.term.output.pipe(stream);
|
self.client.term.output.pipe(stream);
|
||||||
|
|
||||||
|
|
|
@ -174,7 +174,7 @@ exports.getModule = class LastCallersModule extends MenuModule {
|
||||||
let indicatorSumsSql;
|
let indicatorSumsSql;
|
||||||
if(actionIndicatorNames.length > 0) {
|
if(actionIndicatorNames.length > 0) {
|
||||||
indicatorSumsSql = actionIndicatorNames.map(i => {
|
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) {
|
handleNewClient(client, clientSock, modInfo) {
|
||||||
//
|
//
|
||||||
// Start tracking the client. We'll assign it an ID which is
|
// Start tracking the client. A session ID aka client ID
|
||||||
// just the index in our connections array.
|
// will be established in addNewClient() below.
|
||||||
//
|
//
|
||||||
if(_.isUndefined(client.session)) {
|
if(_.isUndefined(client.session)) {
|
||||||
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.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls);
|
||||||
this.viewControllers = {};
|
this.viewControllers = {};
|
||||||
this.interrupt = (_.get(this.menuConfig.config, 'interrupt', MenuModule.InterruptTypes.Queued)).toLowerCase();
|
this.interrupt = (_.get(this.menuConfig.config, 'interrupt', MenuModule.InterruptTypes.Queued)).toLowerCase();
|
||||||
|
|
||||||
|
if(MenuModule.InterruptTypes.Realtime === this.interrupt) {
|
||||||
|
this.realTimeInterrupt = 'blocked';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static get InterruptTypes() {
|
static get InterruptTypes() {
|
||||||
|
@ -137,6 +141,7 @@ exports.MenuModule = class MenuModule extends PluginModule {
|
||||||
},
|
},
|
||||||
function finishAndNext(callback) {
|
function finishAndNext(callback) {
|
||||||
self.finishedLoading();
|
self.finishedLoading();
|
||||||
|
self.realTimeInterrupt = 'allowed';
|
||||||
return self.autoNextMenu(callback);
|
return self.autoNextMenu(callback);
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -194,21 +199,28 @@ exports.MenuModule = class MenuModule extends PluginModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptInterruptNow(interruptItem, cb) {
|
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
|
return cb(null, false); // don't eat up the item; queue for later
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.realTimeInterrupt = 'blocked';
|
||||||
|
|
||||||
//
|
//
|
||||||
// Default impl: clear screen -> standard display -> reload menu
|
// Default impl: clear screen -> standard display -> reload menu
|
||||||
//
|
//
|
||||||
|
const done = (err, removeFromQueue) => {
|
||||||
|
this.realTimeInterrupt = 'allowed';
|
||||||
|
return cb(err, removeFromQueue);
|
||||||
|
};
|
||||||
|
|
||||||
this.client.interruptQueue.displayWithItem(
|
this.client.interruptQueue.displayWithItem(
|
||||||
Object.assign({}, interruptItem, { cls : true }),
|
Object.assign({}, interruptItem, { cls : true }),
|
||||||
err => {
|
err => {
|
||||||
if(err) {
|
if(err) {
|
||||||
return cb(err, false);
|
return done(err, false);
|
||||||
}
|
}
|
||||||
this.reload(err => {
|
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:
|
// A quick rundown:
|
||||||
// * We may have mciData.menu, mciData.prompt, or both.
|
// * We may have mciData.menu, mciData.prompt, or both.
|
||||||
// * Prompt form is favored over menu form if both are present.
|
// * 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;
|
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);
|
return this.prevMenu(cb);
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,7 +11,9 @@ const {
|
||||||
const clientConnections = require('./client_connections.js');
|
const clientConnections = require('./client_connections.js');
|
||||||
const StatLog = require('./stat_log.js');
|
const StatLog = require('./stat_log.js');
|
||||||
const FileBaseFilters = require('./file_base_filter.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 ANSI = require('./ansi_term.js');
|
||||||
const UserProps = require('./user_property.js');
|
const UserProps = require('./user_property.js');
|
||||||
const SysProps = require('./system_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();
|
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) {
|
function sysStatAsString(statName, defaultValue) {
|
||||||
return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString();
|
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());
|
return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat());
|
||||||
},
|
},
|
||||||
US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); },
|
US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); },
|
||||||
UE : function emailAddres(client) { return userStatAsString(client, UserProps.EmailAddress, ''); },
|
UE : function emailAddress(client) { return userStatAsString(client, UserProps.EmailAddress, ''); },
|
||||||
UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); },
|
UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); },
|
||||||
UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); },
|
UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); },
|
||||||
UT : function themeName(client) {
|
UT : function themeName(client) {
|
||||||
return _.get(client, 'currentTheme.info.name', userStatAsString(client, UserProps.ThemeId, ''));
|
return _.get(client, 'currentTheme.info.name', userStatAsString(client, UserProps.ThemeId, ''));
|
||||||
},
|
},
|
||||||
UD : function themeId(client) { return 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(); },
|
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
|
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; },
|
ST : function serverName(client) { return client.session.serverName; },
|
||||||
|
@ -105,12 +116,12 @@ const PREDEFINED_MCI_GENERATORS = {
|
||||||
const activeFilter = FileBaseFilters.getActiveFilter(client);
|
const activeFilter = FileBaseFilters.getActiveFilter(client);
|
||||||
return activeFilter ? activeFilter.name : '(Unknown)';
|
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
|
DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes
|
||||||
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes);
|
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes);
|
||||||
return formatByteSize(byteSize, true); // true=withAbbr
|
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
|
UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes
|
||||||
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes);
|
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes);
|
||||||
return formatByteSize(byteSize, true); // true=withAbbr
|
return formatByteSize(byteSize, true); // true=withAbbr
|
||||||
|
@ -122,10 +133,10 @@ const PREDEFINED_MCI_GENERATORS = {
|
||||||
return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes);
|
return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes);
|
||||||
},
|
},
|
||||||
|
|
||||||
MS : function accountCreatedclient(client) {
|
MS : function accountCreated(client) {
|
||||||
return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat());
|
return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat());
|
||||||
},
|
},
|
||||||
PS : function userPostCount(client) { return userStatAsString(client, UserProps.MessagePostCount, 0); },
|
PS : function userPostCount(client) { return userStatAsCountString(client, UserProps.MessagePostCount, 0); },
|
||||||
PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); },
|
PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); },
|
||||||
|
|
||||||
MD : function currentMenuDescription(client) {
|
MD : function currentMenuDescription(client) {
|
||||||
|
@ -152,6 +163,19 @@ const PREDEFINED_MCI_GENERATORS = {
|
||||||
SH : function termHeight(client) { return client.term.termHeight.toString(); },
|
SH : function termHeight(client) { return client.term.termHeight.toString(); },
|
||||||
SW : function termWidth(client) { return client.term.termWidth.toString(); },
|
SW : function termWidth(client) { return client.term.termWidth.toString(); },
|
||||||
|
|
||||||
|
AC : function achievementCount(client) { return 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
|
// Date/Time
|
||||||
//
|
//
|
||||||
|
@ -166,7 +190,7 @@ const PREDEFINED_MCI_GENERATORS = {
|
||||||
OS : function operatingSystem() {
|
OS : function operatingSystem() {
|
||||||
return {
|
return {
|
||||||
linux : 'Linux',
|
linux : 'Linux',
|
||||||
darwin : 'Mac OS X',
|
darwin : 'OS X',
|
||||||
win32 : 'Windows',
|
win32 : 'Windows',
|
||||||
sunos : 'SunOS',
|
sunos : 'SunOS',
|
||||||
freebsd : 'FreeBSD',
|
freebsd : 'FreeBSD',
|
||||||
|
|
|
@ -120,11 +120,20 @@ class StatLog {
|
||||||
|
|
||||||
//
|
//
|
||||||
// User specific stats
|
// 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
|
// 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) {
|
getUserStat(user, statName) {
|
||||||
|
@ -138,18 +147,34 @@ class StatLog {
|
||||||
incrementUserStat(user, statName, incrementBy, cb) {
|
incrementUserStat(user, statName, incrementBy, cb) {
|
||||||
incrementBy = incrementBy || 1;
|
incrementBy = incrementBy || 1;
|
||||||
|
|
||||||
let newValue = parseInt(user.properties[statName]);
|
const oldValue = user.getPropertyAsNumber(statName) || 0;
|
||||||
if(newValue) {
|
const newValue = oldValue + incrementBy;
|
||||||
if(!_.isNumber(newValue)) {
|
|
||||||
return cb(new Error(`Value for ${statName} is not a number!`));
|
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 :)
|
// the time "now" in the ISO format we use and love :)
|
||||||
|
@ -344,29 +369,8 @@ class StatLog {
|
||||||
}
|
}
|
||||||
|
|
||||||
initUserEvents(cb) {
|
initUserEvents(cb) {
|
||||||
//
|
const systemEventUserLogInit = require('./sys_event_user_log.js');
|
||||||
// We map some user events directly to user stat log entries such that they
|
systemEventUserLogInit(this);
|
||||||
// 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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return cb(null);
|
return cb(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ const {
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
String formatting HEAVILY inspired by David Chambers string-format library
|
String formatting HEAVILY inspired by David Chambers string-format library
|
||||||
|
@ -281,6 +282,10 @@ const transformers = {
|
||||||
countWithAbbr : (n) => formatCount(n, true, 0),
|
countWithAbbr : (n) => formatCount(n, true, 0),
|
||||||
countWithoutAbbr : (n) => formatCount(n, false, 0),
|
countWithoutAbbr : (n) => formatCount(n, false, 0),
|
||||||
countAbbr : (n) => formatCountAbbr(n),
|
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) {
|
function transformValue(transformerName, value) {
|
||||||
|
|
|
@ -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';
|
'use strict';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount }
|
ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount }
|
||||||
ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount }
|
ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount }
|
||||||
TermDetected : 'codes.l33t.enigma.system.term_detected', // { client }
|
TermDetected : 'codes.l33t.enigma.system.term_detected', // { client }
|
||||||
|
|
||||||
ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId }
|
ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId }
|
||||||
ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson)
|
ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson)
|
||||||
MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson)
|
MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson)
|
||||||
PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson)
|
PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson)
|
||||||
|
|
||||||
// User - includes { user, ...}
|
// User - includes { user, ...}
|
||||||
NewUser : 'codes.l33t.enigma.system.user_new',
|
NewUser : 'codes.l33t.enigma.system.user_new', // { ... }
|
||||||
UserLogin : 'codes.l33t.enigma.system.user_login',
|
UserLogin : 'codes.l33t.enigma.system.user_login', // { ... }
|
||||||
UserLogoff : 'codes.l33t.enigma.system.user_logoff',
|
UserLogoff : 'codes.l33t.enigma.system.user_logoff', // { ... }
|
||||||
UserUpload : 'codes.l33t.enigma.system.user_upload', // {..., files[ fileEntry, ...] }
|
UserUpload : 'codes.l33t.enigma.system.user_upload', // { ..., files[ fileEntry, ...] }
|
||||||
UserDownload : 'codes.l33t.enigma.system.user_download', // {..., files[ fileEntry, ...] }
|
UserDownload : 'codes.l33t.enigma.system.user_download', // { ..., files[ fileEntry, ...] }
|
||||||
UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { areaTag }
|
UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { ..., areaTag }
|
||||||
UserSendMail : 'codes.l33t.enigma.system.user_send_mail',
|
UserSendMail : 'codes.l33t.enigma.system.user_send_mail', // { ... }
|
||||||
UserRunDoor : 'codes.l33t.enigma.system.user_run_door',
|
UserRunDoor : 'codes.l33t.enigma.system.user_run_door', // { ..., runTimeMinutes, doorTag|unknown }
|
||||||
UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg',
|
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')) {
|
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);
|
refreshThemeHelpers(theme);
|
||||||
|
@ -131,8 +131,9 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
|
||||||
//
|
//
|
||||||
// Add in data we won't be altering directly from the theme
|
// Add in data we won't be altering directly from the theme
|
||||||
//
|
//
|
||||||
mergedTheme.info = theme.info;
|
mergedTheme.info = theme.info;
|
||||||
mergedTheme.helpers = theme.helpers;
|
mergedTheme.helpers = theme.helpers;
|
||||||
|
mergedTheme.achievements = _.get(theme, 'customization.achievements');
|
||||||
|
|
||||||
//
|
//
|
||||||
// merge customizer to disallow immutable MCI properties
|
// merge customizer to disallow immutable MCI properties
|
||||||
|
|
|
@ -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) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
|
|
||||||
|
this.interrupt = MenuModule.InterruptTypes.Never;
|
||||||
|
|
||||||
if(_.has(options, 'lastMenuResult.recvFilePaths')) {
|
if(_.has(options, 'lastMenuResult.recvFilePaths')) {
|
||||||
this.recvFilePaths = 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) {
|
getProperty(propName) {
|
||||||
return this.properties[propName];
|
return this.properties[propName];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
displayWithItem(interruptItem, cb) {
|
||||||
if(interruptItem.cls) {
|
if(interruptItem.cls) {
|
||||||
this.client.term.rawWrite(ANSI.clearScreen());
|
this.client.term.rawWrite(ANSI.resetScreen());
|
||||||
} else {
|
} else {
|
||||||
this.client.term.rawWrite('\r\n\r\n');
|
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) {
|
if(interruptItem.contents) {
|
||||||
Art.display(this.client, interruptItem.contents, err => {
|
Art.display(this.client, interruptItem.contents, err => {
|
||||||
if(err) {
|
if(err) {
|
||||||
return cb(err);
|
return cb(err);
|
||||||
}
|
}
|
||||||
//this.client.term.rawWrite('\r\n\r\n'); // :TODO: Prob optional based on contents vs text
|
//this.client.term.rawWrite('\r\n\r\n'); // :TODO: Prob optional based on contents vs text
|
||||||
this.client.currentMenuModule.pausePrompt( () => {
|
maybePauseAndFinish();
|
||||||
return cb(null);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
} 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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
|
@ -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',
|
MessageConfTag : 'message_conf_tag',
|
||||||
MessageAreaTag : 'message_area_tag',
|
MessageAreaTag : 'message_area_tag',
|
||||||
MessagePostCount : 'post_count',
|
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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -82,6 +82,7 @@
|
||||||
- [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %})
|
- [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %})
|
||||||
- [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %})
|
- [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %})
|
||||||
- [Node to Node Messaging]({{ site.baseurl }}{% link modding/node-msg.md %})
|
- [Node to Node Messaging]({{ site.baseurl }}{% link modding/node-msg.md %})
|
||||||
|
- [Top X]({{ site.baseurl }}{% link modding/top-x.md %})
|
||||||
|
|
||||||
- Administration
|
- Administration
|
||||||
- [oputil]({{ site.baseurl }}{% link admin/oputil.md %})
|
- [oputil]({{ site.baseurl }}{% link admin/oputil.md %})
|
||||||
|
|
|
@ -16,8 +16,8 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|------|--------------|
|
|------|--------------|
|
||||||
| `BN` | Board Name |
|
| `BN` | Board Name |
|
||||||
| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.3-alpha" |
|
| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.9-alpha" |
|
||||||
| `VN` | Version *number*, eg.. "0.0.3-alpha" |
|
| `VN` | Version *number*, eg.. "0.0.9-alpha" |
|
||||||
| `SN` | SysOp username |
|
| `SN` | SysOp username |
|
||||||
| `SR` | SysOp real name |
|
| `SR` | SysOp real name |
|
||||||
| `SL` | SysOp location |
|
| `SL` | SysOp location |
|
||||||
|
@ -30,7 +30,7 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
|
||||||
| `UR` | Current user's real name |
|
| `UR` | Current user's real name |
|
||||||
| `LO` | Current user's location |
|
| `LO` | Current user's location |
|
||||||
| `UA` | Current user's age |
|
| `UA` | Current user's age |
|
||||||
| `BD` | Current user's birthdate (using theme date format) |
|
| `BD` | Current user's birthday (using theme date format) |
|
||||||
| `US` | Current user's sex |
|
| `US` | Current user's sex |
|
||||||
| `UE` | Current user's email address |
|
| `UE` | Current user's email address |
|
||||||
| `UW` | Current user's web address |
|
| `UW` | Current user's web address |
|
||||||
|
@ -58,6 +58,10 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
|
||||||
| `CM` | Current user's active message conference description |
|
| `CM` | Current user's active message conference description |
|
||||||
| `SH` | Current user's term height |
|
| `SH` | Current user's term height |
|
||||||
| `SW` | Current user's term width |
|
| `SW` | Current user's term width |
|
||||||
|
| `AC` | Current user's total achievements |
|
||||||
|
| `AP` | Current user's total achievement points |
|
||||||
|
| `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) |
|
| `DT` | Current date (using theme date format) |
|
||||||
| `CT` | Current time (using theme time format) |
|
| `CT` | Current time (using theme time format) |
|
||||||
| `OS` | System OS (Linux, Windows, etc.) |
|
| `OS` | System OS (Linux, Windows, etc.) |
|
||||||
|
@ -149,10 +153,25 @@ Standard style types available for `textStyle` and `focusTextStyle`:
|
||||||
| `mixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) |
|
| `mixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) |
|
||||||
| `l33t` | 3n1gm4 bull371n b04rd 50f7w4r3 |
|
| `l33t` | 3n1gm4 bull371n b04rd 50f7w4r3 |
|
||||||
|
|
||||||
### Entry Fromatting
|
### Entry Formatting
|
||||||
Various strings can be formatted using a syntax that allows width & precision specifiers, text styling, etc. Depending on the context, various elements can be referenced by `{name}`. Additional text styles can be supplied as well. The syntax is largely modeled after Python's [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language).
|
Various strings can be formatted using a syntax that allows width & precision specifiers, text styling, etc. Depending on the context, various elements can be referenced by `{name}`. Additional text styles can be supplied as well. The syntax is largely modeled after Python's [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language).
|
||||||
|
|
||||||
### Additional Text Styles
|
### Additional Text Styles
|
||||||
|
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:
|
Additional text styles are available for numbers:
|
||||||
|
|
||||||
| Style | Description |
|
| 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. |
|
| `countWithAbbr` | Count with abbreviation such as `100 K`, `4.3 B`, etc. |
|
||||||
| `countWithoutAbbr` | Just the count |
|
| `countWithoutAbbr` | Just the count |
|
||||||
| `countAbbr` | Just the abbreviation such as `M` for millions. |
|
| `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
|
#### Examples
|
||||||
|
|
|
@ -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_ |
|
| NR<i>ratio</i> | User has upload/download count ratio >= _ratio_ |
|
||||||
| KR<i>ratio</i> | User has a upload/download byte 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_ |
|
| 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.
|
\* Many more ACS codes are planned for the near future.
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ _Note that if you've used the [Docker](docker) installation method, you've alrea
|
||||||
If everything went OK:
|
If everything went OK:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ENiGMA½ Copyright (c) 2014-2018 Bryan Ashby
|
ENiGMA½ Copyright (c) 2014-2019 Bryan Ashby
|
||||||
_____________________ _____ ____________________ __________\_ /
|
_____________________ _____ ____________________ __________\_ /
|
||||||
\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp!
|
\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp!
|
||||||
// __|___// | \// |// | \// | | \// \ /___ /_____
|
// __|___// | \// |// | \// | | \// \ /___ /_____
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
---
|
---
|
||||||
layout: page
|
layout: page
|
||||||
title: Windows Full Install
|
title: Installation Under Windows
|
||||||
---
|
---
|
||||||
|
## Installation Under Windows
|
||||||
|
|
||||||
ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit doors natively then you should use a 32bit Windows.
|
ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit doors natively then you should use a 32bit Windows.
|
||||||
|
|
||||||
|
|
||||||
### Basic Instructions
|
### 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`
|
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`
|
||||||
|
|
||||||
|
|
|
@ -14,13 +14,15 @@ Available `config` block entries:
|
||||||
* `sysop`: Sysop options:
|
* `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.
|
* `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
|
* `hide`: Hide all +op logins
|
||||||
* `actionIndicators`: Maps user actions to indicators. For example: `userDownload` to "D". Available indicators:
|
* `actionIndicators`: Maps user events/actions to indicators. For example: `userDownload` to "D". Available indicators:
|
||||||
* `userDownload`
|
* `newUser`: User is new.
|
||||||
* `userUpload`
|
* `dlFiles`: User downloaded file(s).
|
||||||
* `userPostMsg`
|
* `ulFiles`: User uploaded file(s).
|
||||||
* `userSendMail`
|
* `postMsg`: User posted message(s) to the message base, EchoMail, etc.
|
||||||
* `userRunDoor`
|
* `sendMail`: User sent _private_ mail.
|
||||||
* `userSendNodeMsg`
|
* `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 "-".
|
* `actionIndicatorDefault`: Default indicator when an action is not set. Defaults to "-".
|
||||||
|
|
||||||
Remember that entries such as `actionIndicators` and `actionIndicatorDefault` may contain pipe color codes!
|
Remember that entries such as `actionIndicators` and `actionIndicatorDefault` may contain pipe color codes!
|
||||||
|
|
|
@ -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.
|
|
@ -160,7 +160,7 @@
|
||||||
TW : function termWidth() {
|
TW : function termWidth() {
|
||||||
return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value;
|
return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value;
|
||||||
},
|
},
|
||||||
ID : function isUserId(value) {
|
ID : function isUserId() {
|
||||||
if(!user) {
|
if(!user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -180,6 +180,20 @@
|
||||||
const midnight = now.clone().startOf('day')
|
const midnight = now.clone().startOf('day')
|
||||||
const minutesPastMidnight = now.diff(midnight, 'minutes');
|
const minutesPastMidnight = now.diff(midnight, 'minutes');
|
||||||
return !isNaN(value) && minutesPastMidnight >= value;
|
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);
|
}[acsCode](value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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();
|
Loading…
Reference in New Issue