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 diff --git a/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs b/Refresh.Core/Types/Matching/MatchMethods/CreateRoomMethod.cs index 6bca45dad..ca7ab9719 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.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 6c0f57f1d..53499910f 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.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 bc0c30e17..57f086edd 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.UpdatePlayTimeOnUserMetric(dataContext.User!, dataContext.Game!, dataContext.Platform!); return OK; } diff --git a/Refresh.Database/GameDatabaseContext.Metrics.cs b/Refresh.Database/GameDatabaseContext.Metrics.cs new file mode 100644 index 000000000..b3c41ffb2 --- /dev/null +++ b/Refresh.Database/GameDatabaseContext.Metrics.cs @@ -0,0 +1,98 @@ +using Refresh.Common; +using Refresh.Database.Models.Authentication; +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 GetGameMetricForUser(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. 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}/{platform}."); + metric = new() + { + UserId = user.UserId, + User = user, + Game = game, + Platform = platform, + LastLoginAt = DateTimeOffset.MinValue, + LastRoomUpdateAt = DateTimeOffset.MinValue, + TotalPlayTimeMinutes = 0, + }; + this.UserGameMetrics.Add(metric); + + this.TrackUserAsUnchanged(user); + this.SaveChanges(); + } + + return metric; + } + + /// + /// Gets the user's playtime across all games and platforms. + /// + public long GetTotalPlayTimeByUser(GameUser user) + { + return this.UserGameMetrics + .Where(m => m.UserId == user.UserId) + .Sum(m => m.TotalPlayTimeMinutes); + } + + public UserGameMetric UpdateLoginDateOnUserMetric(GameUser user, TokenGame game, TokenPlatform 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}/{platform}: from {metric.LastLoginAt} to {now}."); + this.UserGameMetrics.Update(metric); + metric.LastLoginAt = now; + + this.SaveChanges(); + return metric; + } + + public UserGameMetric UpdatePlayTimeOnUserMetric(GameUser user, TokenGame game, TokenPlatform 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}."); + + // 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 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) + { + long additionalMinutes = (now.ToUnixTimeSeconds() - metric.LastRoomUpdateAt.ToUnixTimeSeconds()) / 60; + 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; + + 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.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..ea9e59d2c 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() { @@ -326,6 +326,7 @@ private void WriteEnsuringStatistics(GameUser user, Action action) this.CalculateUserStatisticsIfNotPresent(user); action(); this.MarkUserStatisticsDirty(user); + this.GameUserStatistics.Update(user.Statistics!); }); } @@ -353,6 +354,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/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index c231dddd3..b41082c37 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.GameUsers.Attach(user); + if (user.Statistics != null) + { + this.GameUserStatistics.Attach(user.Statistics); + } + } [Pure] [ContractAnnotation("username:null => null; username:notnull => canbenull")] 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/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") diff --git a/Refresh.Database/Models/Metrics/UserGameMetric.cs b/Refresh.Database/Models/Metrics/UserGameMetric.cs new file mode 100644 index 000000000..82bb72aa7 --- /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, determined by the times inbetween their room update requests. + /// + public long TotalPlayTimeMinutes { get; set; } +} \ No newline at end of file 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 diff --git a/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs index 546230faa..318345503 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.UpdateLoginDateOnUserMetric(user, game.Value, platform.Value); + // Clear the user's force match database.ClearForceMatch(user); 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 diff --git a/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs b/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs new file mode 100644 index 000000000..142accf97 --- /dev/null +++ b/RefreshTests.GameServer/Tests/Matching/UserPlaytimeTests.cs @@ -0,0 +1,259 @@ +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, + }, + }; + + // 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); + + 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(1000 * 60)); + Assert.That(metric.TotalPlayTimeMinutes, Is.Zero); + + // Second room update + context.Time.TimestampMilliseconds += 2000 * 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, Is.EqualTo(DateTimeOffset.MinValue)); + Assert.That(metric.LastRoomUpdateAt.ToUnixTimeMilliseconds(), Is.EqualTo(3000 * 60)); + Assert.That(metric.TotalPlayTimeMinutes, Is.EqualTo(2)); + + Assert.That(updatedUser?.Statistics, Is.Not.Null); + Assert.That(updatedUser!.Statistics!.TotalPlayTimeMinutes, Is.EqualTo(2)); + + // Update the login date + context.Time.TimestampMilliseconds += 3000 * 60; + context.Database.UpdateLoginDateOnUserMetric(user, TokenGame.LittleBigPlanet1, TokenPlatform.PS3); + + 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)); // 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 + + 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); + + 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(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] + 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, + }, + }; + + // 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 + match.ExecuteMethod("CreateRoom", roomData, this.GetDataContext(context.Database, token1, match), config); + 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 += 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 += 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 += 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(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(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(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(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(14)); + } + + [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