Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for tracking playtime statistics #584

Merged
merged 32 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d61ccd2
Move Playtime logic into GameManagement
gablm Jun 20, 2024
eba6e58
Playtime - Add Dispose and rename classes
gablm Jun 20, 2024
9fcecc0
Playtime - Add localization support
gablm Jun 22, 2024
a923511
Playtime - Misc fixes + Last Session Played
gablm Jun 26, 2024
e971aec
Merge branch 'main' into playtime-stats
gablm Jun 26, 2024
aa5e5c7
Playtime - Grid Stats Display
gablm Jun 26, 2024
7741bf5
Playtime - Change stats ui
gablm Jun 26, 2024
a4e3383
Merge branch 'main' into playtime-stats
gablm Jun 30, 2024
e4d0a6a
Playtime - Align last played date to the right
gablm Jun 30, 2024
23ed8f4
Playtime - Move Stats UI into Xaml
gablm Jul 8, 2024
f2e80ec
Merge branch 'main' into playtime-stats
gablm Jul 8, 2024
2f67fc1
Merge branch 'main' into playtime-stats
gablm Aug 22, 2024
c91a65e
Playtime - Ensure compatibility with other versions
gablm Aug 22, 2024
1d61cf9
Playtime - Reload playtime before session and when trying to edit
gablm Aug 22, 2024
4a601cf
Playtime - Ignore total playtime and last session serializer wise
gablm Aug 26, 2024
80e5031
Merge branch 'main' into playtime-stats
gablm Aug 26, 2024
ee70215
Fix stop game keyboard shortcut
gablm Aug 26, 2024
4101553
Merge branch 'main' into playtime-stats
gablm Sep 17, 2024
cc4adc8
Sync branch with main
gablm Sep 17, 2024
2a03e82
Localize "Never Played"
gablm Sep 17, 2024
8d45f2e
Update Flyout buttons style to match others
gablm Sep 17, 2024
3127ed5
Merge branch 'main' into playtime-stats
gablm Sep 28, 2024
f638e1e
Playtime - Recover prototype UI
gablm Sep 28, 2024
8e3113f
Switch to Flyout for Playtime Stats
neon-nyan Sep 29, 2024
08cddfa
Merge branch 'main' into playtime-stats
gablm Sep 29, 2024
f3141eb
Playtime - Merge Fix
gablm Sep 29, 2024
1e96726
Align playtime button to the left
gablm Sep 29, 2024
d20929f
Update message related to reseting playtime
gablm Sep 29, 2024
7158608
Merge branch 'main' into playtime-stats
gablm Sep 29, 2024
2be2e32
Update localization
gablm Sep 29, 2024
054895a
rebiew and qodana
gablm Sep 30, 2024
0fcae76
Make Qodana happy
neon-nyan Oct 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;

namespace CollapseLauncher.GamePlaytime
{
[JsonSourceGenerationOptions(IncludeFields = false, GenerationMode = JsonSourceGenerationMode.Metadata, IgnoreReadOnlyFields = true)]
[JsonSerializable(typeof(CollapsePlaytime))]
internal sealed partial class UniversalPlaytimeJSONContext : JsonSerializerContext { }
}
136 changes: 136 additions & 0 deletions CollapseLauncher/Classes/GameManagement/GamePlaytime/Playtime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using CollapseLauncher.Extension;
using CollapseLauncher.Interfaces;
using Hi3Helper;
using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Timers;
using static Hi3Helper.Logger;
using static CollapseLauncher.Dialogs.SimpleDialogs;
using static CollapseLauncher.InnerLauncherConfig;

