From e01f7ea9173edbb58d276f1e47a66f396bbdff5c Mon Sep 17 00:00:00 2001 From: Toaster Date: Thu, 1 Oct 2026 19:43:12 +0200 Subject: [PATCH 01/17] Add UserGameMetric to schema (WIP) --- .../GameDatabaseContext.Metrics.cs | 114 ++++++++++++++++++ Refresh.Database/GameDatabaseContext.cs | 2 + .../Models/Metrics/UserGameMetric.cs | 27 +++++ 3 files changed, 143 insertions(+) create mode 100644 Refresh.Database/GameDatabaseContext.Metrics.cs create mode 100644 Refresh.Database/Models/Metrics/UserGameMetric.cs diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs new file mode 100644 index 000000000..5aed865ca --- /dev/null +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -0,0 +1,114 @@ +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Metrics; +using Refresh.Database.Models.Users; + +namespace Refresh.Database; + +public partial class GameDatabaseContext // Metrics +{ + private IQueryable UserGameMetricsIncluded => this.UserGameMetrics + .Include(m => m.User); + + public UserGameMetric GetGameMetric(GameUser user, TokenGame game, TokenPlatform platform) + { + UserGameMetric? metric = this.UserGameMetricsIncluded + .FirstOrDefault(m => m.UserId == user.UserId && m.Game == game && m.Platform == platform); + + // If it doesn't exist yet, create it + if (metric == null) + { + metric = new() + { + UserId = user.UserId, + User = user, + Game = game, + Platform = platform, + LastSlotChangeAt = DateTimeOffset.MinValue, + LastSlotType = GameSlotType.Pod, + LastSlotId = 0, + TotalPlayTimeMinutes = 0, + TotalLogins = 0, + }; + this.UserGameMetrics.Add(metric); + this.Entry(user).State = EntityState.Unchanged; // avoid inserting user + this.SaveChanges(); + } + + return metric; + } + + public UserGameMetric UpdateLoginDateOnUserMetrics(GameUser user, TokenGame game, TokenPlatform platform) + { + UserGameMetric metric = this.GetGameMetric(user, game, platform); + + metric.LastLoginAt = this._time.Now; + metric.TotalLogins++; + this.Entry(user).State = EntityState.Unchanged; // avoid inserting user + + this.SaveChanges(); + return metric; + } + + public long GetTotalPlayTimeByUser(GameUser user) + { + return this.UserGameMetrics + .Where(m => m.UserId == user.UserId) + .Sum(m => m.TotalPlayTimeMinutes); + } + + // TODO should we also consider slot ID, or should we stick to just type for now? + public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, TokenPlatform platform) + { + UserGameMetric gameMetric = this.GetGameMetric(user, game, platform); + DateTimeOffset now = this._time.Now; + + // Only actually increment if this is at least the second update request for this session. + // We only want to track from the first /match onward, excluding time between login and first /match. + // Technically we don't have to compare against MinValue since LastUpdateAt would also be MinValue in that case, + // but probably doesn't hurt doing this explicitly as well. + if (gameMetric.LastUpdateAt > gameMetric.LastLoginAt && gameMetric.LastUpdateAt > DateTimeOffset.MinValue) + { + long additionalMinutes = (now.ToUnixTimeSeconds() - gameMetric.LastUpdateAt.ToUnixTimeSeconds()) / 60; + long minutesSinceSlotChange = (now.ToUnixTimeSeconds() - gameMetric.LastSlotChangeAt.ToUnixTimeSeconds()) / 60; + + // Always update play time for game, and only update play time for slot if above threshold. + // If type has changed, update LastSlotChangeAt, idk whether we should change it if only ID changes though. + if (minutesSinceSlotChange >= minThreshold) + { + + // If we've also changed type, update the last metric's time instead of the current one + if (currentSlotType != gameMetric.LastSlotType) + { + UserSlotMetric lastSlotMetric = this.GetSlotMetric(user, gameMetric.LastSlotType, game, platform); + lastSlotMetric.TotalPlayTimeMinutes += additionalMinutes; + lastSlotMetric.LastUpdateAt = now; + this.UserSlotMetrics.Update(lastSlotMetric); + + currentSlotMetric.LastEnteredAt = now; + } + else + { + currentSlotMetric.TotalPlayTimeMinutes += additionalMinutes; + } + + currentSlotMetric.LastUpdateAt = now; + this.UserSlotMetrics.Update(currentSlotMetric); + } + } + + if (gameMetric.LastSlotType != currentSlotType) + { + gameMetric.LastSlotChangeAt = now; + gameMetric.LastSlotType = currentSlotType; + } + + // Always update this, since we will use it to determine whether we should update other metrics the next time (see above). + gameMetric.LastUpdateAt = now; + + this.UserGameMetrics.Update(gameMetric); + this.Entry(user).State = EntityState.Unchanged; // avoid inserting user + this.SaveChanges(); + return gameMetric; + } +} \ No newline at end of file diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index c8c5e69b9..c20c10191 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -23,6 +23,7 @@ using Refresh.Database.Models.Workers; using LogLevel = Microsoft.Extensions.Logging.LogLevel; using Refresh.Database.Models.Moderation; +using Refresh.Database.Models.Metrics; namespace Refresh.Database; @@ -87,6 +88,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext internal DbSet GameLevelRevisions { get; set; } internal DbSet ModerationActions { get; set; } internal DbSet EntityUploadRateLimits { get; set; } + internal DbSet UserGameMetrics { get; set; } #pragma warning disable CS8618 // Non-nullable variable must contain a non-null value when exiting constructor. Consider declaring it as nullable. internal GameDatabaseContext(Logger logger, IDateTimeProvider time, IDatabaseConfig dbConfig) diff --git a/Refresh.Database/Models/Metrics/UserGameMetric.cs b/Refresh.Database/Models/Metrics/UserGameMetric.cs new file mode 100644 index 000000000..c94b27ca6 --- /dev/null +++ b/Refresh.Database/Models/Metrics/UserGameMetric.cs @@ -0,0 +1,27 @@ +using MongoDB.Bson; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Users; + +namespace Refresh.Database.Models.Metrics; + +[PrimaryKey(nameof(UserId), nameof(Game), nameof(Platform))] +public class UserGameMetric +{ + [Required] + public ObjectId UserId { get; set; } + + [Required, ForeignKey(nameof(UserId))] + public GameUser User { get; set; } = null!; + + public TokenGame Game { get; set; } + public TokenPlatform Platform { get; set; } + + public DateTimeOffset LastLoginAt { get; set; } + public DateTimeOffset LastRoomUpdateAt { get; set; } + + // total time the user has spent on this game, regardless of slot type. Not just a sum of all UserSlotMetrics, + // since those won't record time spent if it's below a configured threshold. + public long TotalPlayTimeMinutes { get; set; } + public long TotalLogins { get; set; } +} \ No newline at end of file From f45b36a17bc25cbc4c91c4abb92d47b76da55ad3 Mon Sep 17 00:00:00 2001 From: Toaster Date: Thu, 1 Oct 2026 19:44:54 +0200 Subject: [PATCH 02/17] Update metric login date on game login --- .../Endpoints/Handshake/AuthenticationEndpoints.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs index 546230faa..1861b280c 100644 --- a/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs @@ -212,7 +212,11 @@ public class AuthenticationEndpoints : EndpointGroup // !! Token token = database.GenerateTokenForUser(user, TokenType.Game, game.Value, platform.Value, ipAddress, GameDatabaseContext.GameTokenExpirySeconds); // 4 hours - + + // Let the metrics know that this user has started a new session, so the time between login and first room update + // should be ignored. + database.UpdateLoginDateOnUserMetrics(user, game.Value, platform.Value); + // Clear the user's force match database.ClearForceMatch(user); From f2f96d8d3dfd585b985a45f5483e400d5f076043 Mon Sep 17 00:00:00 2001 From: Toaster Date: Fri, 2 Oct 2026 13:28:13 +0200 Subject: [PATCH 03/17] Remove slot metrics stuff from DB context --- .../GameDatabaseContext.Metrics.cs | 81 ++++++------------- 1 file changed, 26 insertions(+), 55 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs index 5aed865ca..7a1660e5f 100644 --- a/Refresh.Database/GameDatabaseContext.Metrics.cs +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -1,5 +1,5 @@ +using Refresh.Common; using Refresh.Database.Models.Authentication; -using Refresh.Database.Models.Levels; using Refresh.Database.Models.Metrics; using Refresh.Database.Models.Users; @@ -24,11 +24,9 @@ public UserGameMetric GetGameMetric(GameUser user, TokenGame game, TokenPlatform User = user, Game = game, Platform = platform, - LastSlotChangeAt = DateTimeOffset.MinValue, - LastSlotType = GameSlotType.Pod, - LastSlotId = 0, + LastLoginAt = DateTimeOffset.MinValue, + LastRoomUpdateAt = DateTimeOffset.MinValue, TotalPlayTimeMinutes = 0, - TotalLogins = 0, }; this.UserGameMetrics.Add(metric); this.Entry(user).State = EntityState.Unchanged; // avoid inserting user @@ -37,78 +35,51 @@ public UserGameMetric GetGameMetric(GameUser user, TokenGame game, TokenPlatform return metric; } + + public long GetTotalPlayTimeByUser(GameUser user) + { + return this.UserGameMetrics + .Where(m => m.UserId == user.UserId) + .Sum(m => m.TotalPlayTimeMinutes); + } public UserGameMetric UpdateLoginDateOnUserMetrics(GameUser user, TokenGame game, TokenPlatform platform) { UserGameMetric metric = this.GetGameMetric(user, game, platform); + DateTimeOffset now = this._time.Now; - metric.LastLoginAt = this._time.Now; - metric.TotalLogins++; + this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s last login date for game {game}/platform {platform}: from {metric.LastLoginAt} to {now}."); + metric.LastLoginAt = now; this.Entry(user).State = EntityState.Unchanged; // avoid inserting user this.SaveChanges(); return metric; } - - public long GetTotalPlayTimeByUser(GameUser user) - { - return this.UserGameMetrics - .Where(m => m.UserId == user.UserId) - .Sum(m => m.TotalPlayTimeMinutes); - } // TODO should we also consider slot ID, or should we stick to just type for now? public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, TokenPlatform platform) { - UserGameMetric gameMetric = this.GetGameMetric(user, game, platform); + UserGameMetric metric = this.GetGameMetric(user, game, platform); DateTimeOffset now = this._time.Now; + this._logger.LogDebug(RefreshContext.UserMetrics, $"Checking whether to update {user}'s playtime for game {game}/platform {platform}: last login at {metric.LastLoginAt}, last room update at {metric.LastRoomUpdateAt}."); - // Only actually increment if this is at least the second update request for this session. - // We only want to track from the first /match onward, excluding time between login and first /match. - // Technically we don't have to compare against MinValue since LastUpdateAt would also be MinValue in that case, - // but probably doesn't hurt doing this explicitly as well. - if (gameMetric.LastUpdateAt > gameMetric.LastLoginAt && gameMetric.LastUpdateAt > DateTimeOffset.MinValue) + // Don't count the time between login and first /match, only count the time between /match requests, + // which we would consider to be actual playtime. Also, explicitly don't increment time if this metric + // hasn't been initialized yet. In that case LastLoginAt is also at MinValue, but it might be better to just + // check for it explicitly anyway. + if (metric.LastRoomUpdateAt > metric.LastLoginAt && metric.LastRoomUpdateAt > DateTimeOffset.MinValue) { - long additionalMinutes = (now.ToUnixTimeSeconds() - gameMetric.LastUpdateAt.ToUnixTimeSeconds()) / 60; - long minutesSinceSlotChange = (now.ToUnixTimeSeconds() - gameMetric.LastSlotChangeAt.ToUnixTimeSeconds()) / 60; - - // Always update play time for game, and only update play time for slot if above threshold. - // If type has changed, update LastSlotChangeAt, idk whether we should change it if only ID changes though. - if (minutesSinceSlotChange >= minThreshold) - { - - // If we've also changed type, update the last metric's time instead of the current one - if (currentSlotType != gameMetric.LastSlotType) - { - UserSlotMetric lastSlotMetric = this.GetSlotMetric(user, gameMetric.LastSlotType, game, platform); - lastSlotMetric.TotalPlayTimeMinutes += additionalMinutes; - lastSlotMetric.LastUpdateAt = now; - this.UserSlotMetrics.Update(lastSlotMetric); - - currentSlotMetric.LastEnteredAt = now; - } - else - { - currentSlotMetric.TotalPlayTimeMinutes += additionalMinutes; - } - - currentSlotMetric.LastUpdateAt = now; - this.UserSlotMetrics.Update(currentSlotMetric); - } - } - - if (gameMetric.LastSlotType != currentSlotType) - { - gameMetric.LastSlotChangeAt = now; - gameMetric.LastSlotType = currentSlotType; + long additionalMinutes = (now.ToUnixTimeSeconds() - metric.LastRoomUpdateAt.ToUnixTimeSeconds()) / 60; + this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s playtime for game {game}/platform {platform}: {metric.TotalPlayTimeMinutes} min + {additionalMinutes} min"); + metric.TotalPlayTimeMinutes += additionalMinutes; } // Always update this, since we will use it to determine whether we should update other metrics the next time (see above). - gameMetric.LastUpdateAt = now; + metric.LastRoomUpdateAt = now; - this.UserGameMetrics.Update(gameMetric); + this.UserGameMetrics.Update(metric); this.Entry(user).State = EntityState.Unchanged; // avoid inserting user this.SaveChanges(); - return gameMetric; + return metric; } } \ No newline at end of file From 8526a4c93dc6a3dda8ef7ee35b23fa0716b643e0 Mon Sep 17 00:00:00 2001 From: Toaster Date: Fri, 2 Oct 2026 13:28:26 +0200 Subject: [PATCH 04/17] No need to track login count --- Refresh.Database/Models/Metrics/UserGameMetric.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Refresh.Database/Models/Metrics/UserGameMetric.cs b/Refresh.Database/Models/Metrics/UserGameMetric.cs index c94b27ca6..dcf258148 100644 --- a/Refresh.Database/Models/Metrics/UserGameMetric.cs +++ b/Refresh.Database/Models/Metrics/UserGameMetric.cs @@ -23,5 +23,4 @@ public class UserGameMetric // total time the user has spent on this game, regardless of slot type. Not just a sum of all UserSlotMetrics, // since those won't record time spent if it's below a configured threshold. public long TotalPlayTimeMinutes { get; set; } - public long TotalLogins { get; set; } } \ No newline at end of file From a866982b7e78517fa23bd2ee5cdcb0d2ea3bf460 Mon Sep 17 00:00:00 2001 From: Toaster Date: Fri, 2 Oct 2026 13:28:41 +0200 Subject: [PATCH 05/17] Add UserMetrics to RefreshContext --- Refresh.Common/RefreshContext.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Refresh.Common/RefreshContext.cs b/Refresh.Common/RefreshContext.cs index 1eefba5cd..5c7bdd47f 100644 --- a/Refresh.Common/RefreshContext.cs +++ b/Refresh.Common/RefreshContext.cs @@ -14,4 +14,5 @@ public enum RefreshContext Database, CacheService, RateLimit, + UserMetrics, } \ No newline at end of file From 12f8fa3e30a8be92cdf2a3ddc87374be0f7d8b76 Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 12:49:35 +0200 Subject: [PATCH 06/17] Improve summary on UserGameMetric.TotalPlayTimeMinutes --- Refresh.Database/Models/Metrics/UserGameMetric.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Refresh.Database/Models/Metrics/UserGameMetric.cs b/Refresh.Database/Models/Metrics/UserGameMetric.cs index dcf258148..82bb72aa7 100644 --- a/Refresh.Database/Models/Metrics/UserGameMetric.cs +++ b/Refresh.Database/Models/Metrics/UserGameMetric.cs @@ -20,7 +20,8 @@ public class UserGameMetric public DateTimeOffset LastLoginAt { get; set; } public DateTimeOffset LastRoomUpdateAt { get; set; } - // total time the user has spent on this game, regardless of slot type. Not just a sum of all UserSlotMetrics, - // since those won't record time spent if it's below a configured threshold. + /// + /// Total time the user has spent on this game, determined by the times inbetween their room update requests. + /// public long TotalPlayTimeMinutes { get; set; } } \ No newline at end of file From c24b9e286ba58a693012ec5e963dad0099a6cb50 Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 12:50:01 +0200 Subject: [PATCH 07/17] Insert UpdatePlayTimeOnUserMetrics() into various match methods --- Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs | 1 + .../Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs | 1 + Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs b/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs index 6bca45dad..ed7dd313a 100644 --- a/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs +++ b/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs @@ -45,6 +45,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe } dataContext.Match.RoomAccessor.UpdateRoom(room); + dataContext.Database.UpdatePlayTimeOnUserMetrics(dataContext.User!, dataContext.Game!, dataContext.Platform!); return OK; } diff --git a/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs b/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs index 6c0f57f1d..9ecb71ffd 100644 --- a/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs +++ b/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs @@ -24,6 +24,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe dataContext.Match.AddPlayerToRoom(playerUsername, room); } + dataContext.Database.UpdatePlayTimeOnUserMetrics(dataContext.User!, dataContext.Game!, dataContext.Platform!); return OK; } } \ No newline at end of file diff --git a/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs b/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs index bc0c30e17..9842de603 100644 --- a/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs +++ b/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs @@ -47,6 +47,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe } dataContext.Match.RoomAccessor.UpdateRoom(room); + dataContext.Database.UpdatePlayTimeOnUserMetrics(dataContext.User!, dataContext.Game!, dataContext.Platform!); return OK; } From aa731cb684babcbf9d89adfbbd2c2df56b0ed6bb Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 12:56:16 +0200 Subject: [PATCH 08/17] Improve comments on metric DB methods, log metric creation --- Refresh.Database/GameDatabaseContext.Metrics.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs index 7a1660e5f..97a64c060 100644 --- a/Refresh.Database/GameDatabaseContext.Metrics.cs +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -15,9 +15,10 @@ public UserGameMetric GetGameMetric(GameUser user, TokenGame game, TokenPlatform UserGameMetric? metric = this.UserGameMetricsIncluded .FirstOrDefault(m => m.UserId == user.UserId && m.Game == game && m.Platform == platform); - // If it doesn't exist yet, create it + // If it doesn't exist yet, create it. Timestamps will be properly set by their dedicated methods when needed. if (metric == null) { + this._logger.LogDebug(RefreshContext.UserMetrics, $"Creating new game metric for {user} for game {game}/platform {platform}."); metric = new() { UserId = user.UserId, @@ -56,7 +57,6 @@ public UserGameMetric UpdateLoginDateOnUserMetrics(GameUser user, TokenGame game return metric; } - // TODO should we also consider slot ID, or should we stick to just type for now? public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, TokenPlatform platform) { UserGameMetric metric = this.GetGameMetric(user, game, platform); @@ -65,7 +65,7 @@ public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, // Don't count the time between login and first /match, only count the time between /match requests, // which we would consider to be actual playtime. Also, explicitly don't increment time if this metric - // hasn't been initialized yet. In that case LastLoginAt is also at MinValue, but it might be better to just + // hasn't been initialized yet. In that case LastLoginAt may also be at MinValue, but it might be better to just // check for it explicitly anyway. if (metric.LastRoomUpdateAt > metric.LastLoginAt && metric.LastRoomUpdateAt > DateTimeOffset.MinValue) { @@ -74,7 +74,7 @@ public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, metric.TotalPlayTimeMinutes += additionalMinutes; } - // Always update this, since we will use it to determine whether we should update other metrics the next time (see above). + // Always update this, since we will use it to determine whether we should update this metric the next time (see above). metric.LastRoomUpdateAt = now; this.UserGameMetrics.Update(metric); From 128ec1f73e79ab7883fe62fb30003122d889bab4 Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 15:03:15 +0200 Subject: [PATCH 09/17] Just use a separate method to track user as unchanged because I no longer want to deal with this nonsense --- Refresh.Database/GameDatabaseContext.Metrics.cs | 7 ++++--- Refresh.Database/GameDatabaseContext.Users.cs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs index 97a64c060..16709887c 100644 --- a/Refresh.Database/GameDatabaseContext.Metrics.cs +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -30,7 +30,8 @@ public UserGameMetric GetGameMetric(GameUser user, TokenGame game, TokenPlatform TotalPlayTimeMinutes = 0, }; this.UserGameMetrics.Add(metric); - this.Entry(user).State = EntityState.Unchanged; // avoid inserting user + + this.TrackUserAsUnchanged(user); this.SaveChanges(); } @@ -51,8 +52,8 @@ public UserGameMetric UpdateLoginDateOnUserMetrics(GameUser user, TokenGame game this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s last login date for game {game}/platform {platform}: from {metric.LastLoginAt} to {now}."); metric.LastLoginAt = now; - this.Entry(user).State = EntityState.Unchanged; // avoid inserting user + this.TrackUserAsUnchanged(user); this.SaveChanges(); return metric; } @@ -78,7 +79,7 @@ public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, metric.LastRoomUpdateAt = now; this.UserGameMetrics.Update(metric); - this.Entry(user).State = EntityState.Unchanged; // avoid inserting user + this.TrackUserAsUnchanged(user); this.SaveChanges(); return metric; } diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index c231dddd3..926af7723 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -22,6 +22,19 @@ public partial class GameDatabaseContext // Users private IQueryable PreviousUsernamesIncluded => this.PreviousUsernames .Include(u => u.User) .Include(u => u.User.Statistics); + + // TODO use this method in more DB methods where an entity referencing a user is updated/inserted without updating the user or any of the user's referenced entities, + // else there will be a high change that the user isn't actually tracked by Postgres yet, making it insert the user (and fail for obvious reasons). + // TODO also use such methods for levels and other commonly referenced entities for even more deduplication. + // public so tests could access this manually if necessary + public void TrackUserAsUnchanged(GameUser user) + { + this.Entry(user).State = EntityState.Unchanged; + if (user.Statistics != null) + { + this.Entry(user.Statistics).State = EntityState.Unchanged; + } + } [Pure] [ContractAnnotation("username:null => null; username:notnull => canbenull")] From 24d959f0ac43ee164f4f3fe89985a7ba016a185e Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 15:45:16 +0200 Subject: [PATCH 10/17] Cache total playtime as stat --- Refresh.Database/GameDatabaseContext.Metrics.cs | 16 +++++++++++----- .../GameDatabaseContext.Statistics.cs | 3 ++- .../Models/Statistics/GameUserStatistics.cs | 1 + 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs index 16709887c..6f1646427 100644 --- a/Refresh.Database/GameDatabaseContext.Metrics.cs +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -38,6 +38,9 @@ public UserGameMetric GetGameMetric(GameUser user, TokenGame game, TokenPlatform return metric; } + /// + /// Gets the user's playtime across all games and platforms. + /// public long GetTotalPlayTimeByUser(GameUser user) { return this.UserGameMetrics @@ -53,6 +56,7 @@ public UserGameMetric UpdateLoginDateOnUserMetrics(GameUser user, TokenGame game this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s last login date for game {game}/platform {platform}: from {metric.LastLoginAt} to {now}."); metric.LastLoginAt = now; + this.UserGameMetrics.Update(metric); this.TrackUserAsUnchanged(user); this.SaveChanges(); return metric; @@ -72,15 +76,17 @@ public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, { long additionalMinutes = (now.ToUnixTimeSeconds() - metric.LastRoomUpdateAt.ToUnixTimeSeconds()) / 60; this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s playtime for game {game}/platform {platform}: {metric.TotalPlayTimeMinutes} min + {additionalMinutes} min"); + metric.TotalPlayTimeMinutes += additionalMinutes; } - // Always update this, since we will use it to determine whether we should update this metric the next time (see above). - metric.LastRoomUpdateAt = now; + this.WriteEnsuringStatistics(user, () => + { + // Always update LastRoomUpdateAt, since we will use it to determine whether we should update this metric the next time (see above). + metric.LastRoomUpdateAt = now; + this.UserGameMetrics.Update(metric); + }); - this.UserGameMetrics.Update(metric); - this.TrackUserAsUnchanged(user); - this.SaveChanges(); return metric; } } \ No newline at end of file diff --git a/Refresh.Database/GameDatabaseContext.Statistics.cs b/Refresh.Database/GameDatabaseContext.Statistics.cs index 4fe080d14..5563584ad 100644 --- a/Refresh.Database/GameDatabaseContext.Statistics.cs +++ b/Refresh.Database/GameDatabaseContext.Statistics.cs @@ -262,7 +262,7 @@ public void RecalculateScoreStatistics(GameLevel level, bool saveChanges = true) #region Users - internal const int UserStatisticsVersion = 2; + internal const int UserStatisticsVersion = 3; public IEnumerable GetUsersWithStatisticsNeedingUpdates() { @@ -353,6 +353,7 @@ private void RecalculateUserStatisticsInternal(GameUser user) user.Statistics.FavouriteLevelCount = this.GetTotalLevelsFavouritedByUser(user); user.Statistics.FavouritePlaylistCount = this.GetTotalPlaylistsFavouritedByUser(user); user.Statistics.PlaylistCount = this.GetTotalPlaylistsByAuthor(user); + user.Statistics.TotalPlayTimeMinutes = this.GetTotalPlayTimeByUser(user); user.Statistics.RecalculateAt = null; user.Statistics.Version = UserStatisticsVersion; diff --git a/Refresh.Database/Models/Statistics/GameUserStatistics.cs b/Refresh.Database/Models/Statistics/GameUserStatistics.cs index 156810956..b09201b10 100644 --- a/Refresh.Database/Models/Statistics/GameUserStatistics.cs +++ b/Refresh.Database/Models/Statistics/GameUserStatistics.cs @@ -19,4 +19,5 @@ public class GameUserStatistics public int FavouritePlaylistCount { get; set; } public int QueueCount { get; set; } public int PlaylistCount { get; set; } + public long TotalPlayTimeMinutes { get; set; } } \ No newline at end of file From bfada4c26d352539047e9e47336b09d413355561 Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 17:44:05 +0200 Subject: [PATCH 11/17] Update metric DB methods --- .../Types/Matching/MatchMethods/CreateRoomMethod.cs | 2 +- .../Matching/MatchMethods/UpdatePlayersInRoomMethod.cs | 2 +- .../Matching/MatchMethods/UpdateRoomDataMethod.cs | 2 +- Refresh.Database/GameDatabaseContext.Metrics.cs | 10 +++++----- .../Endpoints/Handshake/AuthenticationEndpoints.cs | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs b/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs index ed7dd313a..ca7ab9719 100644 --- a/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs +++ b/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs @@ -45,7 +45,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe } dataContext.Match.RoomAccessor.UpdateRoom(room); - dataContext.Database.UpdatePlayTimeOnUserMetrics(dataContext.User!, dataContext.Game!, dataContext.Platform!); + dataContext.Database.UpdatePlayTimeOnUserMetric(dataContext.User!, dataContext.Game!, dataContext.Platform!); return OK; } diff --git a/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs b/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs index 9ecb71ffd..53499910f 100644 --- a/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs +++ b/Refresh.Core/Types/Matching/MatchMethods/UpdatePlayersInRoomMethod.cs @@ -24,7 +24,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe dataContext.Match.AddPlayerToRoom(playerUsername, room); } - dataContext.Database.UpdatePlayTimeOnUserMetrics(dataContext.User!, dataContext.Game!, dataContext.Platform!); + dataContext.Database.UpdatePlayTimeOnUserMetric(dataContext.User!, dataContext.Game!, dataContext.Platform!); return OK; } } \ No newline at end of file diff --git a/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs b/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs index 9842de603..57f086edd 100644 --- a/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs +++ b/Refresh.Core/Types/Matching/MatchMethods/UpdateRoomDataMethod.cs @@ -47,7 +47,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe } dataContext.Match.RoomAccessor.UpdateRoom(room); - dataContext.Database.UpdatePlayTimeOnUserMetrics(dataContext.User!, dataContext.Game!, dataContext.Platform!); + dataContext.Database.UpdatePlayTimeOnUserMetric(dataContext.User!, dataContext.Game!, dataContext.Platform!); return OK; } diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs index 6f1646427..91555fa38 100644 --- a/Refresh.Database/GameDatabaseContext.Metrics.cs +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -10,7 +10,7 @@ public partial class GameDatabaseContext // Metrics private IQueryable UserGameMetricsIncluded => this.UserGameMetrics .Include(m => m.User); - public UserGameMetric GetGameMetric(GameUser user, TokenGame game, TokenPlatform platform) + public UserGameMetric GetGameMetricForUser(GameUser user, TokenGame game, TokenPlatform platform) { UserGameMetric? metric = this.UserGameMetricsIncluded .FirstOrDefault(m => m.UserId == user.UserId && m.Game == game && m.Platform == platform); @@ -48,9 +48,9 @@ public long GetTotalPlayTimeByUser(GameUser user) .Sum(m => m.TotalPlayTimeMinutes); } - public UserGameMetric UpdateLoginDateOnUserMetrics(GameUser user, TokenGame game, TokenPlatform platform) + public UserGameMetric UpdateLoginDateOnUserMetric(GameUser user, TokenGame game, TokenPlatform platform) { - UserGameMetric metric = this.GetGameMetric(user, game, platform); + UserGameMetric metric = this.GetGameMetricForUser(user, game, platform); DateTimeOffset now = this._time.Now; this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s last login date for game {game}/platform {platform}: from {metric.LastLoginAt} to {now}."); @@ -62,9 +62,9 @@ public UserGameMetric UpdateLoginDateOnUserMetrics(GameUser user, TokenGame game return metric; } - public UserGameMetric UpdatePlayTimeOnUserMetrics(GameUser user, TokenGame game, TokenPlatform platform) + public UserGameMetric UpdatePlayTimeOnUserMetric(GameUser user, TokenGame game, TokenPlatform platform) { - UserGameMetric metric = this.GetGameMetric(user, game, platform); + UserGameMetric metric = this.GetGameMetricForUser(user, game, platform); DateTimeOffset now = this._time.Now; this._logger.LogDebug(RefreshContext.UserMetrics, $"Checking whether to update {user}'s playtime for game {game}/platform {platform}: last login at {metric.LastLoginAt}, last room update at {metric.LastRoomUpdateAt}."); diff --git a/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs index 1861b280c..318345503 100644 --- a/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs @@ -215,7 +215,7 @@ public class AuthenticationEndpoints : EndpointGroup // Let the metrics know that this user has started a new session, so the time between login and first room update // should be ignored. - database.UpdateLoginDateOnUserMetrics(user, game.Value, platform.Value); + database.UpdateLoginDateOnUserMetric(user, game.Value, platform.Value); // Clear the user's force match database.ClearForceMatch(user); From f147641cc275e3bdd07d58648b6028e86204c01e Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 23:46:16 +0200 Subject: [PATCH 12/17] Fix total playtime stat not being incremented --- .../GameDatabaseContext.Metrics.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs index 91555fa38..e79dc2c5e 100644 --- a/Refresh.Database/GameDatabaseContext.Metrics.cs +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -77,16 +77,24 @@ public UserGameMetric UpdatePlayTimeOnUserMetric(GameUser user, TokenGame game, long additionalMinutes = (now.ToUnixTimeSeconds() - metric.LastRoomUpdateAt.ToUnixTimeSeconds()) / 60; this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s playtime for game {game}/platform {platform}: {metric.TotalPlayTimeMinutes} min + {additionalMinutes} min"); - metric.TotalPlayTimeMinutes += additionalMinutes; + this.WriteEnsuringStatistics(user, () => + { + metric.LastRoomUpdateAt = now; + metric.TotalPlayTimeMinutes += additionalMinutes; + this.UserGameMetrics.Update(metric); + + user.Statistics!.TotalPlayTimeMinutes += additionalMinutes; + }); } - - this.WriteEnsuringStatistics(user, () => + else { // Always update LastRoomUpdateAt, since we will use it to determine whether we should update this metric the next time (see above). metric.LastRoomUpdateAt = now; this.UserGameMetrics.Update(metric); - }); - + this.TrackUserAsUnchanged(user); + this.SaveChanges(); + } + return metric; } } \ No newline at end of file From 42e232373ebae402f2dd89292150a4137ad805aa Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 5 Oct 2026 23:46:29 +0200 Subject: [PATCH 13/17] wip playtime tests --- .../Tests/Matching/UserPlaytimeTests.cs | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs diff --git a/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs b/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs new file mode 100644 index 000000000..804830002 --- /dev/null +++ b/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs @@ -0,0 +1,232 @@ +using Refresh.Core.Configuration; +using Refresh.Core.Services; +using Refresh.Core.Types.Data; +using Refresh.Core.Types.Matching; +using Refresh.Database; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Metrics; +using Refresh.Database.Models.Users; + +namespace RefreshTests.GameServer.Tests.Matching; + +public class UserPlaytimeTests : GameServerTest +{ + private DataContext GetDataContext(GameDatabaseContext database, Token token, MatchService match) + { + return new DataContext + { + Database = database, + Logger = Logger, + DataStore = null!, //this isn't accessed by matching + Match = match, + GuidChecker = null!, + Token = token, + }; + } + + [Test] + public void IncrementsPlaytimeWhenNoLoginInbetween() + { + using TestContext context = this.GetServer(false); + GameUser user = context.CreateUser(); + Token token = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + + GameServerConfig config = new(); + MatchService match = new(Logger); + match.Initialize(); + + SerializedRoomData roomData = new() + { + NatType = new List + { + NatType.Open, + }, + }; + + // Ensure that the first room update will not add these first 10 ms to the playtime, but last update timestamp will be set. + context.Time.TimestampMilliseconds += 10; + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token, match), config); + context.Database.Refresh(); + + UserGameMetric metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + Assert.That(metric.LastLoginAt, Is.EqualTo(DateTimeOffset.MinValue)); + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(10)); + Assert.That(metric.TotalPlayTimeMinutes, Is.Zero); + + // Send another room update, and ensure that this time, the playtime was incremented by the difference between the + // previous and current timestamps. + // Also, ensure the total playtime stat was incremented aswell. + context.Time.TimestampMilliseconds += 20; + match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token, match), config); + context.Database.Refresh(); + + metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + Assert.That(metric.LastLoginAt, Is.EqualTo(DateTimeOffset.MinValue)); + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(30)); + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(10)); + + GameUser? userUpdated = context.Database.GetUserByObjectId(user.UserId); + Assert.That(userUpdated?.Statistics, Is.Not.Null); + Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(10)); + + // Now let it update the login date, and ensure the login date has updated, + // but the room update date and the playtime have not. + context.Time.TimestampMilliseconds += 30; + context.Database.UpdateLoginDateOnUserMetric(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + context.Database.Refresh(); + + metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + Assert.That(metric.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(60)); // is now above 0 + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(30)); // still the same as before + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(10)); // still the same as before + + userUpdated = context.Database.GetUserByObjectId(user.UserId); + Assert.That(userUpdated?.Statistics, Is.Not.Null); + Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(10)); // still the same as before + + // Ensure the next room update updates the last update timestamp, but not the playtime. + context.Time.TimestampMilliseconds += 40; + match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token, match), config); + context.Database.Refresh(); + + // Ensure both the total stat and the metric for this game/paltform are now above 0 + metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + Assert.That(metric.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(60)); // The same as right now + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(100)); // timestamp has updated + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(50)); // new time difference was added + + userUpdated = context.Database.GetUserByObjectId(user.UserId); + Assert.That(userUpdated?.Statistics, Is.Not.Null); + Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(50)); // was also updated + } + + [Test] + public void IncrementsPlaytimeAcrossGameAndPlatform() + { + using TestContext context = this.GetServer(false); + GameUser user = context.CreateUser(); + Token token1 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + // same as first but different platform + Token token2 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3); + // same as first but different game + Token token3 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanet2, TokenPlatform.PS3); + // completely different game and plaftorm + Token token4 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanetVita, TokenPlatform.Vita); + + GameServerConfig config = new(); + MatchService match = new(Logger); + match.Initialize(); + + SerializedRoomData roomData = new() + { + NatType = new List + { + NatType.Open, + }, + }; + + // -- Set the times -- + // LBP1 PS3 + context.Time.TimestampMilliseconds += 5; + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token1, match), config); + context.Time.TimestampMilliseconds += 10; + match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token1, match), config); + context.Database.Refresh(); + + // LBP1 RPCS3 + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token2, match), config); + context.Time.TimestampMilliseconds += 20; + match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token2, match), config); + context.Database.Refresh(); + + // LBP2 PS3 + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token3, match), config); + context.Time.TimestampMilliseconds += 60; + match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token3, match), config); + context.Database.Refresh(); + + // LBPVita PSVita + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token4, match), config); + context.Time.TimestampMilliseconds += 50; + match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token4, match), config); + context.Database.Refresh(); + + // -- Assertions -- + // LBP1 PS3 + UserGameMetric metric1 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(10)); + Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(15)); + + // LBP1 RPCS3 + UserGameMetric metric2 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3); + Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(20)); + Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(35)); + + // LBP2 PS3 + UserGameMetric metric3 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet2, TokenPlatform.PS3); + Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(60)); + Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(95)); + + // LBPVita PSVita + UserGameMetric metric4 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanetVita, TokenPlatform.Vita); + Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(50)); + Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(145)); + + // Ensure the total playtime stat is actually a sum of all metric playtimes + GameUser? userUpdated = context.Database.GetUserByObjectId(user.UserId); + Assert.That(userUpdated?.Statistics, Is.Not.Null); + Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(140)); + } + + [Test] + public void SetsLoginDateAcrossGameAndPlatform() + { + using TestContext context = this.GetServer(false); + GameUser user = context.CreateUser(); + Token token1 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + // same as first but different platform + Token token2 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3); + // same as first but different game + Token token3 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanet2, TokenPlatform.PS3); + // completely different game and plaftorm + Token token4 = context.CreateToken(user, TokenType.Game, TokenGame.LittleBigPlanetVita, TokenPlatform.Vita); + + // -- Set the dates -- + // LBP1 PS3 + context.Time.TimestampMilliseconds += 5; + context.Database.UpdateLoginDateOnUserMetric(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + + // LBP1 RPCS3 + context.Time.TimestampMilliseconds += 10; + context.Database.UpdateLoginDateOnUserMetric(user, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3); + + // LBP2 PS3 + context.Time.TimestampMilliseconds += 20; + context.Database.UpdateLoginDateOnUserMetric(user, TokenGame.LittleBigPlanet2, TokenPlatform.PS3); + + // LBPVita PSVita + context.Time.TimestampMilliseconds += 30; + context.Database.UpdateLoginDateOnUserMetric(user, TokenGame.LittleBigPlanetVita, TokenPlatform.Vita); + + // -- Assertions -- + // LBP1 PS3 + UserGameMetric metric1 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + Assert.That(metric1.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(5)); + Assert.That(metric1.LastRoomUpdateAt, Is.EqualTo(DateTimeOffset.MinValue)); // ensure this wasn't set + + // LBP1 RPCS3 + UserGameMetric metric2 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3); + Assert.That(metric2.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(15)); + Assert.That(metric2.LastRoomUpdateAt, Is.EqualTo(DateTimeOffset.MinValue)); // ensure this wasn't set + + // LBP2 PS3 + UserGameMetric metric3 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet2, TokenPlatform.PS3); + Assert.That(metric3.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(35)); + Assert.That(metric3.LastRoomUpdateAt, Is.EqualTo(DateTimeOffset.MinValue)); // ensure this wasn't set + + // LBPVita PSVita + UserGameMetric metric4 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanetVita, TokenPlatform.Vita); + Assert.That(metric4.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(65)); + Assert.That(metric4.LastRoomUpdateAt, Is.EqualTo(DateTimeOffset.MinValue)); // ensure this wasn't set + } +} \ No newline at end of file From ef60db3fba30297a86e49baa11423b138d759e1b Mon Sep 17 00:00:00 2001 From: Toaster Date: Tue, 6 Oct 2026 00:45:06 +0200 Subject: [PATCH 14/17] no idea what happened here, but ok --- .../Tests/Matching/MatchingTests.cs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs b/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs index daa8b89df..35a3a0835 100644 --- a/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs +++ b/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs @@ -7,6 +7,7 @@ using Refresh.Core.Types.Data; using Refresh.Core.Types.Matching; using Refresh.Core.Types.Matching.Responses; +using Refresh.Database; using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Users; @@ -14,6 +15,19 @@ namespace RefreshTests.GameServer.Tests.Matching; public class MatchingTests : GameServerTest { + private DataContext GetDataContext(GameDatabaseContext database, Token token, MatchService match) + { + return new DataContext + { + Database = database, + Logger = Logger, + DataStore = null!, //this isn't accessed by matching + Match = match, + GuidChecker = null!, + Token = token, + }; + } + [Test] public void CreatesRooms() { @@ -509,8 +523,8 @@ public void DoesntMatchIfLookingForLevelWhenPod() Token token1 = context.CreateToken(user1); Token token2 = context.CreateToken(user2); - match.ExecuteMethod("CreateRoom", roomData, context.GetDataContext(token1), config); - match.ExecuteMethod("CreateRoom", roomData, context.GetDataContext(token2), config); + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token1, match), config); + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token2, match), config); // Tell user2 to try to find a room Response response = match.ExecuteMethod("FindBestRoom", new SerializedRoomData @@ -523,7 +537,7 @@ public void DoesntMatchIfLookingForLevelWhenPod() (int)RoomSlotType.Online, 1337, ], - }, context.GetDataContext(token2), config); + }, this.GetDataContext(context.Database, token2, match), config); Assert.That(response.StatusCode, Is.EqualTo(NotFound)); } } \ No newline at end of file From bb07055a6e13c44daa1d44ff543e29c577e191a7 Mon Sep 17 00:00:00 2001 From: Toaster Date: Tue, 6 Oct 2026 12:15:15 +0200 Subject: [PATCH 15/17] Explicitly track user statistics as updated in WriteEnsuringStatistics() --- Refresh.Database/GameDatabaseContext.Statistics.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Refresh.Database/GameDatabaseContext.Statistics.cs b/Refresh.Database/GameDatabaseContext.Statistics.cs index 5563584ad..ea9e59d2c 100644 --- a/Refresh.Database/GameDatabaseContext.Statistics.cs +++ b/Refresh.Database/GameDatabaseContext.Statistics.cs @@ -326,6 +326,7 @@ private void WriteEnsuringStatistics(GameUser user, Action action) this.CalculateUserStatisticsIfNotPresent(user); action(); this.MarkUserStatisticsDirty(user); + this.GameUserStatistics.Update(user.Statistics!); }); } From e57c007898f567ea2c80a141f5967e5329b4fb42 Mon Sep 17 00:00:00 2001 From: Toaster Date: Tue, 6 Oct 2026 20:10:44 +0200 Subject: [PATCH 16/17] Fix and improve unit tests, deal with EF/test nonsense --- .../GameDatabaseContext.Metrics.cs | 16 +-- Refresh.Database/GameDatabaseContext.Users.cs | 4 +- .../Tests/Matching/UserPlaytimeTests.cs | 121 +++++++++++------- 3 files changed, 83 insertions(+), 58 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs index e79dc2c5e..b3c41ffb2 100644 --- a/Refresh.Database/GameDatabaseContext.Metrics.cs +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -18,7 +18,7 @@ public UserGameMetric GetGameMetricForUser(GameUser user, TokenGame game, TokenP // If it doesn't exist yet, create it. Timestamps will be properly set by their dedicated methods when needed. if (metric == null) { - this._logger.LogDebug(RefreshContext.UserMetrics, $"Creating new game metric for {user} for game {game}/platform {platform}."); + this._logger.LogDebug(RefreshContext.UserMetrics, $"Creating new game metric for {user} for {game}/{platform}."); metric = new() { UserId = user.UserId, @@ -30,7 +30,7 @@ public UserGameMetric GetGameMetricForUser(GameUser user, TokenGame game, TokenP TotalPlayTimeMinutes = 0, }; this.UserGameMetrics.Add(metric); - + this.TrackUserAsUnchanged(user); this.SaveChanges(); } @@ -53,11 +53,10 @@ public UserGameMetric UpdateLoginDateOnUserMetric(GameUser user, TokenGame game, UserGameMetric metric = this.GetGameMetricForUser(user, game, platform); DateTimeOffset now = this._time.Now; - this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s last login date for game {game}/platform {platform}: from {metric.LastLoginAt} to {now}."); + this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s last login date for {game}/{platform}: from {metric.LastLoginAt} to {now}."); + this.UserGameMetrics.Update(metric); metric.LastLoginAt = now; - this.UserGameMetrics.Update(metric); - this.TrackUserAsUnchanged(user); this.SaveChanges(); return metric; } @@ -75,23 +74,22 @@ public UserGameMetric UpdatePlayTimeOnUserMetric(GameUser user, TokenGame game, if (metric.LastRoomUpdateAt > metric.LastLoginAt && metric.LastRoomUpdateAt > DateTimeOffset.MinValue) { long additionalMinutes = (now.ToUnixTimeSeconds() - metric.LastRoomUpdateAt.ToUnixTimeSeconds()) / 60; - this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s playtime for game {game}/platform {platform}: {metric.TotalPlayTimeMinutes} min + {additionalMinutes} min"); + this._logger.LogWarning(RefreshContext.UserMetrics, $"Updating {user}'s playtime for game {game}/platform {platform}: {metric.TotalPlayTimeMinutes} min + {additionalMinutes} min"); this.WriteEnsuringStatistics(user, () => { + this.UserGameMetrics.Update(metric); metric.LastRoomUpdateAt = now; metric.TotalPlayTimeMinutes += additionalMinutes; - this.UserGameMetrics.Update(metric); user.Statistics!.TotalPlayTimeMinutes += additionalMinutes; }); } else { + this.UserGameMetrics.Update(metric); // Always update LastRoomUpdateAt, since we will use it to determine whether we should update this metric the next time (see above). metric.LastRoomUpdateAt = now; - this.UserGameMetrics.Update(metric); - this.TrackUserAsUnchanged(user); this.SaveChanges(); } diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 926af7723..b41082c37 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -29,10 +29,10 @@ public partial class GameDatabaseContext // Users // public so tests could access this manually if necessary public void TrackUserAsUnchanged(GameUser user) { - this.Entry(user).State = EntityState.Unchanged; + this.GameUsers.Attach(user); if (user.Statistics != null) { - this.Entry(user.Statistics).State = EntityState.Unchanged; + this.GameUserStatistics.Attach(user.Statistics); } } diff --git a/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs b/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs index 804830002..142accf97 100644 --- a/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs +++ b/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs @@ -43,61 +43,85 @@ public void IncrementsPlaytimeWhenNoLoginInbetween() }, }; - // Ensure that the first room update will not add these first 10 ms to the playtime, but last update timestamp will be set. - context.Time.TimestampMilliseconds += 10; + // All time increments here must be whole minutes because the playtime DB method will round (or floor idk) to minutes, + // since playtime is minutes. + + // -- Send the requests -- + // First room update. + context.Time.TimestampMilliseconds += 1000 * 60; match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token, match), config); - context.Database.Refresh(); UserGameMetric metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + GameUser? updatedUser = context.Database.GetUserByObjectId(user.UserId); + + context.Database.Refresh(); Assert.That(metric.LastLoginAt, Is.EqualTo(DateTimeOffset.MinValue)); - Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(10)); + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(1000 * 60)); Assert.That(metric.TotalPlayTimeMinutes, Is.Zero); - // Send another room update, and ensure that this time, the playtime was incremented by the difference between the - // previous and current timestamps. - // Also, ensure the total playtime stat was incremented aswell. - context.Time.TimestampMilliseconds += 20; + // Second room update + context.Time.TimestampMilliseconds += 2000 * 60; match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token, match), config); - context.Database.Refresh(); + context.Database.Refresh(); metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + updatedUser = context.Database.GetUserByObjectId(user.UserId); + + context.Database.Refresh(); Assert.That(metric.LastLoginAt, Is.EqualTo(DateTimeOffset.MinValue)); - Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(30)); - Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(10)); + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(3000 * 60)); + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(2)); - GameUser? userUpdated = context.Database.GetUserByObjectId(user.UserId); - Assert.That(userUpdated?.Statistics, Is.Not.Null); - Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(10)); + Assert.That(updatedUser?.Statistics, Is.Not.Null); + Assert.That(updatedUser!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(2)); - // Now let it update the login date, and ensure the login date has updated, - // but the room update date and the playtime have not. - context.Time.TimestampMilliseconds += 30; + // Update the login date + context.Time.TimestampMilliseconds += 3000 * 60; context.Database.UpdateLoginDateOnUserMetric(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); - context.Database.Refresh(); + context.Database.Refresh(); metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); - Assert.That(metric.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(60)); // is now above 0 - Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(30)); // still the same as before - Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(10)); // still the same as before + updatedUser = context.Database.GetUserByObjectId(user.UserId); - userUpdated = context.Database.GetUserByObjectId(user.UserId); - Assert.That(userUpdated?.Statistics, Is.Not.Null); - Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(10)); // still the same as before + context.Database.Refresh(); + Assert.That(metric.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(6000 * 60)); // is now above 0 + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(3000 * 60)); // still the same as before + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(2)); // still the same as before - // Ensure the next room update updates the last update timestamp, but not the playtime. - context.Time.TimestampMilliseconds += 40; + Assert.That(updatedUser?.Statistics, Is.Not.Null); + Assert.That(updatedUser!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(2)); // still the same as before + + // First room update after new login + context.Time.TimestampMilliseconds += 4000 * 60; match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token, match), config); + + context.Database.Refresh(); + metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + updatedUser = context.Database.GetUserByObjectId(user.UserId); + context.Database.Refresh(); + Assert.That(metric.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(6000 * 60)); // The same as before + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(10000 * 60)); // timestamp has updated + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(2)); // nothing was added + + Assert.That(updatedUser?.Statistics, Is.Not.Null); + Assert.That(updatedUser!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(2)); // was also not updated + + // Second room update after new login + context.Time.TimestampMilliseconds += 6000 * 60; + match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token, match), config); - // Ensure both the total stat and the metric for this game/paltform are now above 0 + context.Database.Refresh(); metric = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); - Assert.That(metric.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(60)); // The same as right now - Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(100)); // timestamp has updated - Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(50)); // new time difference was added + updatedUser = context.Database.GetUserByObjectId(user.UserId); - userUpdated = context.Database.GetUserByObjectId(user.UserId); - Assert.That(userUpdated?.Statistics, Is.Not.Null); - Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(50)); // was also updated + context.Database.Refresh(); + Assert.That(metric.LastLoginAt.ToUnixTimeMilliseconds(), Is.EqualTo(6000 * 60)); // The same as before + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(16000 * 60)); // timestamp has updated + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(8)); // new minutes were added + + Assert.That(updatedUser?.Statistics, Is.Not.Null); + Assert.That(updatedUser!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(8)); // was also updated } [Test] @@ -125,57 +149,60 @@ public void IncrementsPlaytimeAcrossGameAndPlatform() }, }; + // These 3 seconds should be ignored by the playtime itself, since it's rounded to whole minutes during calculation, + // however the dates should reflect the seconds. + context.Time.TimestampMilliseconds += 3000; + // -- Set the times -- // LBP1 PS3 - context.Time.TimestampMilliseconds += 5; match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token1, match), config); - context.Time.TimestampMilliseconds += 10; + context.Time.TimestampMilliseconds += 1000 * 60; match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token1, match), config); context.Database.Refresh(); // LBP1 RPCS3 match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token2, match), config); - context.Time.TimestampMilliseconds += 20; + context.Time.TimestampMilliseconds += 2000 * 60; match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token2, match), config); context.Database.Refresh(); // LBP2 PS3 match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token3, match), config); - context.Time.TimestampMilliseconds += 60; + context.Time.TimestampMilliseconds += 6000 * 60; match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token3, match), config); context.Database.Refresh(); // LBPVita PSVita match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token4, match), config); - context.Time.TimestampMilliseconds += 50; + context.Time.TimestampMilliseconds += 5000 * 60; match.ExecuteMethod("UpdateMyPlayerData", roomData, this.GetDataContext(context.Database, token4, match), config); context.Database.Refresh(); // -- Assertions -- // LBP1 PS3 UserGameMetric metric1 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); - Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(10)); - Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(15)); + Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(1)); + Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(1000 * 60 + 3000)); // LBP1 RPCS3 UserGameMetric metric2 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3); - Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(20)); - Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(35)); + Assert.That(metric2.TotalPlayTimeMinutes, Is.EqualTo(2)); + Assert.That(metric2.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(3000 * 60 + 3000)); // LBP2 PS3 UserGameMetric metric3 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanet2, TokenPlatform.PS3); - Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(60)); - Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(95)); + Assert.That(metric3.TotalPlayTimeMinutes, Is.EqualTo(6)); + Assert.That(metric3.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(9000 * 60 + 3000)); // LBPVita PSVita UserGameMetric metric4 = context.Database.GetGameMetricForUser(user, TokenGame.LittleBigPlanetVita, TokenPlatform.Vita); - Assert.That(metric1.TotalPlayTimeMinutes, Is.EqualTo(50)); - Assert.That(metric1.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(145)); + Assert.That(metric4.TotalPlayTimeMinutes, Is.EqualTo(5)); + Assert.That(metric4.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(14000 * 60 + 3000)); // Ensure the total playtime stat is actually a sum of all metric playtimes GameUser? userUpdated = context.Database.GetUserByObjectId(user.UserId); Assert.That(userUpdated?.Statistics, Is.Not.Null); - Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(140)); + Assert.That(userUpdated!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(14)); } [Test] From 9c8a1455ea6766a0a0798aac2b03583bbd7866b6 Mon Sep 17 00:00:00 2001 From: Toaster Date: Thu, 8 Oct 2026 11:16:43 +0200 Subject: [PATCH 17/17] Add DB migration for UserGameMetrics --- .../20261008091428_AddUserGameMetricsTable.cs | 58 +++++++++++++++++++ .../GameDatabaseContextModelSnapshot.cs | 39 +++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 Refresh.Database/Migrations/20261008091428_AddUserGameMetricsTable.cs diff --git a/Refresh.Database/Migrations/20261008091428_AddUserGameMetricsTable.cs b/Refresh.Database/Migrations/20261008091428_AddUserGameMetricsTable.cs new file mode 100644 index 000000000..5003128e7 --- /dev/null +++ b/Refresh.Database/Migrations/20261008091428_AddUserGameMetricsTable.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + /// + [DbContext(typeof(GameDatabaseContext))] + [Migration("20261008091428_AddUserGameMetricsTable")] + public partial class AddUserGameMetricsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TotalPlayTimeMinutes", + table: "GameUserStatistics", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.CreateTable( + name: "UserGameMetrics", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + Game = table.Column(type: "integer", nullable: false), + Platform = table.Column(type: "integer", nullable: false), + LastLoginAt = table.Column(type: "timestamp with time zone", nullable: false), + LastRoomUpdateAt = table.Column(type: "timestamp with time zone", nullable: false), + TotalPlayTimeMinutes = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserGameMetrics", x => new { x.UserId, x.Game, x.Platform }); + table.ForeignKey( + name: "FK_UserGameMetrics_GameUsers_UserId", + column: x => x.UserId, + principalTable: "GameUsers", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserGameMetrics"); + + migrationBuilder.DropColumn( + name: "TotalPlayTimeMinutes", + table: "GameUserStatistics"); + } + } +} diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 86774e7d0..54cd03f27 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -621,6 +621,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GameScores"); }); + modelBuilder.Entity("Refresh.Database.Models.Metrics.UserGameMetric", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("Game") + .HasColumnType("integer"); + + b.Property("Platform") + .HasColumnType("integer"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRoomUpdateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalPlayTimeMinutes") + .HasColumnType("bigint"); + + b.HasKey("UserId", "Game", "Platform"); + + b.ToTable("UserGameMetrics"); + }); + modelBuilder.Entity("Refresh.Database.Models.Moderation.ModerationAction", b => { b.Property("ActionId") @@ -1503,6 +1528,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReviewCount") .HasColumnType("integer"); + b.Property("TotalPlayTimeMinutes") + .HasColumnType("bigint"); + b.Property("Version") .HasColumnType("integer"); @@ -2074,6 +2102,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Publisher"); }); + modelBuilder.Entity("Refresh.Database.Models.Metrics.UserGameMetric", b => + { + b.HasOne("Refresh.Database.Models.Users.GameUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Refresh.Database.Models.Moderation.ModerationAction", b => { b.HasOne("Refresh.Database.Models.Users.GameUser", "Actor")