diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml index 5729e800..01374765 100644 --- a/.github/workflows/build-dev.yml +++ b/.github/workflows/build-dev.yml @@ -24,6 +24,9 @@ jobs: - name: Restore dependencies run: dotnet restore + - name: Run lifecycle tests + run: dotnet run --project TarkovMonitor.Tests/TarkovMonitor.Tests.csproj --configuration Release --no-restore + - name: Build and publish shell: pwsh run: | diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 368c95ad..66f07274 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -24,6 +24,9 @@ jobs: - name: Restore dependencies run: dotnet restore + - name: Run lifecycle tests + run: dotnet run --project TarkovMonitor.Tests/TarkovMonitor.Tests.csproj --configuration Release --no-restore + - name: Build and publish shell: pwsh run: | diff --git a/README.md b/README.md index 80726d42..84871be3 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. Live writes wait until EFT has explicitly logged a recognized PvE, regular PvP, or seasonal PvP mode; missing or unsupported mode data pauses sync rather than defaulting to another mode, and sync resumes once identity and mode are established. 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 only when Tracker's response identifies `active` as an unsupported task-state value, 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..86d9a67b --- /dev/null +++ b/TarkovMonitor.Tests/Program.cs @@ -0,0 +1,507 @@ +using System.Net; +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), + ("profile defaults to unknown without mode", ProfileDefaultsToUnknown), + ("delayed recognized mode recovers dispatch", DelayedModeRecovery), + ("unparseable mode fails closed and recovers", UnparseableModeRecovery), + ("profile switch authorization is atomic", ProfileSwitchAuthorizationIsAtomic), + ("token replacement cancels pending validation", TokenReplacementCancelsPendingValidation), + ("token replacement and switch registration are atomic", TokenReplacementAndSwitchRegistrationAreAtomic), + ("token persistence rejects stale replacement snapshots", TokenPersistenceRejectsStaleReplacementSnapshots), + ("newer token persistence follows an in-flight older save", NewerTokenPersistenceFollowsInFlightOlderSave), + ("endpoint replacement cannot redirect bearer", EndpointReplacementCannotRedirectBearer), + ("active compatibility requires enum evidence", ActiveCompatibilityRequiresEvidence), + ("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 ProfileDefaultsToUnknown() +{ + var profile = new Profile { Id = "profilea" }; + Equal(ProfileType.Unknown, profile.Type); + False(ProfileIdentity.TryParseMode(null, out _)); + False(TaskLifecycle.ShouldDispatch( + profile, + true, + "profilea", + "PVP_0123456789abcdef01")); +} + +static void DelayedModeRecovery() +{ + var profile = new Profile { Id = "profilea", AccountId = "1" }; + False(TaskLifecycle.ShouldDispatch( + profile, + true, + "profilea", + "PVP_0123456789abcdef01")); + + var transition = ProfileIdentity.ApplyMode(profile, "Regular"); + True(transition.Recognized); + True(transition.Changed); + True(transition.ProfileReady); + Equal(ProfileType.Regular, profile.Type); + True(TaskLifecycle.ShouldDispatch( + profile, + true, + "profilea", + "PVP_0123456789abcdef01")); +} + +static void UnparseableModeRecovery() +{ + var profile = new Profile { Id = "profilea", AccountId = "1" }; + var invalid = ProfileIdentity.ApplyMode(profile, "future-mode"); + False(invalid.Recognized); + Equal(ProfileType.Unknown, profile.Type); + Equal("profilea", profile.Id); + False(TaskLifecycle.ShouldDispatch( + profile, + true, + "profilea", + "PVE_0123456789abcdef01")); + + var recovered = ProfileIdentity.ApplyMode(profile, "PVE"); + True(recovered.Recognized); + True(recovered.ProfileReady); + True(TaskLifecycle.ShouldDispatch( + profile, + true, + "profilea", + "PVE_0123456789abcdef01")); + + var switched = ProfileIdentity.ApplyMode(profile, "Regular"); + True(switched.Recognized); + False(switched.ProfileReady); + Equal("", profile.Id); +} + +static void ProfileSwitchAuthorizationIsAtomic() +{ + var state = new TrackerAuthorizationState>(() => new()); + var profileA = new Profile { Id = "profilea", Type = ProfileType.Regular }; + var switchA = state.BeginSwitch(profileA, "PVP_0123456789abcdef01", 1); + False(state.Valid); + True(state.TryActivate(switchA, new() { "A" }, out var authorizationA)); + True(state.TryAuthorize(profileA, out var capturedA)); + Equal(authorizationA, capturedA); + Equal("Bearer PVP_0123456789abcdef01", capturedA.AuthorizationHeader); + + var profileB = new Profile { Id = "profileb", Type = ProfileType.PVE }; + var switchB = state.BeginSwitch(profileB, "PVE_0123456789abcdef02", 1); + False(state.Valid); + False(state.IsCurrent(capturedA)); + False(state.TryAuthorize(profileA, out _)); + False(state.UpdateIfCurrent(capturedA, progress => progress.Add("stale A"))); + Equal("Bearer PVP_0123456789abcdef01", capturedA.AuthorizationHeader); + + True(state.TryActivate(switchB, new() { "B" }, out var authorizationB)); + Equal("Bearer PVE_0123456789abcdef02", authorizationB.AuthorizationHeader); + Equal("B", state.Progress.Single()); + False(state.TryActivate(switchA, new() { "late A" }, out _)); + Equal("B", state.Progress.Single()); +} + +static void TokenReplacementCancelsPendingValidation() +{ + var state = new TrackerAuthorizationState>(() => new()); + var profile = new Profile { Id = "profilea", Type = ProfileType.Regular }; + var pending = state.BeginSwitch(profile, "PVP_0123456789abcdef01", 7); + Equal("", state.CurrentProfileId); + + True(state.InvalidateProfile("profilea")); + False(state.TryActivate(pending, new() { "stale" }, out _)); + False(state.Valid); + + var replacement = state.BeginSwitch(profile, "PVP_0123456789abcdef02", 7); + True(state.TryActivate(replacement, new() { "replacement" }, out var authorization)); + Equal("PVP_0123456789abcdef02", authorization.Token); + Equal("replacement", state.Progress.Single()); +} + +static void TokenReplacementAndSwitchRegistrationAreAtomic() +{ + const string oldToken = "PVP_0123456789abcdef01"; + const string replacementToken = "PVP_0123456789abcdef02"; + for (var attempt = 0; attempt < 200; attempt++) + { + var authorizationState = new TrackerAuthorizationState>(() => new()); + var tokenState = new TrackerProfileTokenState>( + authorizationState, + new() { ["profilea"] = oldToken }); + var profile = new Profile { Id = "profilea", Type = ProfileType.Regular }; + using var start = new ManualResetEventSlim(); + var replacement = Task.Run(() => + { + start.Wait(); + return tokenState.ReplaceToken(profile.Id, replacementToken); + }); + var profileSwitch = Task.Run(() => + { + start.Wait(); + return tokenState.BeginSwitch(profile, 7); + }); + + start.Set(); + True(Task.WaitAll(new Task[] { replacement, profileSwitch }, TimeSpan.FromSeconds(5))); + if (profileSwitch.Result.Token == oldToken) + { + False(authorizationState.TryActivate( + profileSwitch.Result.ProfileSwitch, + new() { "stale" }, + out _)); + } + else + { + Equal(replacementToken, profileSwitch.Result.Token); + True(authorizationState.TryActivate( + profileSwitch.Result.ProfileSwitch, + new() { "replacement" }, + out var authorization)); + Equal(replacementToken, authorization.Token); + } + } +} + +static void TokenPersistenceRejectsStaleReplacementSnapshots() +{ + const string initialToken = "PVP_0123456789abcdef00"; + const string tokenA = "PVP_0123456789abcdef01"; + const string tokenB = "PVP_0123456789abcdef02"; + var authorizationState = new TrackerAuthorizationState>(() => new()); + var tokenState = new TrackerProfileTokenState>( + authorizationState, + new() { ["profilea"] = initialToken }); + Dictionary persisted = new() { ["profilea"] = initialToken }; + + var replacementA = tokenState.ReplaceToken("profilea", tokenA); + var replacementB = tokenState.ReplaceToken("profilea", tokenB); + + True(tokenState.PersistIfCurrent( + replacementB, + snapshot => persisted = new(snapshot))); + False(tokenState.PersistIfCurrent( + replacementA, + snapshot => persisted = new(snapshot))); + Equal(tokenB, persisted["profilea"]); + + var restartedAuthorization = new TrackerAuthorizationState>(() => new()); + var restartedTokenState = new TrackerProfileTokenState>( + restartedAuthorization, + new(persisted)); + var restartedProfile = new Profile { Id = "profilea", Type = ProfileType.Regular }; + var restartedSwitch = restartedTokenState.BeginSwitch(restartedProfile, 11); + Equal(tokenB, restartedSwitch.Token); + True(restartedAuthorization.TryActivate( + restartedSwitch.ProfileSwitch, + new() { "restart" }, + out var restartedAuthorizationToken)); + Equal(tokenB, restartedAuthorizationToken.Token); +} + +static void NewerTokenPersistenceFollowsInFlightOlderSave() +{ + const string tokenA = "PVP_0123456789abcdef01"; + const string tokenB = "PVP_0123456789abcdef02"; + var authorizationState = new TrackerAuthorizationState>(() => new()); + var tokenState = new TrackerProfileTokenState>( + authorizationState, + new()); + var persistedToken = string.Empty; + using var olderSaveStarted = new ManualResetEventSlim(); + using var allowOlderSaveToFinish = new ManualResetEventSlim(); + + var replacementA = tokenState.ReplaceToken("profilea", tokenA); + var persistA = Task.Run(() => tokenState.PersistIfCurrent( + replacementA, + snapshot => + { + olderSaveStarted.Set(); + True(allowOlderSaveToFinish.Wait(TimeSpan.FromSeconds(5))); + persistedToken = snapshot["profilea"]; + })); + + True(olderSaveStarted.Wait(TimeSpan.FromSeconds(5))); + var replacementB = tokenState.ReplaceToken("profilea", tokenB); + var persistB = Task.Run(() => tokenState.PersistIfCurrent( + replacementB, + snapshot => persistedToken = snapshot["profilea"])); + False(persistB.Wait(TimeSpan.FromMilliseconds(100))); + + allowOlderSaveToFinish.Set(); + True(persistA.Wait(TimeSpan.FromSeconds(5))); + True(persistB.Wait(TimeSpan.FromSeconds(5))); + True(persistA.Result); + True(persistB.Result); + Equal(tokenB, persistedToken); +} + +static void EndpointReplacementCannotRedirectBearer() +{ + var endpoints = new TrackerEndpointState( + "https://api.tarkovtracker.org", + true, + "org-client"); + var state = new TrackerAuthorizationState>(() => new()); + var profile = new Profile { Id = "profilea", Type = ProfileType.Regular }; + var pending = state.BeginSwitch( + profile, + "PVP_0123456789abcdef01", + endpoints.Snapshot.Generation); + True(state.TryActivate(pending, new(), out var authorization)); + True(endpoints.TryResolve(authorization, out var capturedOrgEndpoint)); + Equal("org-client", capturedOrgEndpoint.Client); + Equal("Bearer PVP_0123456789abcdef01", authorization.AuthorizationHeader); + + endpoints.Replace("https://tarkovtracker.io/api/v2", false, "legacy-client"); + False(endpoints.TryResolve(authorization, out _)); + Equal("org-client", capturedOrgEndpoint.Client); + False(capturedOrgEndpoint.Client == "legacy-client"); +} + +static void ActiveCompatibilityRequiresEvidence() +{ + const string enumEvidence = + "{\"path\":[\"state\"],\"message\":\"Invalid enum value active; expected completed, uncompleted, or failed\"}"; + True(TrackerCompatibility.IsUnsupportedActiveState(HttpStatusCode.BadRequest, enumEvidence)); + True(TrackerCompatibility.IsUnsupportedActiveState( + HttpStatusCode.UnprocessableEntity, + "{\"message\":\"unsupported state active\"}")); + + False(TrackerCompatibility.IsUnsupportedActiveState(HttpStatusCode.Forbidden, enumEvidence)); + False(TrackerCompatibility.IsUnsupportedActiveState(HttpStatusCode.NotFound, enumEvidence)); + False(TrackerCompatibility.IsUnsupportedActiveState(HttpStatusCode.Conflict, enumEvidence)); + False(TrackerCompatibility.IsUnsupportedActiveState( + HttpStatusCode.UnprocessableEntity, + "{\"message\":\"Task is already active for the current profile state\"}")); + False(TrackerCompatibility.IsUnsupportedActiveState( + HttpStatusCode.UnprocessableEntity, + "{\"state\":\"active\",\"message\":\"Unknown task id\"}")); + False(TrackerCompatibility.IsUnsupportedActiveState( + HttpStatusCode.UnprocessableEntity, + "{\"state\":\"active\",\"message\":\"Unsupported profile mode\"}")); + False(TrackerCompatibility.IsUnsupportedActiveState( + HttpStatusCode.UnprocessableEntity, + "{\"message\":\"Invalid task id\"}")); +} + +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 TrackerTaskProgress + { + 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/TarkovMonitor.Tests.csproj b/TarkovMonitor.Tests/TarkovMonitor.Tests.csproj new file mode 100644 index 00000000..c822d8fc --- /dev/null +++ b/TarkovMonitor.Tests/TarkovMonitor.Tests.csproj @@ -0,0 +1,13 @@ + + + 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..8c212283 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()); } @@ -112,57 +113,52 @@ { return; } - if (!TarkovTracker.ValidToken) - { - messageLog.AddMessage("You must have a valid Tarkov Tracker API token to read past logs.", "exception"); - } 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, + }; + TrackerWriteAuthorization authorization; 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.TryAuthorizeWrite(currentProfile, out authorization)) { - 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,21 +180,31 @@ var updateStartedUtc = DateTime.UtcNow; try { + if (GameWatcher.CurrentProfile.Id != currentProfile.Id + || GameWatcher.CurrentProfile.Type != currentProfile.Type + || !TarkovTracker.TryAuthorizeWrite(currentProfile, out var currentAuthorization) + || currentAuthorization != authorization) + { + 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); + await TarkovTracker.SetTaskStatuses(authorization, updateTasks); } 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/Blazor/Pages/Settings/Settings.razor b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor index ced1186b..20f2bd4c 100644 --- a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor +++ b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor @@ -645,7 +645,7 @@ var startedUtc = DateTime.UtcNow; try { - await TarkovTracker.SetProfile(GameWatcher.CurrentProfile.Id); + await TarkovTracker.SetProfile(GameWatcher.CurrentProfile.Snapshot()); } catch (Exception ex) { @@ -694,6 +694,7 @@ messageLog.AddMessage("You do not have write permissions with this token.", "warning"); return; } + await TarkovTracker.SetProfile(GameWatcher.CurrentProfile.Snapshot()); } catch (Exception ex) { diff --git a/TarkovMonitor/GameWatcher.cs b/TarkovMonitor/GameWatcher.cs index 0e2db54e..92b5e237 100644 --- a/TarkovMonitor/GameWatcher.cs +++ b/TarkovMonitor/GameWatcher.cs @@ -657,28 +657,22 @@ internal void GameWatcher_NewLogData(object? sender, NewLogDataEventArgs e) DateTime.TryParseExact(logMessage.Groups["date"].Value + " " + logMessage.Groups["time"].Value.Split(" ")[0], "yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out eventDate); var eventLine = logMessage.Groups["message"].Value; //System.Diagnostics.Debug.WriteLine(eventLine); - if (eventLine.Contains("Session mode: ")) + if (eventLine.Contains("Session mode:", StringComparison.Ordinal)) { - var modeMatch = Regex.Match(eventLine, @"Session mode: (?[^\s|]+)"); - if (!modeMatch.Success) + var modeMatch = Regex.Match(eventLine, @"Session mode:\s*(?[^\s|]+)"); + var rawSessionMode = modeMatch.Success + ? modeMatch.Groups["mode"].Value + : "(missing)"; + var transition = ProfileIdentity.ApplyMode(CurrentProfile, rawSessionMode); + raidInfo.Profile = CurrentProfile.Snapshot(); + if (!transition.Recognized) { - continue; + ReportUnsupportedSessionMode(rawSessionMode); } - var rawSessionMode = modeMatch.Groups["mode"].Value; - if (!Enum.TryParse(rawSessionMode, true, out var profileType) - || !Enum.IsDefined(profileType) - || profileType == ProfileType.Unknown) + if (!e.InitialRead && transition.Changed) { - CurrentProfile.Id = ""; - CurrentProfile.AccountId = ""; - CurrentProfile.Type = ProfileType.Unknown; - raidInfo.Profile = CurrentProfile; - ReportUnsupportedSessionMode(rawSessionMode); - ProfileChanged?.Invoke(this, new(CurrentProfile)); - continue; + ProfileChanged?.Invoke(this, new(CurrentProfile.Snapshot())); } - CurrentProfile.Type = profileType; - raidInfo.Profile = CurrentProfile; continue; } // Profile selection messages have changed names across EFT versions. @@ -700,11 +694,8 @@ internal void GameWatcher_NewLogData(object? sender, NewLogDataEventArgs e) raidInfo.EndedTime = eventDate; RaidEnded?.Invoke(this, new(raidInfo, CurrentProfile)); } - else - { - System.Diagnostics.Debug.WriteLine("PROFILE CHANGED"); - ProfileChanged?.Invoke(this, new(CurrentProfile)); - } + System.Diagnostics.Debug.WriteLine("PROFILE CHANGED"); + ProfileChanged?.Invoke(this, new(CurrentProfile.Snapshot())); } continue; } @@ -902,18 +893,19 @@ internal void GameWatcher_NewLogData(object? sender, NewLogDataEventArgs e) if (systemMessageEvent.message.type >= MessageType.TaskStarted && systemMessageEvent.message.type <= MessageType.TaskFinished) { var args = jsonNode?.AsObject().Deserialize() ?? throw new Exception("Error parsing TaskStatusMessageLogContent"); - TaskModified?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + var eventProfile = CurrentProfile.Snapshot(); + TaskModified?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = eventProfile }); if (args.Status == TaskStatus.Started) { - TaskStarted?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + TaskStarted?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = eventProfile }); } if (args.Status == TaskStatus.Failed) { - TaskFailed?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + TaskFailed?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = eventProfile }); } if (args.Status == TaskStatus.Finished) { - TaskFinished?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + TaskFinished?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = eventProfile }); } } } @@ -951,131 +943,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 +964,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 +983,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() @@ -1240,6 +1128,8 @@ private void WatchLogsFolder(string folderPath) if (newType == GameLogType.Application) { CurrentProfile = new(); + raidInfo.Profile = CurrentProfile.Snapshot(); + ProfileChanged?.Invoke(this, new(CurrentProfile.Snapshot())); } if (newType == GameLogType.Output) { @@ -1339,7 +1229,7 @@ private void LogMonitor_InitialReadComplete(object? sender, EventArgs e) if (shouldPublish) { PublishMatchingStarted(false); - InitialReadComplete?.Invoke(this, new(CurrentProfile)); + InitialReadComplete?.Invoke(this, new(CurrentProfile.Snapshot())); } } @@ -1372,24 +1262,6 @@ public enum GameLogType Output, Traces } - public enum MessageType - { - PlayerMessage = 1, - Insurance = 2, - FleaMarket = 4, - InsuranceReturn = 8, - TaskStarted = 10, - TaskFailed = 11, - TaskFinished = 12, - TwitchDrop = 13, - } - public enum TaskStatus - { - None = 0, - Started = 10, - Failed = 11, - Finished = 12 - } public enum RaidType { Unknown, @@ -1514,47 +1386,6 @@ public PlayerPositionEventArgs(RaidInfo raidInfo, Profile profile, Position posi } } - public class LogDetails - { - public Profile Profile { get; set; } - public int AccountId { get; set; } - public DateTime Date { get; set; } - public Version Version { get; set; } - public string Folder { get; set; } - } - - public enum ProfileType - { - PVE, - Regular, - PvpSeason, - Unknown, - } - - public static class ProfileTypeExtensions - { - public static string ToApiString(this ProfileType profileType) => profileType switch - { - ProfileType.PvpSeason => "pvp-season", - ProfileType.Unknown => "unknown", - _ => profileType.ToString().ToLower(), - }; - public static string ToPlayersApiString(this ProfileType profileType) => profileType switch - { - ProfileType.PvpSeason => "pvp-season", - ProfileType.Regular => "profile", - ProfileType.Unknown => "unknown", - _ => profileType.ToString().ToLower(), - }; - } - - public class Profile - { - public string Id { get; set; } = ""; - public ProfileType Type { get; set; } = ProfileType.Regular; - public string AccountId { get; set; } = ""; - } - public class ProfileEventArgs : EventArgs { public Profile Profile { get; set; } diff --git a/TarkovMonitor/MainBlazorUI.cs b/TarkovMonitor/MainBlazorUI.cs index b610f3f2..54301310 100644 --- a/TarkovMonitor/MainBlazorUI.cs +++ b/TarkovMonitor/MainBlazorUI.cs @@ -357,13 +357,13 @@ private void Eft_ControlSettings(object? sender, ControlSettingsEventArgs e) private async void Eft_ProfileChanged(object? sender, ProfileEventArgs e) { - if (e.Profile.Type == ProfileType.Unknown) + if (e.Profile.Type == ProfileType.Unknown || string.IsNullOrWhiteSpace(e.Profile.Id)) { TarkovTracker.ResetActiveState(); return; } - if (e.Profile.Id == TarkovTracker.CurrentProfileId) + if (TarkovTracker.TryAuthorizeWrite(e.Profile, out _)) { return; } @@ -371,7 +371,7 @@ private async void Eft_ProfileChanged(object? sender, ProfileEventArgs e) var startedUtc = DateTime.UtcNow; try { - await TarkovTracker.SetProfile(e.Profile.Id); + await TarkovTracker.SetProfile(e.Profile); } catch (Exception ex) { @@ -698,7 +698,7 @@ private async Task InitializeProgress() var startedUtc = DateTime.UtcNow; try { - await TarkovTracker.SetProfile(GameWatcher.CurrentProfile.Id); + await TarkovTracker.SetProfile(GameWatcher.CurrentProfile.Snapshot()); } catch (Exception ex) { @@ -778,14 +778,14 @@ private async void Eft_TaskFinished(object? sender, LogContentEventArgs GetProgress(); + Task GetProgress([Header("Authorization")] string authorization); [Post("/progress/task/{id}")] - [Headers("Authorization: Bearer")] - Task SetTaskStatus(string id, [Body] TaskStatusBody body); + Task SetTaskStatus( + string id, + [Body] TaskStatusBody body, + [Header("Authorization")] string authorization); [Post("/progress/tasks")] - [Headers("Authorization: Bearer")] - Task SetTaskStatuses([Body] List body); + Task SetTaskStatuses( + [Body] List body, + [Header("Authorization")] string authorization); } private static readonly HttpClient tokenInspectionClient = new(); - private static ITarkovTrackerAPI api = InitAPI(); - - public static ProgressResponse Progress { get; private set; } = new(); - public static bool ValidToken { get; private set; } = false; + private static readonly TrackerAuthorizationState authorizationState = new(() => new()); + private static readonly Dictionary tokens = + JsonSerializer.Deserialize>( + Properties.Settings.Default.tarkovTrackerTokens) ?? new(); + private static readonly TrackerProfileTokenState profileTokenState = + new(authorizationState, tokens); + private static readonly TrackerEndpointState endpointState = CreateInitialEndpoint(); + + public static ProgressResponse Progress => authorizationState.Progress; + public static bool ValidToken => authorizationState.Valid; public static bool IsLegacyService => string.Equals( Properties.Settings.Default.tarkovTrackerDomain, "tarkovtracker.io", StringComparison.OrdinalIgnoreCase); - private static Dictionary tokens = new(); - private static string currentProfile = ""; - public static string CurrentProfileId { get { return currentProfile; } } + public static string CurrentProfileId => authorizationState.CurrentProfileId; public static event EventHandler? TokenValidated; public static event EventHandler? TokenInvalid; @@ -50,7 +68,6 @@ internal interface ITarkovTrackerAPI static TarkovTracker() { tokenInspectionClient.DefaultRequestHeaders.UserAgent.TryParseAdd($"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name} {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); - tokens = JsonSerializer.Deserialize>(Properties.Settings.Default.tarkovTrackerTokens) ?? tokens; } public static string GetApiBaseUrl(string trackerDomain) @@ -64,30 +81,7 @@ public static string GetApiBaseUrl(string trackerDomain) public static bool IsSupportedOrgToken(string? token) { - var value = token?.Trim() ?? string.Empty; - if (value.Length != 22 || value[3] != '_') - { - return false; - } - - var prefix = value[..3].ToUpperInvariant(); - if (prefix is not ("PVE" or "PVP" or "SZN")) - { - return false; - } - - for (var index = 4; index < value.Length; index++) - { - var character = value[index]; - if (!((character >= '0' && character <= '9') - || (character >= 'a' && character <= 'f') - || (character >= 'A' && character <= 'F'))) - { - return false; - } - } - - return true; + return TrackerTokenFormat.IsSupportedOrgToken(token); } private static string GetOrgTokenPrefix(string token) @@ -134,35 +128,40 @@ private static void VerifyOrgTokenResponse(string submittedToken, TokenResponse } } + private static TrackerEndpointState CreateInitialEndpoint() + { + var domain = Properties.Settings.Default.tarkovTrackerDomain; + var baseUrl = GetApiBaseUrl(domain); + return new(baseUrl, IsOrgDomain(domain), CreateApiClient(baseUrl)); + } + + private static ITarkovTrackerAPI CreateApiClient(string baseUrl) + { + var client = RestService.For(baseUrl); + client.Client.DefaultRequestHeaders.UserAgent.TryParseAdd($"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name} {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); + return client; + } + + private static bool IsOrgDomain(string? domain) => + string.Equals(domain, "tarkovtracker.org", StringComparison.OrdinalIgnoreCase); + public static ITarkovTrackerAPI InitAPI() { - api = RestService.For(GetApiBaseUrl(Properties.Settings.Default.tarkovTrackerDomain), - new RefitSettings { - AuthorizationHeaderValueGetter = (rq, cr) => { - return new ValueTask(Task.Run(() => { - return GetToken(currentProfile ?? ""); - })); - }, - } - ); - api.Client.DefaultRequestHeaders.UserAgent.TryParseAdd($"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name} {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); - return api; + var domain = Properties.Settings.Default.tarkovTrackerDomain; + var baseUrl = GetApiBaseUrl(domain); + var endpoint = endpointState.Replace(baseUrl, IsOrgDomain(domain), CreateApiClient(baseUrl)); + authorizationState.Reset(); + return endpoint.Client; } public static void ResetActiveState() { - currentProfile = ""; - ValidToken = false; - Progress = new(); + authorizationState.Reset(); } public static string GetToken(string profileId) { - if (!tokens.ContainsKey(profileId)) - { - return ""; - } - return tokens[profileId]; + return profileTokenState.GetToken(profileId); } public static void SetToken(string profileId, string token) @@ -175,95 +174,115 @@ public static void SetToken(string profileId, string token) { throw new Exception("No PVP or PVE profile initialized, please launch Escape from Tarkov first"); } - tokens[profileId] = token; - Properties.Settings.Default.tarkovTrackerTokens = JsonSerializer.Serialize(tokens); - Properties.Settings.Default.Save(); + var replacement = profileTokenState.ReplaceToken(profileId, token); + profileTokenState.PersistIfCurrent(replacement, tokensSnapshot => + { + Properties.Settings.Default.tarkovTrackerTokens = JsonSerializer.Serialize(tokensSnapshot); + Properties.Settings.Default.Save(); + }); } - public static async Task SetProfile(string profileId) + public static async Task SetProfile(Profile profile) { if (IsLegacyService) { ResetActiveState(); return Progress; } - if (profileId == "") { - throw new Exception("Can't set PVP or PVE profile, please launch Escape from Tarkov and then restart this application"); + if (authorizationState.TryAuthorize(profile, out _)) + { + return Progress; } - if (currentProfile == profileId) + var profileSnapshot = profile.Snapshot(); + var endpoint = endpointState.Snapshot; + var (newToken, profileSwitch) = profileTokenState.BeginSwitch( + profileSnapshot, + endpoint.Generation); + if (profileSnapshot.Id == "" || profileSnapshot.Type == ProfileType.Unknown) + { + throw new Exception("Can't set PVP or PVE profile, please launch Escape from Tarkov and then restart this application"); + } + if (!endpoint.IsOrg || !TrackerTokenFormat.MatchesMode(newToken, profileSnapshot.Type)) { return Progress; } - var newToken = GetToken(profileId); - var oldToken = GetToken(currentProfile); - currentProfile = profileId; - if (oldToken == newToken) + + var tokenResponse = await TestToken(newToken, endpoint); + if (!authorizationState.IsCurrent(profileSwitch)) { return Progress; } - if (newToken == "" || newToken.Length != 22) + if (!tokenResponse.permissions.Contains("WP")) { - ValidToken = false; - Progress = new(); + TokenInvalid?.Invoke(null, EventArgs.Empty); return Progress; } - await TestToken(newToken); + + var loadedProgress = await GetProgress(newToken, endpoint); + if (authorizationState.TryActivate(profileSwitch, loadedProgress, out _)) + { + ProgressRetrieved?.Invoke(null, EventArgs.Empty); + TokenValidated?.Invoke(null, EventArgs.Empty); + } 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); } - public static async Task SetTaskStatus(string questId, TaskStatus status) + public static bool TryAuthorizeWrite( + Profile profile, + out TrackerWriteAuthorization authorization) => + !IsLegacyService && authorizationState.TryAuthorize(profile, out authorization); + + public static async Task SetTaskStatus( + TrackerWriteAuthorization authorization, + string questId, + TaskStatus status) { - if (IsLegacyService || !ValidToken) + if (!authorizationState.IsCurrent(authorization) + || !endpointState.TryResolve(authorization, out var endpoint)) { - throw new Exception("Invalid token"); + throw new InvalidOperationException( + "The TarkovTracker write authorization is no longer current for this profile and mode."); } try { - await api.SetTaskStatus(questId, TaskStatusBody.From(status)); - SyncStoredStatus(questId, status); + await endpoint.Client.SetTaskStatus( + questId, + TaskStatusBody.From(status), + authorization.AuthorizationHeader); + authorizationState.UpdateIfCurrent( + authorization, + progress => SyncStoredStatus(progress, questId, status)); } catch (ApiException ex) { if (ex.StatusCode == HttpStatusCode.Unauthorized) { - InvalidTokenException(); + InvalidateAuthorization(authorization); } if (ex.StatusCode == HttpStatusCode.TooManyRequests) { throw new Exception("Rate limited by Tarkov Tracker API"); } + if (status == TaskStatus.Started + && TrackerCompatibility.IsUnsupportedActiveState(ex.StatusCode, ex.Content)) + { + throw new TrackerActiveStateCompatibilityException(ex.StatusCode, ex); + } throw new Exception($"Invalid TarkovTracker API response code: {ex.StatusCode}.", ex); } catch (Exception ex) @@ -273,31 +292,37 @@ public static async Task SetTaskStatus(string questId, TaskStatus status return "success"; } - public static async Task SetTaskComplete(string questId) + public static async Task SetTaskComplete( + TrackerWriteAuthorization authorization, + string questId) { - await SetTaskStatus(questId, TaskStatus.Finished); + await SetTaskStatus(authorization, questId, TaskStatus.Finished); try { - TarkovDev.Tasks.ForEach(task => { - foreach (var failCondition in task.failConditions) + authorizationState.UpdateIfCurrent(authorization, progress => + { + TarkovDev.Tasks.ForEach(task => { - if (failCondition.task == null) + foreach (var failCondition in task.failConditions) { - continue; - } - if (failCondition.task == questId && failCondition.status?.Contains("complete") == true) - { - foreach (var taskStatus in Progress.data.tasksProgress) + if (failCondition.task == null) + { + continue; + } + if (failCondition.task == questId && failCondition.status?.Contains("complete") == true) { - if (taskStatus.id == failCondition.task) + foreach (var taskStatus in progress.data.tasksProgress) { - taskStatus.failed = true; - break; + if (taskStatus.id == failCondition.task) + { + taskStatus.failed = true; + break; + } } + break; } - break; } - } + }); }); } catch (Exception ex) @@ -307,33 +332,29 @@ public static async Task SetTaskComplete(string questId) return "success"; } - public static async Task SetTaskFailed(string questId) + public static async Task SetTaskFailed( + TrackerWriteAuthorization authorization, + string questId) { - return await SetTaskStatus(questId, TaskStatus.Failed); + return await SetTaskStatus(authorization, questId, TaskStatus.Failed); } - public static async Task SetTaskStarted(string questId) + public static async Task SetTaskStarted( + TrackerWriteAuthorization authorization, + 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(authorization, questId, TaskStatus.Started); } - public static async Task SetTaskStatuses(Dictionary statuses) + public static async Task SetTaskStatuses( + TrackerWriteAuthorization authorization, + Dictionary statuses) { - if (IsLegacyService || !ValidToken) + if (!authorizationState.IsCurrent(authorization) + || !endpointState.TryResolve(authorization, out var endpoint)) { - throw new Exception("Invalid token"); + throw new InvalidOperationException( + "The TarkovTracker write authorization is no longer current for this profile and mode."); } List body = new(); foreach (var kvp in statuses) @@ -344,22 +365,30 @@ public static async Task SetTaskStatuses(Dictionary } try { - await api.SetTaskStatuses(body); - foreach( var kvp in statuses) + await endpoint.Client.SetTaskStatuses(body, authorization.AuthorizationHeader); + authorizationState.UpdateIfCurrent(authorization, progress => { - SyncStoredStatus(kvp.Key, kvp.Value); - } + foreach (var kvp in statuses) + { + SyncStoredStatus(progress, kvp.Key, kvp.Value); + } + }); } catch (ApiException ex) { if (ex.StatusCode == HttpStatusCode.Unauthorized) { - InvalidTokenException(); + InvalidateAuthorization(authorization); } if (ex.StatusCode == HttpStatusCode.TooManyRequests) { throw new Exception("Rate limited by Tarkov Tracker API"); } + if (statuses.Values.Contains(TaskStatus.Started) + && TrackerCompatibility.IsUnsupportedActiveState(ex.StatusCode, ex.Content)) + { + throw new TrackerActiveStateCompatibilityException(ex.StatusCode, ex); + } throw new Exception($"Invalid TarkovTracker API response code: {ex.StatusCode}.", ex); } catch (Exception ex) @@ -369,28 +398,19 @@ public static async Task SetTaskStatuses(Dictionary return "success"; } - public static async Task GetProgress() + private static async Task GetProgress( + string token, + TrackerEndpointSnapshot endpoint) { - if (IsLegacyService) - { - ResetActiveState(); - return Progress; - } - if (!ValidToken) - { - throw new Exception("Invalid token"); - } try { - Progress = await api.GetProgress(); - ProgressRetrieved?.Invoke(null, new EventArgs()); - return Progress; + return await endpoint.Client.GetProgress($"Bearer {token}"); } catch (ApiException ex) { if (ex.StatusCode == HttpStatusCode.Unauthorized) { - InvalidTokenException(); + throw new Exception("Tarkov Tracker API token is invalid", ex); } if (ex.StatusCode == HttpStatusCode.TooManyRequests) { @@ -406,7 +426,14 @@ public static async Task GetProgress() public static async Task TestToken(string apiToken) { - if (IsLegacyService) + return await TestToken(apiToken, endpointState.Snapshot); + } + + private static async Task TestToken( + string apiToken, + TrackerEndpointSnapshot endpoint) + { + if (!endpoint.IsOrg) { throw new InvalidOperationException( "Support for TarkovTracker.io has been retired. Switch to TarkovTracker.org."); @@ -414,7 +441,7 @@ public static async Task TestToken(string apiToken) using var request = new HttpRequestMessage( HttpMethod.Get, - $"{GetApiBaseUrl(Properties.Settings.Default.tarkovTrackerDomain).TrimEnd('/')}/token"); + $"{endpoint.BaseUrl.TrimEnd('/')}/token"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiToken.Trim()); HttpResponseMessage httpResponse; @@ -431,7 +458,7 @@ public static async Task TestToken(string apiToken) { if (httpResponse.StatusCode == HttpStatusCode.Unauthorized) { - InvalidTokenException(); + throw new Exception("Tarkov Tracker API token is invalid"); } if (httpResponse.StatusCode == HttpStatusCode.TooManyRequests) { @@ -446,27 +473,16 @@ public static async Task TestToken(string apiToken) var response = JsonSerializer.Deserialize(responseBody) ?? throw new Exception("TarkovTracker returned an empty token response."); VerifyOrgTokenResponse(apiToken, response); - if (response.permissions.Contains("WP")) - { - ValidToken = true; - await GetProgress(); - TokenValidated?.Invoke(null, new EventArgs()); - } - else - { - Progress = new(); - ValidToken = false; - TokenInvalid?.Invoke(null, new EventArgs()); - } return response; } } - private static void InvalidTokenException() + private static void InvalidateAuthorization(TrackerWriteAuthorization authorization) { - Progress = new(); - ValidToken = false; - TokenInvalid?.Invoke(null, new EventArgs()); + if (authorizationState.InvalidateIfCurrent(authorization)) + { + TokenInvalid?.Invoke(null, EventArgs.Empty); + } throw new Exception("Tarkov Tracker API token is invalid"); } @@ -506,7 +522,7 @@ public class ProgressResponse public class ProgressResponseData { - public List tasksProgress { get; set; } = new(); + public List tasksProgress { get; set; } = new(); public List hideoutModulesProgress { get; set; } = new(); public string? displayName { get; set; } public string userId { get; set; } @@ -515,13 +531,6 @@ public class ProgressResponseData public string pmcFaction { get; set; } } - public class ProgressResponseTask - { - public string id { get; set; } - public bool complete { get; set; } - public bool invalid { get; set; } - public bool failed { get; set; } - } public class ProgressResponseHideoutModules { public string id { get; set; } @@ -542,17 +551,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..92b349f8 --- /dev/null +++ b/TarkovMonitor/TaskLifecycle.cs @@ -0,0 +1,452 @@ +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}) (?