diff --git a/DiscordConnector.csproj b/DiscordConnector.csproj
index 6db13cb..b2d430b 100644
--- a/DiscordConnector.csproj
+++ b/DiscordConnector.csproj
@@ -35,7 +35,7 @@
-
+
diff --git a/Metadata/CHANGELOG.md b/Metadata/CHANGELOG.md
index f8e9eee..2fecaea 100644
--- a/Metadata/CHANGELOG.md
+++ b/Metadata/CHANGELOG.md
@@ -2,6 +2,53 @@
A full changelog for reference.
+### Version 1.5.0
+
+Features:
+
+- Using LiteDB for record storage.
+
+Because of how unreliable storing the records in a "roll-your-own"
+database with a JSON file was, and because of the increased flexibility
+in what could be stored, I've changed the storage system for the
+recorded player stats to use LiteDB. Currently this means records for
+join/leave/death/shout/ping will be timestamped, include the position of
+the event, have the player name, and the player's steamid. Hopefully
+adding this additional information will allow for more customization
+options for the users of this mod.
+
+It is set up to do a migration on first load of the updated plugin, the
+steps it follows for that is:
+
+ 1. check if records.json (or configured name) exists
+ 2. read all records from the file
+ 3. parse the records
+ 4. loop through all the records and add them to the database
+
+ Records added this way will have position of zero and a
+ steamid of 1.
+
+ 5. move the records.json file to records.json.migrated
+
+If you don't want to have it auto-migrate the records, rename your
+records.json or delete it. If the name does not match exactly it will
+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 Leaderbaord
+
+Added an inverse of the Top Player leaderboard.
+
+- Custom leaderboard heading messages
+
+Added configuration for the messages sent at the top of the leaderboard
+messages.
+
+- The variable `%PUBLICIP%` can be used in _any_ message configuration
+ now.
+
### Version 1.4.4
Fixes:
diff --git a/Metadata/DiscordConnector-Nexus.readme b/Metadata/DiscordConnector-Nexus.readme
index fac8ee7..b43b0eb 100644
--- a/Metadata/DiscordConnector-Nexus.readme
+++ b/Metadata/DiscordConnector-Nexus.readme
@@ -1,4 +1,4 @@
-DISCORD CONNECTOR - 1.4.0
+DISCORD CONNECTOR - 1.5.0
Connect your Valheim server to a Discord Webhook. Works for both dedicated and client-hosted servers. (Visit the github repo for a how-to guide.)
:: REQUIREMENTS ::
diff --git a/Metadata/README.md b/Metadata/README.md
index 0eab14e..f6d4def 100644
--- a/Metadata/README.md
+++ b/Metadata/README.md
@@ -52,59 +52,52 @@ records.json 1.2.0+ (PlayerName changed to Key)
[{"Category":"death","Values":[{"Key":"Xithyr","Value":13} ...
```
-### Version 1.4.4
-
-Fixes:
-
-- Position being sent with event messages even if event position was disabled in config
-
-### Version 1.4.3
-
-Fixes:
-
-- Event messages were sending the wrong message (start instead of end and vice-versa)
-- Event Stop messages were sending zero coordinates
-- If you had enabled first death message and death message (this is default settings), you would
-get two messages. This has been changed to merge the messages into one if both settings are on
-and it's a player's first death.
+### Version 1.5.0
Features:
-- Added toggles to enable/disable some event debug messages (all disabled by default)
-- Added a toggle to enable/disable a debug message with responses from the webhook (disabled by default)
-
-### Version 1.4.2
+- Using LiteDB for record storage.
-Fixes:
+Because of how unreliable storing the records in a "roll-your-own"
+database with a JSON file was, and because of the increased flexibility
+in what could be stored, I've changed the storage system for the
+recorded player stats to use LiteDB. Currently this means records for
+join/leave/death/shout/ping will be timestamped, include the position of
+the event, have the player name, and the player's steamid. Hopefully
+adding this additional information will allow for more customization
+options for the users of this mod.
-- Least deaths leaderboard wasn't respecting the correct config entry. (THanks @thedefside)
+It is set up to do a migration on first load of the updated plugin, the
+steps it follows for that is:
-### Version 1.4.1
+ 1. check if records.json (or configured name) exists
+ 2. read all records from the file
+ 3. parse the records
+ 4. loop through all the records and add them to the database
-Fixes:
+ Records added this way will have position of zero and a
+ steamid of 1.
-- Removed the two debug logging calls for events -- sorry for the log spam!
+ 5. move the records.json file to records.json.migrated
-### Version 1.4.0
+If you don't want to have it auto-migrate the records, rename your
+records.json or delete it. If the name does not match exactly it will
+not migrate the data.
-Features:
+For the migration steps, it will be outputting log information (at INFO
+level) with how many records were migrated and which steps completed.
-- 10 user defined variables that can be used an any messages (%VAR1% thru %VAR10%). These are set in their own configuration file,
-`games.nwest.valheim.discordconnector-variables.cfg` which will get generated first time 1.4.0 is run.
-- The position of where the player/ping/event coordinates are inserted into messages is configurable using the `%POS%` variable in
-the messages config. It won't be replaced if the "send coordinates" toggle is off for that message. If you don't include a `%POS%`
-variable, it will append the coordinates as happens with previous versions.
+- Ranked Lowest Player Leaderbaord
-Fixes:
+Added an inverse of the Top Player leaderboard.
-- Fixed an off-by-one error in the Top Players leaderboard (the default leaderboard) (Thanks @thedefside)
-- Fixed configuration not referencing proper settings (Thanks @thedefside)
-- Fixed event messages (now properly functioning on dedicated servers)
+- Custom leaderboard heading messages
-Breaking Changes:
+Added configuration for the messages sent at the top of the leaderboard
+messages.
-- If you used `%PLAYERS%` in any of the event messages, you need to remove it. With the changes required for the event messages
-functionality, it is not supportable at this time.
+- The variable `%PUBLICIP%` can be used in _any_ message configuration
+ now.
Full changelog history available on the
[Github repository](https://github.com/nwesterhausen/valheim-discordconnector/blob/main/Metadata/CHANGELOG.md)
diff --git a/Metadata/manifest.json b/Metadata/manifest.json
index c247b6f..a3c46a8 100644
--- a/Metadata/manifest.json
+++ b/Metadata/manifest.json
@@ -1,6 +1,6 @@
{
"name": "DiscordConnector",
- "version_number": "1.4.4",
+ "version_number": "1.5.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.1600"]
diff --git a/README.md b/README.md
index ff8fd4b..5c4d84f 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,10 @@ The compiled plugin will be in a zip ready for upload at `bin/DiscordConnector.z
### Dependencies
-For JSON serialization, I chose to use the System.Text.Json library which is part of
-the most recent .NET but can be used with .NET 4.8 which is used in this project.
+For JSON serialization, using Newtonsoft.Json
+
+For data storage/retrieval using [LiteDB](https://www.litedb.org/)
+(If you want to read the database file generated, you can use [LitDB Studio](https://github.com/mbdavid/LiteDB.Studio/releases/latest))
### Contributors
diff --git a/docs/changelog.md b/docs/changelog.md
index 85eddde..e9b0381 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,53 @@
A full changelog
+### Version 1.5.0
+
+Features:
+
+- Using LiteDB for record storage.
+
+Because of how unreliable storing the records in a "roll-your-own"
+database with a JSON file was, and because of the increased flexibility
+in what could be stored, I've changed the storage system for the
+recorded player stats to use LiteDB. Currently this means records for
+join/leave/death/shout/ping will be timestamped, include the position of
+the event, have the player name, and the player's steamid. Hopefully
+adding this additional information will allow for more customization
+options for the users of this mod.
+
+It is set up to do a migration on first load of the updated plugin, the
+steps it follows for that is:
+
+ 1. check if records.json (or configured name) exists
+ 2. read all records from the file
+ 3. parse the records
+ 4. loop through all the records and add them to the database
+
+ Records added this way will have position of zero and a
+ steamid of 1.
+
+ 5. move the records.json file to records.json.migrated
+
+If you don't want to have it auto-migrate the records, rename your
+records.json or delete it. If the name does not match exactly it will
+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 Leaderbaord
+
+Added an inverse of the Top Player leaderboard.
+
+- Custom leaderboard heading messages
+
+Added configuration for the messages sent at the top of the leaderboard
+messages.
+
+- The variable `%PUBLICIP%` can be used in _any_ message configuration
+ now.
+
### Version 1.4.4
Fixes:
diff --git a/src/Config/MessagesConfig.cs b/src/Config/MessagesConfig.cs
index a9b53be..a416bd6 100644
--- a/src/Config/MessagesConfig.cs
+++ b/src/Config/MessagesConfig.cs
@@ -14,6 +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";
// Server Messages
private ConfigEntry serverLaunchMessage;
@@ -42,6 +43,12 @@ internal class MessagesConfig
private ConfigEntry eventStopMessage;
private ConfigEntry eventResumedMessage;
+ // Board Messages
+ private ConfigEntry leaderboardTopPlayersMessage;
+ private ConfigEntry leaderboardBottomPlayersMessage;
+ private ConfigEntry leaderboardHighestPlayerMessage;
+ private ConfigEntry leaderboardLowestPlayerMessage;
+
public MessagesConfig(ConfigFile configFile)
{
config = configFile;
@@ -173,6 +180,28 @@ private void LoadConfig()
"The special string %EVENT_END_MSG% will be replaced with the message that is displayed on the screen when the event ends."); // + Environment.NewLine +
// "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 +
+ "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 +
+ "Include %N% to include the number of rankings returned (the configured number)");
+ leaderboardHighestPlayerMessage = config.Bind(BOARD_MESSAGES,
+ "Leaderboard Heading for Highest Player",
+ "Top Performer",
+ "Set the message that is included as a heading when this leaderboard 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",
+ "Bottom Performer",
+ "Set the message that is included as a heading when this leaderboard is sent." + Environment.NewLine +
+ "Include %N% to include the number of rankings returned (the configured number)");
+
config.Save();
}
@@ -209,6 +238,13 @@ public string ConfigAsJson()
jsonString += $"\"eventPausedMessage\":\"{eventPausedMessage.Value}\",";
jsonString += $"\"eventResumedMessage\":\"{eventResumedMessage.Value}\",";
jsonString += $"\"eventStopMessage\":\"{eventStopMessage.Value}\"";
+ jsonString += "},";
+
+ jsonString += $"\"{BOARD_MESSAGES}\":{{";
+ jsonString += $"\"leaderboardTopPlayersMessage\":\"{leaderboardTopPlayersMessage.Value}\",";
+ jsonString += $"\"leaderboardBottomPlayersMessage\":\"{leaderboardBottomPlayersMessage.Value}\",";
+ jsonString += $"\"leaderboardHighestPlayerMessage\":\"{leaderboardHighestPlayerMessage.Value}\",";
+ jsonString += $"\"leaderboardLowestPlayerMessage\":\"{leaderboardLowestPlayerMessage.Value}\"";
jsonString += "}";
jsonString += "}";
@@ -254,5 +290,10 @@ private static string GetRandomStringFromValue(ConfigEntry configEntry)
public string EventStopMesssage => GetRandomStringFromValue(eventStopMessage);
public string EventResumedMesssage => GetRandomStringFromValue(eventResumedMessage);
+ // 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 6b6cb02..4ee0934 100644
--- a/src/Config/PluginConfig.cs
+++ b/src/Config/PluginConfig.cs
@@ -158,13 +158,19 @@ public void ReloadConfig()
public bool DebugEveryEventChange => togglesConfig.DebugEveryEventChange;
public bool DebugHttpRequestResponse => togglesConfig.DebugHttpRequestResponse;
+ // Leaderboard Messages
+ public string LeaderboardTopPlayerHeading => messagesConfig.LeaderboardTopPlayerHeading;
+ public string LeaderboardBottomPlayersHeading => messagesConfig.LeaderboardBottomPlayersHeading;
+ public string LeaderboardHighestHeading => messagesConfig.LeaderboardHighestHeading;
+ public string LeaderboardLowestHeading => messagesConfig.LeaderboardLowestHeading;
+
public string ConfigAsJson()
{
string jsonString = "{";
jsonString += $"\"Config.Main\":{mainConfig.ConfigAsJson()},";
jsonString += $"\"Config.Messages\":{messagesConfig.ConfigAsJson()},";
- jsonString += $"\"Config.Toggles\":{togglesConfig.ConfigAsJson()}";
+ jsonString += $"\"Config.Toggles\":{togglesConfig.ConfigAsJson()},";
jsonString += $"\"Config.Variables\":{variableConfig.ConfigAsJson()}";
jsonString += "}";
diff --git a/src/Leaderboard/BottomPlayer.cs b/src/Leaderboard/BottomPlayer.cs
new file mode 100644
index 0000000..eaafa92
--- /dev/null
+++ b/src/Leaderboard/BottomPlayer.cs
@@ -0,0 +1,46 @@
+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.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.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/Leaderboard.cs b/src/Leaderboard/Leaderboard.cs
index d928538..f2ac408 100644
--- a/src/Leaderboard/Leaderboard.cs
+++ b/src/Leaderboard/Leaderboard.cs
@@ -1,4 +1,6 @@
-using System.Timers;
+using System;
+using System.Collections.Generic;
+using System.Timers;
namespace DiscordConnector
{
@@ -7,17 +9,37 @@ internal class Leaderboard
private Leaderboards.Base overallHighest;
private Leaderboards.Base overallLowest;
private Leaderboards.Base topPlayers;
+ private Leaderboards.Base bottomPlayers;
public Leaderboard()
{
overallHighest = new Leaderboards.OverallHighest();
overallLowest = new Leaderboards.OverallLowest();
topPlayers = new Leaderboards.TopPlayers();
+ bottomPlayers = new Leaderboards.BottomPlayers();
}
public Leaderboards.Base OverallHighest => overallHighest;
public Leaderboards.Base OverallLowest => overallLowest;
public Leaderboards.Base TopPlayers => topPlayers;
+ public Leaderboards.Base BottomPlayers => bottomPlayers;
+
+
+
+ ///
+ /// Takes a sorted list and returns a string listing each member on a line prepended with 1, 2, 3, etc.
+ ///
+ /// A pre-sorted list of CountResults.
+ /// String ready to send to discord listing each player and their value.
+ public static string RankedCountResultToString(List rankings)
+ {
+ string res = "";
+ for (int i = 0; i < rankings.Count; i++)
+ {
+ res += $"{i + 1}: {rankings[i].Name}: {rankings[i].Count}{Environment.NewLine}";
+ }
+ return res;
+ }
}
}
@@ -30,6 +52,7 @@ internal abstract class Base
///
public void SendLeaderboardOnTimer(object sender, ElapsedEventArgs elapsedEventArgs)
{
+ Plugin.StaticLogger.LogDebug($"Running the leaderboard send");
this.SendLeaderboard();
}
diff --git a/src/Leaderboard/OverallHighest.cs b/src/Leaderboard/OverallHighest.cs
index 3aeb75c..1d3e0d5 100644
--- a/src/Leaderboard/OverallHighest.cs
+++ b/src/Leaderboard/OverallHighest.cs
@@ -7,31 +7,32 @@ internal class OverallHighest : Base
{
public override void SendLeaderboard()
{
- var deathLeader = Plugin.StaticRecords.RetrieveHighest(RecordCategories.Death);
- var joinLeader = Plugin.StaticRecords.RetrieveHighest(RecordCategories.Join);
- var shoutLeader = Plugin.StaticRecords.RetrieveHighest(RecordCategories.Shout);
- var pingLeader = Plugin.StaticRecords.RetrieveHighest(RecordCategories.Ping);
+ 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.Item2 > 0)
+ if (Plugin.StaticConfig.MostDeathLeaderboardEnabled && deathLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Most Deaths", $"{deathLeader.Item1} ({deathLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Most Deaths", $"{deathLeader.Name} ({deathLeader.Count})"));
}
- if (Plugin.StaticConfig.MostSessionLeaderboardEnabled && joinLeader.Item2 > 0)
+ if (Plugin.StaticConfig.MostSessionLeaderboardEnabled && joinLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Most Sessions", $"{joinLeader.Item1} ({joinLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Most Sessions", $"{joinLeader.Name} ({joinLeader.Count})"));
}
- if (Plugin.StaticConfig.MostShoutLeaderboardEnabled && shoutLeader.Item2 > 0)
+ if (Plugin.StaticConfig.MostShoutLeaderboardEnabled && shoutLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Most Shouts", $"{shoutLeader.Item1} ({shoutLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Most Shouts", $"{shoutLeader.Name} ({shoutLeader.Count})"));
}
- if (Plugin.StaticConfig.MostPingLeaderboardEnabled && pingLeader.Item2 > 0)
+ if (Plugin.StaticConfig.MostPingLeaderboardEnabled && pingLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Most Pings", $"{pingLeader.Item1} ({pingLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Most Pings", $"{pingLeader.Name} ({pingLeader.Count})"));
}
if (leaderFields.Count > 0)
{
- DiscordApi.SendMessageWithFields("Current Highest stat leader board:", leaderFields);
+ string discordContent = MessageTransformer.FormatLeaderboardHeader(Plugin.StaticConfig.LeaderboardHighestHeading);
+ DiscordApi.SendMessageWithFields(discordContent, leaderFields);
}
else
{
diff --git a/src/Leaderboard/OverallLowest.cs b/src/Leaderboard/OverallLowest.cs
index 6ad46e1..32daefc 100644
--- a/src/Leaderboard/OverallLowest.cs
+++ b/src/Leaderboard/OverallLowest.cs
@@ -7,31 +7,32 @@ internal class OverallLowest : Base
{
public override void SendLeaderboard()
{
- var deathLeader = Plugin.StaticRecords.RetrieveLowest(RecordCategories.Death);
- var joinLeader = Plugin.StaticRecords.RetrieveLowest(RecordCategories.Join);
- var shoutLeader = Plugin.StaticRecords.RetrieveLowest(RecordCategories.Shout);
- var pingLeader = Plugin.StaticRecords.RetrieveLowest(RecordCategories.Ping);
+ 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.Item2 > 0)
+ if (Plugin.StaticConfig.LeastDeathLeaderboardEnabled && deathLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Least Deaths", $"{deathLeader.Item1} ({deathLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Least Deaths", $"{deathLeader.Name} ({deathLeader.Count})"));
}
- if (Plugin.StaticConfig.LeastSessionLeaderboardEnabled && joinLeader.Item2 > 0)
+ if (Plugin.StaticConfig.LeastSessionLeaderboardEnabled && joinLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Least Sessions", $"{joinLeader.Item1} ({joinLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Least Sessions", $"{joinLeader.Name} ({joinLeader.Count})"));
}
- if (Plugin.StaticConfig.LeastShoutLeaderboardEnabled && shoutLeader.Item2 > 0)
+ if (Plugin.StaticConfig.LeastShoutLeaderboardEnabled && shoutLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Least Shouts", $"{shoutLeader.Item1} ({shoutLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Least Shouts", $"{shoutLeader.Name} ({shoutLeader.Count})"));
}
- if (Plugin.StaticConfig.LeastPingLeaderboardEnabled && pingLeader.Item2 > 0)
+ if (Plugin.StaticConfig.LeastPingLeaderboardEnabled && pingLeader.Count > 0)
{
- leaderFields.Add(Tuple.Create("Least Pings", $"{pingLeader.Item1} ({pingLeader.Item2})"));
+ leaderFields.Add(Tuple.Create("Least Pings", $"{pingLeader.Name} ({pingLeader.Count})"));
}
if (leaderFields.Count > 0)
{
- DiscordApi.SendMessageWithFields("Current leaderboard least stats:", leaderFields);
+ string discordContent = MessageTransformer.FormatLeaderboardHeader(Plugin.StaticConfig.LeaderboardLowestHeading);
+ DiscordApi.SendMessageWithFields(discordContent, leaderFields);
}
else
{
diff --git a/src/Leaderboard/TopPlayers.cs b/src/Leaderboard/TopPlayers.cs
index 31d2d1a..4ed89e7 100644
--- a/src/Leaderboard/TopPlayers.cs
+++ b/src/Leaderboard/TopPlayers.cs
@@ -7,59 +7,38 @@ internal class TopPlayers : Base
{
public override void SendLeaderboard()
{
- var deaths = Plugin.StaticRecords.RetrieveAll(RecordCategories.Death);
- var sessions = Plugin.StaticRecords.RetrieveAll(RecordCategories.Join);
- var shouts = Plugin.StaticRecords.RetrieveAll(RecordCategories.Shout);
- var pings = Plugin.StaticRecords.RetrieveAll(RecordCategories.Ping);
-
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)
{
- deaths.Sort(Plugin.StaticRecords.HighToLowSort);
- leaderFields.Add(Tuple.Create("Top Deaths", TopPlayersFormater(deaths.ToArray())));
+ leaderFields.Add(Tuple.Create("Deaths", Leaderboard.RankedCountResultToString(deaths)));
}
if (Plugin.StaticConfig.RankedSessionLeaderboardEnabled && sessions.Count > 0)
{
- sessions.Sort(Plugin.StaticRecords.HighToLowSort);
- leaderFields.Add(Tuple.Create("Top Sessions", TopPlayersFormater(sessions.ToArray())));
+ leaderFields.Add(Tuple.Create("Sessions", Leaderboard.RankedCountResultToString(sessions)));
}
if (Plugin.StaticConfig.RankedShoutLeaderboardEnabled && shouts.Count > 0)
{
- shouts.Sort(Plugin.StaticRecords.HighToLowSort);
- leaderFields.Add(Tuple.Create("Top Shouts", TopPlayersFormater(shouts.ToArray())));
+ leaderFields.Add(Tuple.Create("Shouts", Leaderboard.RankedCountResultToString(shouts)));
}
if (Plugin.StaticConfig.RankedPingLeaderboardEnabled && pings.Count > 0)
{
- pings.Sort(Plugin.StaticRecords.HighToLowSort);
- leaderFields.Add(Tuple.Create("Top Pings", TopPlayersFormater(pings.ToArray())));
+ leaderFields.Add(Tuple.Create("Pings", Leaderboard.RankedCountResultToString(pings)));
}
if (leaderFields.Count > 0)
{
- DiscordApi.SendMessageWithFields("Current top player leaderboard:", leaderFields);
+ 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.");
}
}
-
- ///
- /// Takes a sorted array and returns a string combining the top n results (n as defined in config).
- ///
- /// A pre-sorted array of (playername, value) Tuples.
- /// String ready to send to discord listing each player and their value.
- private string TopPlayersFormater(Tuple[] sortedTopPlayers)
- {
- string result = "";
- for (int i = 0; i < Plugin.StaticConfig.IncludedNumberOfRankings; i++)
- {
- if (i < sortedTopPlayers.Length)
- {
- Tuple player = sortedTopPlayers[i];
- result += $"{i + 1}: {player.Item1}: {player.Item2}{Environment.NewLine}";
- }
- }
- return result;
- }
}
}
diff --git a/src/MessageTransformer.cs b/src/MessageTransformer.cs
index 84bfcfd..6d3aa86 100644
--- a/src/MessageTransformer.cs
+++ b/src/MessageTransformer.cs
@@ -22,6 +22,7 @@ internal static class MessageTransformer
private const string EVENT_END_MSG = "%EVENT_END_MSG%";
private const string EVENT_MSG = "%EVENT_MSG%";
private const string EVENT_PLAYERS = "%PLAYERS%";
+ private const string N = "%N%";
private static string ReplaceVariables(string rawMessage)
{
return rawMessage
@@ -34,12 +35,12 @@ private static string ReplaceVariables(string rawMessage)
.Replace(VAR_6, Plugin.StaticConfig.UserVariable6)
.Replace(VAR_7, Plugin.StaticConfig.UserVariable7)
.Replace(VAR_8, Plugin.StaticConfig.UserVariable8)
- .Replace(VAR_9, Plugin.StaticConfig.UserVariable9);
+ .Replace(VAR_9, Plugin.StaticConfig.UserVariable9)
+ .Replace(PUBLIC_IP, Plugin.PublicIpAddress);
}
public static string FormatServerMessage(string rawMessage)
{
- return MessageTransformer.ReplaceVariables(rawMessage)
- .Replace(PUBLIC_IP, Plugin.PublicIpAddress);
+ return MessageTransformer.ReplaceVariables(rawMessage);
}
public static string FormatPlayerMessage(string rawMessage, string playerName)
@@ -95,5 +96,15 @@ 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)
+ {
+ return MessageTransformer.ReplaceVariables(rawMessage);
+ }
+
+ public static string FormatLeaderboardHeader(string rawMessage, int n)
+ {
+ return MessageTransformer.ReplaceVariables(rawMessage)
+ .Replace(N, n.ToString());
+ }
}
}
diff --git a/src/Patches/ChatPatches.cs b/src/Patches/ChatPatches.cs
index 4214da4..526c9e6 100644
--- a/src/Patches/ChatPatches.cs
+++ b/src/Patches/ChatPatches.cs
@@ -16,10 +16,16 @@ private static void Prefix(ref GameObject go, ref long senderID, ref Vector3 pos
Plugin.StaticLogger.LogInfo($"Ignored shout from user on muted list. User: {user} Shout: {text}. Index {Plugin.StaticConfig.MutedPlayers.IndexOf(user)}");
return;
}
+ ulong peerSteamID = 0;
+ ZNetPeer peerInstance = ZNet.instance.GetPeerByPlayerName(user);
+ if (peerInstance != null)
+ {
+ peerSteamID = ((ZSteamSocket)peerInstance.m_socket).GetPeerID().m_SteamID; // Get the SteamID from peer.
+ }
switch (type)
{
case Talker.Type.Ping:
- if (Plugin.StaticConfig.AnnouncePlayerFirstPingEnabled && Plugin.StaticRecords.Retrieve(RecordCategories.Ping, user) == 0)
+ if (Plugin.StaticConfig.AnnouncePlayerFirstPingEnabled && Plugin.StaticDatabase.CountOfRecordsByName(Records.Categories.Ping, user) == 0)
{
DiscordApi.SendMessage(
MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstPingMessage, user)
@@ -27,7 +33,7 @@ private static void Prefix(ref GameObject go, ref long senderID, ref Vector3 pos
}
if (Plugin.StaticConfig.StatsPingEnabled)
{
- Plugin.StaticRecords.Store(RecordCategories.Ping, user, 1);
+ Plugin.StaticDatabase.InsertSimpleStatRecord(Records.Categories.Ping, user, peerSteamID, pos);
}
if (Plugin.StaticConfig.ChatPingEnabled)
{
@@ -56,7 +62,7 @@ private static void Prefix(ref GameObject go, ref long senderID, ref Vector3 pos
{
if (!Plugin.IsHeadless())
{
- if (Plugin.StaticConfig.AnnouncePlayerFirstJoinEnabled && Plugin.StaticRecords.Retrieve(RecordCategories.Join, user) == 0)
+ if (Plugin.StaticConfig.AnnouncePlayerFirstJoinEnabled && Plugin.StaticDatabase.CountOfRecordsByName(Records.Categories.Join, user) == 0)
{
DiscordApi.SendMessage(
MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstJoinMessage, user)
@@ -64,7 +70,7 @@ private static void Prefix(ref GameObject go, ref long senderID, ref Vector3 pos
}
if (Plugin.StaticConfig.StatsJoinEnabled)
{
- Plugin.StaticRecords.Store(RecordCategories.Join, user, 1);
+ Plugin.StaticDatabase.InsertSimpleStatRecord(Records.Categories.Join, user, peerSteamID, pos);
}
if (Plugin.StaticConfig.PlayerJoinMessageEnabled)
{
@@ -91,7 +97,7 @@ private static void Prefix(ref GameObject go, ref long senderID, ref Vector3 pos
}
else
{
- if (Plugin.StaticConfig.AnnouncePlayerFirstShoutEnabled && Plugin.StaticRecords.Retrieve(RecordCategories.Shout, user) == 0)
+ if (Plugin.StaticConfig.AnnouncePlayerFirstShoutEnabled && Plugin.StaticDatabase.CountOfRecordsByName(Records.Categories.Shout, user) == 0)
{
DiscordApi.SendMessage(
MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstShoutMessage, user, text)
@@ -99,7 +105,7 @@ private static void Prefix(ref GameObject go, ref long senderID, ref Vector3 pos
}
if (Plugin.StaticConfig.StatsShoutEnabled)
{
- Plugin.StaticRecords.Store(RecordCategories.Shout, user, 1);
+ Plugin.StaticDatabase.InsertSimpleStatRecord(Records.Categories.Shout, user, peerSteamID, pos);
}
if (Plugin.StaticConfig.ChatShoutEnabled)
{
diff --git a/src/Patches/ZNetPatches.cs b/src/Patches/ZNetPatches.cs
index 940963c..6ff36e3 100644
--- a/src/Patches/ZNetPatches.cs
+++ b/src/Patches/ZNetPatches.cs
@@ -50,6 +50,7 @@ private static void Postfix(ZRpc rpc, ZDOID characterID)
{
return;
}
+ ulong peerSteamID = ((ZSteamSocket)peer.m_socket).GetPeerID().m_SteamID; // Get the SteamID from peer.
if (joinedPlayers.IndexOf(peer.m_uid) >= 0)
{
// Seems that player is dead if character ZDOID id is 0
@@ -61,50 +62,50 @@ private static void Postfix(ZRpc rpc, ZDOID characterID)
}
if (Plugin.StaticConfig.PlayerDeathMessageEnabled)
{
- if (Plugin.StaticConfig.AnnouncePlayerFirstDeathEnabled && Plugin.StaticRecords.Retrieve(RecordCategories.Death, peer.m_playerName) == 0)
- {
- string firstDeathMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstDeathMessage, peer.m_playerName);
- if (Plugin.StaticConfig.PlayerDeathPosEnabled)
- {
- if (Plugin.StaticConfig.DiscordEmbedsEnabled || !firstDeathMessage.Contains("%POS%"))
- {
- DiscordApi.SendMessage(firstDeathMessage, peer.m_refPos);
- }
- else
- {
- DiscordApi.SendMessage(MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstDeathMessage, peer.m_playerName, peer.m_refPos));
- }
- }
- else
- {
- DiscordApi.SendMessage(firstDeathMessage);
- }
+ if (Plugin.StaticConfig.AnnouncePlayerFirstDeathEnabled && Plugin.StaticDatabase.CountOfRecordsByName(Records.Categories.Death, peer.m_playerName) == 0)
+ {
+ string firstDeathMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstDeathMessage, peer.m_playerName);
+ if (Plugin.StaticConfig.PlayerDeathPosEnabled)
+ {
+ if (Plugin.StaticConfig.DiscordEmbedsEnabled || !firstDeathMessage.Contains("%POS%"))
+ {
+ DiscordApi.SendMessage(firstDeathMessage, peer.m_refPos);
+ }
+ else
+ {
+ DiscordApi.SendMessage(MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstDeathMessage, peer.m_playerName, peer.m_refPos));
+ }
+ }
+ else
+ {
+ DiscordApi.SendMessage(firstDeathMessage);
+ }
}
- else
- {
- string message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.DeathMessage, peer.m_playerName);
- if (Plugin.StaticConfig.PlayerDeathPosEnabled)
- {
- if (Plugin.StaticConfig.DiscordEmbedsEnabled || !message.Contains("%POS%"))
- {
- DiscordApi.SendMessage(message, peer.m_refPos);
- }
- else
- {
- message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.DeathMessage, peer.m_playerName, peer.m_refPos);
- DiscordApi.SendMessage(message);
- }
- }
- else
- {
- DiscordApi.SendMessage(message);
+ else
+ {
+ string message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.DeathMessage, peer.m_playerName);
+ if (Plugin.StaticConfig.PlayerDeathPosEnabled)
+ {
+ if (Plugin.StaticConfig.DiscordEmbedsEnabled || !message.Contains("%POS%"))
+ {
+ DiscordApi.SendMessage(message, peer.m_refPos);
+ }
+ else
+ {
+ message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.DeathMessage, peer.m_playerName, peer.m_refPos);
+ DiscordApi.SendMessage(message);
+ }
+ }
+ else
+ {
+ DiscordApi.SendMessage(message);
}
}
- }
-
+ }
+
if (Plugin.StaticConfig.StatsDeathEnabled)
{
- Plugin.StaticRecords.Store(RecordCategories.Death, peer.m_playerName, 1);
+ Plugin.StaticDatabase.InsertSimpleStatRecord(Records.Categories.Death, peer.m_playerName, peerSteamID, peer.m_refPos);
}
}
else
@@ -114,26 +115,26 @@ private static void Postfix(ZRpc rpc, ZDOID characterID)
Plugin.StaticLogger.LogDebug($"Added player {peer.m_uid} ({peer.m_playerName}) to joined player list.");
if (Plugin.StaticConfig.PlayerJoinMessageEnabled)
{
- if (Plugin.StaticConfig.AnnouncePlayerFirstJoinEnabled && Plugin.StaticRecords.Retrieve(RecordCategories.Join, peer.m_playerName) == 0)
- {
- string firstJoinMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstJoinMessage, peer.m_playerName);
- if (Plugin.StaticConfig.PlayerJoinPosEnabled)
- {
- if (Plugin.StaticConfig.DiscordEmbedsEnabled || !firstJoinMessage.Contains("%POS%"))
- {
- DiscordApi.SendMessage(firstJoinMessage, peer.m_refPos);
- }
- else
- {
- firstJoinMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.JoinMessage, peer.m_playerName, peer.m_refPos);
- DiscordApi.SendMessage(firstJoinMessage);
- }
- }
- else
- {
- DiscordApi.SendMessage(
- MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstJoinMessage, peer.m_playerName));
- }
+ if (Plugin.StaticConfig.AnnouncePlayerFirstJoinEnabled && Plugin.StaticDatabase.CountOfRecordsByName(Records.Categories.Join, peer.m_playerName) == 0)
+ {
+ string firstJoinMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstJoinMessage, peer.m_playerName);
+ if (Plugin.StaticConfig.PlayerJoinPosEnabled)
+ {
+ if (Plugin.StaticConfig.DiscordEmbedsEnabled || !firstJoinMessage.Contains("%POS%"))
+ {
+ DiscordApi.SendMessage(firstJoinMessage, peer.m_refPos);
+ }
+ else
+ {
+ firstJoinMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.JoinMessage, peer.m_playerName, peer.m_refPos);
+ DiscordApi.SendMessage(firstJoinMessage);
+ }
+ }
+ else
+ {
+ DiscordApi.SendMessage(
+ MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstJoinMessage, peer.m_playerName));
+ }
}
string message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.JoinMessage, peer.m_playerName);
if (Plugin.StaticConfig.PlayerJoinPosEnabled)
@@ -142,21 +143,21 @@ private static void Postfix(ZRpc rpc, ZDOID characterID)
{
DiscordApi.SendMessage(message, peer.m_refPos);
}
- else
- {
- message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.JoinMessage, peer.m_playerName, peer.m_refPos);
- DiscordApi.SendMessage(message);
- }
+ else
+ {
+ message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.JoinMessage, peer.m_playerName, peer.m_refPos);
+ DiscordApi.SendMessage(message);
+ }
}
else
{
DiscordApi.SendMessage(message);
}
- }
-
+ }
+
if (Plugin.StaticConfig.StatsJoinEnabled)
{
- Plugin.StaticRecords.Store(RecordCategories.Join, peer.m_playerName, 1);
+ Plugin.StaticDatabase.InsertSimpleStatRecord(Records.Categories.Join, peer.m_playerName, peerSteamID, peer.m_refPos);
}
}
}
@@ -172,25 +173,25 @@ private static void Prefix(ZRpc rpc)
{
if (Plugin.StaticConfig.PlayerLeaveMessageEnabled)
{
- if (Plugin.StaticConfig.AnnouncePlayerFirstLeaveEnabled && Plugin.StaticRecords.Retrieve(RecordCategories.Leave, peer.m_playerName) == 0)
- {
- string firstLeaveMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstLeaveMessage, peer.m_playerName);
- if (Plugin.StaticConfig.PlayerLeavePosEnabled)
- {
- if (Plugin.StaticConfig.DiscordEmbedsEnabled || !firstLeaveMessage.Contains("%POS%"))
- {
- DiscordApi.SendMessage(firstLeaveMessage, peer.m_refPos);
- }
- else
- {
- firstLeaveMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstLeaveMessage, peer.m_playerName, peer.m_refPos);
- DiscordApi.SendMessage(firstLeaveMessage);
- }
- }
- else
- {
- DiscordApi.SendMessage(firstLeaveMessage);
- }
+ if (Plugin.StaticConfig.AnnouncePlayerFirstLeaveEnabled && Plugin.StaticDatabase.CountOfRecordsByName(Records.Categories.Leave, peer.m_playerName) == 0)
+ {
+ string firstLeaveMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstLeaveMessage, peer.m_playerName);
+ if (Plugin.StaticConfig.PlayerLeavePosEnabled)
+ {
+ if (Plugin.StaticConfig.DiscordEmbedsEnabled || !firstLeaveMessage.Contains("%POS%"))
+ {
+ DiscordApi.SendMessage(firstLeaveMessage, peer.m_refPos);
+ }
+ else
+ {
+ firstLeaveMessage = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.PlayerFirstLeaveMessage, peer.m_playerName, peer.m_refPos);
+ DiscordApi.SendMessage(firstLeaveMessage);
+ }
+ }
+ else
+ {
+ DiscordApi.SendMessage(firstLeaveMessage);
+ }
}
string message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.LeaveMessage, peer.m_playerName);
@@ -200,22 +201,23 @@ private static void Prefix(ZRpc rpc)
{
DiscordApi.SendMessage(message, peer.m_refPos);
}
- else
- {
- message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.LeaveMessage, peer.m_playerName, peer.m_refPos);
- DiscordApi.SendMessage(message);
- }
-
+ else
+ {
+ message = MessageTransformer.FormatPlayerMessage(Plugin.StaticConfig.LeaveMessage, peer.m_playerName, peer.m_refPos);
+ DiscordApi.SendMessage(message);
+ }
+
}
else
{
DiscordApi.SendMessage(message);
}
- }
-
+ }
+
if (Plugin.StaticConfig.StatsLeaveEnabled)
{
- Plugin.StaticRecords.Store(RecordCategories.Leave, peer.m_playerName, 1);
+ ulong peerSteamID = ((ZSteamSocket)peer.m_socket).GetPeerID().m_SteamID; // Get the SteamID from peer.
+ Plugin.StaticDatabase.InsertSimpleStatRecord(Records.Categories.Leave, peer.m_playerName, peerSteamID, peer.m_refPos);
}
}
}
diff --git a/src/Plugin.cs b/src/Plugin.cs
index 4c345e9..6d2dd4e 100644
--- a/src/Plugin.cs
+++ b/src/Plugin.cs
@@ -1,5 +1,6 @@
using BepInEx;
using BepInEx.Logging;
+using DiscordConnector.Records;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Rendering;
@@ -11,7 +12,8 @@ public class Plugin : BaseUnityPlugin
{
internal static ManualLogSource StaticLogger;
internal static PluginConfig StaticConfig;
- internal static Records StaticRecords;
+ internal static RecordsOld StaticRecords;
+ internal static Database StaticDatabase;
internal static Leaderboard StaticLeaderboards;
internal static EventWatcher StaticEventWatcher;
internal static string PublicIpAddress;
@@ -21,8 +23,11 @@ public Plugin()
{
StaticLogger = Logger;
StaticConfig = new PluginConfig(Config);
- StaticRecords = new Records(Paths.GameRootPath);
+ StaticDatabase = new Records.Database(Paths.GameRootPath);
StaticLeaderboards = new Leaderboard();
+
+ //! Remove in next major version
+ StaticRecords = new RecordsOld(Paths.GameRootPath);
}
private void Awake()
@@ -49,8 +54,10 @@ private void Awake()
leaderboardTimer.Elapsed += StaticLeaderboards.OverallHighest.SendLeaderboardOnTimer;
leaderboardTimer.Elapsed += StaticLeaderboards.OverallLowest.SendLeaderboardOnTimer;
leaderboardTimer.Elapsed += StaticLeaderboards.TopPlayers.SendLeaderboardOnTimer;
+ leaderboardTimer.Elapsed += StaticLeaderboards.BottomPlayers.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();
}
@@ -62,6 +69,7 @@ private void Awake()
private void OnDestroy()
{
_harmony.UnpatchSelf();
+ StaticDatabase.Dispose();
}
///
diff --git a/src/PluginInfo.cs b/src/PluginInfo.cs
index c5e709c..740fd6f 100644
--- a/src/PluginInfo.cs
+++ b/src/PluginInfo.cs
@@ -17,7 +17,7 @@ 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 = "1.4.4";
+ public const string PLUGIN_VERSION = "1.5.0";
public const string PLUGIN_REPO_SHORT = "github: nwesterhausen/valheim-discordconnector";
public const string PLUGIN_AUTHOR = "Nicholas Westerhausen";
}
diff --git a/src/Records.cs b/src/Records.cs
index d139d4b..b80dd29 100644
--- a/src/Records.cs
+++ b/src/Records.cs
@@ -1,31 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Threading.Tasks;
using Newtonsoft.Json;
namespace DiscordConnector
{
- ///
- /// These are categories used when keeping track of values in the record system. It is a simple system
- /// that currently only supports storing string:integer pairings underneath one of these categories.
- ///
- public static class RecordCategories
- {
- public const string Death = "death";
- public const string Join = "join";
- public const string Leave = "leave";
- public const string Ping = "ping";
- public const string Shout = "shout";
-
- public static string[] All = new string[] {
- Death,
- Join,
- Leave,
- Ping,
- Shout
- };
- }
///
/// The individual key:value pairings used in the record system. It supports only string:int pairings.
///
@@ -42,11 +21,12 @@ internal class Record
public string Category { get; set; }
public List Values { get; set; }
}
- class Records
+ class RecordsOld
{
private static string DEFAULT_FILENAME = "records.json";
- private string storepath;
- private bool saveEnabled;
+ private const string MIGRATED_FILENAME = "records.json.migrated";
+ private string storepath, movepath;
+ // private bool saveEnabled;
private List recordCache;
///
@@ -55,245 +35,29 @@ class Records
///
/// The directory to store the json file of records in.
/// The name of the file to use for storage.
- public Records(string basePath, string fileName = null)
+ public RecordsOld(string basePath, string fileName = null)
{
if (fileName == null)
{
fileName = DEFAULT_FILENAME;
}
storepath = Path.Combine(basePath, fileName);
- saveEnabled = true;
- PopulateCache();
- if (!Plugin.StaticConfig.CollectStatsEnabled)
- {
- Plugin.StaticLogger.LogInfo("Saving stats is disabled, nothing will be recorded.");
- }
- }
-
- ///
- /// Add to a record for under in the records database.
- /// This will not save the record if the is not one defined in RecordCategories.
- ///
- /// RecordCategories category to store the value under
- /// The player's name.
- /// How much to increase current stored value by.
- public void Store(string key, string playername, int value)
- {
- if (Plugin.StaticConfig.CollectStatsEnabled)
- {
- if (Array.IndexOf(RecordCategories.All, key) >= 0)
- {
- foreach (Record r in recordCache)
- {
- if (r.Category.Equals(key))
- {
- bool stored = false;
- foreach (RecordValue v in r.Values)
- {
- if (v.Key.Equals(playername))
- {
- v.Value += value;
- stored = true;
- }
- }
- if (!stored)
- {
- r.Values.Add(new RecordValue()
- {
- Key = playername,
- Value = value
- });
- }
- }
- }
- // After adding new data, flush data to disk.
- FlushCache().ContinueWith(
- t => Plugin.StaticLogger.LogWarning(t.Exception),
- TaskContinuationOptions.OnlyOnFaulted);
- }
- else
- {
- Plugin.StaticLogger.LogWarning($"Unable to store record of {key} for player {playername} - not considered a valid category.");
- }
- }
- }
- ///
- /// Get the value stored under at .
- ///
- /// The RecordCategories category the value is stored under
- /// The name of the player
- /// This will return 0 if there is no record found for that player. It will return -1 if the category is invalid.
- public int Retrieve(string key, string playername)
- {
- if (!Plugin.StaticConfig.CollectStatsEnabled)
- {
- return -1;
- }
- if (Array.IndexOf(RecordCategories.All, key) >= 0)
- {
- foreach (Record r in recordCache)
- {
- if (r.Category.Equals(key))
- {
- foreach (RecordValue v in r.Values)
- {
- if (v.Key.Equals(playername))
- {
- return v.Value;
- }
- }
- }
- }
- }
- Plugin.StaticLogger.LogWarning($"No stored record for player {playername} under {key}, returning default of 0.");
- return 0;
- }
-
- ///
- /// Retrieve all stored values under .
- ///
- /// RecordCategories category to retrieve stored values from
- /// A list of (playername, value) tuples. The list will have length 0 if there are no stored records.
- public List> RetrieveAll(string key)
- {
- List> results = new List>();
- if (!Plugin.StaticConfig.CollectStatsEnabled)
- {
- return results;
- }
-
- if (Array.IndexOf(RecordCategories.All, key) >= 0)
- {
- foreach (Record r in recordCache)
- {
- if (r.Category.Equals(key))
- {
- foreach (RecordValue v in r.Values)
- {
- results.Add(Tuple.Create(
- v.Key, v.Value
- ));
- }
- }
- }
- }
-
- return results;
- }
-
- ///
- /// Retrieve the highest stored value under .
- ///
- /// RecordCategories category to retrieve stored values from
- /// A single (playername, value) tuple.
- public Tuple RetrieveHighest(string key)
- {
- if (!Plugin.StaticConfig.CollectStatsEnabled)
- {
- return Tuple.Create("not allowed", -1);
- }
-
- if (Array.IndexOf(RecordCategories.All, key) >= 0)
- {
- string player = "no result";
- int records = -1;
- foreach (Record r in recordCache)
- {
- if (r.Category.Equals(key))
- {
- foreach (RecordValue v in r.Values)
- {
- if (v.Value > records)
- {
- player = v.Key;
- records = v.Value;
- }
- }
- }
- }
- return Tuple.Create(player, records);
- }
- else
- {
- return Tuple.Create($"not recording for {key}", -1);
- }
- }
-
- ///
- /// Retrieve the lowest stored value under .
- ///
- /// RecordCategories category to retrieve stored values from
- /// A single (playername, value) tuple.
- public Tuple RetrieveLowest(string key)
- {
- if (!Plugin.StaticConfig.CollectStatsEnabled)
- {
- return Tuple.Create("not allowed", -1);
- }
+ movepath = Path.Combine(basePath, MIGRATED_FILENAME);
- if (Array.IndexOf(RecordCategories.All, key) >= 0)
- {
- string player = "no result";
- int records = int.MaxValue;
- foreach (Record r in recordCache)
- {
- if (r.Category.Equals(key))
- {
- foreach (RecordValue v in r.Values)
- {
- if (v.Value < records)
- {
- player = v.Key;
- records = v.Value;
- }
- }
- if (r.Values.Count == 0)
- {
- records = -1;
- }
- }
- }
- return Tuple.Create(player, records);
- }
- else
- {
- return Tuple.Create($"not recording for {key}", -1);
- }
+ MigrateRecords();
}
- ///
- /// (Asynchronous) Writes the in-memory cache of records to disk.
- ///
- private async Task FlushCache()
+ private void MigrateRecords()
{
- if (!saveEnabled)
- {
- Plugin.StaticLogger.LogDebug("Saving records is disabled due to an error at load time.");
- return;
- }
- if (Plugin.StaticConfig.CollectStatsEnabled)
+ //! check if storePath exists..
+ if (System.IO.File.Exists(storepath))
{
- string jsonString = JsonConvert.SerializeObject(recordCache);
-
- using (var stream = new StreamWriter(@storepath, false))
- {
- await stream.WriteAsync(jsonString);
- }
-
- Plugin.StaticLogger.LogDebug($"Flushed cached stats to {storepath}");
- }
- }
+ Plugin.StaticLogger.LogInfo("Migrating from discovered Records.json to LiteDB");
+ //! read all records in from storePath if they exist
- ///
- /// Builds the in-memory cache by reading from disk.
- ///
- private void PopulateCache()
- {
- if (File.Exists(storepath))
- {
- string jsonString = File.ReadAllText(@storepath);
try
{
+ string jsonString = File.ReadAllText(@storepath);
recordCache = JsonConvert.DeserializeObject>(jsonString);
Plugin.StaticLogger.LogInfo($"Read existing stats from disk {storepath}");
}
@@ -301,38 +65,40 @@ private void PopulateCache()
{
Plugin.StaticLogger.LogWarning($"No content found when reading {storepath} to read saved records. We will start with default values for all records.");
Plugin.StaticLogger.LogDebug("File contained null and threw ArgumentNullException");
- InitializeEmptyCache();
+ return;
}
catch (JsonException)
{
Plugin.StaticLogger.LogError($"Unable to parse the contents of {storepath} as JSON.");
Plugin.StaticLogger.LogError("No records will be recorded to disk until existing file is moved, renamed, or deleted.");
- saveEnabled = false;
+ return;
+ }
+
+ //! store records into LiteDB
+ foreach (Record r in recordCache)
+ {
+ int count = 0;
+ foreach (RecordValue v in r.Values)
+ {
+ for (int i = 0; i < v.Value; i++)
+ {
+ Plugin.StaticDatabase.InsertSimpleStatRecord(r.Category, v.Key, 1);
+ count++;
+ }
+
+ }
+ Plugin.StaticLogger.LogInfo($"Migrated {count} {r.Category} records");
+
}
+
+ //! move storePath to a new path with MIGRATED_FILENAME
+ System.IO.File.Move(storepath, movepath);
+ Plugin.StaticLogger.LogInfo($"Moved existing records.json to {MIGRATED_FILENAME}");
}
else
{
- Plugin.StaticLogger.LogInfo($"Unable to find existing stats data at {storepath}. Creating new {DEFAULT_FILENAME}");
- InitializeEmptyCache();
- }
- }
-
- private void InitializeEmptyCache()
- {
- recordCache = new List();
- foreach (string category in RecordCategories.All)
- {
- recordCache.Add(new Record
- {
- Category = category,
- Values = new List()
- });
+ Plugin.StaticLogger.LogDebug("No records.json found, not migrating.");
}
- FlushCache().ContinueWith(
- t => Plugin.StaticLogger.LogWarning(t.Exception),
- TaskContinuationOptions.OnlyOnFaulted);
}
-
- public Comparison> HighToLowSort = (x, y) => y.Item2.CompareTo(x.Item2);
}
}
diff --git a/src/Records/Classes/CountResult.cs b/src/Records/Classes/CountResult.cs
new file mode 100644
index 0000000..eb93369
--- /dev/null
+++ b/src/Records/Classes/CountResult.cs
@@ -0,0 +1,29 @@
+
+using LiteDB;
+
+namespace DiscordConnector.Records
+{
+ public class CountResult
+ {
+ public string Name { get; }
+ public int Count { get; }
+
+ [BsonCtor]
+ public CountResult(string name, int count)
+ {
+
+ Name = name;
+ Count = count;
+ }
+
+ public static int CompareByCount(CountResult cr1, CountResult cr2)
+ {
+ return cr1.Count.CompareTo(cr2.Count);
+ }
+
+ public static int CompareByName(CountResult cr1, CountResult cr2)
+ {
+ return cr1.Name.CompareTo(cr2.Name);
+ }
+ }
+}
diff --git a/src/Records/Classes/SimpleStat.cs b/src/Records/Classes/SimpleStat.cs
new file mode 100644
index 0000000..5a081a1
--- /dev/null
+++ b/src/Records/Classes/SimpleStat.cs
@@ -0,0 +1,67 @@
+
+using LiteDB;
+
+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; }
+ public string Name { get; }
+ public System.DateTime Date { get; }
+ public ulong SteamId { get; }
+ public Position Pos { get; }
+
+ public SimpleStat(string name, ulong steamId)
+ {
+ StatId = ObjectId.NewObjectId();
+ Name = name;
+ SteamId = steamId;
+ Date = System.DateTime.Now;
+ Pos = new Position();
+ }
+
+ public SimpleStat(string name, ulong steamId, float x, float y, float z)
+ {
+ StatId = ObjectId.NewObjectId();
+ Name = name;
+ SteamId = steamId;
+ Date = System.DateTime.Now;
+ Pos = new Position(x, y, z);
+ }
+
+ [BsonCtor]
+ public SimpleStat(ObjectId _id, string name, System.DateTime date, ulong steamId, Position pos)
+ {
+ StatId = _id;
+ Name = name;
+ Date = date;
+ SteamId = steamId;
+ Pos = pos;
+ }
+ }
+
+}
diff --git a/src/Records/Database.cs b/src/Records/Database.cs
new file mode 100644
index 0000000..40a7e94
--- /dev/null
+++ b/src/Records/Database.cs
@@ -0,0 +1,227 @@
+
+using System.Collections.Generic;
+using LiteDB;
+using UnityEngine;
+
+namespace DiscordConnector.Records
+{
+ internal class Database
+ {
+ private const string DB_NAME = "records.db";
+ private static string DbPath;
+ private LiteDatabase db;
+ private ILiteCollection DeathCollection;
+ private ILiteCollection JoinCollection;
+ private ILiteCollection LeaveCollection;
+ private ILiteCollection ShoutCollection;
+ private ILiteCollection PingCollection;
+
+ public Database(string rootStorePath)
+ {
+ DbPath = System.IO.Path.Combine(rootStorePath, DB_NAME);
+ Initialize();
+ }
+
+ public void Initialize()
+ {
+ 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");
+ }
+
+ public void Dispose()
+ {
+ Plugin.StaticLogger.LogDebug("Closing LiteDB connection");
+ db.Dispose();
+ }
+
+ private void InsertSimpleStatRecord(ILiteCollection collection, string playerName, ulong steamId, Vector3 pos)
+ {
+ var newRecord = new SimpleStat(
+ playerName,
+ steamId,
+ pos.x, pos.y, pos.z
+ );
+ collection.Insert(newRecord);
+
+ collection.EnsureIndex(x => x.Name);
+ collection.EnsureIndex(x => x.SteamId);
+ }
+ private void InsertSimpleStatRecord(ILiteCollection collection, string playerName, ulong steamId)
+ {
+ InsertSimpleStatRecord(collection, playerName, steamId, Vector3.zero);
+ }
+
+ private int CountOfRecordsByName(ILiteCollection collection, string playerName)
+ {
+ return DeathCollection.Query()
+ .Where(x => x.Name.Equals(playerName))
+ .Count();
+ }
+
+ private int CountOfRecordsBySteamId(ILiteCollection collection, ulong steamId)
+ {
+ return DeathCollection.Query()
+ .Where(x => x.SteamId == steamId)
+ .Count();
+ }
+
+ private List CountAllRecordsGroupBySteamId(ILiteCollection collection)
+ {
+ return ConvertBsonDocumentCountToDotNet(
+ collection.Query()
+ .GroupBy("SteamId")
+ .Select("{Name: @Key, Count: COUNT(*)}")
+ .ToList()
+ );
+ }
+
+ private List RetrieveAllRecordsGroupByName(ILiteCollection collection)
+ {
+ return ConvertBsonDocumentCountToDotNet(
+ collection.Query()
+ .GroupBy("Name")
+ .Select("{Name: @Key, Count: COUNT(*)}")
+ .ToList()
+ );
+ }
+
+ private List ConvertBsonDocumentCountToDotNet(List bsonDocuments)
+ {
+ List results = new List();
+ foreach (BsonDocument doc in bsonDocuments)
+ {
+ if (doc.ContainsKey("Name") && doc.ContainsKey("Count"))
+ {
+ results.Add(new CountResult(
+ doc["Name"].AsString,
+ doc["Count"].AsInt32
+ ));
+ }
+ }
+ return results;
+ }
+
+ public List RetrieveAllRecordsGroupByName(string key)
+ {
+ switch (key)
+ {
+ case Categories.Death:
+ return RetrieveAllRecordsGroupByName(DeathCollection);
+ case Categories.Join:
+ return RetrieveAllRecordsGroupByName(JoinCollection);
+ case Categories.Leave:
+ return RetrieveAllRecordsGroupByName(LeaveCollection);
+ case Categories.Ping:
+ return RetrieveAllRecordsGroupByName(PingCollection);
+ case Categories.Shout:
+ return RetrieveAllRecordsGroupByName(ShoutCollection);
+ default:
+ Plugin.StaticLogger.LogDebug($"RetrieveAllRecordsGroupByName, invalid key '{key}'");
+ return new List();
+ }
+ }
+ public List CountAllRecordsGroupBySteamId(string key)
+ {
+ switch (key)
+ {
+ case Categories.Death:
+ return CountAllRecordsGroupBySteamId(DeathCollection);
+ case Categories.Join:
+ return CountAllRecordsGroupBySteamId(JoinCollection);
+ case Categories.Leave:
+ return CountAllRecordsGroupBySteamId(LeaveCollection);
+ case Categories.Ping:
+ return CountAllRecordsGroupBySteamId(PingCollection);
+ case Categories.Shout:
+ return CountAllRecordsGroupBySteamId(ShoutCollection);
+ default:
+ Plugin.StaticLogger.LogDebug($"CountAllRecordsGroupBySteamId, invalid key '{key}'");
+ return new List();
+ }
+ }
+
+ public int CountOfRecordsByName(string key, string playerName)
+ {
+ if (!Plugin.StaticConfig.CollectStatsEnabled)
+ {
+ return -1;
+ }
+ switch (key)
+ {
+ case Categories.Death:
+ return CountOfRecordsByName(DeathCollection, playerName);
+ case Categories.Join:
+ return CountOfRecordsByName(JoinCollection, playerName);
+ case Categories.Leave:
+ return CountOfRecordsByName(LeaveCollection, playerName);
+ case Categories.Ping:
+ return CountOfRecordsByName(PingCollection, playerName);
+ case Categories.Shout:
+ return CountOfRecordsByName(ShoutCollection, playerName);
+ default:
+ Plugin.StaticLogger.LogDebug($"CountOfRecordsBySteamId, invalid key '{key}'");
+ return -2;
+ }
+ }
+
+ public int CountOfRecordsBySteamId(string key, ulong steamId)
+ {
+ if (!Plugin.StaticConfig.CollectStatsEnabled)
+ {
+ return -1;
+ }
+ switch (key)
+ {
+ case Categories.Death:
+ return CountOfRecordsBySteamId(DeathCollection, steamId);
+ case Categories.Join:
+ return CountOfRecordsBySteamId(JoinCollection, steamId);
+ case Categories.Leave:
+ return CountOfRecordsBySteamId(LeaveCollection, steamId);
+ case Categories.Ping:
+ return CountOfRecordsBySteamId(PingCollection, steamId);
+ case Categories.Shout:
+ return CountOfRecordsBySteamId(ShoutCollection, steamId);
+ default:
+ Plugin.StaticLogger.LogDebug($"CountOfRecordsBySteamId, invalid key '{key}'");
+ return -2;
+ }
+ }
+
+ public void InsertSimpleStatRecord(string key, string playerName, ulong steamId, Vector3 pos)
+ {
+
+ switch (key)
+ {
+ case Categories.Death:
+ InsertSimpleStatRecord(DeathCollection, playerName, steamId, pos);
+ break;
+ case Categories.Join:
+ InsertSimpleStatRecord(JoinCollection, playerName, steamId, pos);
+ break;
+ case Categories.Leave:
+ InsertSimpleStatRecord(LeaveCollection, playerName, steamId, pos);
+ break;
+ case Categories.Ping:
+ InsertSimpleStatRecord(PingCollection, playerName, steamId, pos);
+ break;
+ case Categories.Shout:
+ InsertSimpleStatRecord(ShoutCollection, playerName, steamId, pos);
+ break;
+ default:
+ Plugin.StaticLogger.LogDebug($"InsertSimpleStatRecord, invalid key '{key}'");
+ break;
+ }
+ }
+ public void InsertSimpleStatRecord(string key, string playerName, ulong steamId)
+ {
+ InsertSimpleStatRecord(key, playerName, steamId, Vector3.zero);
+ }
+
+ }
+}
diff --git a/src/Records/RecordsHelper.cs b/src/Records/RecordsHelper.cs
new file mode 100644
index 0000000..1761ffa
--- /dev/null
+++ b/src/Records/RecordsHelper.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace DiscordConnector.Records
+{
+ ///
+ /// These are categories used when keeping track of values in the record system. It is a simple system
+ /// that currently only supports storing string:integer pairings underneath one of these categories.
+ ///
+ public static class Categories
+ {
+ public const string Death = "death";
+ public const string Join = "join";
+ public const string Leave = "leave";
+ public const string Ping = "ping";
+ public const string Shout = "shout";
+
+ public readonly static string[] All = new string[] {
+ Death,
+ Join,
+ Leave,
+ Ping,
+ Shout
+ };
+ }
+
+ public static class Helper
+ {
+ public static List TopNResultForCategory(string key, int n)
+ {
+ List queryResults = Plugin.StaticDatabase.RetrieveAllRecordsGroupByName(key);
+ Plugin.StaticLogger.LogDebug($"TopNResultForCategory {key} n={n}, results={queryResults.Count}");
+ if (queryResults.Count == 0)
+ {
+ return queryResults;
+ }
+
+ queryResults.Sort(CountResult.CompareByCount); // sorts lowest to highest
+ queryResults.Reverse(); // Now high --> low
+
+ if (queryResults.Count <= n)
+ {
+ return queryResults;
+ }
+
+ return queryResults.GetRange(0, n);
+ }
+
+ public static CountResult TopResultForCategory(string key)
+ {
+ var results = Helper.TopNResultForCategory(key, 1);
+ if (results.Count == 0)
+ {
+ return new CountResult("", 0);
+ }
+ return results[0];
+ }
+
+ public static List BottomNResultForCategory(string key, int n)
+ {
+
+ List queryResults = Plugin.StaticDatabase.RetrieveAllRecordsGroupByName(key);
+ Plugin.StaticLogger.LogDebug($"BottomNResultForCategory {key} n={n}, results={queryResults.Count}");
+ if (queryResults.Count == 0)
+ {
+ return queryResults;
+ }
+
+ queryResults.Sort(CountResult.CompareByCount); // sorts lowest to highest
+
+ if (queryResults.Count <= n)
+ {
+ return queryResults;
+ }
+
+ return queryResults.GetRange(0, n);
+ }
+
+ public static CountResult BottomResultForCategory(string key)
+ {
+ var results = Helper.BottomNResultForCategory(key, 1);
+ if (results.Count == 0)
+ {
+ return new CountResult("", 0);
+ }
+ return results[0];
+ }
+ }
+}