From c925f696f256efc7e3d21b0fa8d3f8c7e45c7ec5 Mon Sep 17 00:00:00 2001 From: Josh Smith <6895577+joshsmithxrm@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:43:40 -0500 Subject: [PATCH 1/5] Implement explicit active task lifecycle sync --- README.md | 12 +- TarkovMonitor.Tests/Program.cs | 226 +++++++++ TarkovMonitor.Tests/Stubs.cs | 62 +++ .../TarkovMonitor.Tests.csproj | 11 + TarkovMonitor.sln | 6 + .../Pages/RawLogs/ForceReadDialog.razor | 83 ++-- TarkovMonitor/GameWatcher.cs | 180 ++----- TarkovMonitor/MainBlazorUI.cs | 10 +- TarkovMonitor/TarkovTracker.cs | 86 ++-- TarkovMonitor/TaskLifecycle.cs | 463 ++++++++++++++++++ 10 files changed, 910 insertions(+), 229 deletions(-) create mode 100644 TarkovMonitor.Tests/Program.cs create mode 100644 TarkovMonitor.Tests/Stubs.cs create mode 100644 TarkovMonitor.Tests/TarkovMonitor.Tests.csproj create mode 100644 TarkovMonitor/TaskLifecycle.cs diff --git a/README.md b/README.md index 80726d42..adb79c93 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ TarkovMonitor is an Escape from Tarkov companion application that provides usefu - Automatically load the website map for the map you're playing on - Take an in-game screenshot and show your position on the website map - Connect to Tarkov Tracker via API token - - Automatically mark quests as complete as you complete them + - Automatically mark ordinary quests as accepted/active, failed, restarted, or complete as EFT writes those lifecycle events - Statistics (all stored locally on your computer) - Track your total sales on the flea market - Track how many times you play on each map @@ -50,7 +50,9 @@ On its own, TarkovMonitor will play audio notifications (e.g., when you match in [Tarkov Tracker](https://tarkovtracker.org) is a free website that allows you to track your quest progress. Once you log in to create a Tarkov Tracker account, you can share your quest progress with other tools (including TarkovMonitor) by creating an API token. Navigate to the [Tarkov Tracker settings page](https://tarkovtracker.org/settings), click the `create a token` button, and create a token that has permissions to `get progression` and `write progression`. You can give the token any name you want, but if you're creating it for Tarkov Monitor, it makes sense to name it `Tarkov Monitor`. Then click the `create token` button and click the token's copy button. Do not try to manually highlight the displayed token and copy it; some of the displayed token's characters are obfuscated with asterisks (*). Once you've copied the token, paste it in the Tarkov Tracker API token box in Tarkov Monitor settings and click the `Test Token` button. If you see a pop up indicating success, Tarkov Tracker is ready to start automatically updating your progress on Tarkov Tracker. -Tarkov Tracker only automatically marks a quest as complete if it's running when the quest is completed. If you already completed a bunch of quests prior to running Tarkov Monitor, see [the below section on how to read past progress](#ive-installed-and-run-tarkovmonitor-why-hasnt-it-marked-all-my-completed-quests-as-complete). +Tarkov Monitor syncs ordinary quest acceptance (`active`), failure, restart, and completion while it is running. Failed tasks retain Tarkov Tracker's completed-plus-failed encoding. Accepted-task sync requires a Tarkov Tracker server version that supports the explicit `active` task state; Tarkov Monitor will warn and leave that state unwritten if the server rejects it, rather than incorrectly falling back to `uncompleted`. If you already made progress prior to running Tarkov Monitor, see [the below section on how to read past progress](#ive-installed-and-run-tarkovmonitor-why-hasnt-it-marked-all-my-completed-quests-as-complete). + +EFT does not continuously write all useful events while you are in a raid. Task events become available to Tarkov Monitor only after EFT writes them to its logs, commonly when you are out of raid. ### Tarkov.dev Website Integration @@ -66,9 +68,11 @@ TarkovMonitor simply watches the log files that the game creates as it's running ### I've installed and run TarkovMonitor, why hasn't it marked all my completed quests as complete? -TarkovMonitor only monitors new logs as they are being written while the app is running. Therefore, it doesn't automatically update quest progress that was made prior to the app running. It will, however, still mark quests as complete going forward while the app is running. +TarkovMonitor only monitors new logs as they are being written while the app is running. Therefore, it doesn't automatically update quest progress that was made prior to the app running. Going forward, it syncs ordinary task acceptance, failure/restart, and completion after EFT writes those events to its logs (typically out of raid). + +If you want to automatically update your progress from previous logs, open the Settings page, scroll down to the Initial Setup section, and click the Read Past Logs button. Tarkov Monitor will then present you with a list of breakpoints to choose the starting point to read logs from. The breakpoints are determined by the game's version number, player profile id, and supported game mode as written into the logs. Select the breakpoint corresponding with the start of the wipe for the correct account and mode, then click OK. -If you want to automatically update your progress from previous logs, open the Settings page, scroll down to the Initial Setup section, and click the Read Past Logs button. Tarkov Monitor will then present you with a list of breakpoints to choose the starting point to read logs from. The breakpoints are determined by the game's version number and your player profile id as written into each set of logs. Select the breakpoint corresponding with the start of the wipe for the correct account, click OK, and Tarkov Monitor will process all logs starting from that point through the present for the selected profile and update your quest progress accordingly. +Read Past Logs orders task lifecycle events globally by their logged timestamp, even when logs span folders or rotated/overlapping files. It establishes profile and mode at each event, ignores duplicates, and sends only the final state for each task in the selected profile and mode as one quota-conscious batch. Events whose profile or mode cannot be established are skipped rather than attributed across PvE, regular PvP, or seasonal PvP. Accepted states have the same Tarkov Tracker `active` server-version dependency described above. ### Is TarkovMonitor a cheat? diff --git a/TarkovMonitor.Tests/Program.cs b/TarkovMonitor.Tests/Program.cs new file mode 100644 index 00000000..52d88ff7 --- /dev/null +++ b/TarkovMonitor.Tests/Program.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using TarkovMonitor; +using TaskStatus = TarkovMonitor.TaskStatus; + +var tests = new (string Name, Action Run)[] +{ + ("start maps to active", StartMapsToActive), + ("dispatch is unconditional after guards", DispatchIsUnconditionalAfterGuards), + ("response active is nullable", ResponseActiveIsNullable), + ("cache lifecycle truth table", CacheLifecycleTruthTable), + ("start then finish coalesces terminal", StartThenFinish), + ("fail then restart coalesces active", FailThenRestart), + ("rotated duplicates are removed", DuplicatesAreRemoved), + ("same-time terminal ordering is deterministic", SameTimeOrdering), + ("profile and mode boundaries stay isolated", MixedProfileModeBoundaries), + ("unknown identity fails closed", UnknownIdentityFailsClosed), + ("coalescing keeps batch quota bounded", CoalescedBatchQuota), +}; + +var failures = new List(); +foreach (var test in tests) +{ + try + { + test.Run(); + Console.WriteLine($"PASS {test.Name}"); + } + catch (Exception exception) + { + failures.Add($"FAIL {test.Name}: {exception.Message}"); + } +} +foreach (var failure in failures) +{ + Console.Error.WriteLine(failure); +} +if (failures.Count > 0) +{ + Environment.Exit(1); +} +Console.WriteLine($"All {tests.Length} lifecycle tests passed."); + +static void StartMapsToActive() +{ + Equal("active", TaskLifecycle.ToTrackerState(TaskStatus.Started)); + Equal("uncompleted", TaskLifecycle.ToTrackerState(TaskStatus.None)); +} + +static void DispatchIsUnconditionalAfterGuards() +{ + var profile = new Profile { Id = "profilea", Type = ProfileType.Regular }; + True(TaskLifecycle.ShouldDispatch(profile, true, "profilea", "PVP_0123456789abcdef01")); + False(TaskLifecycle.ShouldDispatch(profile, false, "profilea", "PVP_0123456789abcdef01")); + False(TaskLifecycle.ShouldDispatch(profile, true, "profileb", "PVP_0123456789abcdef01")); + False(TaskLifecycle.ShouldDispatch(profile, true, "profilea", "PVE_0123456789abcdef01")); + False(TaskLifecycle.ShouldDispatch( + new Profile { Id = "profilea", Type = ProfileType.Unknown }, + true, "profilea", "PVP_0123456789abcdef01")); +} + +static void ResponseActiveIsNullable() +{ + var legacy = JsonSerializer.Deserialize( + "{\"id\":\"a\",\"complete\":false}")!; + var active = JsonSerializer.Deserialize( + "{\"id\":\"a\",\"active\":true}")!; + Equal(null, legacy.active); + Equal(true, active.active); + legacy.complete = true; + True(TaskLifecycle.CacheMatches(legacy, TaskStatus.Finished)); +} + +static void CacheLifecycleTruthTable() +{ + AssertCache(TaskStatus.Started, active: true, complete: false, failed: false, invalid: false); + AssertCache(TaskStatus.Failed, active: false, complete: true, failed: true, invalid: false); + AssertCache(TaskStatus.Finished, active: false, complete: true, failed: false, invalid: false); + AssertCache(TaskStatus.None, active: false, complete: false, failed: false, invalid: false); +} + +static void StartThenFinish() +{ + var result = TaskLifecycle.Replay( + new[] + { + new TaskLifecycleLogSource("folder", "m-identity.log", + Identity("2026-01-01 00:00:00.000", "Regular", "profilea", "1")), + new TaskLifecycleLogSource("folder", "z-start.log", + Lifecycle("2026-01-01 00:00:01.000", 10, "task-a", "start")), + new TaskLifecycleLogSource("folder", "a-finish.log", + Lifecycle("2026-01-01 00:00:02.000", 12, "task-a", "finish")), + }, + DateTimeOffset.MinValue); + Equal(TaskStatus.Finished, result.Coalesce().Single().Value.Status); +} + +static void FailThenRestart() +{ + var result = Replay( + Identity("2026-01-01 00:00:00.000", "Regular", "profilea", "1"), + Lifecycle("2026-01-01 00:00:01.000", 11, "task-a", "fail"), + Lifecycle("2026-01-01 00:00:02.000", 10, "task-a", "restart")); + Equal(TaskStatus.Started, result.Coalesce().Single().Value.Status); +} + +static void DuplicatesAreRemoved() +{ + var identity = Identity("2026-01-01 00:00:00.000", "Regular", "profilea", "1"); + var lifecycle = Lifecycle("2026-01-01 00:00:01.000", 10, "task-a", "same-id"); + var result = TaskLifecycle.Replay( + new[] + { + new TaskLifecycleLogSource("a-unknown-folder", "a-notifications.log", lifecycle), + new TaskLifecycleLogSource("folder", "application.log", identity), + new TaskLifecycleLogSource("folder", "notifications.log", lifecycle), + new TaskLifecycleLogSource("folder", "notifications_000.log", lifecycle), + }, + DateTimeOffset.MinValue); + Equal(1, result.Events.Count); + True(result.DuplicateCount >= 1); +} + +static void SameTimeOrdering() +{ + var result = Replay( + Identity("2026-01-01 00:00:00.000", "Regular", "profilea", "1"), + Lifecycle("2026-01-01 00:00:01.000", 12, "task-a", "z-finish"), + Lifecycle("2026-01-01 00:00:01.000", 10, "task-a", "a-start")); + Equal(TaskStatus.Finished, result.Coalesce().Single().Value.Status); +} + +static void MixedProfileModeBoundaries() +{ + var result = Replay( + Identity("2026-01-01 00:00:00.000", "Regular", "profilea", "1"), + Lifecycle("2026-01-01 00:00:01.000", 10, "task-a", "regular-start"), + Identity("2026-01-01 00:00:02.000", "PVE", "profileb", "2"), + Lifecycle("2026-01-01 00:00:03.000", 12, "task-a", "pve-finish")); + var coalesced = result.Coalesce(); + Equal(2, coalesced.Count); + Equal(TaskStatus.Started, + coalesced[new TaskLifecycleKey("profilea", ProfileType.Regular, "task-a")].Status); + Equal(TaskStatus.Finished, + coalesced[new TaskLifecycleKey("profileb", ProfileType.PVE, "task-a")].Status); +} + +static void UnknownIdentityFailsClosed() +{ + var result = Replay( + Lifecycle("2026-01-01 00:00:00.000", 10, "task-a", "no-identity"), + Identity("2026-01-01 00:00:01.000", "future-mode", "profilea", "1"), + Lifecycle("2026-01-01 00:00:02.000", 12, "task-a", "unknown-mode")); + Equal(0, result.Events.Count); + Equal(2, result.UnknownIdentityCount); +} + +static void CoalescedBatchQuota() +{ + var logs = new List + { + Identity("2026-01-01 00:00:00.000", "PvpSeason", "profilea", "1"), + }; + for (var index = 0; index < 100; index++) + { + logs.Add(Lifecycle( + $"2026-01-01 00:00:{index % 60:00}.{index:000}", + index % 2 == 0 ? 10 : 11, + $"task-{index % 3}", + $"event-{index}")); + } + Equal(3, Replay(logs.ToArray()).Coalesce().Count); +} + +static TaskLifecycleReplayResult Replay(params string[] contents) => + TaskLifecycle.Replay( + contents.Select((value, index) => new TaskLifecycleLogSource("folder", $"{index:000}.log", value)), + DateTimeOffset.MinValue); + +static string Identity(string timestamp, string mode, string profile, string account) => + Line(timestamp, $"0.16.0.0.1|Info|application|Session mode: {mode}") + + Line(timestamp, $"0.16.0.0.1|Info|application|SelectProfile ProfileId:{profile} AccountId:{account}"); + +static string Lifecycle(string timestamp, int type, string task, string id) => + Line( + timestamp, + "0.16.0.0.1|Info|notifications|Got notification | ChatMessageReceived", + $"{{\n \"message\": {{\"_id\":\"{id}\",\"type\":{type},\"templateId\":\"{task} 0\"}}\n}}"); + +static string Line(string timestamp, string message, string? json = null) => + $"{timestamp} +00:00|{message}\n{(json == null ? string.Empty : json + "\n")}"; + +static void AssertCache(TaskStatus status, bool active, bool complete, bool failed, bool invalid) +{ + var actual = new TarkovTracker.ProgressResponseTask + { + id = "task-a", + active = !active, + complete = !complete, + failed = !failed, + invalid = !invalid, + }; + TaskLifecycle.ApplyToCache(actual, status); + Equal(active, actual.active); + Equal(complete, actual.complete); + Equal(failed, actual.failed); + Equal(invalid, actual.invalid); + True(TaskLifecycle.CacheMatches(actual, status)); +} + +static void True(bool condition) +{ + if (!condition) throw new InvalidOperationException("Expected true."); +} + +static void False(bool condition) +{ + if (condition) throw new InvalidOperationException("Expected false."); +} + +static void Equal(T expected, T actual) +{ + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidOperationException($"Expected {expected}, got {actual}."); + } +} diff --git a/TarkovMonitor.Tests/Stubs.cs b/TarkovMonitor.Tests/Stubs.cs new file mode 100644 index 00000000..17229e30 --- /dev/null +++ b/TarkovMonitor.Tests/Stubs.cs @@ -0,0 +1,62 @@ +namespace TarkovMonitor +{ + internal enum TaskStatus + { + None = 0, + Started = 10, + Failed = 11, + Finished = 12, + } + + internal enum MessageType + { + TaskStarted = 10, + TaskFailed = 11, + TaskFinished = 12, + } + + internal enum ProfileType + { + PVE, + Regular, + PvpSeason, + Unknown, + } + + internal sealed class Profile + { + public string Id { get; set; } = ""; + public ProfileType Type { get; set; } + public string AccountId { get; set; } = ""; + } + + internal sealed class LogDetails + { + public Profile Profile { get; set; } = new(); + public int AccountId { get; set; } + public DateTime Date { get; set; } + public Version Version { get; set; } = new(0, 0); + public string Folder { get; set; } = ""; + } + + internal static class TarkovTracker + { + internal sealed class ProgressResponseTask + { + public string id { get; set; } = ""; + public bool complete { get; set; } + public bool invalid { get; set; } + public bool failed { get; set; } + public bool? active { get; set; } + } + + internal static bool IsSupportedOrgToken(string? token) + { + var value = token?.Trim() ?? ""; + return value.Length == 22 + && value[3] == '_' + && value[..3].ToUpperInvariant() is "PVE" or "PVP" or "SZN" + && value[4..].All(character => Uri.IsHexDigit(character)); + } + } +} diff --git a/TarkovMonitor.Tests/TarkovMonitor.Tests.csproj b/TarkovMonitor.Tests/TarkovMonitor.Tests.csproj new file mode 100644 index 00000000..ff496bf7 --- /dev/null +++ b/TarkovMonitor.Tests/TarkovMonitor.Tests.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/TarkovMonitor.sln b/TarkovMonitor.sln index 54ebee0d..f3c3bcee 100644 --- a/TarkovMonitor.sln +++ b/TarkovMonitor.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.2.32519.379 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TarkovMonitor", "TarkovMonitor\TarkovMonitor.csproj", "{24A2238E-2D29-4242-AC20-1C4BE73ACE19}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TarkovMonitor.Tests", "TarkovMonitor.Tests\TarkovMonitor.Tests.csproj", "{7BDF3118-A286-4F02-B62C-4B82FC268211}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{550B8753-F70E-40AA-9498-EF7B76BAE6EE}" ProjectSection(SolutionItems) = preProject Directory.Build.props = Directory.Build.props @@ -20,6 +22,10 @@ Global {24A2238E-2D29-4242-AC20-1C4BE73ACE19}.Debug|Any CPU.Build.0 = Debug|Any CPU {24A2238E-2D29-4242-AC20-1C4BE73ACE19}.Release|Any CPU.ActiveCfg = Release|Any CPU {24A2238E-2D29-4242-AC20-1C4BE73ACE19}.Release|Any CPU.Build.0 = Release|Any CPU + {7BDF3118-A286-4F02-B62C-4B82FC268211}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BDF3118-A286-4F02-B62C-4B82FC268211}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BDF3118-A286-4F02-B62C-4B82FC268211}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BDF3118-A286-4F02-B62C-4B82FC268211}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor b/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor index 034753d8..339be3ba 100644 --- a/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor +++ b/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor @@ -58,7 +58,6 @@ } } List? breakpoints; - Dictionary TaskStatuses = new(); bool SubmitDisabled { get; set; } = true; private string currentProfileName = GameWatcher.CurrentProfile.AccountId; @@ -99,7 +98,9 @@ private void GetBreakPoints() { - breakpoints = customWatcher.GetLogBreakpoints(GameWatcher.CurrentProfile.Id); + breakpoints = customWatcher.GetLogBreakpoints( + GameWatcher.CurrentProfile.Id, + GameWatcher.CurrentProfile.Type); InvokeAsync(() => StateHasChanged()); } @@ -115,54 +116,53 @@ if (!TarkovTracker.ValidToken) { messageLog.AddMessage("You must have a valid Tarkov Tracker API token to read past logs.", "exception"); + return; } SubmitDisabled = true; GameWatcher.ReadingPastLogs = true; - TaskStatuses.Clear(); var totalTasks = 0; Dictionary updateTasks = new(); + Profile currentProfile = new() + { + Id = GameWatcher.CurrentProfile.Id, + Type = GameWatcher.CurrentProfile.Type, + AccountId = GameWatcher.CurrentProfile.AccountId, + }; var startedUtc = DateTime.UtcNow; try { - //eft.ProcessLogs(selectedPath); - customWatcher.TaskModified += UpdateTaskStatus; - Profile currentProfile = new() + if (selectedBreakpoint.Profile.Id != currentProfile.Id + || selectedBreakpoint.Profile.Type != currentProfile.Type + || !TarkovTracker.CanWriteForProfile(currentProfile)) { - Id = GameWatcher.CurrentProfile.Id, - Type = GameWatcher.CurrentProfile.Type, - AccountId = GameWatcher.CurrentProfile.AccountId, - }; - customWatcher.ProcessLogsFromBreakpoint(selectedBreakpoint); - GameWatcher.CurrentProfile = currentProfile; - customWatcher.TaskModified -= UpdateTaskStatus; - foreach (var kvp in TaskStatuses) + messageLog.AddMessage( + "The selected logs do not match the active TarkovTracker profile and mode.", + "warning"); + return; + } + + var replay = customWatcher.ProcessLogsFromBreakpoint(selectedBreakpoint); + var taskStatuses = replay.Coalesce() + .Where(kvp => kvp.Key.ProfileId == currentProfile.Id && kvp.Key.Mode == currentProfile.Type) + .ToDictionary(kvp => kvp.Key.TaskId, kvp => kvp.Value.Status); + if (replay.UnknownIdentityCount > 0) { - if (kvp.Value == TarkovMonitor.TaskStatus.Started) - { - // don't update task status if started - continue; - } - var task = TarkovDev.Tasks.Find((t) => t.id == kvp.Key); + messageLog.AddMessage( + $"Skipped {replay.UnknownIdentityCount} past task event(s) because their profile and mode could not be established safely.", + "warning"); + } + + foreach (var kvp in taskStatuses) + { + var task = TarkovDev.Tasks.Find(t => t.id == kvp.Key); if (task == null) { // probably a daily continue; } totalTasks++; - TarkovMonitor.TaskStatus savedTaskStatus = TarkovMonitor.TaskStatus.None; var taskProgress = TarkovTracker.Progress.data.tasksProgress.Find((prog) => prog.id == kvp.Key); - if (taskProgress != null) - { - if (taskProgress.failed) - { - savedTaskStatus = TarkovMonitor.TaskStatus.Failed; - } - if (taskProgress.complete) - { - savedTaskStatus = TarkovMonitor.TaskStatus.Finished; - } - } - if (kvp.Value == savedTaskStatus) + if (TaskLifecycle.CacheMatches(taskProgress, kvp.Value)) { // status matches, so don't update continue; @@ -184,6 +184,15 @@ var updateStartedUtc = DateTime.UtcNow; try { + if (GameWatcher.CurrentProfile.Id != currentProfile.Id + || GameWatcher.CurrentProfile.Type != currentProfile.Type + || !TarkovTracker.CanWriteForProfile(currentProfile)) + { + messageLog.AddMessage( + "The TarkovTracker profile or mode changed before past-log updates could be sent.", + "warning"); + return; + } if (updateTasks.Count > 0) { await TarkovTracker.SetTaskStatuses(updateTasks); @@ -191,14 +200,14 @@ messageLog.AddMessage($"Of {totalTasks} tasks found in logs, {updateTasks.Count} require status updates in Tarkov Tracker.", "info"); MudDialog.Close(DialogResult.Ok(true)); } + catch (TrackerActiveStateCompatibilityException ex) + { + messageLog.AddMessage(ex.Message, "warning"); + } catch (Exception ex) { messageLog.AddException("Past-log task updates could not be sent.", "TM-API-TRACKER-007", "UpdatePastLogTasks", ex, "TarkovTracker", "PastLogs", $"https://{Properties.Settings.Default.tarkovTrackerDomain}", DiagnosticsService.ElapsedMilliseconds(updateStartedUtc)); } } - private void UpdateTaskStatus(object? sender, LogContentEventArgs e) - { - TaskStatuses[e.LogContent.TaskId] = e.LogContent.Status; - } } diff --git a/TarkovMonitor/GameWatcher.cs b/TarkovMonitor/GameWatcher.cs index 0e2db54e..5c89fcfa 100644 --- a/TarkovMonitor/GameWatcher.cs +++ b/TarkovMonitor/GameWatcher.cs @@ -951,131 +951,16 @@ public Dictionary GetLogFolders() return folderDictionary.OrderByDescending(key => key.Key).ToDictionary(x => x.Key, x => x.Value); } - // Process the log files in the specified folder - public void ProcessLogs(LogDetails target, List profiles) - { - for (var i = 0; i < profiles.Count; i++) - { - var logProfile = profiles[i]; - if (logProfile.Profile.Id != target.Profile.Id) - { - continue; - } - var endDate = DateTime.Now.AddYears(1); - if (profiles.Count > 1 && i + 1 < profiles.Count) - { - endDate = profiles[i + 1].Date; - } - var logFiles = Directory.GetFiles(logProfile.Folder); - // TODO: This could be improved by processing lines in the order they were created - // rather than a full file at a time, this could be valuable for future features - foreach (string logFile in logFiles) - { - GameLogType logType; - // Check which type of log file this is by the filename - if (logFile.Contains("application.log") || logFile.Contains("application_000.log")) - { - logType = GameLogType.Application; - } - else if (logFile.Contains("notifications.log") || logFile.Contains("notifications_000.log")) - { - logType = GameLogType.Notifications; - } - else if (logFile.Contains("traces.log") || logFile.Contains("traces_000.log")) - { - // logType = GameLogType.Traces; - // Traces are not currently used, so skip them - continue; - } - else - { - // We're not a known log type, so skip this file - continue; - } - - // Read the file into memory using UTF-8 encoding - using var fileStream = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var textReader = new StreamReader(fileStream, Encoding.UTF8); - var fileContents = textReader.ReadToEnd(); - - var logMessages = Regex.Matches(fileContents, logPattern, RegexOptions.Multiline); - - foreach (Match match in logMessages) - { - var dateTimeString = match.Groups["date"].Value + " " + match.Groups["time"].Value; - DateTime logMessageDate = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); - - if (logMessageDate < logProfile.Date || logMessageDate >= endDate) - { - continue; - } - - GameWatcher_NewLogData(this, new NewLogDataEventArgs { Type = logType, Data = match.Value }); - } - } - } - } - public List GetLogDetails(string folderPath) { - List logDetails = new(); if (!Directory.Exists(folderPath)) { - return logDetails; - } - var appLogPath = ""; - foreach (var file in Directory.GetFiles(folderPath)) - { - if (file.EndsWith("application.log") || file.EndsWith("application_000.log")) - { - appLogPath = file; - break; - } - } - if (appLogPath == "") - { - return logDetails; - } - using var fileStream = new FileStream(appLogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var textReader = new StreamReader(fileStream, Encoding.UTF8); - var applicationLog = textReader.ReadToEnd(); - var matches = Regex.Matches(applicationLog, @$"{logPatternPrefix}(?\d+\.\d+\.\d+\.\d+)\.\d+\|(?[^|]+)\|(?[^|]+)\|(?:SelectProfile|CompleteSelectedProfile) ProfileId:(?[a-f0-9]+) AccountId:(?\d+)", RegexOptions.Multiline); - if (matches.Count == 0) - { - return logDetails; - } - var profileTypeMatches = Regex.Matches(applicationLog, @$"{logPatternPrefix}(?\d+\.\d+\.\d+\.\d+)\.\d+\|(?[^|]+)\|(?[^|]+)\|Session mode: (?[^\s|]+)", RegexOptions.Multiline); - for (var i = 0; i < matches.Count; i++) - { - Match match = matches[i]; - var dateTimeString = match.Groups["date"].Value + " " + match.Groups["time"].Value; - DateTime profileDate = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); - ProfileType profileType = ProfileType.Regular; - if (matches.Count == profileTypeMatches.Count) - { - if (!Enum.TryParse(profileTypeMatches[i].Groups["profileType"].Value, true, out var parsedProfileType) - || !Enum.IsDefined(parsedProfileType)) - { - profileType = ProfileType.Unknown; - } - else - { - profileType = parsedProfileType; - } - } - logDetails.Add(new LogDetails() - { - Profile = new() { Id = match.Groups["profileId"].Value, Type = profileType }, - AccountId = Int32.Parse(match.Groups["accountId"].Value), - Date = profileDate, - Version = new Version(match.Groups["version"].Value), - Folder = folderPath, - }); + return new(); } - return logDetails; + return TaskLifecycle.GetLogDetails(ReadLifecycleSources(new[] { folderPath }, applicationOnly: true)); } - public List GetLogBreakpoints(string profileId) + public List GetLogBreakpoints(string profileId, ProfileType? profileType = null) { List breakpoints = new(); if (profileId == "") @@ -1087,11 +972,16 @@ public List GetLogBreakpoints(string profileId) List folderBreakpoints = GetLogDetails(kvp.Value); foreach(var breakpoint in folderBreakpoints) { - if (breakpoint.Profile.Id != profileId) + if (breakpoint.Profile.Id != profileId + || breakpoint.Profile.Type == ProfileType.Unknown + || (profileType != null && breakpoint.Profile.Type != profileType)) { continue; } - var matchingBreakpoint = breakpoints.Where((bp) => bp.Version == breakpoint.Version && bp.Profile.Id == breakpoint.Profile.Id).FirstOrDefault(); + var matchingBreakpoint = breakpoints.FirstOrDefault(bp => + bp.Version == breakpoint.Version + && bp.Profile.Id == breakpoint.Profile.Id + && bp.Profile.Type == breakpoint.Profile.Type); if (matchingBreakpoint == null) { breakpoints.Add(breakpoint); @@ -1101,33 +991,39 @@ public List GetLogBreakpoints(string profileId) return breakpoints; } - public void ProcessLogsFromBreakpoint(LogDetails breakpoint) + public TaskLifecycleReplayResult ProcessLogsFromBreakpoint(LogDetails breakpoint) { - List> logDetails = new(); - var logFolders = Directory.GetDirectories(LogsPath); - // For each log folder, get the details - foreach (string folderName in logFolders) + if (string.IsNullOrWhiteSpace(LogsPath) || !Directory.Exists(LogsPath)) { - var details = GetLogDetails(folderName); - if (details.Count == 0) - { - continue; - } - if (!details.Any(d => d.Profile.Id == breakpoint.Profile.Id)) - { - continue; - } - if (!details.Any(d => d.Date >= breakpoint.Date)) - { - continue; - } - logDetails.Add(details); + return new TaskLifecycleReplayResult(); } - logDetails = logDetails.OrderBy(det => det[0].Date).ToList(); - foreach (var details in logDetails) + return TaskLifecycle.Replay( + ReadLifecycleSources(Directory.GetDirectories(LogsPath), applicationOnly: false), + TaskLifecycle.ToLocalOffset(breakpoint.Date)); + } + + private static List ReadLifecycleSources( + IEnumerable folders, + bool applicationOnly) + { + var sources = new List(); + foreach (var folder in folders.OrderBy(folder => folder, StringComparer.OrdinalIgnoreCase)) { - ProcessLogs(breakpoint, details); + foreach (var path in Directory.GetFiles(folder, "*.log").OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + var filename = Path.GetFileName(path); + var isApplication = filename.StartsWith("application", StringComparison.OrdinalIgnoreCase); + var isNotifications = filename.StartsWith("notifications", StringComparison.OrdinalIgnoreCase); + if (!isApplication && (applicationOnly || !isNotifications)) + { + continue; + } + using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var textReader = new StreamReader(fileStream, Encoding.UTF8); + sources.Add(new TaskLifecycleLogSource(folder, path, textReader.ReadToEnd())); + } } + return sources; } private void UpdateProcess() diff --git a/TarkovMonitor/MainBlazorUI.cs b/TarkovMonitor/MainBlazorUI.cs index b610f3f2..832152e6 100644 --- a/TarkovMonitor/MainBlazorUI.cs +++ b/TarkovMonitor/MainBlazorUI.cs @@ -778,7 +778,7 @@ private async void Eft_TaskFinished(object? sender, LogContentEventArgs SetProfile(string profileId) return Progress; } - private static void SyncStoredStatus(string questId, TaskStatus status) + internal static void SyncStoredStatus(ProgressResponse progress, string questId, TaskStatus status) { - var storedStatus = Progress.data.tasksProgress.Find(ts => ts.id == questId); + var storedStatus = progress.data.tasksProgress.Find(ts => ts.id == questId); if (storedStatus == null) { storedStatus = new() { id = questId, }; - Progress.data.tasksProgress.Add(storedStatus); - } - if (status == TaskStatus.Finished && !storedStatus.complete) - { - storedStatus.complete = true; - storedStatus.failed = false; - storedStatus.invalid = false; - } - if (status == TaskStatus.Failed && !storedStatus.failed) - { - storedStatus.complete = false; - storedStatus.failed = true; - storedStatus.invalid = false; - } - if (status == TaskStatus.Started && (storedStatus.failed || storedStatus.invalid || storedStatus.complete)) - { - storedStatus.complete = false; - storedStatus.failed = false; - storedStatus.invalid = false; + progress.data.tasksProgress.Add(storedStatus); } + TaskLifecycle.ApplyToCache(storedStatus, status); } + private static void SyncStoredStatus(string questId, TaskStatus status) => + SyncStoredStatus(Progress, questId, status); + + public static bool CanWriteForProfile(Profile profile) => + TaskLifecycle.ShouldDispatch(profile, ValidToken, currentProfile, GetToken(currentProfile)); + public static async Task SetTaskStatus(string questId, TaskStatus status) { if (IsLegacyService || !ValidToken) @@ -264,6 +265,10 @@ public static async Task SetTaskStatus(string questId, TaskStatus status { throw new Exception("Rate limited by Tarkov Tracker API"); } + if (status == TaskStatus.Started && IsCompatibilityRejection(ex.StatusCode)) + { + throw new TrackerActiveStateCompatibilityException(ex.StatusCode, ex); + } throw new Exception($"Invalid TarkovTracker API response code: {ex.StatusCode}.", ex); } catch (Exception ex) @@ -314,19 +319,7 @@ public static async Task SetTaskFailed(string questId) public static async Task SetTaskStarted(string questId) { - foreach (var taskStatus in Progress.data.tasksProgress) - { - if (taskStatus.id != questId) - { - continue; - } - if (taskStatus.failed) - { - return await SetTaskStatus(questId, TaskStatus.Started); - } - break; - } - return "task not marked as failed"; + return await SetTaskStatus(questId, TaskStatus.Started); } public static async Task SetTaskStatuses(Dictionary statuses) @@ -360,6 +353,10 @@ public static async Task SetTaskStatuses(Dictionary { throw new Exception("Rate limited by Tarkov Tracker API"); } + if (statuses.Values.Contains(TaskStatus.Started) && IsCompatibilityRejection(ex.StatusCode)) + { + throw new TrackerActiveStateCompatibilityException(ex.StatusCode, ex); + } throw new Exception($"Invalid TarkovTracker API response code: {ex.StatusCode}.", ex); } catch (Exception ex) @@ -470,6 +467,15 @@ private static void InvalidTokenException() throw new Exception("Tarkov Tracker API token is invalid"); } + private static bool IsCompatibilityRejection(HttpStatusCode statusCode) + { + var numericStatus = (int)statusCode; + return numericStatus >= 400 + && numericStatus < 500 + && statusCode != HttpStatusCode.Unauthorized + && statusCode != HttpStatusCode.TooManyRequests; + } + public static bool HasAirFilter() { if (Progress == null) @@ -521,6 +527,7 @@ public class ProgressResponseTask public bool complete { get; set; } public bool invalid { get; set; } public bool failed { get; set; } + public bool? active { get; set; } } public class ProgressResponseHideoutModules { @@ -542,17 +549,10 @@ private TaskStatusBody(string newState) public static TaskStatusBody Completed => new("completed"); public static TaskStatusBody Uncompleted => new("uncompleted"); public static TaskStatusBody Failed => new("failed"); + public static TaskStatusBody Active => new("active"); public static TaskStatusBody From(TaskStatus code) { - if (code == TaskStatus.Finished) - { - return TaskStatusBody.Completed; - } - if (code == TaskStatus.Failed) - { - return TaskStatusBody.Failed; - } - return TaskStatusBody.Uncompleted; + return new TaskStatusBody(TaskLifecycle.ToTrackerState(code)); } public static TaskStatusBody From(MessageType messageType) { diff --git a/TarkovMonitor/TaskLifecycle.cs b/TarkovMonitor/TaskLifecycle.cs new file mode 100644 index 00000000..b1fc6b30 --- /dev/null +++ b/TarkovMonitor/TaskLifecycle.cs @@ -0,0 +1,463 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace TarkovMonitor +{ + internal readonly record struct TaskLifecycleKey(string ProfileId, ProfileType Mode, string TaskId); + + internal sealed record TaskLifecycleEvent( + DateTimeOffset Timestamp, + string ProfileId, + ProfileType Mode, + string TaskId, + TaskStatus Status, + string Identity); + + internal sealed record TaskLifecycleLogSource(string Folder, string Path, string Contents); + + internal sealed class TaskLifecycleReplayResult + { + public List Events { get; } = new(); + public int DuplicateCount { get; internal set; } + public int UnknownIdentityCount { get; internal set; } + + public Dictionary Coalesce() + { + var result = new Dictionary(); + foreach (var lifecycleEvent in Events.OrderBy(TaskLifecycle.SortKey)) + { + var key = new TaskLifecycleKey(lifecycleEvent.ProfileId, lifecycleEvent.Mode, lifecycleEvent.TaskId); + result[key] = lifecycleEvent; + } + return result; + } + } + + internal static class TaskLifecycle + { + private const string TimestampPattern = @"(?^\d{4}-\d{2}-\d{2}) (?