diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ae03c4c..7a7110f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: - name: Restore dependencies run: dotnet restore - name: Build - run: dotnet build --no-restore + run: dotnet build --no-restore -c Release - name: Attach release artifacts uses: softprops/action-gh-release@v1 with: diff --git a/.vscode/settings.json b/.vscode/settings.json index ef3ca7a..932cd0c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,18 +1,31 @@ { "cSpell.words": [ + "aequasi", + "Bepin", + "Bson", + "denikson", "Digitalroot", "discordconnector", "ipify", + "Ipify", "Leaderboard", "leaderboards", "PLAYERID", "PUBLICIP", "r2modman", "Roadmap", + "softprops", "Spidey", "STEAMID", + "tcli", "thedefside", + "Thunderstore", + "valheim", "Valheim", - "Xithyr" - ] + "Worldof", + "Xithyr", + "ZDOID", + "ZDOS" + ], + "conventionalCommits.scopes": ["metadata", "gh-actions", "release"] } diff --git a/DiscordConnector.csproj b/DiscordConnector.csproj index b9f6ab7..2474961 100644 --- a/DiscordConnector.csproj +++ b/DiscordConnector.csproj @@ -7,8 +7,7 @@ latest Library true - - + true false @@ -20,23 +19,23 @@ - + - + - + - + - + @@ -53,4 +52,4 @@ - + \ No newline at end of file diff --git a/Metadata/CHANGELOG.md b/Metadata/CHANGELOG.md index 3381028..c86ef73 100644 --- a/Metadata/CHANGELOG.md +++ b/Metadata/CHANGELOG.md @@ -4,6 +4,31 @@ A full changelog for reference. ## History +### Version 2.1.0 + +A full leaderboard overhaul is in the version. The previous settings for the statistic leaderboards are depreciated in favor of configuration defined statistic leaderboard settings. Look in the `discordconnector-leaderboards.cfg` file and configure any number of the 4 leaderboards to present the kind of data you want. In addition to multiple leaderboards, there are now time-based filters for the leaderboards; restrict them to today or this week or leave them set to all-time. By default, all leaderboards are disabled. If you were using a leaderboard before, you will have to set up a leaderboard to accomplish what you were sending before and enable it. Sorry for the inconvenience but this was the safest tradeoff. + +Also relating to statistic leaderboards, there is a new statistic available for the leaderboards, 'Time Online' which uses the saved 'join' and 'leave' records to estimate a player's time on the server and present that as a value. This obviously doesn't work if you had disabled one or the other pieces of tracking (either disabled recording 'join' or 'leave' stats in the toggles config file). This values are calculated when the leaderboard is created but that should be OK since it is in a non-blocking task call. + +The new Active Player's Announcement can be configured to announce server activity at a pre-defined interval. Configurable stats for it include players currently online, unique players online today, unique players online this week and unique players all time. It will use the same method set in the main config file (`discordconnector.cfg`) for how to determine individual players to count unique players for these time spans. + +Additionally, the configuration files are nested in a subdirectory now. This is from a request on the plugin repository. When loading 2.1.0 (or future versions), the Discord Connector config files that are in the `BepInEx/config` directory will be automatically moved to the subdirectory and loaded from there. The subdirectory is `BepInEx/config/games.nwest.valheim.discordconnector`, and the config files themselves have shortened filenames. The records database is also moved to this subdirectory and renamed `records.db`. + +Features: + +- Adds new tracked stat for time on server (only works if you have enabled join and leave stats) +- Adds dynamically configured leaderboards (disabled by default) +- Adds an Active Players Announcement (disabled by default) + +Changes: + +- Configuration files are now nested in a subdirectory (first run will migrate them automatically) +- Database file moved into the subdirectory (first run will migrate it automatically) +- `config-debug.json` file is dumped to subdirectory after config load to be useful for debugging issues with the plugin (sensitive info is redacted, i.e. the webhook url) +- Multiple-choice config options use Enums on the backend now instead of Strings (may affect `discordconnector.cfg`: How to discern players in Record Retrieval) +- Building the plugin with the optimization flag present; in my tests, startup time of a Valheim server with just DiscordConnector installed was quicker +- Public IP is only queried if it is used (by including the %PUBLICIP% variable in a message) + ### Version 2.0.8 Changes: diff --git a/Metadata/README.md b/Metadata/README.md index d4a67d6..bc98e18 100644 --- a/Metadata/README.md +++ b/Metadata/README.md @@ -11,6 +11,7 @@ Connect your Valheim server (dedicated or served from the game itself) to a Disc - Set more than one message for each type and have one randomly chosen! - Record number of logins/deaths/pings and flavor the Discord messages - Works with non-dedicated server (games opened to lan from the client) +- Configure custom leader boards to be sent periodically, listing rankings for any of the tracked stats ### Supported Message Notifications @@ -28,83 +29,33 @@ Connect your Valheim server (dedicated or served from the game itself) to a Disc See the [current roadmap](https://github.com/nwesterhausen/valheim-discordconnector/projects/1) as a Github project. -- Fancier Discord messages -- Discord bot integration - Multiple webhook support -- More statistics able to be sent +- More statistics trackable/able to be sent +- New day messages ## Changelog -### Version 2.0.8 +### Version 2.1.0 -Changes: - -- `%WORLD_NAME%` will now only replace with world name once server has started up to avoid an issue with Key Manager - -### Version 2.0.7 - -Changes: - -- Further guards against null-reference exceptions - -### Version 2.0.6 +A full leaderboard overhaul is in the version. The previous settings for the statistic leaderboards are depreciated in favor of configuration defined statistic leaderboard settings. Look in the `discordconnector-leaderboards.cfg` file and configure any number of the 4 leaderboards to present the kind of data you want. In addition to multiple leaderboards, there are now time-based filters for the leaderboards; restrict them to today or this week or leave them set to all-time. By default, all leaderboards are disabled. If you were using a leaderboard before, you will have to set up a leaderboard to accomplish what you were sending before and enable it. Sorry for the inconvenience but this was the safest tradeoff. -Fixes: +Also relating to statistic leaderboards, there is a new statistic available for the leaderboards, 'Time Online' which uses the saved 'join' and 'leave' records to estimate a player's time on the server and present that as a value. This obviously doesn't work if you had disabled one or the other pieces of tracking (either disabled recording 'join' or 'leave' stats in the toggles config file). This values are calculated when the leaderboard is created but that should be OK since it is in a non-blocking task call. -- Fixes plugin crash that could occur if the game was initiated more than once. -- Removed extraneous discord message on server load +The new Active Player's Announcement can be configured to announce server activity at a pre-defined interval. Configurable stats for it include players currently online, unique players online today, unique players online this week and unique players all time. It will use the same method set in the main config file (`discordconnector.cfg`) for how to determine individual players to count unique players for these time spans. -### Version 2.0.5 +Additionally, the configuration files are nested in a subdirectory now. This is from a request on the plugin repository. When loading 2.1.0 (or future versions), the Discord Connector config files that are in the `BepInEx/config` directory will be automatically moved to the subdirectory and loaded from there. The subdirectory is `BepInEx/config/games.nwest.valheim.discordconnector`, and the config files themselves have shortened filenames. The records database is also moved to this subdirectory and renamed `records.db`. Features: -- Adds a config option to format how position data is formatted -- Adds a config option to format how the automatically-appended position data is formatted -- Adds a new variable which can be used in any messages: `%WORLD_NAME%` turns into the name of the world. +- Adds new tracked stat for time on server (only works if you have enabled join and leave stats) *The duration provided will probably be inaccurate* +- Adds dynamically configured leaderboards (disabled by default) +- Adds an Active Players Announcement (disabled by default) Changes: -- `%POS%` now renders without the enclosing parentheses. - -### Version 2.0.4 - -Features: - -- Adds a config option to enable sending non-player shouts to Discord. This is in the main config file and disabled by default. - -### Version 2.0.3 - -Other Changes: - -- Set BepInEx dependency to exactly 5.4.19 instead of 5.* (this stops a warning from showing up) - -### Version 2.0.2 - -If a shout is performed by a player that isn't a real player (like a mod), it would break the shout call from working. This is because Discord Connector was trying to lookup the player's details and encountering null. The plugin now checks for that and returns early if null is found. - -Fixes: - -- Detect if a shout is by a non-player and gracefully exit. - -### Version 2.0.1 - -With this update, we bring back Steam_ID variable inclusion and leaderboard message sending (respecting your config settings). I recommend you replace your `discordconnector.valheim.nwest.games-records.db` database, since the records will not line up and will be essentially soft-reset because the column name changed with the different type of data. Steam IDs are prefaced with 'Steam_' now, so you could migrate your stat database with a bit of effort. I believe this could all be done with queries inside the LiteDB Query Tool. - -Fixes: - -- Periodic leaderboard messages sending will now respect your config value instead of never sending -- The STEAMID variable works again. An alias is the PLAYERID variable, which does the same thing -- they both provide the full player id, so `Steam_` or `XBox_` - -Breaking changes: - -- Player IDs are tracked in the stat database using a new column name, which resets any stat tracking because the player ID is used to resolve to a single player by combining with the character name. - -### Version 2.0.0 - -Previous version broke with the new updates to Valheim using the PlayFab server stuff. Previously, the steam ID was grabbed directly from the socket but that doesn't work anymore. To get something workable (the other messages work), I have removed the code which tried to get the SteamID and disabled leaderboard sending. - -Breaking changes: - -- Removed steamid variable (internally) and tracking stats by steamid. This broke with the PlayFab changes to Valheim. It will be a bit involved to figure out how to deliver the same thing again, so if you have an idea or seen it done in another mod, please reach out with a Github Issue or ping on Discord. -- Leaderboard records will reset and a new database with suffix '-records2.db' will be saved anew. This is because what is being tracked is changed (used to be steamid, now it is using the character id). -- Periodic leaderboard messages will not send, ignoring the setting in the config (for now). This is until a more reliable method of determining players apart. +- Configuration files are now nested in a subdirectory (first run will migrate them automatically) +- Database file moved into the subdirectory (first run will migrate it automatically) +- `config-debug.json` file is dumped to subdirectory after config load to be useful for debugging issues with the plugin (sensitive info is redacted, i.e. the webhook url) +- Multiple-choice config options use Enums on the backend now instead of Strings (may affect `discordconnector.cfg`: How to discern players in Record Retrieval) +- Building the plugin with the optimization flag present; in my tests, startup time of a Valheim server with just DiscordConnector installed was quicker +- Public IP is only queried if it is used (by including the %PUBLICIP% variable in a message) diff --git a/Metadata/icon2-hdpi.png b/Metadata/icon2-hdpi.png new file mode 100644 index 0000000..0cb7ee4 Binary files /dev/null and b/Metadata/icon2-hdpi.png differ diff --git a/Metadata/icon2-xhdpi-box.png b/Metadata/icon2-xhdpi-box.png new file mode 100644 index 0000000..20793ef Binary files /dev/null and b/Metadata/icon2-xhdpi-box.png differ diff --git a/Metadata/icon2-xhdpi.png b/Metadata/icon2-xhdpi.png new file mode 100644 index 0000000..2e9e794 Binary files /dev/null and b/Metadata/icon2-xhdpi.png differ diff --git a/Metadata/icon2.png b/Metadata/icon2.png new file mode 100644 index 0000000..2450bba Binary files /dev/null and b/Metadata/icon2.png differ diff --git a/Metadata/icon2.svg b/Metadata/icon2.svg new file mode 100644 index 0000000..a7d0cc1 --- /dev/null +++ b/Metadata/icon2.svg @@ -0,0 +1,67 @@ + + + + diff --git a/Metadata/manifest.json b/Metadata/manifest.json index 0c75dc7..6852ef2 100644 --- a/Metadata/manifest.json +++ b/Metadata/manifest.json @@ -1,6 +1,6 @@ { "name": "DiscordConnector", - "version_number": "2.0.8", + "version_number": "2.1.0", "website_url": "https://discordconnector.valheim.nwest.games/", "description": "Connects your Valheim server to a Discord webhook. Works for both dedicated and client-hosted servers.", "dependencies": ["denikson-BepInExPack_Valheim-5.4.1901"] diff --git a/Metadata/thunderstore.toml b/Metadata/thunderstore.toml index 716def3..0ce296d 100644 --- a/Metadata/thunderstore.toml +++ b/Metadata/thunderstore.toml @@ -4,7 +4,7 @@ schemaVersion = "0.0.1" [package] namespace = "nwesterhausen" name = "DiscordConnector" -versionNumber = "2.0.8" +versionNumber = "2.1.0" description = "Connects your Valheim server to a Discord webhook. Works for both dedicated and client-hosted servers." websiteUrl = "https://discordconnector.valheim.nwest.games/" containsNsfwContent = false @@ -24,4 +24,4 @@ target = "" [publish] repository = "https://thunderstore.io" communities = ["valheim"] -categories = [] \ No newline at end of file +categories = [] diff --git a/README.md b/README.md index d18bc79..46c11ee 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Build](https://github.com/nwesterhausen/valheim-discordconnector/actions/workflows/dotnet.yml/badge.svg)](https://github.com/nwesterhausen/valheim-discordconnector/actions/workflows/dotnet.yml) [![Publish](https://github.com/nwesterhausen/valheim-discordconnector/actions/workflows/publish.yml/badge.svg)](https://github.com/nwesterhausen/valheim-discordconnector/actions/workflows/publish.yml) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/nwesterhausen/valheim-discordconnector?label=Github%20Release&style=flat&labelColor=%2332393F)](https://github.com/nwesterhausen/valheim-discordconnector/releases/latest) -[![Thunderstore.io](https://img.shields.io/badge/Thunderstore.io-2.0.8-%23375a7f?style=flat&labelColor=%2332393F)](https://valheim.thunderstore.io/package/nwesterhausen/DiscordConnector/) +[![Thunderstore.io](https://img.shields.io/badge/Thunderstore.io-2.1.0-%23375a7f?style=flat&labelColor=%2332393F)](https://valheim.thunderstore.io/package/nwesterhausen/DiscordConnector/) [![NexusMods](https://img.shields.io/badge/NexusMods-2.0.6-%23D98F40?style=flat&labelColor=%2332393F)](https://www.nexusmods.com/valheim/mods/1551/) Connect your Valheim server to Discord. ([See website for installation or configuration instructions](https://discordconnector.valheim.nwest.games/)). This plugin is largely based on [valheim-discord-notifier](https://github.com/aequasi/valheim-discord-notifier), but this plugin supports randomized messages, muting players, and Discord message embeds. diff --git a/docs/changelog.md b/docs/changelog.md index 9bc46ac..db9e3fa 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,31 @@ A full changelog ## History +### Version 2.1.0 + +A full leaderboard overhaul is in the version. The previous settings for the statistic leaderboards are depreciated in favor of configuration defined statistic leaderboard settings. Look in the `discordconnector-leaderboards.cfg` file and configure any number of the 4 leaderboards to present the kind of data you want. In addition to multiple leaderboards, there are now time-based filters for the leaderboards; restrict them to today or this week or leave them set to all-time. By default, all leaderboards are disabled. If you were using a leaderboard before, you will have to set up a leaderboard to accomplish what you were sending before and enable it. Sorry for the inconvenience but this was the safest tradeoff. + +Also relating to statistic leaderboards, there is a new statistic available for the leaderboards, 'Time Online' which uses the saved 'join' and 'leave' records to estimate a player's time on the server and present that as a value. This obviously doesn't work if you had disabled one or the other pieces of tracking (either disabled recording 'join' or 'leave' stats in the toggles config file). This values are calculated when the leaderboard is created but that should be OK since it is in a non-blocking task call. + +The new Active Player's Announcement can be configured to announce server activity at a pre-defined interval. Configurable stats for it include players currently online, unique players online today, unique players online this week and unique players all time. It will use the same method set in the main config file (`discordconnector.cfg`) for how to determine individual players to count unique players for these time spans. + +Additionally, the configuration files are nested in a subdirectory now. This is from a request on the plugin repository. When loading 2.1.0 (or future versions), the Discord Connector config files that are in the `BepInEx/config` directory will be automatically moved to the subdirectory and loaded from there. The subdirectory is `BepInEx/config/games.nwest.valheim.discordconnector`, and the config files themselves have shortened filenames. The records database is also moved to this subdirectory and renamed `records.db`. + +Features: + +- Adds new tracked stat for time on server (only works if you have enabled join and leave stats) +- Adds dynamically configured leaderboards (disabled by default) +- Adds an Active Players Announcement (disabled by default) + +Changes: + +- Configuration files are now nested in a subdirectory (first run will migrate them automatically) +- Database file moved into the subdirectory (first run will migrate it automatically) +- `config-debug.json` file is dumped to subdirectory after config load to be useful for debugging issues with the plugin (sensitive info is redacted, i.e. the webhook url) +- Multiple-choice config options use Enums on the backend now instead of Strings (may affect `discordconnector.cfg`: How to discern players in Record Retrieval) +- Building the plugin with the optimization flag present; in my tests, startup time of a Valheim server with just DiscordConnector installed was quicker +- Public IP is only queried if it is used (by including the %PUBLICIP% variable in a message) + ### Version 2.0.8 Changes: @@ -57,12 +82,12 @@ Fixes: ### Version 2.0.1 -With this update, we bring back Steam_ID variable inclusion and leaderboard message sending (respecting your config settings). I recommend you replace your `discordconnector.valheim.nwest.games-records.db` database, since the records will not line up and will be essentially soft-reset because the column name changed with the different type of data. Steam IDs are prefaced with 'Steam_' now, so you could migrate your stat database with a bit of effort. I believe this could all be done with queries inside the LiteDB Query Tool. +With this update, we bring back Steam_ID variable inclusion and leader board message sending (respecting your config settings). I recommend you replace your `discordconnector.valheim.nwest.games-records.db` database, since the records will not line up and will be essentially soft-reset because the column name changed with the different type of data. Steam IDs are prefaced with 'Steam_' now, so you could migrate your stat database with a bit of effort. I believe this could all be done with queries inside the LiteDB Query Tool. Fixes: -- Periodic leaderboard messages sending will now respect your config value instead of never sending -- The STEAMID variable works again. An alias is the PLAYERID variable, which does the same thing -- they both provide the full player id, so `Steam_` or `XBox_` +- Periodic leader board messages sending will now respect your config value instead of never sending +- The `%STEAMID%` variable works again. An alias is the `%PLAYERID%` variable, which does the same thing -- they both provide the full player id, so `Steam_` or `XBox_` Breaking changes: @@ -70,13 +95,13 @@ Breaking changes: ### Version 2.0.0 -Previous version broke with the new updates to Valheim using the PlayFab server stuff. Previously, the steam ID was grabbed directly from the socket but that doesn't work anymore. To get something workable (the other messages work), I have removed the code which tried to get the SteamID and disabled leaderboard sending. +Previous version broke with the new updates to Valheim using the PlayFab server stuff. Previously, the steam ID was grabbed directly from the socket but that doesn't work anymore. To get something workable (the other messages work), I have removed the code which tried to get the SteamID and disabled leader board sending. Breaking changes: - Removed steamid variable (internally) and tracking stats by steamid. This broke with the PlayFab changes to Valheim. It will be a bit involved to figure out how to deliver the same thing again, so if you have an idea or seen it done in another mod, please reach out with a Github Issue or ping on Discord. -- Leaderboard records will reset and a new database with suffix '-records2.db' will be saved anew. This is because what is being tracked is changed (used to be steamid, now it is using the character id). -- Periodic leaderboard messages will not send, ignoring the setting in the config (for now). This is until a more reliable method of determining players apart. +- Leader board records will reset and a new database with suffix '-records2.db' will be saved anew. This is because what is being tracked is changed (used to be steamid, now it is using the character id). +- Periodic leader board messages will not send, ignoring the setting in the config (for now). This is until a more reliable method of determining players apart. ### Version 1.8.0 @@ -138,13 +163,13 @@ Breaking Changes: Fixes: -- Leaderboard interval was half of what was configured (now is properly minutes) +- Leader board interval was half of what was configured (now is properly minutes) ### Version 1.5.2 Fixes: -- Highest and Lowest leaderboards were not checking the correct tables +- Highest and Lowest leader boards were not checking the correct tables - Configurable retrieval strategy for all records (either SteamID, PLayer Name, or both) -- always returns player names Due to how records.json recorded stats and the LiteDB, you will not be able to use the old records with strategies @@ -154,7 +179,7 @@ involving the SteamID because prior to 1.5.0 we were not recording the SteamID w Fixes: -- Toggles for the bottom n players leaderboards (inverse ranked leaderboards) +- Toggles for the bottom n players leader boards (inverse ranked leader boards) ### Version 1.5.0 @@ -191,13 +216,13 @@ not migrate the data. For the migration steps, it will be outputting log information (at INFO level) with how many records were migrated and which steps completed. -- Ranked Lowest Player Leaderboard +- Ranked Lowest Player Leader board -Added an inverse of the Top Player leaderboard. +Added an inverse of the Top Player leader board. -- Custom leaderboard heading messages +- Custom leader board heading messages -Added configuration for the messages sent at the top of the leaderboard +Added configuration for the messages sent at the top of the leader board messages. - The variable `%PUBLICIP%` can be used in _any_ message configuration @@ -228,7 +253,7 @@ Features: Fixes: -- Least deaths leaderboard wasn't respecting the correct config entry. (THanks @thedefside) +- Least deaths leader board wasn't respecting the correct config entry. (THanks @thedefside) ### Version 1.4.1 @@ -248,7 +273,7 @@ Features: Fixes: -- Fixed an off-by-one error in the Top Players leaderboard (the default leaderboard) (Thanks @thedefside) +- Fixed an off-by-one error in the Top Players leader board (the default leader board) (Thanks @thedefside) - Fixed configuration not referencing proper settings (Thanks @thedefside) - Fixed event messages (now properly functioning on dedicated servers) @@ -261,8 +286,8 @@ Breaking Changes: Features: -- Additional leaderboard options. The existing leaderboard option will now default to sending top 3 players for what is enabled. - You can enable a highest and lowest leaderboard for each tracked stat now. All leaderboards get sent on the same interval. +- Additional leader board options. The existing leader board option will now default to sending top 3 players for what is enabled. + You can enable a highest and lowest leader board for each tracked stat now. All leader boards get sent on the same interval. ### Version 1.2.2 @@ -278,7 +303,7 @@ for the server. Fixes: -- The leaderboard toggles were not working properly, behind the scenes they were all following the death leaderboard toggle +- The leader board toggles were not working properly, behind the scenes they were all following the death leader board toggle A breaking change was found with the records.json in 1.2.0. The records.json file needs to have all `PlayerName` changed to `Key`. If you are seeing an error message in your logs from Discord Connector, this is the likely culprit (should see something about @@ -370,7 +395,7 @@ Fixes: Fixes -- Time interval for leaderboard in **minutes** not seconds. +- Time interval for leader board in **minutes** not seconds. - Don't display a leave message for disconnects due to version mismatch ### Version 0.9.0 @@ -379,7 +404,7 @@ Default config options are updated to be true for all notification and coordinat Features: -- Periodic stats leaderboard functionality (opt-in) +- Periodic stats leader board functionality (opt-in) Fixes: diff --git a/docs/configuration-details.md b/docs/configuration-details.md index 5aa7242..c32b2e3 100644 --- a/docs/configuration-details.md +++ b/docs/configuration-details.md @@ -2,26 +2,29 @@ The details on where configuration settings are and what they do: how to fine-tune your configuration. -DiscordConnector uses multiple configuration files to make find the setting you want to change faster, and hopefully easier. The configuration is divided into the following files: +## Configuration File Location -| Configuration File | Details | Purpose | -| ---------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------ | -| `games.nwest.valheim.discordconnector.cfg` | [Details](#main-config) | Master settings, including the main webhook and turning settings on or off globally | -| `games.nwest.valheim.discordconnector-messages.cfg` | [Details](#messages) | The messages used/chosen from when DiscordConnector sends messages to Discord | -| `games.nwest.valheim.discordconnector-toggles.cfg` | [Details](#toggles) | Used to turn individual notifications and/or their included extra details on or off. | -| `games.nwest.valheim.discordconnector-variables.cfg` | [Details](#variable-definitions) | Used to assign strings to variables which can be referenced any messages | +DiscordConnector uses multiple configuration files to make find the setting you want to change faster, and hopefully easier. Since 2.1.0, DiscordConnector puts all its config files into a single directory in the `BePinEx/config` directory. The configuration is divided into the following files: + +| Configuration File | Details | Purpose | +| ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------ | +| `discordconnector.cfg` | [Details](#main-config) | Master settings, including the main webhook and turning settings on or off globally | +| `discordconnector-messages.cfg` | [Details](#messages) | The messages used/chosen from when DiscordConnector sends messages to Discord | +| `discordconnector-toggles.cfg` | [Details](#toggles) | Used to turn individual notifications and/or their included extra details on or off. | +| `discordconnector-variables.cfg` | [Details](#variable-definitions) | Used to assign strings to variables which can be referenced any messages | +| `discordconnector-leaderBoards.cfg` | [Details](#leader-board-definitions) | Define custom leader boards to be periodically sent to Discord | ## Main Config -Filename `games.nwest.valheim.discordconnector.cfg` +Filename `discordconnector.cfg` | Option | Default | Description | | ------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Webhook URL | (none) | The main Discord webhook URL to send notifications/messages to. | | Use fancier discord messages | false | Set to true to enable using embeds in the Discord messages. If left false, all messages will remain plain strings (except for the leaderboard). | | Allow positions to be sent | true | Set to false to prevent any positions/coordinates from being sent. If this is true, it can be overridden per message in the toggles config file. | -| Ignored players | (none) | List of player names to never send a discord message for (they also won't be tracked in stats). This list should be semicolon (`;`) separated. | -| Ignored players (Regex) | (none) | Regex which player names are matched against to determine to not send a discord message for (they also won't be tracked in stats) | +| Ignored players | (none) | List of player names to never send a discord message for (they also won't be tracked in stats). This list should be semicolon (`;`) separated. | +| Ignored players (Regex) | (none) | Regex which player names are matched against to determine to not send a discord message for (they also won't be tracked in stats) | | Collect stats | true | When this setting is enabled, DiscordConnector will record basic stats (leave, join, ping, shout, death) about players. | | Send leaderboard updates | false | If you set this to true, that will enable DiscordConnector to send a leaderboard for stats to Discord on the set interval | | Leaderboard update interval | 600 | Time in minutes between each leaderboard update sent to Discord. | @@ -31,15 +34,15 @@ Filename `games.nwest.valheim.discordconnector.cfg` !!! info "Stat Collection Details" - Stat collection will create a file in the BepInEx config directory `games.nwest.valheim.discordconnector-records.db`, where it will record the number of times each player joins, leaves, dies, shouts or pings. + Stat collection will create a file in the `discordconnector` config directory `records.db`, where it will record the number of times each player joins, leaves, dies, shouts or pings. If this is set to false, DiscordConnector will not keep a record of number of times each player does something it alerts to. - If this is false, it takes precedent over the "Send leaderboard updates" setting and no leaderboards will get sent. + If this is false, it takes precedent over the "Send leader board updates" setting and no leader boards will get sent. The stat collection database uses the [LiteDB](https://www.litedb.org/) library and if you are so inclined they offer a database gui which you can use to view/modify this database. (Find the LiteDB Editor on their site.) ## Variable Definitions -Filename `games.nwest.valheim.discordconnector-variables.cfg` +Filename `discordconnector-variables.cfg` You may assign strings to these variables to reference them in any messages. @@ -60,10 +63,10 @@ You may assign strings to these variables to reference them in any messages. Some variables can be configured. Mainly the positional information. -| Option | Default | Description | -| -- | -- | -- | -| POS Variable Formatting | `%X%, %Y%, %Z%` | Change how the %POS% variable is formatted. -| Auto-Appended POS Format | `Coords: (%POS%)` | Change this to modify how Discord Connector automatically appends the POS data. +| Option | Default | Description | +| ------------------------ | ----------------- | ------------------------------------------------------------------------------- | +| POS Variable Formatting | `%X%, %Y%, %Z%` | Change how the %POS% variable is formatted. | +| Auto-Appended POS Format | `Coords: (%POS%)` | Change this to modify how Discord Connector automatically appends the POS data. | !!! info @@ -73,7 +76,7 @@ Some variables can be configured. Mainly the positional information. ## Messages -Filename `games.nwest.valheim.discordconnector-messages.cfg` +Filename `discordconnector-messages.cfg` All of the message options support having multiple messages defined in a semicolon (`;`) separated list. If you have multiple messages defined for these settings, one gets chosen at random when DiscordConnector decides to send the corresponding message. @@ -167,9 +170,9 @@ In the event messages, anywhere in the message you can use the string vars `%EVE ## Toggles -Filename `games.nwest.valheim.discordconnector-toggles.cfg` +Filename `discordconnector-toggles.cfg` -The toggle configuration is a collection of on/off switches for all the message types and all the extra data that can be sent with them. It's broken up into 3 sections, "Toggles.Messages" which turns on or off each type of message, "Toggles.Positions" which turns on or off sending player coordinates with messages, "Toggles.Stats" which turns on or off collection of individual stats and "Toggles.Leaderboards" which turns on or off what stats to send with the leaderboard updates +The toggle configuration is a collection of on/off switches for all the message types and all the extra data that can be sent with them. It's broken up into 3 sections, "Toggles.Messages" which turns on or off each type of message, "Toggles.Positions" which turns on or off sending player coordinates with messages, and "Toggles.Stats" which turns on or off collection of individual stats. ### Toggles.Messages @@ -212,44 +215,69 @@ The toggle configuration is a collection of on/off switches for all the message | Allow recording player shouts | true | Set to false to never record player shouts in records.json | | Allow recording player deaths | true | Set to false to never record player deaths in records.json | -### Toggles.Leaderboard +### Toggles.PlayerFirsts -!!! Info -All leaderboard toggles are restricted by the `Send leaderboard updates` toggle in the [Main config](#main-config). +| Option | Default | Description | +| -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Send Player Join Messages | true | If enabled (and player-first announcements are enabled), will send an extra message on a player's first leave from the server. | +| Send Player Leave Messages | false | If enabled (and player-first announcements are enabled), will send an extra message on a player's first join to the server. | +| Send Player Death Messages | true | If enabled (and player-first announcements are enabled), will send an extra message on a player's first death." | +| Send Player Shout Messages | false | If enabled (and player-first announcements are enabled), will send an extra message on a player's first ping. | +| Send Player Ping Messages | false | If enabled (and player-first announcements are enabled), will send an extra message on a player's first shout. | -For the ranked leaderboards, you choose how many ranks to calculate and display with the `How many places to list in the top ranking leaderboards` setting in the [Main Config](#main-config). +## Leader Board Definitions -| Option | Default | Description | -| --------------------------------------------- | ------- | ------------------------------------------------------------------------------- | -| Send Periodic Leaderboard for Player Deaths | true | If enabled, will send a ranked leaderboard for player deaths at the interval. | -| Send Periodic Leaderboard for Player Pings | false | If enabled, will send a ranked leaderboard for player pings at the interval. | -| Send Periodic Leaderboard for Player Sessions | false | If enabled, will send a ranked leaderboard for player sessions at the interval. | -| Send Periodic Leaderboard for Player Shouts | false | If enabled, will send a ranked leaderboard for player shouts at the interval. | +Filename `discordconnector-leaderBoards.cfg` -### Toggles.Leaderboard.Lowest +Use this file to define custom leader board messages to be sent. Previous versions of DiscordConnector offered only a few options for leader boards, but the custom leader board configuration introduced in 2.1.0, up to 5 custom leader boards can be defined. -| Option | Default | Description | -| ---------------------------------------------- | ------- | ------------------------------------------------------------------------ | -| Include Least Deaths in Periodic Leaderboard | true | If enabled, will include player with the least deaths at the interval. | -| Include Least Pings in Periodic Leaderboard | false | If enabled, will include player with the least pings at the interval. | -| Include Least Sessions in Periodic Leaderboard | false | If enabled, will include player with the least sessions at the interval. | -| Include Least Shouts in Periodic Leaderboard | false | If enabled, will include player with the least shouts at the interval. | +There is a section of this config for each custom board that can be defined. Each is named `LeaderBoard.#`, where `#` is 1-5. By default, all the boards are disabled, so you must manually enable any you wish to use. -### Toggles.Leaderboard.Highest +### Leader Board Configuration -| Option | Default | Description | -| --------------------------------------------- | ------- | ----------------------------------------------------------------------- | -| Include Most Deaths in Periodic Leaderboard | true | If enabled, will include player with the most deaths at the interval. | -| Include Most Pings in Periodic Leaderboard | false | If enabled, will include player with the most pings at the interval. | -| Include Most Sessions in Periodic Leaderboard | false | If enabled, will include player with the most sessions at the interval. | -| Include Most Shouts in Periodic Leaderboard | false | If enabled, will include player with the most shouts at the interval. | +Each leader board lets you set these options: -### Toggles.PlayerFirsts +| Option | Default | Description | +| ----------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Enabled | false | Enable or disable this leader board | +| Leader Board Time Range | All Time | Set the time restriction of this leader board to one of the [time range values](#leader-board-time-ranges) | +| Number of Rankings | 3 | How many rankings to include in the leader board. | +| Type | Most to Least (Descending) | Can be either "Most to Least (Descending)" or "Least to Most (Ascending)" | +| Sending Period | 600 | How many minutes to wait before sending the leader board. 600 minutes is the old default of 10 hours. | +| Death Statistics | true | Include player death statistics in the leader board | +| Session Statistics | true | Include player session statistics in the leader board (how many times they have played on the server) | +| Ping Statistics | true | Include player ping statistics in the leader board | +| Time Online Statistics | true | Include player total play time in the leader board | +| Leader Board Heading | LeaderBoard.N | Title of the leader board which is displayed when sent to discord. | -| Option | Default | Description | -| -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | -| Send Player Join Messages | true | If enabled (and player-first announcements are enabled), will send an extra message on a player's first leave from the server. | -| Send Player Leave Messages | false | If enabled (and player-first announcements are enabled), will send an extra message on a player's first join to the server. | -| Send Player Death Messages | true | If enabled (and player-first announcements are enabled), will send an extra message on a player's first death." | -| Send Player Shout Messages | false | If enabled (and player-first announcements are enabled), will send an extra message on a player's first ping. | -| Send Player Ping Messages | false | If enabled (and player-first announcements are enabled), will send an extra message on a player's first shout. | +#### Available Predefined Variables (Leader Boards) + +In the "Leader Board Heading," the following variables are available: + +| Variable | Replaced with.. | +| -------- | ------------------------------------------------- | +| `%N` | The value of "Number of Rankings" (by default: 3) | + +#### Leader Board Time Ranges + +| Option | Description | +| -------------------------------- | ------------------------------------------------------- | +| All Time | Include every record, as far back as the database goes. | +| Today | Include only records from today. | +| Yesterday | Include only records from yesterday. | +| Past 7 Days | Include only the past 7 days (inclusive of today) | +| Current Week, Sunday to Saturday | Include all records from this week (inclusive of today) | +| Current Week, Monday to Sunday | Include all records from this week (inclusive of today) | + +### Active Player Announcement Configuration + +These options are available: + +| Option | Default | Description | +| ---------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | +| Enabled | false | Enable or disable this leader board | +| Sending Period | 600 | How many minutes to wait before sending the leader board. 600 minutes is the old default of 10 hours. | +| Include Currently Online Players | true | Enable or disable currently online players as part of the active players announcement | +| Include Unique Players for Today | true | Enable or disable unique online players for today as part of the active players announcement | +| Include Unique Players for the Past Week | true | Enable or disable unique online players for the past week (including today) as part of the active players announcement | +| Include Unique Players from All Time | true | Enable or disable unique online players from all time as part of the active players announcement | | diff --git a/lib/Newtonsoft.Json/LICENSE.md b/lib/Newtonsoft.Json/LICENSE.md deleted file mode 100644 index 0fecee7..0000000 --- a/lib/Newtonsoft.Json/LICENSE.md +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2007 James Newton-King - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/lib/Newtonsoft.Json/Newtonsoft.Json.dll b/lib/Newtonsoft.Json/Newtonsoft.Json.dll deleted file mode 100644 index de20c67..0000000 Binary files a/lib/Newtonsoft.Json/Newtonsoft.Json.dll and /dev/null differ diff --git a/lib/Newtonsoft.Json/System.Data.dll b/lib/Newtonsoft.Json/System.Data.dll deleted file mode 100644 index e553174..0000000 Binary files a/lib/Newtonsoft.Json/System.Data.dll and /dev/null differ diff --git a/lib/Newtonsoft.Json/System.Runtime.Serialization.dll b/lib/Newtonsoft.Json/System.Runtime.Serialization.dll deleted file mode 100644 index cb123ad..0000000 Binary files a/lib/Newtonsoft.Json/System.Runtime.Serialization.dll and /dev/null differ diff --git a/src/Config/ActivePlayersAnnouncementConfig.cs b/src/Config/ActivePlayersAnnouncementConfig.cs new file mode 100644 index 0000000..d315b6f --- /dev/null +++ b/src/Config/ActivePlayersAnnouncementConfig.cs @@ -0,0 +1,108 @@ +using BepInEx.Configuration; + +namespace DiscordConnector.Config +{ + internal class ActivePlayersAnnouncementConfig + { + private ConfigEntry enabled; + private ConfigEntry periodInMinutes; + private ConfigEntry includeCurrentlyOnline; + private ConfigEntry includePlayersToday; + private ConfigEntry includePlayersPastWeek; + private ConfigEntry includePlayersAllTime; + + private const string EnabledTitle = "Enabled"; + private const bool EnabledDefault = false; + private const string EnableDescription = "Enable or disable the active players announcement being sent to Discord"; + + public const string PeriodTitle = "Sending Period"; + public const int PeriodDefault = 360; + public const string PeriodDescription = "Set the number of minutes between a leader board announcement sent to discord. (Default period is 6 hours.)"; + + private const string IncludeCurrentlyOnlineTitle = "Include Currently Online Players"; + private const bool IncludeCurrentlyOnlineDefault = true; + private const string IncludeCurrentlyOnlineDescription = "Enable or disable currently online players as part of the active players announcement"; + + private const string IncludePlayersTodayTitle = "Include Unique Players for Today"; + private const bool IncludePlayersTodayDefault = true; + private const string IncludePlayersTodayDescription = "Enable or disable unique online players for today as part of the active players announcement"; + + private const string IncludePlayersPastWeekTitle = "Include Unique Players for the Past Week"; + private const bool IncludePlayersPastWeekDefault = true; + private const string IncludePlayersPastWeekDescription = "Enable or disable unique online players for the past week (including today) as part of the active players announcement"; + + private const string IncludePlayersAllTimeTitle = "Include Unique Players from All Time"; + private const bool IncludePlayersAllTimeDefault = true; + private const string IncludePlayersAllTimeDescription = "Enable or disable unique online players from all time as part of the active players announcement"; + + + public ActivePlayersAnnouncementConfig(ConfigFile config, string header) + { + enabled = config.Bind(header, + EnabledTitle, + EnabledDefault, + EnableDescription); + + periodInMinutes = config.Bind(header, + PeriodTitle, + PeriodDefault, + PeriodDescription + ); + + includeCurrentlyOnline = config.Bind(header, + IncludeCurrentlyOnlineTitle, + IncludeCurrentlyOnlineDefault, + IncludeCurrentlyOnlineDescription); + + includePlayersToday = config.Bind(header, + IncludePlayersTodayTitle, + IncludePlayersTodayDefault, + IncludePlayersTodayDescription); + + includePlayersPastWeek = config.Bind(header, + IncludePlayersPastWeekTitle, + IncludePlayersPastWeekDefault, + IncludePlayersPastWeekDescription); + + includePlayersAllTime = config.Bind(header, + IncludePlayersAllTimeTitle, + IncludePlayersAllTimeDefault, + IncludePlayersAllTimeDescription); + } + + public string ConfigAsJson() + { + string jsonString = "{"; + jsonString += $"\"enabled\":\"{enabled.Value}\","; + jsonString += $"\"periodInMinutes\":{periodInMinutes.Value},"; + jsonString += $"\"includeCurrentlyOnline\":\"{includeCurrentlyOnline.Value}\","; + jsonString += $"\"includePlayersToday\":\"{includePlayersToday.Value}\","; + jsonString += $"\"includePlayersPastWeek\":\"{includePlayersPastWeek.Value}\","; + jsonString += $"\"includePlayersAllTime\":\"{includePlayersAllTime.Value}\""; + jsonString += "}"; + return jsonString; + } + + public ActivePlayersAnnouncementConfigValues Value => + new ActivePlayersAnnouncementConfigValues + { + Enabled = enabled.Value, + PeriodInMinutes = periodInMinutes.Value, + IncludeCurrentlyOnline = includeCurrentlyOnline.Value, + IncludeTotalToday = includePlayersToday.Value, + IncludeTotalPastWeek = includePlayersPastWeek.Value, + IncludeTotalAllTime = includePlayersAllTime.Value + }; + + } + + public class ActivePlayersAnnouncementConfigValues + { + public bool Enabled; + public int PeriodInMinutes; + public bool IncludeCurrentlyOnline; + public bool IncludeTotalToday; + public bool IncludeTotalPastWeek; + public bool IncludeTotalAllTime; + } +} diff --git a/src/Config/ConfigWatcher.cs b/src/Config/ConfigWatcher.cs index ec66301..9e83931 100644 --- a/src/Config/ConfigWatcher.cs +++ b/src/Config/ConfigWatcher.cs @@ -1,4 +1,5 @@ -using System; +using System.Threading.Tasks; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -8,10 +9,25 @@ namespace DiscordConnector { class ConfigWatcher { - private static Regex watchedConfigFilesRegex = new Regex(@"games.nwest.valheim.discordconnector-?\w*\.cfg$"); + /// + /// Regex which matches only DiscordConnector config files; basically matches discordconnector*.cfg but restricted to + /// exactly how the files are named. + /// + private static Regex watchedConfigFilesRegex = new Regex(@"discordconnector?[\w\-]*\.cfg$"); + private static Regex configExtensionMatcherRegex = new Regex(@"discordconnector-(\w+)\.cfg$"); + /// + /// Date when the last change to any config file was detected. + /// private static DateTime lastChangeDetected; + /// + /// Period of time (in seconds) to ignore subsequent changes to config files. + /// private static int DEBOUNCE_SECONDS = 10; + /// + /// A dictionary of 'filename' -> 'hash' to determine if config files were changed in a meaningful way. + /// private static Dictionary _fileHashDictionary; + public ConfigWatcher() { var watcher = new FileSystemWatcher(); @@ -22,68 +38,114 @@ public ConfigWatcher() watcher.Changed += OnChanged; watcher.Error += OnError; - watcher.Path = BepInEx.Paths.ConfigPath; + watcher.Path = Plugin.StaticConfig.configPath; - watcher.Filter = "games.nwest.valheim.discordconnector*.cfg"; + watcher.Filter = "discordconnector*.cfg"; watcher.IncludeSubdirectories = true; watcher.EnableRaisingEvents = true; Plugin.StaticLogger.LogInfo("File watcher loaded and watching for changes to configs."); // Create and populate the file hash dictionary (a collection of MD5 hashes of our configs, to be able - // to detmine if the files were properly changed or not). + // to determine if the files were properly changed or not). _fileHashDictionary = new Dictionary(); - var myConfigFiles = Directory.EnumerateFiles(BepInEx.Paths.ConfigPath).Where(file => watchedConfigFilesRegex.IsMatch(file)); - foreach (String filename in myConfigFiles) - { - String fullPath = $"{filename}"; - _fileHashDictionary.Add(fullPath, DiscordConnector.Hashing.GetMD5Checksum(filename)); - } + PopulateHashDictionary(); - Plugin.StaticLogger.LogDebug($"Initialization of file hash dictionary completed."); - Plugin.StaticLogger.LogDebug(string.Join(Environment.NewLine, _fileHashDictionary)); - - // Set an inital value for last change detected. + // Set an initial value for last change detected. lastChangeDetected = DateTime.Now; } + /// + /// Offload population of hash dictionary to a separate thread if possible. + /// + private void PopulateHashDictionary() + { + Task.Run(() => + { + // Get an iterable of files in the DiscordConnector config directory, where the file matches our config file regex + var myConfigFiles = Directory.EnumerateFiles(Plugin.StaticConfig.configPath).Where(file => watchedConfigFilesRegex.IsMatch(file)); + foreach (String filename in myConfigFiles) + { + string extension = ConfigExtensionFromFilename(filename); + // Put the filename str and the hash of the file into the dictionary + _fileHashDictionary.Add(extension, DiscordConnector.Hashing.GetMD5Checksum(filename)); + } + + Plugin.StaticLogger.LogDebug($"Initialization of file hash dictionary completed."); + Plugin.StaticLogger.LogDebug(string.Join(Environment.NewLine, _fileHashDictionary)); + }); + } + + /// + /// Get the config file extension from the config file path + /// + /// Filename or full file path to extract config file extension from + /// The extension slug for the config file + private static string ConfigExtensionFromFilename(string filename) + { + // Determine config extension + string extension = "main"; + var extensionMatch = configExtensionMatcherRegex.Match(filename); + if (extensionMatch.Success && extensionMatch.Groups.Count > 1) + { + extension = extensionMatch.Groups[1].Value; + } + return extension; + } + + /// + /// Method for reacting to changes in the files (from the FileWatcher). + /// + /// + /// This method reacts to the change in config file by hashing the file again and on a different result, it tells the mod to reload that config. + /// private static void OnChanged(object sender, FileSystemEventArgs e) { + // Guard against other change types if (e.ChangeType != WatcherChangeTypes.Changed) { return; } - Plugin.StaticLogger.LogInfo($"Changed: {e.FullPath}"); + + String configExtension = ConfigExtensionFromFilename(e.FullPath); + + Plugin.StaticLogger.LogDebug($"Detected change of {configExtension} config file"); // Hash the changed file - String filehash = DiscordConnector.Hashing.GetMD5Checksum(e.FullPath); + String fileHash = DiscordConnector.Hashing.GetMD5Checksum(e.FullPath); // Create an entry if we haven't yet - if (!_fileHashDictionary.ContainsKey(e.FullPath)) + if (!_fileHashDictionary.ContainsKey(configExtension)) { Plugin.StaticLogger.LogWarning("Unexpectedly encountered unhashed config file!"); - _fileHashDictionary.Add(e.FullPath, filehash); + Plugin.StaticLogger.LogDebug($"Added {configExtension} config to config hash dictionary."); + _fileHashDictionary.Add(configExtension, fileHash); return; } // Check if current hash differs from stored hash. - if (String.Equals(_fileHashDictionary[e.FullPath], filehash)) + if (String.Equals(_fileHashDictionary[configExtension], fileHash)) { Plugin.StaticLogger.LogDebug("Changes to file were determined to be inconsequential."); + return; } - else if (lastChangeDetected.AddSeconds(DEBOUNCE_SECONDS) > DateTime.Now) + + // Check if we are within a very short amount of time from last change. If so, ignore the change. + if (lastChangeDetected.AddSeconds(DEBOUNCE_SECONDS) > DateTime.Now) { Plugin.StaticLogger.LogDebug("Skipping config reload, within DEBOUNCE timing."); - } - else - { - Plugin.StaticConfig.ReloadConfig(e.FullPath); - lastChangeDetected = DateTime.Now; + return; } + // Tell the plugin to reload the config file + Plugin.StaticConfig.ReloadConfig(configExtension); + lastChangeDetected = DateTime.Now; // Update last changed date } + /// + /// Error passthrough for the config watcher. + /// private static void OnError(object sender, ErrorEventArgs e) => Plugin.StaticLogger.LogError(e.GetException()); diff --git a/src/Config/CustomLeaderBoardConfig.cs b/src/Config/CustomLeaderBoardConfig.cs new file mode 100644 index 0000000..d1f9766 --- /dev/null +++ b/src/Config/CustomLeaderBoardConfig.cs @@ -0,0 +1,183 @@ +using System; +using BepInEx.Configuration; + +namespace DiscordConnector.Config +{ + internal class LeaderBoardConfigValues + { + // Each leader board has these values to configure + public ConfigEntry type; + public ConfigEntry timeRange; + public ConfigEntry numberListings; + public ConfigEntry enabled; + public ConfigEntry periodInMinutes; + public ConfigEntry deaths; + public ConfigEntry sessions; + public ConfigEntry shouts; + public ConfigEntry pings; + public ConfigEntry timeOnline; + public ConfigEntry displayedHeading; + + public const string EnabledTitle = "Enabled"; + public const bool EnabledDefault = false; + public const string EnableDescription = "Enable or disable this leader board."; + + public const string TimeRangeTitle = "Leader Board Time Range"; + public const LeaderBoards.TimeRange TimeRangeDefault = LeaderBoards.TimeRange.AllTime; + public const string TimeRangeDescription = "A more restrictive filter of time can be applied to the leader board. This restricts it to tally up statistics within the range specified."; + public static readonly string TimeRangeDescription1 = $"{LeaderBoards.TimeRange.AllTime}: Apply no time restriction to the leader board, use all available records."; + public static readonly string TimeRangeDescription2 = $"{LeaderBoards.TimeRange.Today}: Restrict leader board to recorded events from today."; + public static readonly string TimeRangeDescription3 = $"{LeaderBoards.TimeRange.Yesterday}: Restrict leader board to recorded events from yesterday."; + public static readonly string TimeRangeDescription4 = $"{LeaderBoards.TimeRange.PastWeek}: Restrict leader board to recorded events from the past week (including today)."; + public static readonly string TimeRangeDescription5 = $"{LeaderBoards.TimeRange.WeekSundayToSaturday}: Restrict leader board to recorded events from the current week, beginning on Sunday and ending Saturday."; + public static readonly string TimeRangeDescription6 = $"{LeaderBoards.TimeRange.WeekMondayToSunday}: Restrict leader board to recorded events from the current week, beginning on Monday and ending Sunday."; + + public const string NumberListingsTitle = "Number of Rankings"; + public const int NumberListingsDefault = 3; + public const string NumberListingsDescription = "Specify a number of places in the leader board. Setting this can help prevent a very long leader board in the case of active servers."; + public static readonly string NumberListingsDescription1 = $"Setting to 0 (zero) results in limiting to the hard-coded maximum of {LeaderBoard.MAX_LEADER_BOARD_SIZE}."; + + public const string TypeTitle = "Type"; + public const LeaderBoards.Ordering TypeDefault = LeaderBoards.Ordering.Descending; + public const string TypeDescription = "Choose what type of leader board this should be. There are 2 options:"; + public static readonly string TypeDescription1 = $"{LeaderBoards.Ordering.Descending}:\"Number of Rankings\" players (with at least 1 record) are listed in descending order"; + public static readonly string TypeDescription2 = $"{LeaderBoards.Ordering.Ascending}: \"Number of Rankings\" players (with at least 1 record) are listed in ascending order"; + + public const string PeriodTitle = "Sending Period"; + public const int PeriodDefault = 600; + public const string PeriodDescription = "Set the number of minutes between a leader board announcement sent to discord."; + public const string PeriodDescription1 = "This timer starts when the server is started. Default is set to 10 hours (600 minutes)."; + + public const string DeathsTitle = "Death Statistics"; + public const bool DeathsDefault = true; + public const string DeathsDescription = "If enabled, player death statistics will be part of the leader board."; + + public const string SessionsTitle = "Session Statistics"; + public const bool SessionsDefault = false; + public const string SessionsDescription = "If enabled, player session statistics will be part of the leader board."; + + public const string ShoutsTitle = "Shout Statistics"; + public const bool ShoutsDefault = false; + public const string ShoutsDescription = "If enabled, player shout statistics will be part of the leader board."; + + public const string PingsTitle = "Ping Statistics"; + public const bool PingsDefault = false; + public const string PingsDescription = "If enabled, player ping statistics will be part of the leader board."; + + public const string TimeOnlineTitle = "Time Online Statistics"; + public const bool TimeOnlineDefault = false; + public const string TimeOnlineDescription = "If enabled, player online time statistics will be part of the leader board."; + + public const string DisplayedHeadingTitle = "Leader Board Heading"; + public const string DisplayedHeadingDescription = "Define the heading message to display with this leader board."; + public const string DisplayedHeadingDescription1 = "Include %N% to dynamically reference the value in \"Number of Rankings\""; + + public LeaderBoardConfigValues(ConfigFile config, string header) + { + enabled = config.Bind(header, + EnabledTitle, + EnabledDefault, + EnableDescription); + + displayedHeading = config.Bind(header, + DisplayedHeadingTitle, + $"{header} Statistic Leader Board", + DisplayedHeadingDescription + System.Environment.NewLine + + DisplayedHeadingDescription1); + + timeRange = config.Bind(header, + TimeRangeTitle, + TimeRangeDefault, + TimeRangeDescription + System.Environment.NewLine + + TimeRangeDescription1 + System.Environment.NewLine + + TimeRangeDescription2 + System.Environment.NewLine + + TimeRangeDescription3 + System.Environment.NewLine + + TimeRangeDescription4 + System.Environment.NewLine + + TimeRangeDescription5 + System.Environment.NewLine + + TimeRangeDescription6 + ); + + periodInMinutes = config.Bind(header, + PeriodTitle, + PeriodDefault, + PeriodDescription + System.Environment.NewLine + + PeriodDescription1 + ); + + type = config.Bind(header, + TypeTitle, + TypeDefault, + TypeDescription + System.Environment.NewLine + + TypeDescription1 + System.Environment.NewLine + + TypeDescription2 + ); + + numberListings = config.Bind(header, + NumberListingsTitle, + NumberListingsDefault, + new ConfigDescription( + NumberListingsDescription + System.Environment.NewLine + + NumberListingsDescription1, + new AcceptableValueRange(0, LeaderBoard.MAX_LEADER_BOARD_SIZE * 3) + )); + + deaths = config.Bind(header, + DeathsTitle, + DeathsDefault, + DeathsDescription); + + sessions = config.Bind(header, + SessionsTitle, + SessionsDefault, + SessionsDescription); + + shouts = config.Bind(header, + ShoutsTitle, + ShoutsDefault, + ShoutsDescription); + + pings = config.Bind(header, + PingsTitle, + PingsDefault, + PingsDescription); + + timeOnline = config.Bind(header, + TimeOnlineTitle, + TimeOnlineDefault, + TimeOnlineDescription); + } + + public string ConfigAsJson() + { + string jsonString = "{"; + jsonString += $"\"enabled\":\"{enabled.Value}\","; + jsonString += $"\"periodInMinutes\":{periodInMinutes.Value},"; + jsonString += $"\"displayedHeading\":\"{displayedHeading.Value}\","; + jsonString += $"\"type\":\"{type.Value}\","; + jsonString += $"\"timeRange\":\"{timeRange.Value}\","; + jsonString += $"\"numberListings\":{numberListings.Value},"; + jsonString += $"\"deaths\":\"{deaths.Value}\","; + jsonString += $"\"sessions\":\"{sessions.Value}\","; + jsonString += $"\"shouts\":\"{shouts.Value}\","; + jsonString += $"\"pings\":\"{pings.Value}\","; + jsonString += $"\"timeOnline\":\"{timeOnline.Value}\""; + jsonString += "}"; + return jsonString; + } + } + + public class LeaderBoardConfigReference + { + public LeaderBoards.Ordering Type; + public LeaderBoards.TimeRange TimeRange; + public int NumberListings; + public bool Enabled; + public int PeriodInMinutes; + public bool Deaths; + public bool Sessions; + public bool Shouts; + public bool Pings; + public bool TimeOnline; + public string DisplayedHeading; + } +} \ No newline at end of file diff --git a/src/Config/LeaderboardConfig.cs b/src/Config/LeaderboardConfig.cs new file mode 100644 index 0000000..74996ab --- /dev/null +++ b/src/Config/LeaderboardConfig.cs @@ -0,0 +1,139 @@ +using BepInEx.Configuration; + +namespace DiscordConnector.Config +{ + internal class LeaderBoardConfig + { + private static ConfigFile config; + + public static string ConfigExtension = "leaderBoards"; + + // config header strings + private const string LEADER_BOARD_1 = "LeaderBoard.1"; + private const string LEADER_BOARD_2 = "LeaderBoard.2"; + private const string LEADER_BOARD_3 = "LeaderBoard.3"; + private const string LEADER_BOARD_4 = "LeaderBoard.4"; + private const string LEADER_BOARD_5 = "LeaderBoard.5"; + private const string ACTIVE_PLAYERS = "ActivePlayers.Announcement"; + + // Config Definitions + private LeaderBoardConfigValues leaderBoard1; + private LeaderBoardConfigValues leaderBoard2; + private LeaderBoardConfigValues leaderBoard3; + private LeaderBoardConfigValues leaderBoard4; + private LeaderBoardConfigValues leaderBoard5; + private LeaderBoardConfigReference[] _leaderBoards; + private ActivePlayersAnnouncementConfig activePlayersAnnouncementConfig; + + public LeaderBoardConfig(ConfigFile configFile) + { + config = configFile; + LoadConfig(); + } + + public void ReloadConfig() + { + config.Reload(); + config.Save(); + } + private void LoadConfig() + { + leaderBoard1 = new LeaderBoardConfigValues(config, LEADER_BOARD_1); + leaderBoard2 = new LeaderBoardConfigValues(config, LEADER_BOARD_2); + leaderBoard3 = new LeaderBoardConfigValues(config, LEADER_BOARD_3); + leaderBoard4 = new LeaderBoardConfigValues(config, LEADER_BOARD_4); + leaderBoard5 = new LeaderBoardConfigValues(config, LEADER_BOARD_5); + activePlayersAnnouncementConfig = new ActivePlayersAnnouncementConfig(config, ACTIVE_PLAYERS); + + config.Save(); + _leaderBoards = new LeaderBoardConfigReference[]{ + new LeaderBoardConfigReference + { + Type = leaderBoard1.type.Value, + TimeRange = leaderBoard1.timeRange.Value, + DisplayedHeading = leaderBoard1.displayedHeading.Value, + NumberListings = leaderBoard1.numberListings.Value == 0 ? LeaderBoard.MAX_LEADER_BOARD_SIZE : leaderBoard1.numberListings.Value, + Enabled = leaderBoard1.enabled.Value, + PeriodInMinutes = leaderBoard1.periodInMinutes.Value, + Deaths = leaderBoard1.deaths.Value, + Sessions = leaderBoard1.sessions.Value, + Shouts = leaderBoard1.shouts.Value, + Pings = leaderBoard1.pings.Value, + TimeOnline = leaderBoard1.timeOnline.Value, + }, + new LeaderBoardConfigReference + { + Type = leaderBoard2.type.Value, + TimeRange = leaderBoard2.timeRange.Value, + DisplayedHeading = leaderBoard2.displayedHeading.Value, + NumberListings = leaderBoard2.numberListings.Value == 0 ? LeaderBoard.MAX_LEADER_BOARD_SIZE : leaderBoard2.numberListings.Value, + Enabled = leaderBoard2.enabled.Value, + PeriodInMinutes = leaderBoard2.periodInMinutes.Value, + Deaths = leaderBoard2.deaths.Value, + Sessions = leaderBoard2.sessions.Value, + Shouts = leaderBoard2.shouts.Value, + Pings = leaderBoard2.pings.Value, + TimeOnline = leaderBoard2.timeOnline.Value, + }, + new LeaderBoardConfigReference + { + Type = leaderBoard3.type.Value, + TimeRange = leaderBoard3.timeRange.Value, + DisplayedHeading = leaderBoard3.displayedHeading.Value, + NumberListings = leaderBoard3.numberListings.Value == 0 ? LeaderBoard.MAX_LEADER_BOARD_SIZE : leaderBoard3.numberListings.Value, + Enabled = leaderBoard3.enabled.Value, + PeriodInMinutes = leaderBoard3.periodInMinutes.Value, + Deaths = leaderBoard3.deaths.Value, + Sessions = leaderBoard3.sessions.Value, + Shouts = leaderBoard3.shouts.Value, + Pings = leaderBoard3.pings.Value, + TimeOnline = leaderBoard3.timeOnline.Value, + }, + new LeaderBoardConfigReference + { + Type = leaderBoard4.type.Value, + TimeRange = leaderBoard4.timeRange.Value, + DisplayedHeading = leaderBoard4.displayedHeading.Value, + NumberListings = leaderBoard4.numberListings.Value == 0 ? LeaderBoard.MAX_LEADER_BOARD_SIZE : leaderBoard4.numberListings.Value, + Enabled = leaderBoard4.enabled.Value, + PeriodInMinutes = leaderBoard4.periodInMinutes.Value, + Deaths = leaderBoard4.deaths.Value, + Sessions = leaderBoard4.sessions.Value, + Shouts = leaderBoard4.shouts.Value, + Pings = leaderBoard4.pings.Value, + TimeOnline = leaderBoard4.timeOnline.Value, + }, + new LeaderBoardConfigReference + { + Type = leaderBoard5.type.Value, + TimeRange = leaderBoard5.timeRange.Value, + DisplayedHeading = leaderBoard5.displayedHeading.Value, + NumberListings = leaderBoard5.numberListings.Value == 0 ? LeaderBoard.MAX_LEADER_BOARD_SIZE : leaderBoard5.numberListings.Value, + Enabled = leaderBoard5.enabled.Value, + PeriodInMinutes = leaderBoard5.periodInMinutes.Value, + Deaths = leaderBoard5.deaths.Value, + Sessions = leaderBoard5.sessions.Value, + Shouts = leaderBoard5.shouts.Value, + Pings = leaderBoard5.pings.Value, + TimeOnline = leaderBoard5.timeOnline.Value, + }}; + + } + + public string ConfigAsJson() + { + string jsonString = "{"; + jsonString += $"\"leaderBoard1\":{leaderBoard1.ConfigAsJson()},"; + jsonString += $"\"leaderBoard2\":{leaderBoard2.ConfigAsJson()},"; + jsonString += $"\"leaderBoard3\":{leaderBoard3.ConfigAsJson()},"; + jsonString += $"\"leaderBoard4\":{leaderBoard4.ConfigAsJson()},"; + jsonString += $"\"leaderBoard5\":{leaderBoard5.ConfigAsJson()},"; + jsonString += $"\"activePlayersAnnouncement\":{activePlayersAnnouncementConfig.ConfigAsJson()}"; + jsonString += "}"; + return jsonString; + } + // Variables + public LeaderBoardConfigReference[] LeaderBoards => _leaderBoards; + public ActivePlayersAnnouncementConfigValues ActivePlayersAnnouncement => activePlayersAnnouncementConfig.Value; + } +} diff --git a/src/Config/MainConfig.cs b/src/Config/MainConfig.cs index 166b43d..42ef0f6 100644 --- a/src/Config/MainConfig.cs +++ b/src/Config/MainConfig.cs @@ -5,15 +5,23 @@ namespace DiscordConnector.Config { - public static class RetrievalDiscernmentMethods - { - public static readonly string BySteamID = "Treat each SteamID as a separate player"; - public static readonly string ByNameAndSteamID = "Treat each SteamID:PlayerName combo as a separate player"; - public static readonly string ByName = "Treat each PlayerName as a separate player"; - - } internal class MainConfig { + /// + /// Allowed methods for differentiating between players on the server + /// + public enum RetrievalDiscernmentMethods + { + [System.ComponentModel.Description(RetrieveBySteamID)] + PlayerId, + [System.ComponentModel.Description(RetrieveByNameAndSteamID)] + Name, + [System.ComponentModel.Description(RetrieveByName)] + NameAndPlayerId, + } + public const string RetrieveBySteamID = "PlayerId: Treat each PlayerId as a separate player"; + public const string RetrieveByNameAndSteamID = "NameAndPlayerId: Treat each [PlayerId:CharacterName] combo as a separate player"; + public const string RetrieveByName = "Name: Treat each CharacterName as a separate player"; private ConfigFile config; private static List mutedPlayers; private static Regex mutedPlayersRegex; @@ -24,13 +32,10 @@ internal class MainConfig private ConfigEntry discordEmbedMessagesToggle; private ConfigEntry mutedDiscordUserList; private ConfigEntry mutedDiscordUserListRegex; - private ConfigEntry statsAnnouncementToggle; - private ConfigEntry statsAnnouncementPeriod; private ConfigEntry collectStatsToggle; private ConfigEntry sendPositionsToggle; private ConfigEntry announcePlayerFirsts; - private ConfigEntry numberRankingsListed; - private ConfigEntry playerLookupPreference; + private ConfigEntry playerLookupPreference; private ConfigEntry allowNonPlayerShoutLogging; public MainConfig(ConfigFile configFile) @@ -100,37 +105,19 @@ private void LoadConfig() true, "Disable this setting to disable all stat collection. (Overwrites any individual setting.)"); - statsAnnouncementToggle = config.Bind(MAIN_SETTINGS, - "Periodic Player Stats Notifications", - false, - "Disable this setting to disable all stat announcements (i.e. leader board messages). (Overwrites any individual setting.)" + Environment.NewLine + - "EX: Top Player Deaths: etc etc Top Player Joins: etc etc"); - - statsAnnouncementPeriod = config.Bind(MAIN_SETTINGS, - "Player Stats Notifications Period", - 600, - "Set the number of minutes between a leader board announcement sent to discord." + Environment.NewLine + - "This time starts when the server is started. Default is set to 10 hours (600 minutes)."); - announcePlayerFirsts = config.Bind(MAIN_SETTINGS, "Announce Player Firsts", true, "Disable this setting to disable all extra announcements the first time each player does something. (Overwrites any individual setting.)"); - numberRankingsListed = config.Bind(MAIN_SETTINGS, - "How many places to list in the top ranking leaderboards", - 3, - "Set how many places (1st, 2nd, 3rd by default) to display when sending the ranked leaderboard."); - - playerLookupPreference = config.Bind(MAIN_SETTINGS, + playerLookupPreference = config.Bind(MAIN_SETTINGS, "How to discern players in Record Retrieval", - RetrievalDiscernmentMethods.BySteamID, - new ConfigDescription("Choose a method for how players will be separated from the results of a record query.", - new AcceptableValueList(new string[] { - RetrievalDiscernmentMethods.BySteamID, - RetrievalDiscernmentMethods.ByName, - RetrievalDiscernmentMethods.ByNameAndSteamID - }))); + RetrievalDiscernmentMethods.PlayerId, + "Choose a method for how players will be separated from the results of a record query (used for statistic leader boards)." + Environment.NewLine + + RetrieveByName + Environment.NewLine + + RetrieveBySteamID + Environment.NewLine + + RetrieveByNameAndSteamID + ); allowNonPlayerShoutLogging = config.Bind(MAIN_SETTINGS, "Send Non-Player Shouts to Discord", @@ -151,29 +138,22 @@ public string ConfigAsJson() jsonString += $"\"ignoredPlayers\":\"{mutedDiscordUserList.Value}\","; jsonString += $"\"ignoredPlayersRegex\":\"{mutedDiscordUserListRegex.Value}\""; jsonString += "},"; - jsonString += $"\"periodicLeaderboardEnabled\":\"{StatsAnnouncementEnabled}\","; - jsonString += $"\"periodicLeaderboardPeriodSeconds\":{StatsAnnouncementPeriod},"; jsonString += $"\"collectStatsEnabled\":\"{CollectStatsEnabled}\","; jsonString += $"\"sendPositionsEnabled\":\"{SendPositionsEnabled}\","; jsonString += $"\"announcePlayerFirsts\":\"{AnnouncePlayerFirsts}\","; - jsonString += $"\"numberRankingsListed\":\"{IncludedNumberOfRankings}\","; - jsonString += $"\"playerLookupPreference\":\"{RecordRetrievalDiscernmentMethod}\","; - jsonString += $"\"allowNonPlayerShoutLogging\":\"{AllowNonPlayerShoutLogging}\""; + jsonString += $"\"playerLookupPreference\":\"{RecordRetrievalDiscernmentMethod}\""; jsonString += "}"; return jsonString; } public string WebHookURL => webhookUrl.Value; - public bool StatsAnnouncementEnabled => statsAnnouncementToggle.Value; - public int StatsAnnouncementPeriod => statsAnnouncementPeriod.Value; public bool CollectStatsEnabled => collectStatsToggle.Value; public bool DiscordEmbedsEnabled => discordEmbedMessagesToggle.Value; public bool SendPositionsEnabled => sendPositionsToggle.Value; public List MutedPlayers => mutedPlayers; public Regex MutedPlayersRegex => mutedPlayersRegex; public bool AnnouncePlayerFirsts => announcePlayerFirsts.Value; - public int IncludedNumberOfRankings => numberRankingsListed.Value; - public string RecordRetrievalDiscernmentMethod => playerLookupPreference.Value; + public RetrievalDiscernmentMethods RecordRetrievalDiscernmentMethod => playerLookupPreference.Value; public bool AllowNonPlayerShoutLogging => allowNonPlayerShoutLogging.Value; } diff --git a/src/Config/MessagesConfig.cs b/src/Config/MessagesConfig.cs index 8771bda..5723eaa 100644 --- a/src/Config/MessagesConfig.cs +++ b/src/Config/MessagesConfig.cs @@ -14,7 +14,7 @@ internal class MessagesConfig private const string PLAYER_MESSAGES = "Messages.Player"; private const string PLAYER_FIRSTS_MESSAGES = "Messages.PlayerFirsts"; private const string EVENT_MESSAGES = "Messages.Events"; - private const string BOARD_MESSAGES = "Messages.Leaderbaords"; + private const string BOARD_MESSAGES = "Messages.LeaderBoards"; // Server Messages private ConfigEntry serverLaunchMessage; @@ -44,10 +44,10 @@ internal class MessagesConfig private ConfigEntry eventResumedMessage; // Board Messages - private ConfigEntry leaderboardTopPlayersMessage; - private ConfigEntry leaderboardBottomPlayersMessage; - private ConfigEntry leaderboardHighestPlayerMessage; - private ConfigEntry leaderboardLowestPlayerMessage; + private ConfigEntry leaderBoardTopPlayersMessage; + private ConfigEntry leaderBoardBottomPlayersMessage; + private ConfigEntry leaderBoardHighestPlayerMessage; + private ConfigEntry leaderBoardLowestPlayerMessage; public MessagesConfig(ConfigFile configFile) { @@ -182,25 +182,25 @@ private void LoadConfig() // "The special string %PLAYERS% will be replaced with a list of players in the event area."); //! Removed due to unreliability // Board Messages - leaderboardTopPlayersMessage = config.Bind(BOARD_MESSAGES, - "Leaderboard Heading for Top N Players", - "Top %N% Player Leaderboards:", - "Set the message that is included as a heading when this leaderboard is sent." + Environment.NewLine + + leaderBoardTopPlayersMessage = config.Bind(BOARD_MESSAGES, + "Leader Board Heading for Top N Players", + "Top %N% Player Leader Boards:", + "Set the message that is included as a heading when this leader board is sent." + Environment.NewLine + "Include %N% to include the number of rankings returned (the configured number)"); - leaderboardBottomPlayersMessage = config.Bind(BOARD_MESSAGES, - "Leaderboard Heading for Bottom N Players", - "Bottom %N% Player Leaderboards:", - "Set the message that is included as a heading when this leaderboard is sent." + Environment.NewLine + + leaderBoardBottomPlayersMessage = config.Bind(BOARD_MESSAGES, + "Leader Board Heading for Bottom N Players", + "Bottom %N% Player Leader Boards:", + "Set the message that is included as a heading when this leader board is sent." + Environment.NewLine + "Include %N% to include the number of rankings returned (the configured number)"); - leaderboardHighestPlayerMessage = config.Bind(BOARD_MESSAGES, - "Leaderboard Heading for Highest Player", + leaderBoardHighestPlayerMessage = config.Bind(BOARD_MESSAGES, + "Leader Board Heading for Highest Player", "Top Performer", - "Set the message that is included as a heading when this leaderboard is sent." + Environment.NewLine + + "Set the message that is included as a heading when this leader board is sent." + Environment.NewLine + "Include %N% to include the number of rankings returned (the configured number)"); - leaderboardLowestPlayerMessage = config.Bind(BOARD_MESSAGES, - "Leaderboard Heading for Lowest Player", + leaderBoardLowestPlayerMessage = config.Bind(BOARD_MESSAGES, + "Leader Board Heading for Lowest Player", "Bottom Performer", - "Set the message that is included as a heading when this leaderboard is sent." + Environment.NewLine + + "Set the message that is included as a heading when this leader board is sent." + Environment.NewLine + "Include %N% to include the number of rankings returned (the configured number)"); config.Save(); @@ -242,10 +242,10 @@ public string ConfigAsJson() jsonString += "},"; jsonString += $"\"{BOARD_MESSAGES}\":{{"; - jsonString += $"\"leaderboardTopPlayersMessage\":\"{leaderboardTopPlayersMessage.Value}\","; - jsonString += $"\"leaderboardBottomPlayersMessage\":\"{leaderboardBottomPlayersMessage.Value}\","; - jsonString += $"\"leaderboardHighestPlayerMessage\":\"{leaderboardHighestPlayerMessage.Value}\","; - jsonString += $"\"leaderboardLowestPlayerMessage\":\"{leaderboardLowestPlayerMessage.Value}\""; + jsonString += $"\"leaderBoardTopPlayersMessage\":\"{leaderBoardTopPlayersMessage.Value}\","; + jsonString += $"\"leaderBoardBottomPlayersMessage\":\"{leaderBoardBottomPlayersMessage.Value}\","; + jsonString += $"\"leaderBoardHighestPlayerMessage\":\"{leaderBoardHighestPlayerMessage.Value}\","; + jsonString += $"\"leaderBoardLowestPlayerMessage\":\"{leaderBoardLowestPlayerMessage.Value}\""; jsonString += "}"; jsonString += "}"; @@ -290,15 +290,15 @@ private static string GetRandomStringFromValue(ConfigEntry configEntry) public string PlayerFirstShoutMessage => GetRandomStringFromValue(playerFirstShoutMessage); // Messages.Events - public string EventStartMesssage => GetRandomStringFromValue(eventStartMessage); - public string EventPausedMesssage => GetRandomStringFromValue(eventPausedMessage); - public string EventStopMesssage => GetRandomStringFromValue(eventStopMessage); - public string EventResumedMesssage => GetRandomStringFromValue(eventResumedMessage); + public string EventStartMessage => GetRandomStringFromValue(eventStartMessage); + public string EventPausedMessage => GetRandomStringFromValue(eventPausedMessage); + public string EventStopMessage => GetRandomStringFromValue(eventStopMessage); + public string EventResumedMessage => GetRandomStringFromValue(eventResumedMessage); - // Messages.Leaderboards - public string LeaderboardTopPlayerHeading => GetRandomStringFromValue(leaderboardTopPlayersMessage); - public string LeaderboardBottomPlayersHeading => GetRandomStringFromValue(leaderboardBottomPlayersMessage); - public string LeaderboardHighestHeading => GetRandomStringFromValue(leaderboardHighestPlayerMessage); - public string LeaderboardLowestHeading => GetRandomStringFromValue(leaderboardLowestPlayerMessage); + // Messages.LeaderBoards + public string LeaderBoardTopPlayerHeading => GetRandomStringFromValue(leaderBoardTopPlayersMessage); + public string LeaderBoardBottomPlayersHeading => GetRandomStringFromValue(leaderBoardBottomPlayersMessage); + public string LeaderBoardHighestHeading => GetRandomStringFromValue(leaderBoardHighestPlayerMessage); + public string LeaderBoardLowestHeading => GetRandomStringFromValue(leaderBoardLowestPlayerMessage); } } diff --git a/src/Config/PluginConfig.cs b/src/Config/PluginConfig.cs index eda2cfc..7604de9 100644 --- a/src/Config/PluginConfig.cs +++ b/src/Config/PluginConfig.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; using System.Text.RegularExpressions; using BepInEx.Configuration; using DiscordConnector.Config; @@ -11,37 +13,97 @@ internal class PluginConfig private MessagesConfig messagesConfig; private TogglesConfig togglesConfig; private VariableConfig variableConfig; - private Dictionary filenameDictionaryRegex; + private LeaderBoardConfig leaderBoardConfig; + public readonly string configPath; + + /// + /// Valid extensions for the config files, plus a reference for main. + /// + internal static string[] ConfigExtensions = new string[]{ + "messages", + "variables", + "leaderBoard", + "toggles", + "main" + }; + + /// + /// In 2.1.0, moving to using a subdirectory for config files, since there are a handful of different files to manage and the feature was requested. + /// This method will make the new config sub-directory (if it doesn't exist) and then move the DiscordConnector config files into the new config + /// sub-directory. If the files already exist in the new sub-directory, then this will log a warning for each config that exists there, since they + /// should not exist there yet! + /// + internal void migrateConfigIfNeeded() + { + if (!Directory.Exists(configPath)) + { + Directory.CreateDirectory(configPath); + } + + foreach (string extension in ConfigExtensions) + { + string oldConfig = Path.Combine(BepInEx.Paths.ConfigPath, $"{PluginInfo.PLUGIN_ID}-{extension}.cfg"); + string newConfig = Path.Combine(configPath, $"{PluginInfo.SHORT_PLUGIN_ID}-{extension}.cfg"); + // Main config has special handling (no -main extension on it) + if (extension.Equals("main")) + { + // Main config uses no extensions + oldConfig = Path.Combine(BepInEx.Paths.ConfigPath, $"{PluginInfo.PLUGIN_ID}.cfg"); + newConfig = Path.Combine(configPath, $"{PluginInfo.SHORT_PLUGIN_ID}.cfg"); + } + + if (File.Exists(oldConfig)) + { + if (File.Exists(newConfig)) + { + // There already exists a config in the destination, which is weird because configs also exist in the old location + Plugin.StaticLogger.LogWarning($"Expected to be moving {extension} config from pre-2.1.0 location to new config location, but already exists!"); + } + else + { + // Migrate the file if it doesn't already exist there. + File.Move(oldConfig, newConfig); + } + } + } + } public PluginConfig(ConfigFile config) { - // Set up the config file paths - string messageConfigFilename = $"{PluginInfo.PLUGIN_ID}-{MessagesConfig.ConfigExtension}.cfg"; - string togglesConfigFilename = $"{PluginInfo.PLUGIN_ID}-{TogglesConfig.ConfigExtension}.cfg"; - string variableConfigFilename = $"{PluginInfo.PLUGIN_ID}-{VariableConfig.ConfigExtension}.cfg"; + // Set up base path for config and other files + configPath = Path.Combine(BepInEx.Paths.ConfigPath, PluginInfo.PLUGIN_ID); - string messagesConfigPath = System.IO.Path.Combine(BepInEx.Paths.ConfigPath, messageConfigFilename); - string togglesConfigPath = System.IO.Path.Combine(BepInEx.Paths.ConfigPath, togglesConfigFilename); - string variableConfigPath = System.IO.Path.Combine(BepInEx.Paths.ConfigPath, variableConfigFilename); + // Migrate configs if needed, since we now nest them in a subdirectory + migrateConfigIfNeeded(); + // Set up the config file paths + string mainConfigFilename = $"{PluginInfo.SHORT_PLUGIN_ID}.cfg"; + string messageConfigFilename = $"{PluginInfo.SHORT_PLUGIN_ID}-{MessagesConfig.ConfigExtension}.cfg"; + string togglesConfigFilename = $"{PluginInfo.SHORT_PLUGIN_ID}-{TogglesConfig.ConfigExtension}.cfg"; + string variableConfigFilename = $"{PluginInfo.SHORT_PLUGIN_ID}-{VariableConfig.ConfigExtension}.cfg"; + string leaderBoardConfigFilename = $"{PluginInfo.SHORT_PLUGIN_ID}-{LeaderBoardConfig.ConfigExtension}.cfg"; + + string mainConfigPath = Path.Combine(configPath, mainConfigFilename); + string messagesConfigPath = Path.Combine(configPath, messageConfigFilename); + string togglesConfigPath = Path.Combine(configPath, togglesConfigFilename); + string variableConfigPath = Path.Combine(configPath, variableConfigFilename); + string leaderBoardConfigPath = Path.Combine(configPath, leaderBoardConfigFilename); + + Plugin.StaticLogger.LogDebug($"Main config: {mainConfigPath}"); Plugin.StaticLogger.LogDebug($"Messages config: {messagesConfigPath}"); Plugin.StaticLogger.LogDebug($"Toggles config: {togglesConfigPath}"); Plugin.StaticLogger.LogDebug($"Variable config: {variableConfigPath}"); + Plugin.StaticLogger.LogDebug($"Leader board config: {leaderBoardConfigFilename}"); - mainConfig = new MainConfig(config); + mainConfig = new MainConfig(new BepInEx.Configuration.ConfigFile(mainConfigPath, true)); messagesConfig = new MessagesConfig(new BepInEx.Configuration.ConfigFile(messagesConfigPath, true)); togglesConfig = new TogglesConfig(new BepInEx.Configuration.ConfigFile(togglesConfigPath, true)); variableConfig = new VariableConfig(new BepInEx.Configuration.ConfigFile(variableConfigPath, true)); + leaderBoardConfig = new LeaderBoardConfig(new BepInEx.Configuration.ConfigFile(leaderBoardConfigPath, true)); Plugin.StaticLogger.LogDebug("Configuration Loaded"); - Plugin.StaticLogger.LogDebug(ConfigAsJson()); - Plugin.StaticLogger.LogDebug($"Regex pattern ('a^' is default for no matches): {mainConfig.MutedPlayersRegex.ToString()}"); - - filenameDictionaryRegex = new Dictionary(); - filenameDictionaryRegex.Add("main", new Regex(@"games.nwest.valheim.discordconnector\.cfg$")); - filenameDictionaryRegex.Add("messages", new Regex(@"games.nwest.valheim.discordconnector-message\.cfg$")); - filenameDictionaryRegex.Add("toggles", new Regex(@"games.nwest.valheim.discordconnector-toggles\.cfg$")); - filenameDictionaryRegex.Add("variables", new Regex(@"games.nwest.valheim.discordconnector-variables\.cfg$")); + Plugin.StaticLogger.LogDebug($"Muted Players Regex pattern ('a^' is default for no matches): {mainConfig.MutedPlayersRegex.ToString()}"); + DumpConfigAsJson(); } public void ReloadConfig() @@ -50,30 +112,35 @@ public void ReloadConfig() messagesConfig.ReloadConfig(); togglesConfig.ReloadConfig(); variableConfig.ReloadConfig(); + leaderBoardConfig.ReloadConfig(); } - public void ReloadConfig(string configPath) + /// + /// Reload a config by specifying the configKey (one of ) + /// + /// Config extension to reload + public void ReloadConfig(string configExt) { - if (filenameDictionaryRegex["main"].IsMatch(configPath)) - { - mainConfig.ReloadConfig(); - } - - if (filenameDictionaryRegex["messages"].IsMatch(configPath)) + switch (configExt) { - messagesConfig.ReloadConfig(); + case "main": + mainConfig.ReloadConfig(); + return; + case "messages": + messagesConfig.ReloadConfig(); + return; + case "toggles": + togglesConfig.ReloadConfig(); + return; + case "variables": + variableConfig.ReloadConfig(); + return; + case "leaderBoard": + leaderBoardConfig.ReloadConfig(); + return; + default: + return; } - - if (filenameDictionaryRegex["toggles"].IsMatch(configPath)) - { - togglesConfig.ReloadConfig(); - } - - if (filenameDictionaryRegex["variables"].IsMatch(configPath)) - { - variableConfig.ReloadConfig(); - } - } // Exposed Config Values @@ -116,13 +183,11 @@ public void ReloadConfig(string configPath) // Main Config public string WebHookURL => mainConfig.WebHookURL; - public bool StatsAnnouncementEnabled => mainConfig.StatsAnnouncementEnabled; - public int StatsAnnouncementPeriod => mainConfig.StatsAnnouncementPeriod; public bool CollectStatsEnabled => mainConfig.CollectStatsEnabled; public bool DiscordEmbedsEnabled => mainConfig.DiscordEmbedsEnabled; public bool SendPositionsEnabled => mainConfig.SendPositionsEnabled; public bool AnnouncePlayerFirsts => mainConfig.AnnouncePlayerFirsts; - public string RecordRetrievalDiscernmentMethod => mainConfig.RecordRetrievalDiscernmentMethod; + public MainConfig.RetrievalDiscernmentMethods RecordRetrievalDiscernmentMethod => mainConfig.RecordRetrievalDiscernmentMethod; public List MutedPlayers => mainConfig.MutedPlayers; public Regex MutedPlayersRegex => mainConfig.MutedPlayersRegex; public bool AllowNonPlayerShoutLogging => mainConfig.AllowNonPlayerShoutLogging; @@ -149,28 +214,6 @@ public void ReloadConfig(string configPath) public string PlayerFirstPingMessage => messagesConfig.PlayerFirstPingMessage; public string PlayerFirstShoutMessage => messagesConfig.PlayerFirstShoutMessage; - // Toggles.Leaderboard - public bool RankedDeathLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.RankedDeathLeaderboardEnabled; - public bool RankedPingLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.RankedPingLeaderboardEnabled; - public bool RankedSessionLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.RankedSessionLeaderboardEnabled; - public bool RankedShoutLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.RankedShoutLeaderboardEnabled; - // Toggles.Leaderboard - public bool InverseRankedDeathLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.InverseRankedDeathLeaderboardEnabled; - public bool InverseRankedPingLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.InverseRankedPingLeaderboardEnabled; - public bool InverseRankedSessionLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.InverseRankedSessionLeaderboardEnabled; - public bool InverseRankedShoutLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.InverseRankedShoutLeaderboardEnabled; - - - public int IncludedNumberOfRankings => mainConfig.IncludedNumberOfRankings; - public bool MostSessionLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.MostSessionLeaderboardEnabled; - public bool MostPingLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.MostPingLeaderboardEnabled; - public bool MostDeathLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.MostDeathLeaderboardEnabled; - public bool MostShoutLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.MostShoutLeaderboardEnabled; - public bool LeastSessionLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.LeastSessionLeaderboardEnabled; - public bool LeastPingLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.LeastPingLeaderboardEnabled; - public bool LeastDeathLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.LeastDeathLeaderboardEnabled; - public bool LeastShoutLeaderboardEnabled => mainConfig.StatsAnnouncementEnabled && togglesConfig.LeastShoutLeaderboardEnabled; - public bool AnnouncePlayerFirstDeathEnabled => mainConfig.AnnouncePlayerFirsts && togglesConfig.AnnouncePlayerFirstDeathEnabled; public bool AnnouncePlayerFirstJoinEnabled => mainConfig.AnnouncePlayerFirsts && togglesConfig.AnnouncePlayerFirstJoinEnabled; public bool AnnouncePlayerFirstLeaveEnabled => mainConfig.AnnouncePlayerFirsts && togglesConfig.AnnouncePlayerFirstLeaveEnabled; @@ -178,10 +221,10 @@ public void ReloadConfig(string configPath) public bool AnnouncePlayerFirstShoutEnabled => mainConfig.AnnouncePlayerFirsts && togglesConfig.AnnouncePlayerFirstShoutEnabled; // Messages.Events - public string EventStartMessage => messagesConfig.EventStartMesssage; - public string EventStopMesssage => messagesConfig.EventStopMesssage; - public string EventPausedMesssage => messagesConfig.EventPausedMesssage; - public string EventResumedMesssage => messagesConfig.EventResumedMesssage; + public string EventStartMessage => messagesConfig.EventStartMessage; + public string EventStopMessage => messagesConfig.EventStopMessage; + public string EventPausedMessage => messagesConfig.EventPausedMessage; + public string EventResumedMessage => messagesConfig.EventResumedMessage; // Variable Definition public string UserVariable => variableConfig.UserVariable; @@ -206,23 +249,34 @@ public void ReloadConfig(string configPath) public bool DebugHttpRequestResponse => togglesConfig.DebugHttpRequestResponse; public bool DebugDatabaseMethods => togglesConfig.DebugDatabaseMethods; - // Leaderboard Messages - public string LeaderboardTopPlayerHeading => messagesConfig.LeaderboardTopPlayerHeading; - public string LeaderboardBottomPlayersHeading => messagesConfig.LeaderboardBottomPlayersHeading; - public string LeaderboardHighestHeading => messagesConfig.LeaderboardHighestHeading; - public string LeaderboardLowestHeading => messagesConfig.LeaderboardLowestHeading; + // Leader board Messages + public string LeaderBoardTopPlayerHeading => messagesConfig.LeaderBoardTopPlayerHeading; + public string LeaderBoardBottomPlayersHeading => messagesConfig.LeaderBoardBottomPlayersHeading; + public string LeaderBoardHighestHeading => messagesConfig.LeaderBoardHighestHeading; + public string LeaderBoardLowestHeading => messagesConfig.LeaderBoardLowestHeading; + + // Leader board configs + public LeaderBoardConfigReference[] LeaderBoards => leaderBoardConfig.LeaderBoards; + public ActivePlayersAnnouncementConfigValues ActivePlayersAnnouncement => leaderBoardConfig.ActivePlayersAnnouncement; - public string ConfigAsJson() + public void DumpConfigAsJson() { string jsonString = "{"; jsonString += $"\"Config.Main\":{mainConfig.ConfigAsJson()},"; jsonString += $"\"Config.Messages\":{messagesConfig.ConfigAsJson()},"; jsonString += $"\"Config.Toggles\":{togglesConfig.ConfigAsJson()},"; - jsonString += $"\"Config.Variables\":{variableConfig.ConfigAsJson()}"; + jsonString += $"\"Config.Variables\":{variableConfig.ConfigAsJson()},"; + jsonString += $"\"Config.LeaderBoard\":{leaderBoardConfig.ConfigAsJson()}"; jsonString += "}"; - return jsonString; + + System.Threading.Tasks.Task.Run(() => + { + string configDump = Path.Combine(configPath, "config-debug.json"); + File.WriteAllText(configDump, jsonString); + Plugin.StaticLogger.LogDebug("Dumped configuration files to JSON for debugging (if needed)."); + }); } } } diff --git a/src/Config/TogglesConfig.cs b/src/Config/TogglesConfig.cs index bbbfa50..2d97bd4 100644 --- a/src/Config/TogglesConfig.cs +++ b/src/Config/TogglesConfig.cs @@ -12,10 +12,6 @@ internal class TogglesConfig private const string MESSAGES_TOGGLES = "Toggles.Messages"; private const string POSITION_TOGGLES = "Toggles.Position"; private const string STATS_TOGGLES = "Toggles.Stats"; - private const string LEADERBOARD_TOGGLES = "Toggles.Leaderboard"; - private const string LEADERBOARD_BOTTOM_N_TOGGLES = "Toggles.InverseLeaderboard"; - private const string LEADERBOARD_TOGGLES_HIGHEST = "Toggles.Leaderboard.Highest"; - private const string LEADERBOARD_TOGGLES_LOWEST = "Toggles.Leaderboard.Lowest"; private const string PLAYER_FIRSTS_TOGGLES = "Toggles.PlayerFirsts"; private const string DEBUG_TOGGLES = "Toggles.DebugMessages"; @@ -53,30 +49,6 @@ internal class TogglesConfig private ConfigEntry collectStatsShouts; private ConfigEntry collectStatsPings; - // Send Leaderboard Settings (highest) - private ConfigEntry sendMostSessionLeaderboard; - private ConfigEntry sendMostPingLeaderboard; - private ConfigEntry sendMostDeathLeaderboard; - private ConfigEntry sendMostShoutLeaderboard; - - // Send Leaderboard Settings (lowest) - private ConfigEntry sendLeastSessionLeaderboard; - private ConfigEntry sendLeastPingLeaderboard; - private ConfigEntry sendLeastDeathLeaderboard; - private ConfigEntry sendLeastShoutLeaderboard; - - // Send Leaderboard Settings (rankings) - private ConfigEntry sendSessionRankingLeaderboard; - private ConfigEntry sendPingRankingLeaderboard; - private ConfigEntry sendDeathRankingLeaderboard; - private ConfigEntry sendShoutRankingLeaderboard; - - // Send Leaderboard Settings (inverse rankings) - private ConfigEntry sendSessionInverseRankingLeaderboard; - private ConfigEntry sendPingInverseRankingLeaderboard; - private ConfigEntry sendDeathInverseRankingLeaderboard; - private ConfigEntry sendShoutInverseRankingLeaderboard; - // Player-firsts Settings private ConfigEntry announcePlayerFirstDeath; private ConfigEntry announcePlayerFirstJoin; @@ -224,60 +196,6 @@ private void LoadConfig() true, "If enabled, will allow collection of the number of times a player has shouted."); - // Leaderboard - sendDeathRankingLeaderboard = config.Bind(LEADERBOARD_TOGGLES, - "Send Periodic Leaderboard for Player Deaths", - true, - "If enabled, will send a ranked leaderboard for player deaths at the interval."); - sendPingRankingLeaderboard = config.Bind(LEADERBOARD_TOGGLES, - "Send Periodic Leaderboard for Player Pings", - false, - "If enabled, will send a ranked leaderboard for player pings at the interval."); - sendSessionRankingLeaderboard = config.Bind(LEADERBOARD_TOGGLES, - "Send Periodic Leaderboard for Player Sessions", - false, - "If enabled, will send a ranked leaderboard for player sessions at the interval."); - sendShoutRankingLeaderboard = config.Bind(LEADERBOARD_TOGGLES, - "Send Periodic Leaderboard for Player Shouts", - false, - "If enabled, will send a ranked leaderboard for player shouts at the interval."); - - // Leaderboard for Highest - sendMostDeathLeaderboard = config.Bind(LEADERBOARD_TOGGLES_HIGHEST, - "Include Most Deaths in Periodic Leaderboard", - true, - "If enabled, will include player with the most deaths at the interval."); - sendMostPingLeaderboard = config.Bind(LEADERBOARD_TOGGLES_HIGHEST, - "Include Most Pings in Periodic Leaderboard", - false, - "If enabled, will include player with the most pings at the interval."); - sendMostSessionLeaderboard = config.Bind(LEADERBOARD_TOGGLES_HIGHEST, - "Include Most Sessions in Periodic Leaderboard", - false, - "If enabled, will include player with the most sessions at the interval."); - sendMostShoutLeaderboard = config.Bind(LEADERBOARD_TOGGLES_HIGHEST, - "Include Most Shouts in Periodic Leaderboard", - false, - "If enabled, will include player with the most shouts at the interval."); - - // Leaderboard for Lowest - sendLeastDeathLeaderboard = config.Bind(LEADERBOARD_TOGGLES_LOWEST, - "Include Least Deaths in Periodic Leaderboard", - true, - "If enabled, will include player with the least deaths at the interval."); - sendLeastPingLeaderboard = config.Bind(LEADERBOARD_TOGGLES_LOWEST, - "Include Least Pings in Periodic Leaderboard", - false, - "If enabled, will include player with the least pings at the interval."); - sendLeastSessionLeaderboard = config.Bind(LEADERBOARD_TOGGLES_LOWEST, - "Include Least Sessions in Periodic Leaderboard", - false, - "If enabled, will include player with the least sessions at the interval."); - sendLeastShoutLeaderboard = config.Bind(LEADERBOARD_TOGGLES_LOWEST, - "Include Least Shouts in Periodic Leaderboard", - false, - "If enabled, will include player with the least shouts at the interval."); - // Player Firsts announcePlayerFirstDeath = config.Bind(PLAYER_FIRSTS_TOGGLES, "Send a Message for the First Death of a Player", @@ -322,24 +240,6 @@ private void LoadConfig() false, "If enabled, this will write a log message at the DEBUG level with logs generated while executing database methods."); - // LEADERBOARD_BOTTOM_N_TOGGLES - sendDeathInverseRankingLeaderboard = config.Bind(LEADERBOARD_BOTTOM_N_TOGGLES, - "Send Periodic Inverse Leaderboard for Player Deaths", - true, - "If enabled, will send a ranked leaderboard (least to most) for player deaths at the interval."); - sendPingInverseRankingLeaderboard = config.Bind(LEADERBOARD_BOTTOM_N_TOGGLES, - "Send Periodic Inverse Leaderboard for Player Pings", - false, - "If enabled, will send a ranked leaderboard (least to most) for player pings at the interval."); - sendSessionInverseRankingLeaderboard = config.Bind(LEADERBOARD_BOTTOM_N_TOGGLES, - "Send Periodic Inverse Leaderboard for Player Sessions", - false, - "If enabled, will send a ranked leaderboard (least to most) for player sessions at the interval."); - sendShoutInverseRankingLeaderboard = config.Bind(LEADERBOARD_BOTTOM_N_TOGGLES, - "Send Periodic Inverse Leaderboard for Player Shouts", - false, - "If enabled, will send a ranked leaderboard (least to most) for player shouts at the interval."); - config.Save(); } @@ -382,27 +282,6 @@ public string ConfigAsJson() jsonString += $"\"statsShoutEnabled\":\"{StatsShoutEnabled}\""; jsonString += "},"; - jsonString += $"\"{LEADERBOARD_TOGGLES}\":{{"; - jsonString += $"\"leaderboardDeathEnabled\":\"{RankedDeathLeaderboardEnabled}\","; - jsonString += $"\"leaderboardPingEnabled\":\"{RankedPingLeaderboardEnabled}\","; - jsonString += $"\"leaderboardShoutEnabled\":\"{RankedShoutLeaderboardEnabled}\","; - jsonString += $"\"leaderboardSessionEnabled\":\"{RankedSessionLeaderboardEnabled}\""; - jsonString += "},"; - - jsonString += $"\"{LEADERBOARD_TOGGLES_HIGHEST}\":{{"; - jsonString += $"\"sendMostSessionLeaderboard\":\"{MostSessionLeaderboardEnabled}\","; - jsonString += $"\"sendMostPingLeaderboard\":\"{MostPingLeaderboardEnabled}\","; - jsonString += $"\"sendMostDeathLeaderboard\":\"{MostDeathLeaderboardEnabled}\","; - jsonString += $"\"sendMostShoutLeaderboard\":\"{MostShoutLeaderboardEnabled}\""; - jsonString += "},"; - - jsonString += $"\"{LEADERBOARD_TOGGLES_LOWEST}\":{{"; - jsonString += $"\"sendLeastSessionLeaderboard\":\"{LeastSessionLeaderboardEnabled}\","; - jsonString += $"\"sendLeastPingLeaderboard\":\"{LeastPingLeaderboardEnabled}\","; - jsonString += $"\"sendLeastDeathLeaderboard\":\"{LeastDeathLeaderboardEnabled}\","; - jsonString += $"\"sendLeastShoutLeaderboard\":\"{LeastShoutLeaderboardEnabled}\""; - jsonString += "},"; - jsonString += $"\"{PLAYER_FIRSTS_TOGGLES}\":{{"; jsonString += $"\"announceFirstDeathEnabled\":\"{AnnouncePlayerFirstDeathEnabled}\","; jsonString += $"\"announceFirstJoinEnabled\":\"{AnnouncePlayerFirstJoinEnabled}\","; @@ -419,13 +298,6 @@ public string ConfigAsJson() jsonString += $"\"debugHttpRequestResponses\":\"{DebugHttpRequestResponse}\""; jsonString += "},"; - jsonString += $"\"{LEADERBOARD_BOTTOM_N_TOGGLES}\":{{"; - jsonString += $"\"leaderboardInverseDeathEnabled\":\"{InverseRankedDeathLeaderboardEnabled}\","; - jsonString += $"\"leaderboardInversePingEnabled\":\"{InverseRankedPingLeaderboardEnabled}\","; - jsonString += $"\"leaderboardInverseShoutEnabled\":\"{InverseRankedShoutLeaderboardEnabled}\","; - jsonString += $"\"leaderboardInverseSessionEnabled\":\"{InverseRankedSessionLeaderboardEnabled}\""; - jsonString += "}"; - jsonString += "}"; return jsonString; } @@ -450,18 +322,6 @@ public string ConfigAsJson() public bool StatsLeaveEnabled => collectStatsLeaves.Value; public bool StatsPingEnabled => collectStatsPings.Value; public bool StatsShoutEnabled => collectStatsShouts.Value; - public bool RankedDeathLeaderboardEnabled => sendDeathRankingLeaderboard.Value; - public bool RankedPingLeaderboardEnabled => sendPingRankingLeaderboard.Value; - public bool RankedSessionLeaderboardEnabled => sendSessionRankingLeaderboard.Value; - public bool RankedShoutLeaderboardEnabled => sendShoutRankingLeaderboard.Value; - public bool MostSessionLeaderboardEnabled => sendMostSessionLeaderboard.Value; - public bool MostPingLeaderboardEnabled => sendMostPingLeaderboard.Value; - public bool MostDeathLeaderboardEnabled => sendMostDeathLeaderboard.Value; - public bool MostShoutLeaderboardEnabled => sendMostShoutLeaderboard.Value; - public bool LeastSessionLeaderboardEnabled => sendLeastSessionLeaderboard.Value; - public bool LeastPingLeaderboardEnabled => sendLeastPingLeaderboard.Value; - public bool LeastDeathLeaderboardEnabled => sendLeastDeathLeaderboard.Value; - public bool LeastShoutLeaderboardEnabled => sendLeastShoutLeaderboard.Value; public bool AnnouncePlayerFirstDeathEnabled => announcePlayerFirstDeath.Value; public bool AnnouncePlayerFirstJoinEnabled => announcePlayerFirstJoin.Value; public bool AnnouncePlayerFirstLeaveEnabled => announcePlayerFirstLeave.Value; @@ -480,9 +340,5 @@ public string ConfigAsJson() public bool DebugEveryEventChange => debugEventChanges.Value; public bool DebugHttpRequestResponse => debugHttpRequestResponses.Value; public bool DebugDatabaseMethods => debugDatabaseMethods.Value; - public bool InverseRankedDeathLeaderboardEnabled => sendDeathInverseRankingLeaderboard.Value; - public bool InverseRankedPingLeaderboardEnabled => sendPingInverseRankingLeaderboard.Value; - public bool InverseRankedSessionLeaderboardEnabled => sendSessionInverseRankingLeaderboard.Value; - public bool InverseRankedShoutLeaderboardEnabled => sendShoutInverseRankingLeaderboard.Value; } } diff --git a/src/DiscordApi.cs b/src/DiscordApi.cs index 54a586a..0b7c6bf 100644 --- a/src/DiscordApi.cs +++ b/src/DiscordApi.cs @@ -50,7 +50,7 @@ public static void SendMessage(string message) /// Send a with to Discord. /// /// A string optionally formatted with Discord-approved markdown syntax. - /// Discord fields as defined in the API, as Tuples (fieldname, value) + /// Discord fields as defined in the API, as Tuples (field name, value) public static void SendMessageWithFields(string content = null, List> fields = null) { diff --git a/src/EventWatcher.cs b/src/EventWatcher.cs index 8dc9223..080069e 100644 --- a/src/EventWatcher.cs +++ b/src/EventWatcher.cs @@ -216,7 +216,7 @@ internal void TriggerEventStart() if (Plugin.StaticConfig.EventStartMessageEnabled) { string message = MessageTransformer.FormatEventStartMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Status.StartMessage, Status.EndMessage // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -233,7 +233,7 @@ internal void TriggerEventStart() else { message = MessageTransformer.FormatEventStartMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Status.EndMessage, Status.StartMessage, // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes @@ -248,7 +248,7 @@ internal void TriggerEventPaused() if (Plugin.StaticConfig.EventPausedMessageEnabled) { string message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventPausedMesssage, + Plugin.StaticConfig.EventPausedMessage, Status.StartMessage, Status.EndMessage // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -265,7 +265,7 @@ internal void TriggerEventPaused() else { message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventPausedMesssage, + Plugin.StaticConfig.EventPausedMessage, Status.StartMessage, Status.EndMessage, // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes @@ -280,7 +280,7 @@ internal void TriggerEventResumed() if (Plugin.StaticConfig.EventResumedMessageEnabled) { string message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Status.StartMessage, Status.EndMessage // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -297,7 +297,7 @@ internal void TriggerEventResumed() else { message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Status.StartMessage, Status.EndMessage, // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes @@ -312,7 +312,7 @@ internal void TriggerEventStop() if (Plugin.StaticConfig.EventStopMessageEnabled) { string message = MessageTransformer.FormatEventEndMessage( - Plugin.StaticConfig.EventStopMesssage, + Plugin.StaticConfig.EventStopMessage, PreviousEventStartMessage, PreviousEventEndMessage // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -329,7 +329,7 @@ internal void TriggerEventStop() else { message = MessageTransformer.FormatEventEndMessage( - Plugin.StaticConfig.EventStopMesssage, + Plugin.StaticConfig.EventStopMessage, PreviousEventStartMessage, PreviousEventEndMessage, // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes diff --git a/src/Leaderboard/ActivePlayers.cs b/src/Leaderboard/ActivePlayers.cs new file mode 100644 index 0000000..c93e166 --- /dev/null +++ b/src/Leaderboard/ActivePlayers.cs @@ -0,0 +1,92 @@ + +using System; +using System.Timers; + +namespace DiscordConnector.LeaderBoards +{ + /// + /// + /// A board that posts periodically the number of unique players and other stats. + /// + /// + /// Provided example from github issue: + /// + /// SERVER PLAYERS + /// Online now: 4 + /// Today: 8 + /// Week: 29 + /// Month: 31 + /// Total Unique: 42 + /// Most at once: 17 + /// + /// + /// + /// What is currently supported: + /// + /// Active Players + /// Online now: 1 + /// Players today: 2 + /// This week: 2 + /// All time: 0 + /// + /// + /// + internal static class ActivePlayersAnnouncement + { + /// + /// Return the number of currently online players. This is grabbed from the GetAllCharacterZDOS method. + /// + /// Count of character ZDOS + private static int CurrentOnlinePlayers() + { + return ZNet.instance.GetAllCharacterZDOS().Count; + } + + /// + /// Send an announcement leader board to Discord with the current active players and total unique players for some values. + /// To count unique players we do a trick by getting the total number of Joins for each player for each time range we care + /// about and then doing an meta count of how many records come back (see ). + /// + private static void SendActivePlayersBoard() + { + string formattedAnnouncement = $"**Active Players**\n"; + if (Plugin.StaticConfig.ActivePlayersAnnouncement.IncludeCurrentlyOnline) + { + int currentlyOnline = CurrentOnlinePlayers(); + formattedAnnouncement += $"Online now: {currentlyOnline}\n"; + } + + if (Plugin.StaticConfig.ActivePlayersAnnouncement.IncludeTotalToday) + { + int uniqueToday = Records.Helper.CountUniquePlayers(Records.Categories.Join, TimeRange.Today); + formattedAnnouncement += $"Players today: {uniqueToday}\n"; + } + + if (Plugin.StaticConfig.ActivePlayersAnnouncement.IncludeTotalPastWeek) + { + int uniqueThisWeek = Records.Helper.CountUniquePlayers(Records.Categories.Join, TimeRange.PastWeek); + formattedAnnouncement += $"This week: {uniqueThisWeek}\n"; + } + + if (Plugin.StaticConfig.ActivePlayersAnnouncement.IncludeTotalAllTime) + { + int uniqueAllTime = Records.Helper.CountUniquePlayers(Records.Categories.Join, TimeRange.AllTime); + formattedAnnouncement += $"All time: {uniqueAllTime}"; + } + + + DiscordApi.SendMessage(formattedAnnouncement); + } + + /// + /// An interface for sending the leader board as a timer event. + /// + public static void SendOnTimer(object sender, ElapsedEventArgs elapsedEventArgs) + { + System.Threading.Tasks.Task.Run(() => + { + SendActivePlayersBoard(); + }); + } + } +} \ No newline at end of file diff --git a/src/Leaderboard/BottomPlayer.cs b/src/Leaderboard/BottomPlayer.cs deleted file mode 100644 index e8425ee..0000000 --- a/src/Leaderboard/BottomPlayer.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace DiscordConnector.Leaderboards -{ - internal class BottomPlayers : Base - { - public override void SendLeaderboard() - { - List> leaderFields = new List>(); - - var deaths = Records.Helper.BottomNResultForCategory(Records.Categories.Death, Plugin.StaticConfig.IncludedNumberOfRankings); - var sessions = Records.Helper.BottomNResultForCategory(Records.Categories.Join, Plugin.StaticConfig.IncludedNumberOfRankings); - var shouts = Records.Helper.BottomNResultForCategory(Records.Categories.Shout, Plugin.StaticConfig.IncludedNumberOfRankings); - var pings = Records.Helper.BottomNResultForCategory(Records.Categories.Ping, Plugin.StaticConfig.IncludedNumberOfRankings); - - - - if (Plugin.StaticConfig.InverseRankedDeathLeaderboardEnabled && deaths.Count > 0) - { - leaderFields.Add(Tuple.Create("Deaths", Leaderboard.RankedCountResultToString(deaths))); - } - if (Plugin.StaticConfig.InverseRankedSessionLeaderboardEnabled && sessions.Count > 0) - { - leaderFields.Add(Tuple.Create("Sessions", Leaderboard.RankedCountResultToString(sessions))); - } - if (Plugin.StaticConfig.InverseRankedShoutLeaderboardEnabled && shouts.Count > 0) - { - leaderFields.Add(Tuple.Create("Shouts", Leaderboard.RankedCountResultToString(shouts))); - } - if (Plugin.StaticConfig.InverseRankedPingLeaderboardEnabled && pings.Count > 0) - { - leaderFields.Add(Tuple.Create("Pings", Leaderboard.RankedCountResultToString(pings))); - } - if (leaderFields.Count > 0) - { - string discordContent = MessageTransformer.FormatLeaderboardHeader(Plugin.StaticConfig.LeaderboardBottomPlayersHeading, Plugin.StaticConfig.IncludedNumberOfRankings); - DiscordApi.SendMessageWithFields(discordContent, leaderFields); - } - else - { - Plugin.StaticLogger.LogInfo("Not sending a leaderboard because theirs either no leaders, or nothing allowed."); - } - } - } -} diff --git a/src/Leaderboard/Composer.cs b/src/Leaderboard/Composer.cs new file mode 100644 index 0000000..baf1c0c --- /dev/null +++ b/src/Leaderboard/Composer.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using DiscordConnector.Config; +using DiscordConnector.Records; +using Newtonsoft.Json; + +namespace DiscordConnector.LeaderBoards +{ + internal class Composer : Base + { + private int leaderBoardIdx; + public Composer(int leaderBoard) + { + leaderBoardIdx = leaderBoard; + } + public override void SendLeaderBoard() + { + if (leaderBoardIdx > Plugin.StaticConfig.LeaderBoards.Length || leaderBoardIdx < 0) + { + Plugin.StaticLogger.LogWarning($"Tried to get leader board out of index bounds (index:{leaderBoardIdx}, length:{Plugin.StaticConfig.LeaderBoards.Length})"); + return; + } + + LeaderBoardConfigReference settings = Plugin.StaticConfig.LeaderBoards[leaderBoardIdx]; + + if (!settings.Enabled) + { + return; + } + + // Build standings + var rankings = makeRankings(settings); + + // Build leader board for discord + List> leaderFields = new List>(); + if (rankings[Statistic.Death].Count > 0) + { + leaderFields.Add(Tuple.Create("Deaths", LeaderBoard.RankedCountResultToString(rankings[Statistic.Death]))); + } + if (rankings[Statistic.Session].Count > 0) + { + leaderFields.Add(Tuple.Create("Sessions", LeaderBoard.RankedCountResultToString(rankings[Statistic.Session]))); + } + if (rankings[Statistic.Shout].Count > 0) + { + leaderFields.Add(Tuple.Create("Shouts", LeaderBoard.RankedCountResultToString(rankings[Statistic.Shout]))); + } + if (rankings[Statistic.Ping].Count > 0) + { + leaderFields.Add(Tuple.Create("Pings", LeaderBoard.RankedCountResultToString(rankings[Statistic.Ping]))); + } + + string discordContent = MessageTransformer.FormatLeaderBoardHeader( + settings.DisplayedHeading, settings.NumberListings + ); + + DiscordApi.SendMessageWithFields(discordContent, leaderFields); + + // string json = JsonConvert.SerializeObject(rankings, Formatting.Indented); + // DiscordApi.SendMessage($"```json\n{json}\n```"); + } + + private Dictionary> makeRankings(LeaderBoardConfigReference settings) + { + if (settings.TimeRange == TimeRange.AllTime) + { + return AllRankings(settings); + } + + var BeginEndDate = DateHelper.StartEndDatesForTimeRange(settings.TimeRange); + return TimeBasedRankings(settings, BeginEndDate.Item1, BeginEndDate.Item2); + } + + private Dictionary> AllRankings(LeaderBoardConfigReference settings) + { + Dictionary> Dict = new Dictionary>(); + if (settings.Type == LeaderBoards.Ordering.Descending) + { + if (settings.Deaths) + { + Dict.Add(Statistic.Death, Records.Helper.TopNResultForCategory(Categories.Death, settings.NumberListings)); + } + if (settings.Sessions) + { + Dict.Add(Statistic.Session, Records.Helper.TopNResultForCategory(Categories.Join, settings.NumberListings)); + } + if (settings.Shouts) + { + Dict.Add(Statistic.Shout, Records.Helper.TopNResultForCategory(Categories.Shout, settings.NumberListings)); + } + if (settings.Pings) + { + Dict.Add(Statistic.Ping, Records.Helper.TopNResultForCategory(Categories.Ping, settings.NumberListings)); + } + if (settings.TimeOnline) + { + Dict.Add(Statistic.TimeOnline, Records.Helper.TopNResultForCategory(Categories.TimeOnline, settings.NumberListings)); + } + } + if (settings.Type == LeaderBoards.Ordering.Ascending) + { + if (settings.Deaths) + { + Dict.Add(Statistic.Death, Records.Helper.BottomNResultForCategory(Categories.Death, settings.NumberListings)); + } + if (settings.Sessions) + { + Dict.Add(Statistic.Session, Records.Helper.BottomNResultForCategory(Categories.Join, settings.NumberListings)); + } + if (settings.Shouts) + { + Dict.Add(Statistic.Shout, Records.Helper.BottomNResultForCategory(Categories.Shout, settings.NumberListings)); + } + if (settings.Pings) + { + Dict.Add(Statistic.Ping, Records.Helper.BottomNResultForCategory(Categories.Ping, settings.NumberListings)); + } + if (settings.TimeOnline) + { + Dict.Add(Statistic.TimeOnline, Records.Helper.BottomNResultForCategory(Categories.TimeOnline, settings.NumberListings)); + } + } + + return Dict; + + } + + + private Dictionary> TimeBasedRankings(LeaderBoardConfigReference settings, System.DateTime startDate, System.DateTime endDate) + { + Dictionary> Dict = new Dictionary>(); + if (settings.Type == LeaderBoards.Ordering.Descending) + { + if (settings.Deaths) + { + Dict.Add(Statistic.Death, Records.Helper.TopNResultForCategory(Categories.Death, settings.NumberListings, startDate, endDate)); + } + if (settings.Sessions) + { + Dict.Add(Statistic.Session, Records.Helper.TopNResultForCategory(Categories.Join, settings.NumberListings, startDate, endDate)); + } + if (settings.Shouts) + { + Dict.Add(Statistic.Shout, Records.Helper.TopNResultForCategory(Categories.Shout, settings.NumberListings, startDate, endDate)); + } + if (settings.Pings) + { + Dict.Add(Statistic.Ping, Records.Helper.TopNResultForCategory(Categories.Ping, settings.NumberListings, startDate, endDate)); + } + if (settings.TimeOnline) + { + Dict.Add(Statistic.TimeOnline, Records.Helper.TopNResultForCategory(Categories.TimeOnline, settings.NumberListings, startDate, endDate)); + } + } + if (settings.Type == LeaderBoards.Ordering.Ascending) + { + if (settings.Deaths) + { + Dict.Add(Statistic.Death, Records.Helper.BottomNResultForCategory(Categories.Death, settings.NumberListings, startDate, endDate)); + } + if (settings.Sessions) + { + Dict.Add(Statistic.Session, Records.Helper.BottomNResultForCategory(Categories.Join, settings.NumberListings, startDate, endDate)); + } + if (settings.Shouts) + { + Dict.Add(Statistic.Shout, Records.Helper.BottomNResultForCategory(Categories.Shout, settings.NumberListings, startDate, endDate)); + } + if (settings.Pings) + { + Dict.Add(Statistic.Ping, Records.Helper.BottomNResultForCategory(Categories.Ping, settings.NumberListings, startDate, endDate)); + } + if (settings.TimeOnline) + { + Dict.Add(Statistic.TimeOnline, Records.Helper.BottomNResultForCategory(Categories.Ping, settings.NumberListings, startDate, endDate)); + } + } + + return Dict; + + } + + } +} diff --git a/src/Leaderboard/Leaderboard.cs b/src/Leaderboard/Leaderboard.cs index 229214e..8e64422 100644 --- a/src/Leaderboard/Leaderboard.cs +++ b/src/Leaderboard/Leaderboard.cs @@ -4,27 +4,26 @@ namespace DiscordConnector { - internal class Leaderboard + internal class LeaderBoard { - private Leaderboards.Base overallHighest; - private Leaderboards.Base overallLowest; - private Leaderboards.Base topPlayers; - private Leaderboards.Base bottomPlayers; + private LeaderBoards.Base leaderBoard1; + private LeaderBoards.Base leaderBoard2; + private LeaderBoards.Base leaderBoard3; + private LeaderBoards.Base leaderBoard4; + public static readonly int MAX_LEADER_BOARD_SIZE = 16; - public Leaderboard() + public LeaderBoard() { - overallHighest = new Leaderboards.OverallHighest(); - overallLowest = new Leaderboards.OverallLowest(); - topPlayers = new Leaderboards.TopPlayers(); - bottomPlayers = new Leaderboards.BottomPlayers(); + leaderBoard1 = new LeaderBoards.Composer(0); + leaderBoard2 = new LeaderBoards.Composer(1); + leaderBoard3 = new LeaderBoards.Composer(2); + leaderBoard4 = new LeaderBoards.Composer(3); } - public Leaderboards.Base OverallHighest => overallHighest; - public Leaderboards.Base OverallLowest => overallLowest; - public Leaderboards.Base TopPlayers => topPlayers; - public Leaderboards.Base BottomPlayers => bottomPlayers; - - + public LeaderBoards.Base LeaderBoard1 => leaderBoard1; + public LeaderBoards.Base LeaderBoard2 => leaderBoard2; + public LeaderBoards.Base LeaderBoard3 => leaderBoard3; + public LeaderBoards.Base LeaderBoard4 => leaderBoard4; /// /// Takes a sorted list and returns a string listing each member on a line prepended with 1, 2, 3, etc. @@ -43,22 +42,136 @@ public static string RankedCountResultToString(List ranking } } -namespace DiscordConnector.Leaderboards +namespace DiscordConnector.LeaderBoards { + /// + /// A base class for leaderboards to inherit from. It includes a method that lets the leader board be sent on a timer + /// and an abstract method which sends the leader board. + /// internal abstract class Base { /// - /// An interface for sending the leaderboard as a timer event. + /// An interface for sending the leader board as a timer event. /// - public void SendLeaderboardOnTimer(object sender, ElapsedEventArgs elapsedEventArgs) + public void SendLeaderBoardOnTimer(object sender, ElapsedEventArgs elapsedEventArgs) { - this.SendLeaderboard(); + System.Threading.Tasks.Task.Run(() => + { + this.SendLeaderBoard(); + }); } /// - /// Send the leaderboard to the DiscordAPI + /// Send the leader board to the DiscordAPI /// - public abstract void SendLeaderboard(); + public abstract void SendLeaderBoard(); + } + /// + /// Time ranges that are supported for querying from the database using a "where" clause on the date. + /// + public enum TimeRange + { + [System.ComponentModel.Description("All Time")] + AllTime, + [System.ComponentModel.Description("Today")] + Today, + [System.ComponentModel.Description("Yesterday")] + Yesterday, + [System.ComponentModel.Description("Past 7 Days")] + PastWeek, + [System.ComponentModel.Description("Current Week, Sunday to Saturday")] + WeekSundayToSaturday, + [System.ComponentModel.Description("Current Week, Monday to Sunday")] + WeekMondayToSunday, + } + /// + /// Available options for sorting the results gathered from the database. This is used when defining the custom leader boards. + /// + public enum Ordering + { + [System.ComponentModel.Description("Most to Least (Descending)")] + Descending, + [System.ComponentModel.Description("Least to Most (Ascending)")] + Ascending, } + /// + /// Tracked statistics which can be stored in the records database. The value is calculated dynamically. + /// + public enum Statistic + { + Death, + Session, + Shout, + Ping, + TimeOnline, + } + public static class DateHelper + { + /// + /// A "dummy" date time, set to 20 years ago. This is used internally as both the start and end date to indicate all records. + /// + public static readonly System.DateTime DummyDateTime = System.DateTime.Now.AddYears(-20); + + /// + /// Get a tuple with the start and end date for the specified + /// + /// TimeRange that you want the actual start and end date for + /// A tuple with two dates for the time range, where the earlier date is Item1 + public static Tuple StartEndDatesForTimeRange(TimeRange timeRange) + { + switch (timeRange) + { + case TimeRange.Today: + System.DateTime today = System.DateTime.Today; + return new Tuple(today, today); + + case TimeRange.Yesterday: + System.DateTime yesterday = System.DateTime.Today.AddDays(-1.0); + return new Tuple(yesterday, yesterday); + + case TimeRange.PastWeek: + System.DateTime weekAgo = System.DateTime.Today.AddDays(-7.0); + System.DateTime today1 = System.DateTime.Today; + return new Tuple(weekAgo, today1); + + case TimeRange.WeekSundayToSaturday: + System.DateTime today2 = System.DateTime.Today; + int dow = (int)today2.DayOfWeek; + + System.DateTime sunday = today2.AddDays(-dow); + System.DateTime saturday = today2.AddDays(6 - dow); + // If we are on sunday, show for the current week + if (today2.DayOfWeek == System.DayOfWeek.Sunday) + { + sunday = today2; + saturday = today2.AddDays(6); + } + + return new Tuple(sunday, saturday); + + case TimeRange.WeekMondayToSunday: + System.DateTime today3 = System.DateTime.Today; + int dow1 = (int)today3.DayOfWeek; + System.DateTime monday = today3.AddDays(1 - dow1); // Monday - day of week = goes backward to previous monday until we are in Sunday + System.DateTime sunday1 = today3.AddDays(7 - dow1); // (Next monday) - day of week = goes to next monday until we are in Sunday then shows next Sunday + + // If we are on sunday, fix to show "current" week still + if (today3.DayOfWeek == System.DayOfWeek.Sunday) + { + monday = today3.AddDays(-6); // Sunday - 6 = previous monday + sunday = today3; // Sunday is today + } + + return new Tuple(monday, sunday1); + + case TimeRange.AllTime: + return new Tuple(DummyDateTime, DummyDateTime); + + default: + Plugin.StaticLogger.LogWarning("DateHelper fell through, probably not wanted!"); + return new Tuple(DummyDateTime, DummyDateTime); + } + } + } } diff --git a/src/Leaderboard/OverallHighest.cs b/src/Leaderboard/OverallHighest.cs deleted file mode 100644 index 1d3e0d5..0000000 --- a/src/Leaderboard/OverallHighest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace DiscordConnector.Leaderboards -{ - internal class OverallHighest : Base - { - public override void SendLeaderboard() - { - var deathLeader = Records.Helper.TopResultForCategory(Records.Categories.Death); - var joinLeader = Records.Helper.TopResultForCategory(Records.Categories.Join); - var shoutLeader = Records.Helper.TopResultForCategory(Records.Categories.Shout); - var pingLeader = Records.Helper.TopResultForCategory(Records.Categories.Ping); - - List> leaderFields = new List>(); - if (Plugin.StaticConfig.MostDeathLeaderboardEnabled && deathLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Most Deaths", $"{deathLeader.Name} ({deathLeader.Count})")); - } - if (Plugin.StaticConfig.MostSessionLeaderboardEnabled && joinLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Most Sessions", $"{joinLeader.Name} ({joinLeader.Count})")); - } - if (Plugin.StaticConfig.MostShoutLeaderboardEnabled && shoutLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Most Shouts", $"{shoutLeader.Name} ({shoutLeader.Count})")); - } - if (Plugin.StaticConfig.MostPingLeaderboardEnabled && pingLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Most Pings", $"{pingLeader.Name} ({pingLeader.Count})")); - } - if (leaderFields.Count > 0) - { - string discordContent = MessageTransformer.FormatLeaderboardHeader(Plugin.StaticConfig.LeaderboardHighestHeading); - DiscordApi.SendMessageWithFields(discordContent, leaderFields); - } - else - { - Plugin.StaticLogger.LogInfo("Not sending a leaderboard because theirs either no leaders, or nothing allowed."); - } - } - - } -} diff --git a/src/Leaderboard/OverallLowest.cs b/src/Leaderboard/OverallLowest.cs deleted file mode 100644 index 32daefc..0000000 --- a/src/Leaderboard/OverallLowest.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace DiscordConnector.Leaderboards -{ - internal class OverallLowest : Base - { - public override void SendLeaderboard() - { - var deathLeader = Records.Helper.BottomResultForCategory(Records.Categories.Death); - var joinLeader = Records.Helper.BottomResultForCategory(Records.Categories.Join); - var shoutLeader = Records.Helper.BottomResultForCategory(Records.Categories.Shout); - var pingLeader = Records.Helper.BottomResultForCategory(Records.Categories.Ping); - - List> leaderFields = new List>(); - if (Plugin.StaticConfig.LeastDeathLeaderboardEnabled && deathLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Least Deaths", $"{deathLeader.Name} ({deathLeader.Count})")); - } - if (Plugin.StaticConfig.LeastSessionLeaderboardEnabled && joinLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Least Sessions", $"{joinLeader.Name} ({joinLeader.Count})")); - } - if (Plugin.StaticConfig.LeastShoutLeaderboardEnabled && shoutLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Least Shouts", $"{shoutLeader.Name} ({shoutLeader.Count})")); - } - if (Plugin.StaticConfig.LeastPingLeaderboardEnabled && pingLeader.Count > 0) - { - leaderFields.Add(Tuple.Create("Least Pings", $"{pingLeader.Name} ({pingLeader.Count})")); - } - if (leaderFields.Count > 0) - { - string discordContent = MessageTransformer.FormatLeaderboardHeader(Plugin.StaticConfig.LeaderboardLowestHeading); - DiscordApi.SendMessageWithFields(discordContent, leaderFields); - } - else - { - Plugin.StaticLogger.LogInfo("Not sending a leaderboard because theirs either no leaders, or nothing allowed."); - } - } - } -} diff --git a/src/Leaderboard/TopPlayers.cs b/src/Leaderboard/TopPlayers.cs deleted file mode 100644 index 4ed89e7..0000000 --- a/src/Leaderboard/TopPlayers.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace DiscordConnector.Leaderboards -{ - internal class TopPlayers : Base - { - public override void SendLeaderboard() - { - List> leaderFields = new List>(); - - var deaths = Records.Helper.TopNResultForCategory(Records.Categories.Death, Plugin.StaticConfig.IncludedNumberOfRankings); - var sessions = Records.Helper.TopNResultForCategory(Records.Categories.Join, Plugin.StaticConfig.IncludedNumberOfRankings); - var shouts = Records.Helper.TopNResultForCategory(Records.Categories.Shout, Plugin.StaticConfig.IncludedNumberOfRankings); - var pings = Records.Helper.TopNResultForCategory(Records.Categories.Ping, Plugin.StaticConfig.IncludedNumberOfRankings); - - if (Plugin.StaticConfig.RankedDeathLeaderboardEnabled && deaths.Count > 0) - { - leaderFields.Add(Tuple.Create("Deaths", Leaderboard.RankedCountResultToString(deaths))); - } - if (Plugin.StaticConfig.RankedSessionLeaderboardEnabled && sessions.Count > 0) - { - leaderFields.Add(Tuple.Create("Sessions", Leaderboard.RankedCountResultToString(sessions))); - } - if (Plugin.StaticConfig.RankedShoutLeaderboardEnabled && shouts.Count > 0) - { - leaderFields.Add(Tuple.Create("Shouts", Leaderboard.RankedCountResultToString(shouts))); - } - if (Plugin.StaticConfig.RankedPingLeaderboardEnabled && pings.Count > 0) - { - leaderFields.Add(Tuple.Create("Pings", Leaderboard.RankedCountResultToString(pings))); - } - if (leaderFields.Count > 0) - { - string discordContent = MessageTransformer.FormatLeaderboardHeader(Plugin.StaticConfig.LeaderboardTopPlayerHeading, Plugin.StaticConfig.IncludedNumberOfRankings); - DiscordApi.SendMessageWithFields(discordContent, leaderFields); - } - else - { - Plugin.StaticLogger.LogInfo("Not sending a leaderboard because theirs either no leaders, or nothing allowed."); - } - } - } -} diff --git a/src/MessageTransformer.cs b/src/MessageTransformer.cs index d513048..86a1245 100644 --- a/src/MessageTransformer.cs +++ b/src/MessageTransformer.cs @@ -80,9 +80,9 @@ public static string FormatPlayerMessage(string rawMessage, string playerName, s return MessageTransformer.FormatPlayerMessage(rawMessage, playerName, playerId) .Replace(POS, $"{pos}"); } - public static string FormatPlayerMessage(string rawMessage, string playerName, string playerid, string shout) + public static string FormatPlayerMessage(string rawMessage, string playerName, string playerId, string shout) { - return MessageTransformer.FormatPlayerMessage(rawMessage, playerName, playerid) + return MessageTransformer.FormatPlayerMessage(rawMessage, playerName, playerId) .Replace(SHOUT, shout); } public static string FormatPlayerMessage(string rawMessage, string playerName, string playerSteamId, string shout, Vector3 pos) @@ -122,12 +122,12 @@ public static string FormatEventEndMessage(string rawMessage, string eventStartM return MessageTransformer.FormatEventMessage(rawMessage, eventStartMsg, eventEndMsg, pos) .Replace(EVENT_MSG, eventEndMsg); } - public static string FormatLeaderboardHeader(string rawMessage) + public static string FormatLeaderBoardHeader(string rawMessage) { return MessageTransformer.ReplaceVariables(rawMessage); } - public static string FormatLeaderboardHeader(string rawMessage, int n) + public static string FormatLeaderBoardHeader(string rawMessage, int n) { return MessageTransformer.ReplaceVariables(rawMessage) .Replace(N, n.ToString()); diff --git a/src/Patches/EventPatches.cs b/src/Patches/EventPatches.cs index 4368220..e86752d 100644 --- a/src/Patches/EventPatches.cs +++ b/src/Patches/EventPatches.cs @@ -43,7 +43,7 @@ private static void Prefix(ref RandomEvent __instance) if (Plugin.StaticConfig.EventResumedMessageEnabled) { string message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage) // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -60,7 +60,7 @@ private static void Prefix(ref RandomEvent __instance) else { message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage), // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes @@ -75,7 +75,7 @@ private static void Prefix(ref RandomEvent __instance) if (Plugin.StaticConfig.EventStartMessageEnabled) { string message = MessageTransformer.FormatEventStartMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage) // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -92,7 +92,7 @@ private static void Prefix(ref RandomEvent __instance) else { message = MessageTransformer.FormatEventStartMessage( - Plugin.StaticConfig.EventResumedMesssage, + Plugin.StaticConfig.EventResumedMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage), // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes @@ -137,7 +137,7 @@ private static void Prefix(ref RandomEvent __instance, ref bool end) if (Plugin.StaticConfig.EventPausedMessageEnabled) { string message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventPausedMesssage, + Plugin.StaticConfig.EventPausedMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage) // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -154,7 +154,7 @@ private static void Prefix(ref RandomEvent __instance, ref bool end) else { message = MessageTransformer.FormatEventMessage( - Plugin.StaticConfig.EventPausedMesssage, + Plugin.StaticConfig.EventPausedMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage), // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes @@ -169,7 +169,7 @@ private static void Prefix(ref RandomEvent __instance, ref bool end) if (Plugin.StaticConfig.EventStopMessageEnabled) { string message = MessageTransformer.FormatEventEndMessage( - Plugin.StaticConfig.EventStopMesssage, + Plugin.StaticConfig.EventStopMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage) // string.Join(",", involvedPlayers.ToArray()) //! Removed with event changes @@ -186,7 +186,7 @@ private static void Prefix(ref RandomEvent __instance, ref bool end) else { message = MessageTransformer.FormatEventEndMessage( - Plugin.StaticConfig.EventStopMesssage, + Plugin.StaticConfig.EventStopMessage, Localization.instance.Localize(__instance.m_endMessage), Localization.instance.Localize(__instance.m_startMessage), // string.Join(",", involvedPlayers.ToArray()), //! Removed with event changes diff --git a/src/Plugin.cs b/src/Plugin.cs index 4389fb4..7dc9aed 100644 --- a/src/Plugin.cs +++ b/src/Plugin.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System; using BepInEx; using BepInEx.Logging; using DiscordConnector.Records; @@ -14,10 +14,27 @@ public class Plugin : BaseUnityPlugin internal static ManualLogSource StaticLogger; internal static PluginConfig StaticConfig; internal static Database StaticDatabase; - internal static Leaderboard StaticLeaderboards; + internal static LeaderBoard StaticLeaderBoards; internal static EventWatcher StaticEventWatcher; internal static ConfigWatcher StaticConfigWatcher; - internal static string PublicIpAddress; + internal static string PublicIpAddress + { + /// + /// Return the public IP address if we already know it. If we don't know it, find out. + /// We avoid always getting the IP address to only get it when needed. + /// + /// The public IP address of the server + get + { + if (!String.IsNullOrEmpty(_publicIpAddress)) + { + return _publicIpAddress; + } + _publicIpAddress = IpifyAPI.PublicIpAddress(); + return _publicIpAddress; + } + } + private static string _publicIpAddress; private Harmony _harmony; public Plugin() @@ -25,9 +42,11 @@ public Plugin() StaticLogger = Logger; StaticConfig = new PluginConfig(Config); StaticDatabase = new Records.Database(Paths.GameRootPath); - StaticLeaderboards = new Leaderboard(); + StaticLeaderBoards = new LeaderBoard(); StaticConfigWatcher = new ConfigWatcher(); + + _publicIpAddress = ""; } private void Awake() @@ -48,20 +67,64 @@ private void Awake() StaticLogger.LogWarning("No value set for WebHookURL! Plugin will run without using a main Discord webhook."); } - if (StaticConfig.StatsAnnouncementEnabled) + if (StaticConfig.LeaderBoards[0].Enabled) + { + System.Timers.Timer leaderBoard1Timer = new System.Timers.Timer(); + leaderBoard1Timer.Elapsed += StaticLeaderBoards.LeaderBoard1.SendLeaderBoardOnTimer; + // Interval is learned from config file in minutes + leaderBoard1Timer.Interval = 60 * 1000 * StaticConfig.LeaderBoards[0].PeriodInMinutes; + Plugin.StaticLogger.LogInfo($"Enabling LeaderBoard.1 timer with interval 1:{leaderBoard1Timer.Interval} ms"); + leaderBoard1Timer.Start(); + } + + if (StaticConfig.LeaderBoards[1].Enabled) + { + System.Timers.Timer leaderBoard2Timer = new System.Timers.Timer(); + leaderBoard2Timer.Elapsed += StaticLeaderBoards.LeaderBoard2.SendLeaderBoardOnTimer; + // Interval is learned from config file in minutes + leaderBoard2Timer.Interval = 60 * 1000 * StaticConfig.LeaderBoards[1].PeriodInMinutes; + Plugin.StaticLogger.LogInfo($"Enabling LeaderBoard.2 timer with interval 1:{leaderBoard2Timer.Interval} ms"); + leaderBoard2Timer.Start(); + } + + if (StaticConfig.LeaderBoards[2].Enabled) + { + System.Timers.Timer leaderBoard3Timer = new System.Timers.Timer(); + leaderBoard3Timer.Elapsed += StaticLeaderBoards.LeaderBoard3.SendLeaderBoardOnTimer; + // Interval is learned from config file in minutes + leaderBoard3Timer.Interval = 60 * 1000 * StaticConfig.LeaderBoards[2].PeriodInMinutes; + Plugin.StaticLogger.LogInfo($"Enabling LeaderBoard.3 timer with interval 1:{leaderBoard3Timer.Interval} ms"); + leaderBoard3Timer.Start(); + } + + if (StaticConfig.LeaderBoards[3].Enabled) + { + System.Timers.Timer leaderBoard4Timer = new System.Timers.Timer(); + leaderBoard4Timer.Elapsed += StaticLeaderBoards.LeaderBoard4.SendLeaderBoardOnTimer; + // Interval is learned from config file in minutes + leaderBoard4Timer.Interval = 60 * 1000 * StaticConfig.LeaderBoards[3].PeriodInMinutes; + Plugin.StaticLogger.LogInfo($"Enabling LeaderBoard.4 timer with interval 1:{leaderBoard4Timer.Interval} ms"); + leaderBoard4Timer.Start(); + } + + if (StaticConfig.LeaderBoards[4].Enabled) { - System.Timers.Timer leaderboardTimer = new System.Timers.Timer(); - leaderboardTimer.Elapsed += StaticLeaderboards.OverallHighest.SendLeaderboardOnTimer; - leaderboardTimer.Elapsed += StaticLeaderboards.OverallLowest.SendLeaderboardOnTimer; - leaderboardTimer.Elapsed += StaticLeaderboards.TopPlayers.SendLeaderboardOnTimer; - leaderboardTimer.Elapsed += StaticLeaderboards.BottomPlayers.SendLeaderboardOnTimer; + System.Timers.Timer leaderBoard5Timer = new System.Timers.Timer(); + leaderBoard5Timer.Elapsed += StaticLeaderBoards.LeaderBoard4.SendLeaderBoardOnTimer; // Interval is learned from config file in minutes - leaderboardTimer.Interval = 60 * 1000 * StaticConfig.StatsAnnouncementPeriod; - Plugin.StaticLogger.LogDebug($"Enabling leaderboard timers with interval {leaderboardTimer.Interval}ms"); - leaderboardTimer.Start(); + leaderBoard5Timer.Interval = 60 * 1000 * StaticConfig.LeaderBoards[4].PeriodInMinutes; + Plugin.StaticLogger.LogInfo($"Enabling LeaderBoard.4 timer with interval 1:{leaderBoard5Timer.Interval} ms"); + leaderBoard5Timer.Start(); } - PublicIpAddress = IpifyAPI.PublicIpAddress(); + if (StaticConfig.ActivePlayersAnnouncement.Enabled) + { + System.Timers.Timer playerActivityTimer = new System.Timers.Timer(); + playerActivityTimer.Elapsed += LeaderBoards.ActivePlayersAnnouncement.SendOnTimer; + // Interval is learned from config file in minutes + playerActivityTimer.Interval = 60 * 1000 * StaticConfig.ActivePlayersAnnouncement.PeriodInMinutes; + playerActivityTimer.Start(); + } _harmony = Harmony.CreateAndPatchAll(typeof(Plugin).Assembly, PluginInfo.PLUGIN_ID); } diff --git a/src/PluginInfo.cs b/src/PluginInfo.cs index 6dff266..dbeb99f 100644 --- a/src/PluginInfo.cs +++ b/src/PluginInfo.cs @@ -17,8 +17,9 @@ internal static class PluginInfo { public const string PLUGIN_ID = "games.nwest.valheim.discordconnector"; public const string PLUGIN_NAME = "Valheim Discord Connector"; - public const string PLUGIN_VERSION = "2.0.7"; + public const string PLUGIN_VERSION = "2.1.0"; public const string PLUGIN_REPO_SHORT = "github: nwesterhausen/valheim-discordconnector"; public const string PLUGIN_AUTHOR = "Nicholas Westerhausen"; + public const string SHORT_PLUGIN_ID = "discordconnector"; } } diff --git a/src/Records/Classes/CountResult.cs b/src/Records/Classes/CountResult.cs index eb93369..6a384a0 100644 --- a/src/Records/Classes/CountResult.cs +++ b/src/Records/Classes/CountResult.cs @@ -1,8 +1,12 @@  +using System.Collections.Generic; using LiteDB; namespace DiscordConnector.Records { + /// + /// Holds the name of the collection and value for it. + /// public class CountResult { public string Name { get; } @@ -11,7 +15,6 @@ public class CountResult [BsonCtor] public CountResult(string name, int count) { - Name = name; Count = count; } @@ -25,5 +28,50 @@ public static int CompareByName(CountResult cr1, CountResult cr2) { return cr1.Name.CompareTo(cr2.Name); } + + /// + /// Converts a list of BSON documents with "player" and "count" values into our CountResult value. + /// + /// List of BSON with player and count values. + /// List of count results + public static List ConvertFromBsonDocuments(List bsonDocuments) + { + List results = new List(); + + if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"ConvertBsonDocumentCountToDotNet r={bsonDocuments.Count}"); } + foreach (BsonDocument doc in bsonDocuments) + { + if (!doc.ContainsKey("Count")) + { + continue; + } + + if (doc.ContainsKey("Name")) + { + results.Add(new CountResult( + doc["Name"].AsString, + doc["Count"].AsInt32 + )); + } + else if (doc.ContainsKey("NamePlayer")) + { + results.Add(new CountResult( + doc["NamePlayer"]["Name"].AsString, + doc["Count"].AsInt32 + )); + } + else if (doc.ContainsKey("Player")) + { + if (!doc["Player"].IsNull) + { + results.Add(new CountResult( + Plugin.StaticDatabase.GetLatestCharacterNameForPlayer(doc["Player"]), + doc["Count"].AsInt32 + )); + } + } + } + return results; + } } } diff --git a/src/Records/Classes/PlayerNames.cs b/src/Records/Classes/PlayerNames.cs new file mode 100644 index 0000000..6cc77ac --- /dev/null +++ b/src/Records/Classes/PlayerNames.cs @@ -0,0 +1,27 @@ + +using LiteDB; + +namespace DiscordConnector.Records +{ + public class PlayerToName + { + public ObjectId _id { get; } + public string CharacterName { get; } + public string PlayerId { get; } + public System.DateTime InsertedDate { get; } + + public PlayerToName(string characterName, string playerHostName) + { + _id = ObjectId.NewObjectId(); + CharacterName = characterName; + PlayerId = playerHostName; + InsertedDate = System.DateTime.Now; + } + + public override string ToString() + { + return $"{CharacterName} ({PlayerId})"; + } + + } +} diff --git a/src/Records/Classes/Position.cs b/src/Records/Classes/Position.cs new file mode 100644 index 0000000..f7a4f3a --- /dev/null +++ b/src/Records/Classes/Position.cs @@ -0,0 +1,28 @@ + +namespace DiscordConnector.Records +{ + public class Position + { + public float x { get; } + public float y { get; } + public float z { get; } + + public Position() + { + x = 0; + y = 0; + z = 0; + } + public Position(float _x, float _y, float _z) + { + x = _x; + y = _y; + z = _z; + } + + public override string ToString() + { + return $"({x},{y},{z})"; + } + } +} diff --git a/src/Records/Classes/SimpleStat.cs b/src/Records/Classes/SimpleStat.cs index d381b07..cc12125 100644 --- a/src/Records/Classes/SimpleStat.cs +++ b/src/Records/Classes/SimpleStat.cs @@ -3,30 +3,6 @@ namespace DiscordConnector.Records { - public class Position - { - public float x { get; } - public float y { get; } - public float z { get; } - - public Position() - { - x = 0; - y = 0; - z = 0; - } - public Position(float _x, float _y, float _z) - { - x = _x; - y = _y; - z = _z; - } - - public override string ToString() - { - return $"({x},{y},{z})"; - } - } public class SimpleStat { public ObjectId StatId { get; } diff --git a/src/Records/Database.cs b/src/Records/Database.cs index a306d14..1496460 100644 --- a/src/Records/Database.cs +++ b/src/Records/Database.cs @@ -1,13 +1,20 @@  +using System; using System.Collections.Generic; +using System.Threading.Tasks; +using DiscordConnector.LeaderBoards; using LiteDB; +using Newtonsoft.Json; using UnityEngine; namespace DiscordConnector.Records { + /// + /// The database class holds the connection to the database, and so all the database-accessing methods are contained within. + /// internal class Database { - private const string DB_NAME = $"{PluginInfo.PLUGIN_ID}-records.db"; + private const string DB_NAME = "records.db"; private static string DbPath; private LiteDatabase db; private ILiteCollection DeathCollection; @@ -15,169 +22,277 @@ internal class Database private ILiteCollection LeaveCollection; private ILiteCollection ShoutCollection; private ILiteCollection PingCollection; + private ILiteCollection PlayerToNameCollection; + /// + /// Set's up the database using the compiled string `"${PluginInfo.PLUGIN_ID}-records.db"`, which in this + /// case is probably `games.nwest.valheim.discordconnector-records.db`. This method needs to know where to + /// store the database, since that is something that is only known at runtime. + /// + /// Directory to save the LiteDB database in. public Database(string rootStorePath) { - DbPath = System.IO.Path.Combine(BepInEx.Paths.ConfigPath, DB_NAME); + DbPath = System.IO.Path.Combine(BepInEx.Paths.ConfigPath, PluginInfo.PLUGIN_ID, DB_NAME); + + // Check for database in old location and move if necessary + string oldDatabase = System.IO.Path.Combine(BepInEx.Paths.ConfigPath, $"{PluginInfo.PLUGIN_ID}-records.db"); + if (System.IO.File.Exists(oldDatabase)) + { + System.IO.File.Move(oldDatabase, DbPath); + } Initialize(); } + /// + /// Method to initialize the database reference and content. + /// + /// This method creates the database file (if it doesn't exist) and opens it for reading. + /// + /// Because of how LiteDB works (it creates tables as needed), this method simply creates the collection handles + /// used later on as records get added to the database. + /// public void Initialize() { - try + Task.Run(() => { - db = new LiteDatabase(DbPath); - Plugin.StaticLogger.LogDebug($"LiteDB Connection Established to {DbPath}"); - DeathCollection = db.GetCollection("deaths"); - JoinCollection = db.GetCollection("joins"); - LeaveCollection = db.GetCollection("leaves"); - ShoutCollection = db.GetCollection("shouts"); - PingCollection = db.GetCollection("pings"); - } - catch (System.IO.IOException e) - { - Plugin.StaticLogger.LogError($"Unable to acquire un-shared access to {DbPath}"); - Plugin.StaticLogger.LogDebug(e); - } + try + { + db = new LiteDatabase(DbPath); + Plugin.StaticLogger.LogDebug($"LiteDB Connection Established to {DbPath}"); + + // Grab references to the collections + DeathCollection = db.GetCollection("deaths"); + JoinCollection = db.GetCollection("joins"); + LeaveCollection = db.GetCollection("leaves"); + ShoutCollection = db.GetCollection("shouts"); + PingCollection = db.GetCollection("pings"); + PlayerToNameCollection = db.GetCollection("player_name"); + + // Ensure indices on the collections + Task.Run(() => + { + EnsureIndicesOnCollections(); + Plugin.StaticLogger.LogDebug("Created indices on database collections"); + }).ConfigureAwait(false); + } + catch (System.IO.IOException e) + { + Plugin.StaticLogger.LogError($"Unable to acquire un-shared access to {DbPath}"); + Plugin.StaticLogger.LogDebug(e); + } + }).ConfigureAwait(false); + } + + /// + /// Runs EnsureIndex() on each collection, targeting the columns we index on + /// + private void EnsureIndicesOnCollections() + { + DeathCollection.EnsureIndex(x => x.PlayerId); + DeathCollection.EnsureIndex(x => x.Name); + JoinCollection.EnsureIndex(x => x.PlayerId); + JoinCollection.EnsureIndex(x => x.Name); + LeaveCollection.EnsureIndex(x => x.PlayerId); + LeaveCollection.EnsureIndex(x => x.Name); + ShoutCollection.EnsureIndex(x => x.PlayerId); + ShoutCollection.EnsureIndex(x => x.Name); + PingCollection.EnsureIndex(x => x.PlayerId); + PingCollection.EnsureIndex(x => x.Name); + PlayerToNameCollection.EnsureIndex(x => x.PlayerId); + PlayerToNameCollection.EnsureIndex(x => x.CharacterName); } + /// + /// Closes the database connection nicely. + /// public void Dispose() { Plugin.StaticLogger.LogDebug("Closing LiteDB connection"); db.Dispose(); } + /// + /// Insert a "simple stat" record into the database for the provided collection. + /// + /// Collection to insert the record into + /// Character name + /// Player connection id/hostname + /// Position private void InsertSimpleStatRecord(ILiteCollection collection, string playerName, string playerHostName, Vector3 pos) { - var newRecord = new SimpleStat( - playerName, - playerHostName, - pos.x, pos.y, pos.z - ); - collection.Insert(newRecord); - - collection.EnsureIndex(x => x.Name); - collection.EnsureIndex(x => x.PlayerId); + Task.Run(() => + { + var newRecord = new SimpleStat( + playerName, + playerHostName, + pos.x, pos.y, pos.z + ); + collection.Insert(newRecord); + }).ConfigureAwait(false); } - private void InsertSimpleStatRecord(ILiteCollection collection, string playerName, string playerHostName) + + /// + /// Inserts a record with the player hostname and id into the database, to simplify retrieval at a later date. + /// + /// Player character's name + /// Player's connection hostname + private void EnsurePlayerNameRecorded(string characterName, string playerHostName) { - InsertSimpleStatRecord(collection, playerName, playerHostName, Vector3.zero); + Task.Run(() => + { + if (PlayerToNameCollection.Exists(x => x.PlayerId.Equals(playerHostName) && x.CharacterName.Equals(characterName))) + { + // If the player record exists with both playerHostName and characterName, do nothing. + return; + } + + if (PlayerToNameCollection.Exists(x => x.PlayerId.Equals(playerHostName))) + { + // If the player record exists but only with the playerHostName, a new "latest" name is here + Plugin.StaticLogger.LogDebug($"Multiple characters from {playerHostName}, latest is {characterName}"); + + } + // Insert the player name record if it doesn't exist + PlayerToName newPlayer = new PlayerToName(characterName, playerHostName); + PlayerToNameCollection.Insert(newPlayer); + }).ConfigureAwait(false); } - private int CountOfRecordsByName(ILiteCollection collection, string playerName) + /// + /// Insert a "simple stat" record without the position data. + /// + /// Collection to insert the record into + /// Character name + /// Player connection id/hostname + private void InsertSimpleStatRecord(ILiteCollection collection, string playerName, string playerHostName) { - return collection.Query() - .Where(x => x.Name.Equals(playerName)) - .Count(); + InsertSimpleStatRecord(collection, playerName, playerHostName, Vector3.zero); } - private int CountOfRecordsByCharacterId(ILiteCollection collection, string playerHostName) + /// + /// Insert a simple stat record with position for the provided key into the LiteDB database. + /// + /// What kind of record to insert + /// Player character name + /// Player connection host name (e.g. `Steam_{steamId}`) + /// World position to record with the stat + public void InsertSimpleStatRecord(string key, string playerName, string playerHostName, Vector3 pos) { - return collection.Query() - .Where(x => x.PlayerId == playerHostName) - .Count(); + switch (key) + { + case Categories.Death: + InsertSimpleStatRecord(DeathCollection, playerName, playerHostName, pos); + break; + case Categories.Join: + EnsurePlayerNameRecorded(playerName, playerHostName); + InsertSimpleStatRecord(JoinCollection, playerName, playerHostName, pos); + break; + case Categories.Leave: + InsertSimpleStatRecord(LeaveCollection, playerName, playerHostName, pos); + break; + case Categories.Ping: + InsertSimpleStatRecord(PingCollection, playerName, playerHostName, pos); + break; + case Categories.Shout: + InsertSimpleStatRecord(ShoutCollection, playerName, playerHostName, pos); + break; + default: + Plugin.StaticLogger.LogDebug($"InsertSimpleStatRecord, invalid key '{key}'"); + break; + } } - private int CountOfRecordsByNameAndCharacterId(ILiteCollection collection, string playerName, string playerHostName) + /// + /// Insert a simple stat record without position for the provided key into the LiteDB database. + /// + /// This method simply wraps the positional InsertSimpleStatRecord method. + /// + /// What kind of record to insert + /// Player character name + /// Player connection host name (e.g. `Steam_{steamId}`) + public void InsertSimpleStatRecord(string key, string playerName, string playerHostName) { - return collection.Query() - .Where(x => (x.Name.Equals(playerName) && x.PlayerId == playerHostName)) - .Count(); + InsertSimpleStatRecord(key, playerName, playerHostName, Vector3.zero); } - private List CountAllRecordsGrouped(ILiteCollection collection) + /// + /// Returns the latest known character name for the given player identifier. + /// + /// This first tries to find the name in the player_name table, and failing that references the join table with a query. + /// If it has to use the join table to find the name, if it does find a valid name, it adds a record to the player_name + /// table to make future lookups faster. + /// + /// The player's connection host name + /// Last known character name of the player + internal string GetLatestCharacterNameForPlayer(string playerHostName) { - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"CountAllRecordsGrouped {Plugin.StaticConfig.RecordRetrievalDiscernmentMethod}"); } - //! if treat name as character, each name combined regardless - if (Plugin.StaticConfig.RecordRetrievalDiscernmentMethod.Equals(Config.RetrievalDiscernmentMethods.ByName)) - { - return ConvertBsonDocumentCountToDotNet( - collection.Query() - .GroupBy("Name") - .Select("{Name: @Key, Count: COUNT(*)}") - .ToList() - ); - } - else if (Plugin.StaticConfig.RecordRetrievalDiscernmentMethod.Equals(Config.RetrievalDiscernmentMethods.ByNameAndSteamID)) + if (Plugin.StaticConfig.DebugDatabaseMethods) { - return ConvertBsonDocumentCountToDotNet( - collection.Query() - .GroupBy("{Name,PlayerId}") - .Select("{NamePlayer: @Key, Count: COUNT(*)}") - .ToList() - ); + Plugin.StaticLogger.LogDebug($"GetLatestNameForCharacterId {playerHostName} begin"); } - else // Leaving only Config.RetrievalDiscernmentMethods.BySteamID - { - return ConvertBsonDocumentCountToDotNet( - collection.Query() - .GroupBy("PlayerId") - .Select("{Player: @Key, Count: COUNT(*)}") - .ToList() - ); + if (PlayerToNameCollection.Exists(x => x.PlayerId.Equals(playerHostName))) + { + try + { + PlayerToName playerInfo = PlayerToNameCollection + .Query() + // Select only the player we're looking for + .Where(x => x.PlayerId.Equals(playerHostName)) + // Get the most recent name at the top + .OrderByDescending(x => x.InsertedDate) + .First(); + return playerInfo.CharacterName; + } + catch (System.InvalidOperationException) + { + // We should never not find the record, since we check for exists above! + Plugin.StaticLogger.LogWarning($"Should have found {playerHostName} in player_name table but did not!"); + } } - } - private string GetLatestNameForCharacterId(string playerHostName) - { - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"GetLatestNameForCharacterId {playerHostName} begin"); } + // Some manual query business to grab the name from the Join table. This section should only get reached for old records, + // where the player has not logged in for a while. var nameQuery = JoinCollection.Query() - .Where(x => x.PlayerId == playerHostName) + .Where(x => x.PlayerId.Equals(playerHostName)) .OrderByDescending("Date") .Select("$.Name") .ToList(); if (nameQuery.Count == 0) { - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"GetLatestNameForCharacterId {playerHostName} result = NONE"); } + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug($"GetLatestNameForCharacterId {playerHostName} result = NONE"); + } return "undefined"; } - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"nameQuery has {nameQuery.Count} results"); } + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug($"nameQuery has {nameQuery.Count} results"); + } + // simplify results to single record var result = nameQuery[0]; - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"GetLatestNameForCharacterId {playerHostName} result = {result}"); } - return result["Name"].AsString; - } - - private List ConvertBsonDocumentCountToDotNet(List bsonDocuments) - { - List results = new List(); - - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"ConvertBsonDocumentCountToDotNet r={bsonDocuments.Count}"); } - foreach (BsonDocument doc in bsonDocuments) + if (Plugin.StaticConfig.DebugDatabaseMethods) { - if (!doc.ContainsKey("Count")) - { - continue; - } - if (doc.ContainsKey("Name")) - { - results.Add(new CountResult( - doc["Name"].AsString, - doc["Count"].AsInt32 - )); - } - else if (doc.ContainsKey("NamePlayer")) - { - results.Add(new CountResult( - doc["NamePlayer"]["Name"].AsString, - doc["Count"].AsInt32 - )); - } - else if (doc.ContainsKey("Player")) - { - if (!doc["Player"].IsNull) - { - results.Add(new CountResult( - GetLatestNameForCharacterId(doc["Player"]), - doc["Count"].AsInt32 - )); - } - } + Plugin.StaticLogger.LogDebug($"GetLatestNameForCharacterId {playerHostName} result = {result}"); } - return results; + + Task.Run(() => + { + // Insert the player name, since we didn't have it in the player_name database! + EnsurePlayerNameRecorded(result["Name"], playerHostName); + }).ConfigureAwait(false); + + return result["Name"].AsString; } + + /// + /// Get totals for each player's tracked records + /// + /// Record type to return sums for + /// List of results with character names and totals public List CountAllRecordsGrouped(string key) { switch (key) @@ -192,12 +307,250 @@ public List CountAllRecordsGrouped(string key) return CountAllRecordsGrouped(PingCollection); case Categories.Shout: return CountAllRecordsGrouped(ShoutCollection); + case Categories.TimeOnline: + return AllTimeOnlineRecordsGrouped(); default: - Plugin.StaticLogger.LogDebug($"RetrieveAllRecordsGroupByName, invalid key '{key}'"); + Plugin.StaticLogger.LogDebug($"CountAllRecordsGrouped, invalid key '{key}'"); return new List(); } } + private List AllTimeOnlineRecordsGrouped() + { + + PlayerToName[] players = PlayerToNameCollection.Query().ToArray(); + List results = new List(); + + foreach (PlayerToName player in players) + { + // Create a spot to record total online time for player + Tuple playerTuple = Tuple.Create(player.PlayerId, player.CharacterName); + System.TimeSpan onlineTime = System.TimeSpan.FromSeconds(0.0); + + // Grab joins and leaves for player + SimpleStat[] joins = JoinCollection.Query().Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName).ToArray(); + SimpleStat[] leaves = LeaveCollection.Query().Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName).ToArray(); + + // Compare their lengths + Plugin.StaticLogger.LogDebug($"{player.PlayerId} as {player.CharacterName} has {joins.Length} joins, {leaves.Length}"); + int sessionDifference = joins.Length - leaves.Length; + if (sessionDifference > 1) + { + Plugin.StaticLogger.LogDebug($"{sessionDifference} more joins than leaves, timeOnline likely to be very inaccurate"); + } + // Should either be equal to joins.Length or joins.Length - 1 (basically leaves.Length) + int travelableLength = joins.Length - sessionDifference; + + // Add up time between each one. + for (int i = 0; i < travelableLength; i++) + { + // Get the dates + System.DateTime login = joins[i].Date; + System.DateTime logout = leaves[i].Date; + + // Get the time between them + System.TimeSpan difference = logout.Subtract(login); + + // Add that time to the player's total + onlineTime.Add(difference); + } + + // Total time is then stored + Plugin.StaticLogger.LogDebug($"{onlineTime.ToString()} total online time."); + + // Append to list + results.Add(new CountResult(player.CharacterName, (int)onlineTime.TotalSeconds)); + } + + Task.Run(() => + { + DiscordApi.SendMessage(JsonConvert.SerializeObject(results)); + }); + + return results; + } + + /// + /// Provides time online in seconds for all players within the date range provided. By default, it includes the start date. + /// + /// This looks through the provided simple stat table and counts up time differences between joins and leaves. + /// + /// Date to start including records from + /// Date to stop including records before + /// Whether to include the start date or not in the returned results. If true, it will use `>=` for the startDate comparison; otherwise `>`. + /// Whether to include the end date or not in the returned results. If true, it will use `<=` for the startDate comparison; otherwise `<`. + /// List of counts with CharacterName and Total (x) for the provided SimpleStat collection. + private List TimeOnlineRecordsWhereDate(System.DateTime startDate, System.DateTime endDate, bool inclusiveStart = true, bool inclusiveEnd = true) + { + + PlayerToName[] players = PlayerToNameCollection.Query().ToArray(); + List results = new List(); + + foreach (PlayerToName player in players) + { + // Create a spot to record total online time for player + Tuple playerTuple = Tuple.Create(player.PlayerId, player.CharacterName); + System.TimeSpan onlineTime = System.TimeSpan.FromSeconds(0.0); + + // Grab joins and leaves for player + SimpleStat[] joins; + SimpleStat[] leaves; + + + if (inclusiveStart && inclusiveEnd) + { + joins = JoinCollection.Query() + // Filter to dates inclusively + .Where(x => x.Date.Year >= startDate.Date.Year + && x.Date.Month >= startDate.Date.Month + && x.Date.Day >= startDate.Date.Day + && x.Date.Year <= endDate.Date.Year + && x.Date.Month <= endDate.Date.Month + && x.Date.Day <= endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + leaves = LeaveCollection.Query() + // Filter to dates inclusively + .Where(x => x.Date.Year >= startDate.Date.Year + && x.Date.Month >= startDate.Date.Month + && x.Date.Day >= startDate.Date.Day + && x.Date.Year <= endDate.Date.Year + && x.Date.Month <= endDate.Date.Month + && x.Date.Day <= endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + + } + else if (inclusiveEnd) + { + joins = JoinCollection.Query() + // Filter to dates: end date inclusively, start date exclusively + .Where(x => x.Date.Year > startDate.Date.Year + && x.Date.Month > startDate.Date.Month + && x.Date.Day > startDate.Date.Day + && x.Date.Year <= endDate.Date.Year + && x.Date.Month <= endDate.Date.Month + && x.Date.Day <= endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + leaves = LeaveCollection.Query() + // Filter to dates: end date inclusively, start date exclusively + .Where(x => x.Date.Year > startDate.Date.Year + && x.Date.Month > startDate.Date.Month + && x.Date.Day > startDate.Date.Day + && x.Date.Year <= endDate.Date.Year + && x.Date.Month <= endDate.Date.Month + && x.Date.Day <= endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + } + else if (inclusiveStart) + { + joins = JoinCollection.Query() + // Filter to dates: start date inclusively, end date exclusively + .Where(x => x.Date.Year >= startDate.Date.Year + && x.Date.Month >= startDate.Date.Month + && x.Date.Day >= startDate.Date.Day + && x.Date.Year < endDate.Date.Year + && x.Date.Month < endDate.Date.Month + && x.Date.Day < endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + leaves = LeaveCollection.Query() + // Filter to dates: start date inclusively, end date exclusively + .Where(x => x.Date.Year >= startDate.Date.Year + && x.Date.Month >= startDate.Date.Month + && x.Date.Day >= startDate.Date.Day + && x.Date.Year < endDate.Date.Year + && x.Date.Month < endDate.Date.Month + && x.Date.Day < endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + } + else + { + joins = JoinCollection.Query() + // Filter to dates exclusively + .Where(x => x.Date.Year > startDate.Date.Year + && x.Date.Month > startDate.Date.Month + && x.Date.Day > startDate.Date.Day + && x.Date.Year < endDate.Date.Year + && x.Date.Month < endDate.Date.Month + && x.Date.Day < endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + leaves = LeaveCollection.Query() + // Filter to dates exclusively + .Where(x => x.Date.Year > startDate.Date.Year + && x.Date.Month > startDate.Date.Month + && x.Date.Day > startDate.Date.Day + && x.Date.Year < endDate.Date.Year + && x.Date.Month < endDate.Date.Month + && x.Date.Day < endDate.Date.Day) + // Filter to player + .Where(x => x.PlayerId == player.PlayerId && x.Name == player.CharacterName) + .ToArray(); + } + + + // Compare their lengths + Plugin.StaticLogger.LogDebug($"{player.PlayerId} as {player.CharacterName} has {joins.Length} joins, {leaves.Length}"); + int sessionDifference = joins.Length - leaves.Length; + if (sessionDifference > 1) + { + Plugin.StaticLogger.LogDebug($"{sessionDifference} more joins than leaves, timeOnline likely to be very inaccurate"); + } + // Should either be equal to joins.Length or joins.Length - 1 (basically leaves.Length) + int travelableLength = joins.Length - sessionDifference; + + // Add up time between each one. + for (int i = 0; i < travelableLength; i++) + { + // Get the dates + System.DateTime login = joins[i].Date; + System.DateTime logout = leaves[i].Date; + + // Get the time between them + System.TimeSpan difference = logout.Subtract(login); + + // Add that time to the player's total + onlineTime.Add(difference); + } + + // Total time is then stored + Plugin.StaticLogger.LogDebug($"{onlineTime.ToString()} total online time."); + + // Append to list + results.Add(new CountResult(player.CharacterName, (int)onlineTime.TotalSeconds)); + } + + Task.Run(() => + { + DiscordApi.SendMessage(JsonConvert.SerializeObject(results)); + }); + + return results; + } + + /// + /// Get the total count of records in the category for the player (by character name). + /// + /// This can be used to figure out if its the first action the player has taken. + /// + /// Returns -1 if collecting stats is disabled. + /// Returns -2 if the specified category key is invalid + /// Returns -3 if the collection in the database has an issue + /// + /// + /// + /// public int CountOfRecordsByName(string key, string playerName) { if (!Plugin.StaticConfig.CollectStatsEnabled) @@ -222,59 +575,226 @@ public int CountOfRecordsByName(string key, string playerName) } } - public int CountOfRecordsBySteamId(string key, string playerHostName) + public List CountAllRecordsGrouped(string key, LeaderBoards.TimeRange timeRange) { - if (!Plugin.StaticConfig.CollectStatsEnabled) + if (timeRange == TimeRange.AllTime) { - return -1; + return CountAllRecordsGrouped(key); } + + var BeginEndDate = DateHelper.StartEndDatesForTimeRange(timeRange); + return CountRecordsBetweenDatesGrouped(key, BeginEndDate.Item1, BeginEndDate.Item2); + } + + internal List CountRecordsBetweenDatesGrouped(string key, System.DateTime startDate, System.DateTime endDate, bool inclusiveStart = true, bool inclusiveEnd = true) + { switch (key) { case Categories.Death: - return CountOfRecordsByCharacterId(DeathCollection, playerHostName); + return CountAllRecordsGroupsWhereDate(DeathCollection, startDate, endDate, inclusiveStart, inclusiveEnd); case Categories.Join: - return CountOfRecordsByCharacterId(JoinCollection, playerHostName); + return CountAllRecordsGroupsWhereDate(JoinCollection, startDate, endDate, inclusiveStart, inclusiveEnd); case Categories.Leave: - return CountOfRecordsByCharacterId(LeaveCollection, playerHostName); + return CountAllRecordsGroupsWhereDate(LeaveCollection, startDate, endDate, inclusiveStart, inclusiveEnd); case Categories.Ping: - return CountOfRecordsByCharacterId(PingCollection, playerHostName); + return CountAllRecordsGroupsWhereDate(PingCollection, startDate, endDate, inclusiveStart, inclusiveEnd); case Categories.Shout: - return CountOfRecordsByCharacterId(ShoutCollection, playerHostName); + return CountAllRecordsGroupsWhereDate(ShoutCollection, startDate, endDate, inclusiveStart, inclusiveEnd); + case Categories.TimeOnline: + return TimeOnlineRecordsWhereDate(startDate, endDate, inclusiveStart, inclusiveEnd); default: - Plugin.StaticLogger.LogDebug($"CountOfRecordsBySteamId, invalid key '{key}'"); - return -2; + Plugin.StaticLogger.LogDebug($"CountTodaysRecordsGrouped, invalid key '{key}'"); + return new List(); } + } - public void InsertSimpleStatRecord(string key, string playerName, string playerHostName, Vector3 pos) + internal int CountOfRecordsByName(ILiteCollection collection, string playerName) { + try + { + return collection.Query() + .Where(x => x.Name.Equals(playerName)) + .Count(); + } + catch + { + Plugin.StaticLogger.LogDebug($"Error when trying to find {playerName} to count!"); + return -3; + } + } - switch (key) + /// + /// Return a list of leaders for the collection. + /// + /// This looks through the provided simple stat table and counts up results for each player using the method defined in the config. + /// + /// Simple stat collection to count player totals in + /// List of counts with CharacterName and Total (x) for the provided SimpleStat collection. + internal List CountAllRecordsGrouped(ILiteCollection collection) + { + if (Plugin.StaticConfig.DebugDatabaseMethods) { - case Categories.Death: - InsertSimpleStatRecord(DeathCollection, playerName, playerHostName, pos); - break; - case Categories.Join: - InsertSimpleStatRecord(JoinCollection, playerName, playerHostName, pos); - break; - case Categories.Leave: - InsertSimpleStatRecord(LeaveCollection, playerName, playerHostName, pos); - break; - case Categories.Ping: - InsertSimpleStatRecord(PingCollection, playerName, playerHostName, pos); - break; - case Categories.Shout: - InsertSimpleStatRecord(ShoutCollection, playerName, playerHostName, pos); - break; - default: - Plugin.StaticLogger.LogDebug($"InsertSimpleStatRecord, invalid key '{key}'"); - break; + Plugin.StaticLogger.LogDebug($"CountAllRecordsGrouped {Plugin.StaticConfig.RecordRetrievalDiscernmentMethod}"); + } + + if (collection.Count() == 0) + { + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug("Collection is empty, skipping."); + } + return new List(); + } + + // Config.RetrievalDiscernmentMethods.BySteamID by default (should be most common), conditionally check for others + string GroupByClause = "PlayerId"; + string SelectClause = "{Player: @Key, Count: Count(*)}"; + + if (Plugin.StaticConfig.RecordRetrievalDiscernmentMethod == Config.MainConfig.RetrievalDiscernmentMethods.NameAndPlayerId) + { + GroupByClause = "{Name,PlayerId}"; + SelectClause = "{NamePlayer: @Key, Count: COUNT(*)}"; + } + if (Plugin.StaticConfig.RecordRetrievalDiscernmentMethod == Config.MainConfig.RetrievalDiscernmentMethods.Name) + { + GroupByClause = "Name"; + SelectClause = "{Name: @Key, Count: Count(*)}"; + } + + var result = CountResult.ConvertFromBsonDocuments( + collection.Query() + .GroupBy(GroupByClause) + .Select(SelectClause) + .ToList() + ); + + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug($"CountAllRecordsGrouped {result.Count} records returned"); } + + return result; } - public void InsertSimpleStatRecord(string key, string playerName, string characterId) + + + /// + /// Provides count summaries for the collection within the date range provided. By default, it includes the start date. + /// + /// This looks through the provided simple stat table and counts up results for each player using the method defined in the config. + /// + /// Simple stat collection to count player totals in + /// Date to start including records from + /// Date to stop including records before + /// Whether to include the start date or not in the returned results. If true, it will use `>=` for the startDate comparison; otherwise `>`. + /// Whether to include the end date or not in the returned results. If true, it will use `<=` for the startDate comparison; otherwise `<`. + /// List of counts with CharacterName and Total (x) for the provided SimpleStat collection. + internal List CountAllRecordsGroupsWhereDate(ILiteCollection collection, System.DateTime startDate, System.DateTime endDate, bool inclusiveStart = true, bool inclusiveEnd = true) { - InsertSimpleStatRecord(key, playerName, characterId, Vector3.zero); + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug($"CountAllRecordsGroupsWhereDate {Plugin.StaticConfig.RecordRetrievalDiscernmentMethod} {startDate} {endDate}"); + } + + if (collection.Count() == 0) + { + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug("Collection is empty, skipping."); + } + return new List(); + } + + // Config.RetrievalDiscernmentMethods.BySteamID by default (should be most common), conditionally check for others + string GroupByClause = "PlayerId"; + string SelectClause = "{Player: @Key, Count: Count(*)}"; + + if (Plugin.StaticConfig.RecordRetrievalDiscernmentMethod == Config.MainConfig.RetrievalDiscernmentMethods.NameAndPlayerId) + { + GroupByClause = "{Name,PlayerId}"; + SelectClause = "{NamePlayer: @Key, Count: COUNT(*)}"; + } + if (Plugin.StaticConfig.RecordRetrievalDiscernmentMethod == Config.MainConfig.RetrievalDiscernmentMethods.Name) + { + GroupByClause = "Name"; + SelectClause = "{Name: @Key, Count: Count(*)}"; + } + + List result; + if (inclusiveStart && inclusiveEnd) + { + result = CountResult.ConvertFromBsonDocuments( + collection.Query() + // Filter to dates inclusively + .Where(x => x.Date.Year >= startDate.Date.Year + && x.Date.Month >= startDate.Date.Month + && x.Date.Day >= startDate.Date.Day + && x.Date.Year <= endDate.Date.Year + && x.Date.Month <= endDate.Date.Month + && x.Date.Day <= endDate.Date.Day) + .GroupBy(GroupByClause) + .Select(SelectClause) + .ToList() + ); + } + else if (inclusiveEnd) + { + result = CountResult.ConvertFromBsonDocuments( + collection.Query() + // Filter to dates: end date inclusively, start date exclusively + .Where(x => x.Date.Year > startDate.Date.Year + && x.Date.Month > startDate.Date.Month + && x.Date.Day > startDate.Date.Day + && x.Date.Year <= endDate.Date.Year + && x.Date.Month <= endDate.Date.Month + && x.Date.Day <= endDate.Date.Day) + .GroupBy(GroupByClause) + .Select(SelectClause) + .ToList() + ); + } + else if (inclusiveStart) + { + result = CountResult.ConvertFromBsonDocuments( + collection.Query() + // Filter to dates: start date inclusively, end date exclusively + .Where(x => x.Date.Year >= startDate.Date.Year + && x.Date.Month >= startDate.Date.Month + && x.Date.Day >= startDate.Date.Day + && x.Date.Year < endDate.Date.Year + && x.Date.Month < endDate.Date.Month + && x.Date.Day < endDate.Date.Day) + .GroupBy(GroupByClause) + .Select(SelectClause) + .ToList() + ); + } + else + { + result = CountResult.ConvertFromBsonDocuments( + collection.Query() + // Filter to dates exclusively + .Where(x => x.Date.Year > startDate.Date.Year + && x.Date.Month > startDate.Date.Month + && x.Date.Day > startDate.Date.Day + && x.Date.Year < endDate.Date.Year + && x.Date.Month < endDate.Date.Month + && x.Date.Day < endDate.Date.Day) + .GroupBy(GroupByClause) + .Select(SelectClause) + .ToList() + ); + } + + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug($"CountAllRecordsGroupsWhereDate {result.Count} records returned"); + } + + return result; + } + } } diff --git a/src/Records/RecordsHelper.cs b/src/Records/RecordsHelper.cs index a3ebcd6..5a8348c 100644 --- a/src/Records/RecordsHelper.cs +++ b/src/Records/RecordsHelper.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using DiscordConnector.LeaderBoards; namespace DiscordConnector.Records { @@ -8,81 +10,243 @@ namespace DiscordConnector.Records /// public static class Categories { + /// + /// Count of player deaths + /// public const string Death = "death"; + /// + /// Count of times player has joined + /// public const string Join = "join"; + /// + /// Count of times player has left + /// public const string Leave = "leave"; + /// + /// Count of times player has pinged the map + /// public const string Ping = "ping"; + /// + /// Count of times player has shouted + /// public const string Shout = "shout"; + /// + /// Sum of time between player join and leaves + /// + public const string TimeOnline = "time_online"; + /// + /// Categories that are known how to be queried using the + /// + /// Valid category to query public readonly static string[] All = new string[] { Death, Join, Leave, Ping, - Shout + Shout, + TimeOnline, }; } + /// + /// This class provides some convenience methods for querying the database, allowing for queries of all records or records within a date range. + /// public static class Helper { + /// + /// Retrieve the top players for the category. + /// This returns the values in descending (most to least) order. + /// + /// One of + /// Number of results to return + /// A list of players and their totals for the , in descending order. public static List TopNResultForCategory(string key, int n) { - List queryResults = Plugin.StaticDatabase.CountAllRecordsGrouped(key); - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"TopNResultForCategory {key} n={n}, results={queryResults.Count}"); } + return TopNResultForCategory(key, n, DateHelper.DummyDateTime, DateHelper.DummyDateTime); + } + + /// + /// Retrieve the top players for the category. + /// This returns the values in descending (most to least) order. + /// + /// Returns an empty list if the provided is invalid. + /// + /// One of + /// Number of results to return + /// Earliest valid date for the stat records used to gather the results + /// Latest valid date for the stat records used to gather the results + /// A list of players and their totals for the , in descending order. + public static List TopNResultForCategory(string key, int n, System.DateTime startDate, System.DateTime endDate) + { + // Validate key + if (Array.IndexOf(Records.Categories.All, key) == -1) + { + Plugin.StaticLogger.LogWarning($"Invalid key \"{key}\" when getting top {n} results."); + Plugin.StaticLogger.LogDebug("Empty list returned because of invalid key."); + return new List(); + } + + List queryResults; + + // Determine if we are getting ALL or being limited by start and end dates. + if (startDate != DateHelper.DummyDateTime && endDate != DateHelper.DummyDateTime) + { + // Get records from database for category 'key' between dates + queryResults = Plugin.StaticDatabase.CountRecordsBetweenDatesGrouped(key, startDate, endDate); + } + else + { + // Get all records from database for category 'key' + queryResults = Plugin.StaticDatabase.CountAllRecordsGrouped(key); + } + + // Check if we are debugging all database calls, and print debug + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug($"TopNResultForCategory {key} n={n}, results={queryResults.Count}"); + } + + // If the amount of results is 0, no need to process further, just return. if (queryResults.Count == 0) { return queryResults; } + // Perform sorting of results queryResults.Sort(CountResult.CompareByCount); // sorts lowest to highest queryResults.Reverse(); // Now high --> low + // If the amount of results is less than the number desired, just return it if (queryResults.Count <= n) { return queryResults; } + // Return results limited ot the number desired return queryResults.GetRange(0, n); } - public static CountResult TopResultForCategory(string key) + /// + /// Retrieve the bottom players for the category. + /// This returns the values in ascending (least to most) order. + /// + /// Returns an empty list if the provided is invalid. + /// + /// One of + /// Number of results to return + /// A list of players and their totals for the , in ascending order. + public static List BottomNResultForCategory(string key, int n) { - var results = Helper.TopNResultForCategory(key, 1); - if (results.Count == 0) - { - return new CountResult("", 0); - } - return results[0]; + return BottomNResultForCategory(key, n, DateHelper.DummyDateTime, DateHelper.DummyDateTime); } - public static List BottomNResultForCategory(string key, int n) + /// + /// Retrieve the bottom players for the category. + /// This returns the values in ascending (least to most) order. + /// + /// One of + /// Number of results to return + /// Earliest valid date for the stat records used to gather the results + /// Latest valid date for the stat records used to gather the results + /// A list of players and their totals for the , in ascending order. + public static List BottomNResultForCategory(string key, int n, System.DateTime startDate, System.DateTime endDate) { + // Validate key + if (Array.IndexOf(Records.Categories.All, key) == -1) + { + Plugin.StaticLogger.LogWarning($"Invalid key \"{key}\" when getting bottom {n} results."); + Plugin.StaticLogger.LogDebug("Empty list returned because of invalid key."); + return new List(); + } + + List queryResults; - List queryResults = Plugin.StaticDatabase.CountAllRecordsGrouped(key); - if (Plugin.StaticConfig.DebugDatabaseMethods) { Plugin.StaticLogger.LogDebug($"BottomNResultForCategory {key} n={n}, results={queryResults.Count}"); } + // Determine if we are getting ALL or being limited by start and end dates. + if (startDate != DateHelper.DummyDateTime && endDate != DateHelper.DummyDateTime) + { + // Get records from database for category 'key' between dates + queryResults = Plugin.StaticDatabase.CountRecordsBetweenDatesGrouped(key, startDate, endDate); + } + else + { + // Get all records from database for category 'key' + queryResults = Plugin.StaticDatabase.CountAllRecordsGrouped(key); + } + + // Check if we are debugging all database calls, and print debug + if (Plugin.StaticConfig.DebugDatabaseMethods) + { + Plugin.StaticLogger.LogDebug($"BottomNResultForCategory {key} n={n}, results={queryResults.Count}"); + } + + // If the amount of results is 0, no need to process further, just return. if (queryResults.Count == 0) { return queryResults; } + // Perform sorting of results queryResults.Sort(CountResult.CompareByCount); // sorts lowest to highest + // If the amount of results is less than the number desired, just return it if (queryResults.Count <= n) { return queryResults; } + // Return results limited ot the number desired return queryResults.GetRange(0, n); } - public static CountResult BottomResultForCategory(string key) + /// + /// Query the database for a count of all values for the category , limited to + /// + /// Which category to get count from + /// Time range to restrict count to + /// Number of records that fit category within the time range + internal static int CountUniquePlayers(string key, TimeRange timeRange) { - var results = Helper.BottomNResultForCategory(key, 1); - if (results.Count == 0) + // Validate key + if (Array.IndexOf(Records.Categories.All, key) == -1) { - return new CountResult("", 0); + Plugin.StaticLogger.LogWarning($"Invalid key \"{key}\" when getting total unique players."); + Plugin.StaticLogger.LogDebug("Zero returned because of invalid key."); + return 0; } - return results[0]; + + // Simplify the all time record gathering + if (timeRange == TimeRange.AllTime) + { + List results = Plugin.StaticDatabase.CountAllRecordsGrouped(key); + return results.Count; + } + + // All others expand out the time to a range for querying + Tuple dates = DateHelper.StartEndDatesForTimeRange(timeRange); + return CountUniquePlayers(key, dates.Item1, dates.Item2); + } + + /// + /// Query the database for a count of all values for the category , limited to + /// + /// Which category to get count from + /// Earliest valid date for the stat records used to gather the results + /// Latest valid date for the stat records used to gather the results + /// Number of records that fit category within the time range + internal static int CountUniquePlayers(string key, System.DateTime startDate, System.DateTime endDate) + { + // Validate key + if (Array.IndexOf(Records.Categories.All, key) == -1) + { + Plugin.StaticLogger.LogWarning($"Invalid key \"{key}\" when getting total unique players."); + Plugin.StaticLogger.LogDebug("Zero returned because of invalid key."); + return 0; + } + + List allCounted = Plugin.StaticDatabase.CountRecordsBetweenDatesGrouped(key, startDate, endDate); + + return allCounted.Count; } } } diff --git a/src/Utility.cs b/src/Utility.cs index 309d1a6..364d89e 100644 --- a/src/Utility.cs +++ b/src/Utility.cs @@ -19,7 +19,7 @@ public static string PublicIpAddress() WebRequest request = WebRequest.Create(ENDPOINT); request.Method = "GET"; - // Wrap firing the response in a TRY/CATCH and on an exception, abandon trying to retreive the public IP + // Wrap firing the response in a TRY/CATCH and on an exception, abandon trying to retrieve the public IP try { WebResponse response = request.GetResponse();