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

Implement Database Synchronization Function for Game Playtime #586

Open
wants to merge 37 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9234c9c
Sqlite database prototype
bagusnl Sep 28, 2024
6fdbbf4
Fix everything broke idk my head hurt
bagusnl Sep 28, 2024
e6cf38a
switch base to `playtime-stats`
bagusnl Oct 1, 2024
3a2560b
Fix DBHandler
bagusnl Oct 1, 2024
6964a98
Initial DB implementation - Playtime
bagusnl Oct 1, 2024
18aa7f3
Fix stuff
bagusnl Oct 1, 2024
c8bd066
[PlaytimeDB] Add more value store
bagusnl Oct 1, 2024
0f3a6a3
Merge branch 'main' into col-db
bagusnl Oct 5, 2024
4bb4a65
Initial implementation for the playtime sync
bagusnl Oct 5, 2024
882b921
[PlaytimeDB] Save new value if database values is older
bagusnl Oct 5, 2024
22b8d4b
Change inner prop name to reduce confusion
bagusnl Oct 5, 2024
61c83a0
Fix property not updating local playtime
neon-nyan Oct 5, 2024
296ebc4
[PlaytimeDB] Invoke forceDbUpdate on user force
bagusnl Oct 5, 2024
3b593ec
[PlaytimeDB] Initial frontend
bagusnl Oct 5, 2024
31142ae
[DB] Close config file after creation
bagusnl Oct 6, 2024
1e22f95
[DB] Handler improvements
bagusnl Oct 6, 2024
2670044
[PlaytimeDB] Dem frontends
bagusnl Oct 6, 2024
c283501
CodeQA
bagusnl Oct 6, 2024
5f57b9a
Merge remote-tracking branch 'origin/main' into col-db
bagusnl Oct 8, 2024
81236f8
[DB] Fix crashing due to empty Token
bagusnl Oct 8, 2024
c845699
[PlaytimeDB] Pass gameSettings to new instance
bagusnl Oct 8, 2024
dd77b16
[DB] Save on guid generate
bagusnl Oct 8, 2024
cfcab0c
[PlaytimeDB] UI Fixes
bagusnl Oct 8, 2024
57f5a2e
[DB] Localize error throw
bagusnl Oct 8, 2024
c3c1d32
[DB] Oops... Dropped my unused usings
bagusnl Oct 8, 2024
d8a908c
[DB] Fix Handler exception throw logics
bagusnl Oct 8, 2024
cad6242
[DB] Force first init on config change
bagusnl Oct 8, 2024
007d035
[PlaytimeDB] Fetch and checkstamp first during sync
bagusnl Oct 8, 2024
97d13ba
[PlaytimeDB] CodeQA async fix
bagusnl Oct 8, 2024
c31afd3
[PlaytimeDB] Move database puller in sync op
bagusnl Oct 8, 2024
9e19ed6
Sync base
bagusnl Oct 10, 2024
ef8feb9
[DB] Add Unauthorized throw
bagusnl Oct 13, 2024
34a0384
[PlaytimeDB] Fix button margin
bagusnl Oct 13, 2024
4db29bf
[DB] Force user to press validate and save button to apply changes
bagusnl Oct 13, 2024
8b76ef7
[DB] Typo fixes
bagusnl Oct 13, 2024
d3df330
Merge branch 'main' into col-db
gablm Oct 13, 2024
ae8f379
[DB] Force InvariantCulture when converting to double
gablm Oct 13, 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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 27 additions & 9 deletions CollapseLauncher/Classes/GameManagement/GamePlaytime/Playtime.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CollapseLauncher.Extension;
using CollapseLauncher.Helper.Database;
using CollapseLauncher.Interfaces;
using Hi3Helper;
using Microsoft.Win32;
Expand Down Expand Up @@ -28,24 +29,40 @@ internal class Playtime : IGamePlaytime
private CancellationTokenSourceWrapper _token = new();
#endregion