namespace CollapseLauncher.GamePlaytime
{
internal class Playtime : IGamePlaytime
{
#region Properties
public event EventHandler<CollapsePlaytime> PlaytimeUpdated;
#nullable enable
public CollapsePlaytime CollapsePlaytime => _playtime;

private static HashSet<int> _activeSessions = [];
private RegistryKey? _registryRoot;
private CollapsePlaytime _playtime;
private IGameVersionCheck _gameVersionManager;

private CancellationTokenSourceWrapper _token = new();
#endregion

public Playtime(IGameVersionCheck GameVersionManager)
{
string registryPath = Path.Combine($"Software\\{GameVersionManager.VendorTypeProp.VendorType}", GameVersionManager.GamePreset.InternalGameNameInConfig!);
_registryRoot = Registry.CurrentUser.OpenSubKey(registryPath, true);

_registryRoot ??= Registry.CurrentUser.CreateSubKey(registryPath, true, RegistryOptions.None);

_gameVersionManager = GameVersionManager;

_playtime = CollapsePlaytime.Load(_registryRoot, _gameVersionManager.GamePreset.HashID);
}
#nullable disable

public void Update(TimeSpan timeSpan)
{
TimeSpan oldTimeSpan = _playtime.TotalPlaytime;

_playtime.Update(timeSpan);
PlaytimeUpdated?.Invoke(this, _playtime);

LogWriteLine($"Playtime counter changed to {TimeSpanToString(timeSpan)}. (Previous value: {TimeSpanToString(oldTimeSpan)})", writeToLog: true);
}

public void Reset()
{
TimeSpan oldTimeSpan = _playtime.TotalPlaytime;

_playtime.Reset();
PlaytimeUpdated?.Invoke(this, _playtime);

LogWriteLine($"Playtime counter was reset! (Previous value: {TimeSpanToString(oldTimeSpan)})", writeToLog: true);
}

public async void StartSession(Process proc, DateTime? begin = null)
{
int hashId = _gameVersionManager.GamePreset.HashID;

// If a playtime HashSet has already tracked, then return (do not track the playtime more than once)
if (_activeSessions.Contains(hashId)) return;

// Otherwise, add it to track list
_activeSessions.Add(hashId);

begin ??= DateTime.Now;

TimeSpan initialTimeSpan = _playtime.TotalPlaytime;

_playtime.LastPlayed = begin;
_playtime.LastSession = TimeSpan.Zero;
_playtime.Save();
PlaytimeUpdated?.Invoke(this, _playtime);

#if DEBUG
LogWriteLine($"{_gameVersionManager.GamePreset.ProfileName} - Started session at {begin?.ToLongTimeString()}.");

Check warning on line 84 in CollapseLauncher/Classes/GameManagement/GamePlaytime/Playtime.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Conditional access qualifier expression is known to be null or not null

Conditional access qualifier expression is known to be not null
Fixed Show fixed Hide fixed
#endif
int elapsedSeconds = 0;

using (var inGameTimer = new Timer())
{
inGameTimer.Interval = 60000;
inGameTimer.Elapsed += (_, _) =>
{
elapsedSeconds += 60;
DateTime now = DateTime.Now;

_playtime.AddMinute();
PlaytimeUpdated?.Invoke(this, _playtime);
#if DEBUG
LogWriteLine($"{_gameVersionManager.GamePreset.ProfileName} - {elapsedSeconds}s elapsed. ({now.ToLongTimeString()})");
#endif
};

inGameTimer.Start();
await proc.WaitForExitAsync(_token.Token);
inGameTimer.Stop();
}

DateTime end = DateTime.Now;
double totalElapsedSeconds = (end - begin.Value).TotalSeconds;
if (totalElapsedSeconds < 0)
{
LogWriteLine($"[HomePage::StartPlaytimeCounter] Date difference cannot be lower than 0. ({totalElapsedSeconds}s)", LogType.Error);
Dialog_InvalidPlaytime(m_mainPage?.Content, elapsedSeconds);
totalElapsedSeconds = elapsedSeconds;
}

TimeSpan totalTimeSpan = TimeSpan.FromSeconds(totalElapsedSeconds);
LogWriteLine($"Added {totalElapsedSeconds}s [{totalTimeSpan.Hours}h {totalTimeSpan.Minutes}m {totalTimeSpan.Seconds}s] " +
$"to {_gameVersionManager.GamePreset.ProfileName} playtime.", LogType.Default, true);

_playtime.Update(initialTimeSpan.Add(totalTimeSpan), false);
PlaytimeUpdated?.Invoke(this, _playtime);

_activeSessions.Remove(hashId);
}

private static string TimeSpanToString(TimeSpan timeSpan) => $"{timeSpan.Days * 24 + timeSpan.Hours}h {timeSpan.Minutes}m";

public void Dispose()
{
_token.Cancel();
_playtime.Save();
_registryRoot = null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
using Hi3Helper;
using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using static Hi3Helper.Logger;

namespace CollapseLauncher.GamePlaytime
{
internal class CollapsePlaytime
{
#region Fields
private static DateTime BaseDate => new(2012, 2, 13, 0, 0, 0, DateTimeKind.Utc);

private const string TotalTimeValueName = "CollapseLauncher_Playtime";
private const string LastPlayedValueName = "CollapseLauncher_LastPlayed";
private const string StatsValueName = "CollapseLauncher_PlaytimeStats";

private static HashSet<int> _isDeserializing = [];
private RegistryKey _registryRoot;
private int _hashID;

#endregion

#region Properties
/// <summary>
/// Represents the total time a game was played.<br/><br/>
/// Default: TimeSpan.Zero
/// </summary>
[JsonIgnore]
public TimeSpan TotalPlaytime { get; private set; } = TimeSpan.Zero;

/// <summary>
/// Represents the daily playtime.<br/>
/// The ControlDate field is used to check if this value should be reset.<br/><br/>
/// Default: TimeSpan.Zero
/// </summary>
public TimeSpan DailyPlaytime { get; private set; } = TimeSpan.Zero;

/// <summary>
/// Represents the weekly playtime.<br/>
/// The ControlDate field is used to check if this value should be reset.<br/><br/>
/// Default: TimeSpan.Zero
/// </summary>
public TimeSpan WeeklyPlaytime { get; private set; } = TimeSpan.Zero;

/// <summary>
/// Represents the monthly playtime.<br/>
/// The ControlDate field is used to check if this value should be reset.<br/><br/>
/// Default: TimeSpan.Zero
/// </summary>
public TimeSpan MonthlyPlaytime { get; private set; } = TimeSpan.Zero;

/// <summary>
/// Represents the total time the last/current session lasted.<br/><br/>
/// Default: TimeSpan.Zero
/// </summary>
public TimeSpan LastSession { get; set; } = TimeSpan.Zero;

/// <summary>
/// Represents the last time the game was launched.<br/><br/>
/// Default: null
/// </summary>
[JsonIgnore]
public DateTime? LastPlayed { get; set; }

/// <summary>
/// Represents a control date.<br/>
/// This date is used to check if a specific playtime statistic should be reset.<br/><br/>
/// Default: DateTime.Today
/// </summary>
public DateTime ControlDate { get; private set; } = DateTime.Today;
#endregion

#region Methods
#nullable enable
/// <summary>
/// Reads from the Registry and deserializes the contents. <br/>
/// Converts RegistryKey values if they are of type DWORD (that is, if they were saved by the old implementation).
/// </summary>
public static CollapsePlaytime Load(RegistryKey root, int hashID)
{
try
{
_isDeserializing.Add(hashID);
if (root == null) throw new NullReferenceException($"Cannot load playtime. RegistryKey is unexpectedly not initialized!");

int? totalTime = (int?)root.GetValue(TotalTimeValueName,null);
int? lastPlayed = (int?)root.GetValue(LastPlayedValueName,null);
object? stats = root.GetValue(StatsValueName, null);

CollapsePlaytime playtime;

if (stats != null)
{
ReadOnlySpan<byte> byteStr = (byte[])stats;
#if DEBUG
LogWriteLine($"Loaded Playtime:\r\nTotal: {totalTime}s\r\nLastPlayed: {lastPlayed}\r\nStats: {Encoding.UTF8.GetString(byteStr.TrimEnd((byte)0))}", LogType.Debug, true);
#endif
playtime = byteStr.Deserialize<CollapsePlaytime>(UniversalPlaytimeJSONContext.Default) ?? new CollapsePlaytime();
}
else
{
playtime = new CollapsePlaytime();
}

playtime._registryRoot = root;
playtime._hashID = hashID;
playtime.TotalPlaytime = TimeSpan.FromSeconds(totalTime ?? 0);
playtime.LastPlayed = lastPlayed != null ? BaseDate.AddSeconds((int)lastPlayed) : null;

return playtime;
}
catch (Exception ex)
{
LogWriteLine($"Failed while reading playtime.\r\n{ex}", LogType.Error, true);
}
finally
{
_isDeserializing.Remove(hashID);
}

return new CollapsePlaytime() { _hashID = hashID, _registryRoot = root };
}

/// <summary>
/// Serializes all fields and saves them to the Registry.
/// </summary>
public void Save()
{
try
{
if (_registryRoot == null) throw new NullReferenceException($"Cannot save playtime since RegistryKey is unexpectedly not initialized!");

string data = this.Serialize(UniversalPlaytimeJSONContext.Default, true);
byte[] dataByte = Encoding.UTF8.GetBytes(data);
#if DEBUG
LogWriteLine($"Saved Playtime:\r\n{data}", LogType.Debug, true);
#endif
_registryRoot.SetValue(StatsValueName, dataByte, RegistryValueKind.Binary);
_registryRoot.SetValue(TotalTimeValueName, TotalPlaytime.TotalSeconds, RegistryValueKind.DWord);

double? lastPlayed = (LastPlayed?.ToUniversalTime() - BaseDate)?.TotalSeconds;
if (lastPlayed != null)
_registryRoot.SetValue(LastPlayedValueName, lastPlayed, RegistryValueKind.DWord);
}
catch (Exception ex)
{
LogWriteLine($"Failed to save playtime!\r\n{ex}", LogType.Error, true);
}
}

/// <summary>
/// Resets all fields and saves to the Registry.
/// </summary>
public void Reset()
{
TotalPlaytime = TimeSpan.Zero;
LastSession = TimeSpan.Zero;
DailyPlaytime = TimeSpan.Zero;
WeeklyPlaytime = TimeSpan.Zero;
MonthlyPlaytime = TimeSpan.Zero;
ControlDate = DateTime.Today;
LastPlayed = null;

if (!_isDeserializing.Contains(_hashID)) Save();
}

/// <summary>
/// Updates the current Playtime TimeSpan to the provided value and saves to the Registry.<br/><br/>
/// </summary>
/// <param name="timeSpan">New playtime value</param>
/// <param name="reset">Reset all other fields</param>
public void Update(TimeSpan timeSpan, bool reset = true)
{
if (reset)
{
LastSession = TimeSpan.Zero;
DailyPlaytime = TimeSpan.Zero;
WeeklyPlaytime = TimeSpan.Zero;
MonthlyPlaytime = TimeSpan.Zero;
ControlDate = DateTime.Today;
}

TotalPlaytime = timeSpan;

if (!_isDeserializing.Contains(_hashID)) Save();
}

/// <summary>
/// Adds a minute to all fields, and checks if any should be reset.<br/>
/// After it saves to the Registry.<br/><br/>
/// </summary>
public void AddMinute()
{
TimeSpan minute = TimeSpan.FromMinutes(1);
DateTime today = DateTime.Today;

TotalPlaytime = TotalPlaytime.Add(minute);
LastSession = LastSession.Add(minute);

if (ControlDate == today)
{
DailyPlaytime = DailyPlaytime.Add(minute);
WeeklyPlaytime = WeeklyPlaytime.Add(minute);
MonthlyPlaytime = MonthlyPlaytime.Add(minute);
}
else
{
DailyPlaytime = minute;
WeeklyPlaytime = IsDifferentWeek(ControlDate, today) ? minute : WeeklyPlaytime.Add(minute);
MonthlyPlaytime = IsDifferentMonth(ControlDate, today) ? minute : MonthlyPlaytime.Add(minute);

ControlDate = today;
}

if (!_isDeserializing.Contains(_hashID)) Save();
}

#endregion

#region Utility
private static bool IsDifferentMonth(DateTime date1, DateTime date2) => date1.Year != date2.Year || date1.Month != date2.Month;

private static bool IsDifferentWeek(DateTime date1, DateTime date2) => date1.Year != date2.Year || ISOWeek.GetWeekOfYear(date1) != ISOWeek.GetWeekOfYear(date2);
#endregion
}
}
Loading
Loading