public Playtime(IGameVersionCheck GameVersionManager)
public Playtime(IGameVersionCheck gameVersionManager, IGameSettings gameSettings)
{
string registryPath = Path.Combine($"Software\\{GameVersionManager.VendorTypeProp.VendorType}", GameVersionManager.GamePreset.InternalGameNameInConfig!);
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;
_gameVersionManager = gameVersionManager;

_playtime = CollapsePlaytime.Load(_registryRoot, _gameVersionManager.GamePreset.HashID);
_playtime = CollapsePlaytime.Load(_registryRoot,
_gameVersionManager.GamePreset.HashID,
_gameVersionManager,
gameSettings);


if (DbHandler.IsEnabled && gameSettings.AsIGameSettingsUniversal().SettingsCollapseMisc.IsSyncPlaytimeToDatabase)
CheckDb();
}
#nullable disable

public void Update(TimeSpan timeSpan)
public async void CheckDb()
{
var needUpdate = await _playtime.DbSync();
if (needUpdate is not { IsUpdated: true, PlaytimeData: not null }) return;

_playtime = needUpdate.PlaytimeData;
PlaytimeUpdated?.Invoke(this, _playtime);
}

public void Update(TimeSpan timeSpan, bool forceUpdateDb = false)
{
TimeSpan oldTimeSpan = _playtime.TotalPlaytime;

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

LogWriteLine($"Playtime counter changed to {TimeSpanToString(timeSpan)}. (Previous value: {TimeSpanToString(oldTimeSpan)})", writeToLog: true);
Expand Down Expand Up @@ -118,7 +135,7 @@ public async void StartSession(Process proc, DateTime? begin = null)
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);
_playtime.Update(initialTimeSpan.Add(totalTimeSpan), false, true);
PlaytimeUpdated?.Invoke(this, _playtime);

_activeSessions.Remove(hashId);
Expand All @@ -129,8 +146,9 @@ public async void StartSession(Process proc, DateTime? begin = null)
public void Dispose()
{
_token.Cancel();
_playtime.Save();
_registryRoot = null;
_playtime.Save(true);
_playtime.LastDbUpdate = DateTime.MinValue;
_registryRoot = null;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using Hi3Helper;
using CollapseLauncher.Interfaces;
using CollapseLauncher.Helper.Database;
using Hi3Helper;
using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using static Hi3Helper.Logger;

namespace CollapseLauncher.GamePlaytime
Expand All @@ -18,10 +21,13 @@ internal class CollapsePlaytime
private const string LastPlayedValueName = "CollapseLauncher_LastPlayed";
private const string StatsValueName = "CollapseLauncher_PlaytimeStats";

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

// ReSharper disable once FieldCanBeMadeReadOnly.Local
private static HashSet<int> _isDeserializing = [];
private RegistryKey _registryRoot;
private int _hashID;
private IGameVersionCheck _gameVersion;
private IGameSettings _gameSettings;

#endregion

#region Properties
Expand Down Expand Up @@ -79,7 +85,9 @@ internal class CollapsePlaytime
/// <summary>
/// Reads from the Registry and deserializes the contents.
/// </summary>
public static CollapsePlaytime Load(RegistryKey root, int hashID)
public static CollapsePlaytime Load(RegistryKey root, int hashID,
IGameVersionCheck gameVersion,
IGameSettings gameSettings)
{
try
{
Expand All @@ -90,27 +98,29 @@ public static CollapsePlaytime Load(RegistryKey root, int hashID)
int? lastPlayed = (int?)root.GetValue(LastPlayedValueName,null);
object? stats = root.GetValue(StatsValueName, null);

CollapsePlaytime playtime;
CollapsePlaytime? playtimeInner;

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(UniversalPlaytimeJSONContext.Default.CollapsePlaytime, new CollapsePlaytime())!;
playtimeInner = byteStr.Deserialize(UniversalPlaytimeJSONContext.Default.CollapsePlaytime, new CollapsePlaytime())!;
}
else
{
playtime = new CollapsePlaytime();
playtimeInner = new CollapsePlaytime();
}

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

return playtime;
return playtimeInner;
}
catch (Exception ex)
{
Expand All @@ -121,19 +131,25 @@ public static CollapsePlaytime Load(RegistryKey root, int hashID)
_isDeserializing.Remove(hashID);
}

return new CollapsePlaytime() { _hashID = hashID, _registryRoot = root };
return new CollapsePlaytime
{
_hashID = hashID,
_registryRoot = root,
_gameVersion = gameVersion,
_gameSettings = gameSettings
};
}

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

string data = this.Serialize(UniversalPlaytimeJSONContext.Default.CollapsePlaytime, true);
string data = this.Serialize(UniversalPlaytimeJSONContext.Default.CollapsePlaytime);
byte[] dataByte = Encoding.UTF8.GetBytes(data);
#if DEBUG
LogWriteLine($"Saved Playtime:\r\n{data}", LogType.Debug, true);
Expand All @@ -144,6 +160,12 @@ public void Save()
double? lastPlayed = (LastPlayed?.ToUniversalTime() - BaseDate)?.TotalSeconds;
if (lastPlayed != null)
_registryRoot.SetValue(LastPlayedValueName, lastPlayed, RegistryValueKind.DWord);

if (DbHandler.IsEnabled && _gameSettings.AsIGameSettingsUniversal().SettingsCollapseMisc.IsSyncPlaytimeToDatabase &&
((DateTime.Now - LastDbUpdate).TotalMinutes >= 5 || forceUpdateDb)) // Sync only every 5 minutes to reduce database usage
{
Cryotechnic marked this conversation as resolved.
Show resolved Hide resolved
_ = UpdatePlaytime_Database_Push(data, TotalPlaytime.TotalSeconds, lastPlayed);
}
}
catch (Exception ex)
{
Expand All @@ -164,15 +186,16 @@ public void Reset()
ControlDate = DateTime.Today;
LastPlayed = null;

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

/// <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)
/// <param name="forceUpdateDb">Force update database data</param>
public void Update(TimeSpan timeSpan, bool reset = true, bool forceUpdateDb = false)
{
if (reset)
{
Expand All @@ -185,7 +208,7 @@ public void Update(TimeSpan timeSpan, bool reset = true)

TotalPlaytime = timeSpan;

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

/// <summary>
Expand Down Expand Up @@ -225,5 +248,153 @@ public void AddMinute()

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

#region Database Extension
// processing flags, prevents double task
private bool _isDbSyncing;
private bool _isDbPulling;
private bool _isDbPullSuccess;

public DateTime LastDbUpdate = DateTime.MinValue;

// value store
private string? _jsonDataDb;
private double? _totalTimeDb;
private double? _lastPlayedDb;
private int? _unixStampDb;

// Key names
private string KeyPlaytimeJson => $"{_gameVersion.GameType.ToString()}-{_gameVersion.GameRegion}-pt-js";
private string KeyTotalTime => $"{_gameVersion.GameType.ToString()}-{_gameVersion.GameRegion}-pt-total";
private string KeyLastPlayed => $"{_gameVersion.GameType.ToString()}-{_gameVersion.GameRegion}-pt-lastPlayed";
private string KeyLastUpdated => $"{_gameVersion.GameType.ToString()}-{_gameVersion.GameRegion}-pt-lu";


#region Sync Methods
/// <summary>
/// Sync from/to DB at init
/// </summary>
/// <returns>true if require refresh, false if dont.</returns>
public async ValueTask<(bool IsUpdated, CollapsePlaytime? PlaytimeData)> DbSync()
{
LogWriteLine("[CollapsePlaytime::DbSync] Starting sync operation...", LogType.Default, true);
try
{
// Fetch database last update stamp
var stampDbStr = await DbHandler.QueryKey(KeyLastUpdated);
_unixStampDb = !string.IsNullOrEmpty(stampDbStr) ? Convert.ToInt32(stampDbStr) : null;

// Compare unix stamp from config
var unixStampLocal = Convert.ToInt32(DbConfig.GetConfig(KeyLastUpdated).ToString());
if (_unixStampDb == unixStampLocal)
{
LogWriteLine("[CollapsePlaytime::DbSync] Sync stamp equal, nothing needs to be done~", LogType.Default, true);
return (false, null); // Do nothing if stamp is equal
}

// When Db stamp is newer, sync from Db
if (_unixStampDb > unixStampLocal)
{
// Pull values from DB
await UpdatePlaytime_Database_Pull();
if (!_isDbPullSuccess)
{
LogWriteLine("[CollapsePlaytime::DbSync] Database pull failed, skipping sync~", LogType.Error);
return (false, null); // Return if pull failed
}

if (string.IsNullOrEmpty(_jsonDataDb))
{
LogWriteLine("[CollapsePlaytime::DbSync] _jsonDataDb is empty, skipping sync~", default, true);
return (false, null);
}
LogWriteLine("[CollapsePlaytime::DbSync] Database data is newer! Pulling data~", LogType.Default, true);
CollapsePlaytime? playtimeInner = _jsonDataDb.Deserialize(UniversalPlaytimeJSONContext.Default.CollapsePlaytime,
new CollapsePlaytime());
gablm marked this conversation as resolved.
Show resolved Hide resolved

playtimeInner!.TotalPlaytime = TimeSpan.FromSeconds(_totalTimeDb ?? 0);
if (_lastPlayedDb != null) playtimeInner.LastPlayed = BaseDate.AddSeconds((int)_lastPlayedDb);
playtimeInner._registryRoot = _registryRoot;
playtimeInner._gameSettings = _gameSettings;
gablm marked this conversation as resolved.
Show resolved Hide resolved
playtimeInner._hashID = _hashID;
playtimeInner._gameVersion = _gameVersion;
DbConfig.SetAndSaveValue(KeyLastUpdated, _unixStampDb.ToString());
LastDbUpdate = DateTime.Now;
playtimeInner.Save();
return (true, playtimeInner);
}

if (_unixStampDb < unixStampLocal)
{
LogWriteLine("[CollapsePlaytime::DbSync] Database data is older! Pushing data~", default, true);
Save(true);
}
return (false, null);
}
catch (Exception ex)
{
LogWriteLine($"[CollapsePlaytime::DbSync] Failed when trying to do sync operation\r\n{ex}",
LogType.Error, true);
return (false, null);
}
}
#endregion

#region DB Operation Methods
private async Task UpdatePlaytime_Database_Push(string jsonData, double totalTime, double? lastPlayed)
{
if (_isDbSyncing) return;
var curDateTime = DateTime.Now;
bagusnl marked this conversation as resolved.
Show resolved Hide resolved
_isDbSyncing = true;
try
{
var unixStamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
await DbHandler.StoreKeyValue(KeyPlaytimeJson, jsonData);
await DbHandler.StoreKeyValue(KeyTotalTime, totalTime.ToString(CultureInfo.InvariantCulture));
await DbHandler.StoreKeyValue(KeyLastPlayed, lastPlayed != null ? lastPlayed.Value.ToString(CultureInfo.InvariantCulture) : "null");
await DbHandler.StoreKeyValue(KeyLastUpdated, unixStamp.ToString());
DbConfig.SetAndSaveValue(KeyLastUpdated, unixStamp);
_unixStampDb = Convert.ToInt32(unixStamp);
LastDbUpdate = curDateTime;
}
catch (Exception e)
{
LogWriteLine($"Failed when syncing Playtime to DB!\r\n{e}", LogType.Error, true);
}
finally
{
_isDbSyncing = false;
}
}

private async Task UpdatePlaytime_Database_Pull()
{
if (_isDbPulling) return;
_isDbPullSuccess = false;
try
{
_isDbPulling = true;

_jsonDataDb = await DbHandler.QueryKey(KeyPlaytimeJson);

var totalTimeDbStr = await DbHandler.QueryKey(KeyTotalTime);
_totalTimeDb = string.IsNullOrEmpty(totalTimeDbStr) ? null : Convert.ToDouble(totalTimeDbStr, CultureInfo.InvariantCulture);

var lpDb = await DbHandler.QueryKey(KeyLastPlayed);
_lastPlayedDb = !string.IsNullOrEmpty(lpDb) && !lpDb.Contains("null") ? Convert.ToDouble(lpDb, CultureInfo.InvariantCulture) : null; // if Db data is null, return null

_isDbPullSuccess = true;
}
catch (Exception e)
{
LogWriteLine($"Failed when syncing Playtime to DB!\r\n{e}", LogType.Error, true);
}
finally
{
_isDbPulling = false;
}
}
#endregion
#endregion
}
}
Loading
Loading