diff --git a/Pal.Client/Commands/PalConfigCommand.cs b/Pal.Client/Commands/PalConfigCommand.cs new file mode 100644 index 0000000..296392f --- /dev/null +++ b/Pal.Client/Commands/PalConfigCommand.cs @@ -0,0 +1,31 @@ +using Dalamud.Interface.Windowing; +using Pal.Client.Configuration; +using Pal.Client.Windows; + +namespace Pal.Client.Commands +{ + internal class PalConfigCommand + { + private readonly IPalacePalConfiguration _configuration; + private readonly AgreementWindow _agreementWindow; + private readonly ConfigWindow _configWindow; + + public PalConfigCommand( + IPalacePalConfiguration configuration, + AgreementWindow agreementWindow, + ConfigWindow configWindow) + { + _configuration = configuration; + _agreementWindow = agreementWindow; + _configWindow = configWindow; + } + + public void Execute() + { + if (_configuration.FirstUse) + _agreementWindow.IsOpen = true; + else + _configWindow.Toggle(); + } + } +} diff --git a/Pal.Client/Commands/PalNearCommand.cs b/Pal.Client/Commands/PalNearCommand.cs new file mode 100644 index 0000000..53e9b8a --- /dev/null +++ b/Pal.Client/Commands/PalNearCommand.cs @@ -0,0 +1,71 @@ +using System; +using System.Linq; +using Dalamud.Game.ClientState; +using Pal.Client.DependencyInjection; +using Pal.Client.Extensions; +using Pal.Client.Floors; +using Pal.Client.Rendering; + +namespace Pal.Client.Commands +{ + internal sealed class PalNearCommand + { + private readonly Chat _chat; + private readonly ClientState _clientState; + private readonly TerritoryState _territoryState; + private readonly FloorService _floorService; + + public PalNearCommand(Chat chat, ClientState clientState, TerritoryState territoryState, + FloorService floorService) + { + _chat = chat; + _clientState = clientState; + _territoryState = territoryState; + _floorService = floorService; + } + + public void Execute(string arguments) + { + switch (arguments) + { + default: + DebugNearest(_ => true); + break; + + case "tnear": + DebugNearest(m => m.Type == MemoryLocation.EType.Trap); + break; + + case "hnear": + DebugNearest(m => m.Type == MemoryLocation.EType.Hoard); + break; + } + } + + private void DebugNearest(Predicate predicate) + { + if (!_territoryState.IsInDeepDungeon()) + return; + + var state = _floorService.GetTerritoryIfReady(_clientState.TerritoryType); + if (state == null) + return; + + var playerPosition = _clientState.LocalPlayer?.Position; + if (playerPosition == null) + return; + _chat.Message($"{playerPosition}"); + + var nearbyMarkers = state.Locations + .Where(m => predicate(m)) + .Where(m => m.RenderElement != null && m.RenderElement.Color != RenderData.ColorInvisible) + .Select(m => new { m, distance = (playerPosition.Value - m.Position).Length() }) + .OrderBy(m => m.distance) + .Take(5) + .ToList(); + foreach (var nearbyMarker in nearbyMarkers) + _chat.UnformattedMessage( + $"{nearbyMarker.distance:F2} - {nearbyMarker.m.Type} {nearbyMarker.m.NetworkId?.ToPartialId(length: 8)} - {nearbyMarker.m.Position}"); + } + } +} diff --git a/Pal.Client/Commands/PalStatsCommand.cs b/Pal.Client/Commands/PalStatsCommand.cs new file mode 100644 index 0000000..917efcc --- /dev/null +++ b/Pal.Client/Commands/PalStatsCommand.cs @@ -0,0 +1,18 @@ +using System; +using Pal.Client.DependencyInjection; + +namespace Pal.Client.Commands +{ + internal sealed class PalStatsCommand + { + private readonly StatisticsService _statisticsService; + + public PalStatsCommand(StatisticsService statisticsService) + { + _statisticsService = statisticsService; + } + + public void Execute() + => _statisticsService.ShowGlobalStatistics(); + } +} diff --git a/Pal.Client/Commands/PalTestConnectionCommand.cs b/Pal.Client/Commands/PalTestConnectionCommand.cs new file mode 100644 index 0000000..174330c --- /dev/null +++ b/Pal.Client/Commands/PalTestConnectionCommand.cs @@ -0,0 +1,20 @@ +using ECommons.Schedulers; +using Pal.Client.Windows; + +namespace Pal.Client.Commands +{ + internal sealed class PalTestConnectionCommand + { + private readonly ConfigWindow _configWindow; + + public PalTestConnectionCommand(ConfigWindow configWindow) + { + _configWindow = configWindow; + } + + public void Execute() + { + var _ = new TickScheduler(() => _configWindow.TestConnection()); + } + } +} diff --git a/Pal.Client/Configuration/AccountConfigurationV7.cs b/Pal.Client/Configuration/AccountConfigurationV7.cs new file mode 100644 index 0000000..e02769a --- /dev/null +++ b/Pal.Client/Configuration/AccountConfigurationV7.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text.Json.Serialization; +using Dalamud.Logging; +using Microsoft.Extensions.Logging; +using Pal.Client.DependencyInjection.Logging; + +namespace Pal.Client.Configuration +{ + public sealed class AccountConfigurationV7 : IAccountConfiguration + { + private const int DefaultEntropyLength = 16; + + private static readonly ILogger _logger = + DependencyInjectionContext.LoggerProvider.CreateLogger(); + + [JsonConstructor] + public AccountConfigurationV7() + { + } + + public AccountConfigurationV7(string server, Guid accountId) + { + Server = server; + (EncryptedId, Entropy, Format) = EncryptAccountId(accountId); + } + + [Obsolete("for V1 import")] + public AccountConfigurationV7(string server, string accountId) + { + Server = server; + + if (accountId.StartsWith("s:")) + { + EncryptedId = accountId.Substring(2); + Entropy = ConfigurationData.FixedV1Entropy; + Format = EFormat.UseProtectedData; + EncryptIfNeeded(); + } + else if (Guid.TryParse(accountId, out Guid guid)) + (EncryptedId, Entropy, Format) = EncryptAccountId(guid); + else + throw new InvalidOperationException($"Invalid account id format, can't migrate account for server {server}"); + } + + [JsonInclude] + [JsonRequired] + public EFormat Format { get; private set; } = EFormat.Unencrypted; + + /// + /// Depending on , this is either a Guid as string or a base64 encoded byte array. + /// + [JsonPropertyName("Id")] + [JsonInclude] + [JsonRequired] + public string EncryptedId { get; private set; } = null!; + + [JsonInclude] + public byte[]? Entropy { get; private set; } + + [JsonRequired] + public string Server { get; init; } = null!; + + [JsonIgnore] public bool IsUsable => DecryptAccountId() != null; + + [JsonIgnore] public Guid AccountId => DecryptAccountId() ?? throw new InvalidOperationException("Account id can't be read"); + + public List CachedRoles { get; set; } = new(); + + private Guid? DecryptAccountId() + { + if (Format == EFormat.UseProtectedData && ConfigurationData.SupportsDpapi) + { + try + { + byte[] guidBytes = ProtectedData.Unprotect(Convert.FromBase64String(EncryptedId), Entropy, DataProtectionScope.CurrentUser); + return new Guid(guidBytes); + } + catch (Exception e) + { + _logger.LogTrace(e, "Could not load account id {Id}", EncryptedId); + return null; + } + } + else if (Format == EFormat.Unencrypted) + return Guid.Parse(EncryptedId); + else if (Format == EFormat.ProtectedDataUnsupported && !ConfigurationData.SupportsDpapi) + return Guid.Parse(EncryptedId); + else + return null; + } + + private (string encryptedId, byte[]? entropy, EFormat format) EncryptAccountId(Guid g) + { + if (!ConfigurationData.SupportsDpapi) + return (g.ToString(), null, EFormat.ProtectedDataUnsupported); + else + { + try + { + byte[] entropy = RandomNumberGenerator.GetBytes(DefaultEntropyLength); + byte[] guidBytes = ProtectedData.Protect(g.ToByteArray(), entropy, DataProtectionScope.CurrentUser); + return (Convert.ToBase64String(guidBytes), entropy, EFormat.UseProtectedData); + } + catch (Exception) + { + return (g.ToString(), null, EFormat.Unencrypted); + } + } + } + + public bool EncryptIfNeeded() + { + if (Format == EFormat.Unencrypted) + { + var (newId, newEntropy, newFormat) = EncryptAccountId(Guid.Parse(EncryptedId)); + if (newFormat != EFormat.Unencrypted) + { + EncryptedId = newId; + Entropy = newEntropy; + Format = newFormat; + return true; + } + } + else if (Format == EFormat.UseProtectedData && Entropy is { Length: < DefaultEntropyLength }) + { + Guid? g = DecryptAccountId(); + if (g != null) + { + (EncryptedId, Entropy, Format) = EncryptAccountId(g.Value); + return true; + } + } + + return false; + } + + public enum EFormat + { + Unencrypted = 1, + UseProtectedData = 2, + + /// + /// Used for filtering: We don't want to overwrite any entries of this value using DPAPI, ever. + /// This is mostly a wine fallback. + /// + ProtectedDataUnsupported = 3, + } + } +} diff --git a/Pal.Client/Configuration/ConfigurationData.cs b/Pal.Client/Configuration/ConfigurationData.cs new file mode 100644 index 0000000..cdb341c --- /dev/null +++ b/Pal.Client/Configuration/ConfigurationData.cs @@ -0,0 +1,44 @@ +using Dalamud.Logging; +using System; +using System.Linq; +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Pal.Client.DependencyInjection.Logging; + +namespace Pal.Client.Configuration +{ + internal static class ConfigurationData + { + private static readonly ILogger _logger = + DependencyInjectionContext.LoggerProvider.CreateLogger(typeof(ConfigurationData)); + + [Obsolete("for V1 import")] + internal static readonly byte[] FixedV1Entropy = { 0x22, 0x4b, 0xe7, 0x21, 0x44, 0x83, 0x69, 0x55, 0x80, 0x38 }; + + private static bool? _supportsDpapi = null; + public static bool SupportsDpapi + { + get + { + if (_supportsDpapi == null) + { + try + { + byte[] input = RandomNumberGenerator.GetBytes(32); + byte[] entropy = RandomNumberGenerator.GetBytes(16); + byte[] temp = ProtectedData.Protect(input, entropy, DataProtectionScope.CurrentUser); + byte[] output = ProtectedData.Unprotect(temp, entropy, DataProtectionScope.CurrentUser); + _supportsDpapi = input.SequenceEqual(output); + } + catch (Exception) + { + _supportsDpapi = false; + } + + _logger.LogTrace("DPAPI support: {Supported}", _supportsDpapi); + } + return _supportsDpapi.Value; + } + } + } +} diff --git a/Pal.Client/Configuration/ConfigurationManager.cs b/Pal.Client/Configuration/ConfigurationManager.cs new file mode 100644 index 0000000..f8c584f --- /dev/null +++ b/Pal.Client/Configuration/ConfigurationManager.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Dalamud.Logging; +using Dalamud.Plugin; +using ImGuiNET; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration.Legacy; +using Pal.Client.Database; +using NJson = Newtonsoft.Json; + +namespace Pal.Client.Configuration +{ + internal sealed class ConfigurationManager + { + private readonly ILogger _logger; + private readonly DalamudPluginInterface _pluginInterface; + private readonly IServiceProvider _serviceProvider; + + public event EventHandler? Saved; + + public ConfigurationManager(ILogger logger, DalamudPluginInterface pluginInterface, IServiceProvider serviceProvider) + { + _logger = logger; + _pluginInterface = pluginInterface; + _serviceProvider = serviceProvider; + } + + private string ConfigPath => Path.Join(_pluginInterface.GetPluginConfigDirectory(), "palace-pal.config.json"); + + public IPalacePalConfiguration Load() + { + return JsonSerializer.Deserialize(File.ReadAllText(ConfigPath, Encoding.UTF8)) ?? + new ConfigurationV7(); + } + + public void Save(IConfigurationInConfigDirectory config, bool queue = true) + { + File.WriteAllText(ConfigPath, + JsonSerializer.Serialize(config, config.GetType(), + new JsonSerializerOptions + { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }), + Encoding.UTF8); + + if (queue && config is ConfigurationV7 v7) + Saved?.Invoke(this, v7); + } + +#pragma warning disable CS0612 +#pragma warning disable CS0618 + public void Migrate() + { + if (_pluginInterface.ConfigFile.Exists) + { + _logger.LogInformation("Migrating config file from v1-v6 format"); + + ConfigurationV1 configurationV1 = + NJson.JsonConvert.DeserializeObject( + File.ReadAllText(_pluginInterface.ConfigFile.FullName)) ?? new ConfigurationV1(); + configurationV1.Migrate(_pluginInterface, _serviceProvider.GetRequiredService>()); + configurationV1.Save(_pluginInterface); + + var v7 = MigrateToV7(configurationV1); + Save(v7, queue: false); + + using (var scope = _serviceProvider.CreateScope()) + { + using var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Imports.RemoveRange(dbContext.Imports); + + foreach (var importHistory in configurationV1.ImportHistory) + { + _logger.LogInformation("Migrating import {Id}", importHistory.Id); + dbContext.Imports.Add(new ImportHistory + { + Id = importHistory.Id, + RemoteUrl = importHistory.RemoteUrl?.Replace(".μ.tv", ".liza.sh"), + ExportedAt = importHistory.ExportedAt, + ImportedAt = importHistory.ImportedAt + }); + } + + dbContext.SaveChanges(); + } + + File.Move(_pluginInterface.ConfigFile.FullName, _pluginInterface.ConfigFile.FullName + ".old", true); + } + } + + private ConfigurationV7 MigrateToV7(ConfigurationV1 v1) + { + ConfigurationV7 v7 = new() + { + Version = 7, + FirstUse = v1.FirstUse, + Mode = v1.Mode, + BetaKey = v1.BetaKey, + + DeepDungeons = new DeepDungeonConfiguration + { + Traps = new MarkerConfiguration + { + Show = v1.ShowTraps, + Color = ImGui.ColorConvertFloat4ToU32(v1.TrapColor), + OnlyVisibleAfterPomander = v1.OnlyVisibleTrapsAfterPomander, + Fill = false + }, + HoardCoffers = new MarkerConfiguration + { + Show = v1.ShowHoard, + Color = ImGui.ColorConvertFloat4ToU32(v1.HoardColor), + OnlyVisibleAfterPomander = v1.OnlyVisibleHoardAfterPomander, + Fill = false + }, + SilverCoffers = new MarkerConfiguration + { + Show = v1.ShowSilverCoffers, + Color = ImGui.ColorConvertFloat4ToU32(v1.SilverCofferColor), + OnlyVisibleAfterPomander = false, + Fill = v1.FillSilverCoffers + } + } + }; + + foreach (var (server, oldAccount) in v1.Accounts) + { + string? accountId = oldAccount.Id; + if (string.IsNullOrEmpty(accountId)) + continue; + + string serverName = server.Replace(".μ.tv", ".liza.sh"); + IAccountConfiguration newAccount = v7.CreateAccount(serverName, accountId); + newAccount.CachedRoles = oldAccount.CachedRoles.ToList(); + } + + // TODO Migrate ImportHistory + + return v7; + } +#pragma warning restore CS0618 +#pragma warning restore CS0612 + } +} diff --git a/Pal.Client/Configuration/ConfigurationV7.cs b/Pal.Client/Configuration/ConfigurationV7.cs new file mode 100644 index 0000000..f25f2b5 --- /dev/null +++ b/Pal.Client/Configuration/ConfigurationV7.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Pal.Client.Configuration +{ + public sealed class ConfigurationV7 : IPalacePalConfiguration, IConfigurationInConfigDirectory + { + public int Version { get; set; } = 7; + + public bool FirstUse { get; set; } = true; + public EMode Mode { get; set; } + public string BetaKey { get; init; } = ""; + + public DeepDungeonConfiguration DeepDungeons { get; set; } = new(); + public RendererConfiguration Renderer { get; set; } = new(); + public List Accounts { get; set; } = new(); + public BackupConfiguration Backups { get; set; } = new(); + + public IAccountConfiguration CreateAccount(string server, Guid accountId) + { + var account = new AccountConfigurationV7(server, accountId); + Accounts.Add(account); + return account; + } + + [Obsolete("for V1 import")] + internal IAccountConfiguration CreateAccount(string server, string accountId) + { + var account = new AccountConfigurationV7(server, accountId); + Accounts.Add(account); + return account; + } + + public IAccountConfiguration? FindAccount(string server) + { + return Accounts.FirstOrDefault(a => a.Server == server && a.IsUsable); + } + + public void RemoveAccount(string server) + { + Accounts.RemoveAll(a => a.Server == server && a.IsUsable); + } + + public bool HasRoleOnCurrentServer(string server, string role) + { + if (Mode != EMode.Online) + return false; + + var account = FindAccount(server); + return account == null || account.CachedRoles.Contains(role); + } + } +} diff --git a/Pal.Client/Configuration/EMode.cs b/Pal.Client/Configuration/EMode.cs new file mode 100644 index 0000000..40d17d7 --- /dev/null +++ b/Pal.Client/Configuration/EMode.cs @@ -0,0 +1,15 @@ +namespace Pal.Client.Configuration +{ + public enum EMode + { + /// + /// Fetches trap locations from remote server. + /// + Online = 1, + + /// + /// Only shows traps found by yourself uisng a pomander of sight. + /// + Offline = 2, + } +} diff --git a/Pal.Client/Configuration/ERenderer.cs b/Pal.Client/Configuration/ERenderer.cs new file mode 100644 index 0000000..2ea7568 --- /dev/null +++ b/Pal.Client/Configuration/ERenderer.cs @@ -0,0 +1,11 @@ +namespace Pal.Client.Configuration +{ + public enum ERenderer + { + /// + Simple = 0, + + /// + Splatoon = 1, + } +} diff --git a/Pal.Client/Configuration/IPalacePalConfiguration.cs b/Pal.Client/Configuration/IPalacePalConfiguration.cs new file mode 100644 index 0000000..848dc36 --- /dev/null +++ b/Pal.Client/Configuration/IPalacePalConfiguration.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using ImGuiNET; +using Newtonsoft.Json; + +namespace Pal.Client.Configuration +{ + public interface IVersioned + { + int Version { get; set; } + } + public interface IConfigurationInConfigDirectory : IVersioned + { + } + + public interface IPalacePalConfiguration : IConfigurationInConfigDirectory + { + bool FirstUse { get; set; } + EMode Mode { get; set; } + string BetaKey { get; } + + DeepDungeonConfiguration DeepDungeons { get; set; } + RendererConfiguration Renderer { get; set; } + BackupConfiguration Backups { get; set; } + + IAccountConfiguration CreateAccount(string server, Guid accountId); + IAccountConfiguration? FindAccount(string server); + void RemoveAccount(string server); + + bool HasRoleOnCurrentServer(string server, string role); + } + + public class DeepDungeonConfiguration + { + public MarkerConfiguration Traps { get; set; } = new() + { + Show = true, + Color = ImGui.ColorConvertFloat4ToU32(new Vector4(1, 0, 0, 0.4f)), + OnlyVisibleAfterPomander = true, + Fill = false + }; + + public MarkerConfiguration HoardCoffers { get; set; } = new() + { + Show = true, + Color = ImGui.ColorConvertFloat4ToU32(new Vector4(0, 1, 1, 0.4f)), + OnlyVisibleAfterPomander = true, + Fill = false + }; + + public MarkerConfiguration SilverCoffers { get; set; } = new() + { + Show = false, + Color = ImGui.ColorConvertFloat4ToU32(new Vector4(1, 1, 1, 0.4f)), + OnlyVisibleAfterPomander = false, + Fill = true + }; + } + + public class MarkerConfiguration + { + [JsonRequired] + public bool Show { get; set; } + + [JsonRequired] + public uint Color { get; set; } + + public bool OnlyVisibleAfterPomander { get; set; } + public bool Fill { get; set; } + } + + public class RendererConfiguration + { + public ERenderer SelectedRenderer { get; set; } = ERenderer.Splatoon; + } + + public interface IAccountConfiguration + { + bool IsUsable { get; } + string Server { get; } + Guid AccountId { get; } + + /// + /// This is taken from the JWT, and is only refreshed on a successful login. + /// + /// If you simply reload the plugin without any server interaction, this doesn't change. + /// + /// This has no impact on what roles the JWT actually contains, but is just to make it + /// easier to draw a consistent UI. The server will still reject unauthorized calls. + /// + List CachedRoles { get; set; } + + bool EncryptIfNeeded(); + } + + public class BackupConfiguration + { + public int MinimumBackupsToKeep { get; set; } = 3; + public int DaysToDeleteAfter { get; set; } = 21; + } +} diff --git a/Pal.Client/Configuration.cs b/Pal.Client/Configuration/Legacy/ConfigurationV1.cs similarity index 50% rename from Pal.Client/Configuration.cs rename to Pal.Client/Configuration/Legacy/ConfigurationV1.cs index b1a88a7..6b94807 100644 --- a/Pal.Client/Configuration.cs +++ b/Pal.Client/Configuration/Legacy/ConfigurationV1.cs @@ -1,23 +1,18 @@ -using Dalamud.Configuration; -using Dalamud.Logging; -using ECommons.Schedulers; -using Newtonsoft.Json; -using Pal.Client.Scheduled; -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Numerics; -using System.Security.Cryptography; -using Pal.Client.Extensions; +using Dalamud.Plugin; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; -namespace Pal.Client +namespace Pal.Client.Configuration.Legacy { - public class Configuration : IPluginConfiguration + [Obsolete] + public sealed class ConfigurationV1 { - private static readonly byte[] Entropy = { 0x22, 0x4b, 0xe7, 0x21, 0x44, 0x83, 0x69, 0x55, 0x80, 0x38 }; - public int Version { get; set; } = 6; #region Saved configuration values @@ -55,12 +50,11 @@ namespace Pal.Client public string BetaKey { get; set; } = ""; #endregion -#pragma warning disable CS0612 // Type or member is obsolete - public void Migrate() + public void Migrate(DalamudPluginInterface pluginInterface, ILogger logger) { if (Version == 1) { - PluginLog.Information("Updating config to version 2"); + logger.LogInformation("Updating config to version 2"); if (DebugAccountId != null && Guid.TryParse(DebugAccountId, out Guid debugAccountId)) AccountIds["http://localhost:5145"] = debugAccountId; @@ -69,33 +63,33 @@ namespace Pal.Client AccountIds["https://pal.μ.tv"] = accountId; Version = 2; - Save(); + Save(pluginInterface); } if (Version == 2) { - PluginLog.Information("Updating config to version 3"); + logger.LogInformation("Updating config to version 3"); Accounts = AccountIds.ToDictionary(x => x.Key, x => new AccountInfo { - Id = x.Value + Id = x.Value.ToString() // encryption happens in V7 migration at latest }); Version = 3; - Save(); + Save(pluginInterface); } if (Version == 3) { Version = 4; - Save(); + Save(pluginInterface); } if (Version == 4) { // 2.2 had a bug that would mark chests as traps, there's no easy way to detect this -- or clean this up. // Not a problem for online players, but offline players might be fucked. - bool changedAnyFile = false; - LocalState.ForEach(s => + //bool changedAnyFile = false; + JsonFloorState.ForEach(s => { foreach (var marker in s.Markers) marker.SinceVersion = "0.0"; @@ -105,10 +99,10 @@ namespace Pal.Client { s.Backup(suffix: "bak"); - s.Markers = new ConcurrentBag(s.Markers.Where(m => m.SinceVersion != "0.0" || m.Type == Marker.EType.Hoard || m.WasImported)); + s.Markers = new ConcurrentBag(s.Markers.Where(m => m.SinceVersion != "0.0" || m.Type == JsonMarker.EType.Hoard || m.WasImported)); s.Save(); - changedAnyFile = true; + //changedAnyFile = true; } else { @@ -117,6 +111,7 @@ namespace Pal.Client } }); + /* // Only notify offline users - we can just re-download the backup markers from the server seamlessly. if (Mode == EMode.Offline && changedAnyFile) { @@ -127,122 +122,37 @@ namespace Pal.Client Service.Chat.PrintError("You can also manually restore .json.bak files (by removing the '.bak') if you have not been in any deep dungeon since February 2, 2023."); }, 2500); } + */ Version = 5; - Save(); + Save(pluginInterface); } if (Version == 5) { - LocalState.UpdateAll(); + JsonFloorState.UpdateAll(); Version = 6; - Save(); + Save(pluginInterface); } } -#pragma warning restore CS0612 // Type or member is obsolete - public void Save() + public void Save(DalamudPluginInterface pluginInterface) { - Service.PluginInterface.SavePluginConfig(this); - Service.Plugin.EarlyEventQueue.Enqueue(new QueuedConfigUpdate()); + File.WriteAllText(pluginInterface.ConfigFile.FullName, JsonConvert.SerializeObject(this, Formatting.Indented, new JsonSerializerSettings + { + TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple, + TypeNameHandling = TypeNameHandling.Objects + })); } - public enum EMode + public sealed class AccountInfo { - /// - /// Fetches trap locations from remote server. - /// - Online = 1, - - /// - /// Only shows traps found by yourself uisng a pomander of sight. - /// - Offline = 2, - } - - public enum ERenderer - { - /// - Simple = 0, - - /// - Splatoon = 1, - } - - public class AccountInfo - { - [JsonConverter(typeof(AccountIdConverter))] - public Guid? Id { get; set; } - - /// - /// This is taken from the JWT, and is only refreshed on a successful login. - /// - /// If you simply reload the plugin without any server interaction, this doesn't change. - /// - /// This has no impact on what roles the JWT actually contains, but is just to make it - /// easier to draw a consistent UI. The server will still reject unauthorized calls. - /// + public string? Id { get; set; } public List CachedRoles { get; set; } = new(); } - public class AccountIdConverter : JsonConverter - { - public override bool CanConvert(Type objectType) => true; - - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) - { - if (reader.TokenType == JsonToken.String) - { - string? text = reader.Value?.ToString(); - if (string.IsNullOrEmpty(text)) - return null; - - if (Guid.TryParse(text, out Guid guid) && guid != Guid.Empty) - return guid; - - if (text.StartsWith("s:")) - { - try - { - byte[] guidBytes = ProtectedData.Unprotect(Convert.FromBase64String(text.Substring(2)), Entropy, DataProtectionScope.CurrentUser); - return new Guid(guidBytes); - } - catch (CryptographicException e) - { - PluginLog.Error(e, "Could not load account id"); - return null; - } - } - } - throw new JsonSerializationException(); - } - - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - if (value == null) - { - writer.WriteNull(); - return; - } - - Guid g = (Guid)value; - string text; - try - { - byte[] guidBytes = ProtectedData.Protect(g.ToByteArray(), Entropy, DataProtectionScope.CurrentUser); - text = $"s:{Convert.ToBase64String(guidBytes)}"; - } - catch (CryptographicException) - { - text = g.ToString(); - } - - writer.WriteValue(text); - } - } - - public class ImportHistoryEntry + public sealed class ImportHistoryEntry { public Guid Id { get; set; } public string? RemoteUrl { get; set; } diff --git a/Pal.Client/LocalState.cs b/Pal.Client/Configuration/Legacy/JsonFloorState.cs similarity index 64% rename from Pal.Client/LocalState.cs rename to Pal.Client/Configuration/Legacy/JsonFloorState.cs index 4044768..5742b2f 100644 --- a/Pal.Client/LocalState.cs +++ b/Pal.Client/Configuration/Legacy/JsonFloorState.cs @@ -1,41 +1,50 @@ -using Pal.Common; -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using Pal.Client.Extensions; +using Pal.Common; -namespace Pal.Client +namespace Pal.Client.Configuration.Legacy { /// - /// JSON for a single floor set (e.g. 51-60). + /// Legacy JSON file for marker locations. /// - internal class LocalState + [Obsolete] + public sealed class JsonFloorState { private static readonly JsonSerializerOptions JsonSerializerOptions = new() { IncludeFields = true }; private const int CurrentVersion = 4; - public uint TerritoryType { get; set; } - public ConcurrentBag Markers { get; set; } = new(); + private static string _pluginConfigDirectory = null!; + private static readonly EMode _mode = EMode.Online; // might not be true, but this is 'less strict filtering' for migrations - public LocalState(uint territoryType) + internal static void SetContextProperties(string pluginConfigDirectory) + { + _pluginConfigDirectory = pluginConfigDirectory; + } + + public ushort TerritoryType { get; set; } + public ConcurrentBag Markers { get; set; } = new(); + + public JsonFloorState(ushort territoryType) { TerritoryType = territoryType; } private void ApplyFilters() { - if (Service.Configuration.Mode == Configuration.EMode.Offline) - Markers = new ConcurrentBag(Markers.Where(x => x.Seen || (x.WasImported && x.Imports.Count > 0))); + if (_mode == EMode.Offline) + Markers = new ConcurrentBag(Markers.Where(x => x.Seen || (x.WasImported && x.Imports.Count > 0))); else // ensure old import markers are removed if they are no longer part of a "current" import // this MAY remove markers the server sent you (and that you haven't seen), but this should be fixed the next time you enter the zone - Markers = new ConcurrentBag(Markers.Where(x => x.Seen || !x.WasImported || x.Imports.Count > 0)); + Markers = new ConcurrentBag(Markers.Where(x => x.Seen || !x.WasImported || x.Imports.Count > 0)); } - public static LocalState? Load(uint territoryType) + public static JsonFloorState? Load(ushort territoryType) { string path = GetSaveLocation(territoryType); if (!File.Exists(path)) @@ -45,14 +54,14 @@ namespace Pal.Client if (content.Length == 0) return null; - LocalState localState; + JsonFloorState localState; int version = 1; if (content[0] == '[') { // v1 only had a list of markers, not a JSON object as root - localState = new LocalState(territoryType) + localState = new JsonFloorState(territoryType) { - Markers = new ConcurrentBag(JsonSerializer.Deserialize>(content, JsonSerializerOptions) ?? new()), + Markers = new ConcurrentBag(JsonSerializer.Deserialize>(content, JsonSerializerOptions) ?? new()), }; } else @@ -61,9 +70,9 @@ namespace Pal.Client if (save == null) return null; - localState = new LocalState(territoryType) + localState = new JsonFloorState(territoryType) { - Markers = new ConcurrentBag(save.Markers.Where(o => o.Type == Marker.EType.Trap || o.Type == Marker.EType.Hoard)), + Markers = new ConcurrentBag(save.Markers.Where(o => o.Type == JsonMarker.EType.Trap || o.Type == JsonMarker.EType.Hoard)), }; version = save.Version; } @@ -114,20 +123,24 @@ namespace Pal.Client File.WriteAllText(path, JsonSerializer.Serialize(new SaveFile { Version = CurrentVersion, - Markers = new HashSet(Markers) + Markers = new HashSet(Markers) }, JsonSerializerOptions)); } } public string GetSaveLocation() => GetSaveLocation(TerritoryType); - private static string GetSaveLocation(uint territoryType) => Path.Join(Service.PluginInterface.GetPluginConfigDirectory(), $"{territoryType}.json"); + private static string GetSaveLocation(uint territoryType) => Path.Join(_pluginConfigDirectory, $"{territoryType}.json"); - public static void ForEach(Action action) + public static void ForEach(Action action) { foreach (ETerritoryType territory in typeof(ETerritoryType).GetEnumValues()) { - LocalState? localState = Load((ushort)territory); + // we never had markers for eureka orthos, so don't bother + if (territory > ETerritoryType.HeavenOnHigh_91_100) + break; + + JsonFloorState? localState = Load((ushort)territory); if (localState != null) action(localState); } @@ -146,10 +159,10 @@ namespace Pal.Client marker.Imports.RemoveAll(importIds.Contains); } - public class SaveFile + public sealed class SaveFile { public int Version { get; set; } - public HashSet Markers { get; set; } = new(); + public HashSet Markers { get; set; } = new(); } } } diff --git a/Pal.Client/Configuration/Legacy/JsonMarker.cs b/Pal.Client/Configuration/Legacy/JsonMarker.cs new file mode 100644 index 0000000..06b4607 --- /dev/null +++ b/Pal.Client/Configuration/Legacy/JsonMarker.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace Pal.Client.Configuration.Legacy +{ + [Obsolete] + public class JsonMarker + { + public EType Type { get; set; } = EType.Unknown; + public Vector3 Position { get; set; } + public bool Seen { get; set; } + public List RemoteSeenOn { get; set; } = new(); + public List Imports { get; set; } = new(); + public bool WasImported { get; set; } + public string? SinceVersion { get; set; } + + public enum EType + { + Unknown = 0, + Trap = 1, + Hoard = 2, + Debug = 3, + } + } +} diff --git a/Pal.Client/Configuration/Legacy/JsonMigration.cs b/Pal.Client/Configuration/Legacy/JsonMigration.cs new file mode 100644 index 0000000..c9b9a6a --- /dev/null +++ b/Pal.Client/Configuration/Legacy/JsonMigration.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dalamud.Plugin; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Database; +using Pal.Common; + +namespace Pal.Client.Configuration.Legacy +{ + /// + /// Imports legacy territoryType.json files into the database if it exists, and no markers for that territory exist. + /// + internal sealed class JsonMigration + { + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly DalamudPluginInterface _pluginInterface; + + public JsonMigration(ILogger logger, IServiceScopeFactory serviceScopeFactory, + DalamudPluginInterface pluginInterface) + { + _logger = logger; + _serviceScopeFactory = serviceScopeFactory; + _pluginInterface = pluginInterface; + } + +#pragma warning disable CS0612 + public async Task MigrateAsync(CancellationToken cancellationToken) + { + List floorsToMigrate = new(); + JsonFloorState.ForEach(floorsToMigrate.Add); + + if (floorsToMigrate.Count == 0) + { + _logger.LogInformation("Found no floors to migrate"); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + + await using var scope = _serviceScopeFactory.CreateAsyncScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var fileStream = new FileStream( + Path.Join(_pluginInterface.GetPluginConfigDirectory(), + $"territory-backup-{DateTime.Now:yyyyMMdd-HHmmss}.zip"), + FileMode.CreateNew); + using (var backup = new ZipArchive(fileStream, ZipArchiveMode.Create, false)) + { + IReadOnlyDictionary imports = + await dbContext.Imports.ToDictionaryAsync(import => import.Id, cancellationToken); + + foreach (var floorToMigrate in floorsToMigrate) + { + backup.CreateEntryFromFile(floorToMigrate.GetSaveLocation(), + Path.GetFileName(floorToMigrate.GetSaveLocation()), CompressionLevel.SmallestSize); + await MigrateFloor(dbContext, floorToMigrate, imports, cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + _logger.LogInformation("Removing {Count} old json files", floorsToMigrate.Count); + foreach (var floorToMigrate in floorsToMigrate) + File.Delete(floorToMigrate.GetSaveLocation()); + } + + /// Whether to archive this file once complete + private async Task MigrateFloor( + PalClientContext dbContext, + JsonFloorState floorToMigrate, + IReadOnlyDictionary imports, + CancellationToken cancellationToken) + { + using var logScope = _logger.BeginScope($"Import {(ETerritoryType)floorToMigrate.TerritoryType}"); + if (floorToMigrate.Markers.Count == 0) + { + _logger.LogInformation("Skipping migration, floor has no markers"); + } + + if (await dbContext.Locations.AnyAsync(o => o.TerritoryType == floorToMigrate.TerritoryType, + cancellationToken)) + { + _logger.LogInformation("Skipping migration, floor already has locations in the database"); + return; + } + + _logger.LogInformation("Starting migration of {Count} locations", floorToMigrate.Markers.Count); + List clientLocations = floorToMigrate.Markers + .Where(o => o.Type == JsonMarker.EType.Trap || o.Type == JsonMarker.EType.Hoard) + .Select(o => + { + var clientLocation = new ClientLocation + { + TerritoryType = floorToMigrate.TerritoryType, + Type = MapJsonType(o.Type), + X = o.Position.X, + Y = o.Position.Y, + Z = o.Position.Z, + Seen = o.Seen, + + // the SelectMany is misleading here, each import has either 0 or 1 associated db entry with that id + ImportedBy = o.Imports + .Select(importId => + imports.TryGetValue(importId, out ImportHistory? import) ? import : null) + .Where(import => import != null) + .Cast() + .Distinct() + .ToList(), + + // if we have a location not encountered locally, which also wasn't imported, + // it very likely is a download (but we have no information to track this). + Source = o.Seen ? ClientLocation.ESource.SeenLocally : + o.Imports.Count > 0 ? ClientLocation.ESource.Import : ClientLocation.ESource.Download, + SinceVersion = o.SinceVersion ?? "0.0", + }; + + clientLocation.RemoteEncounters = o.RemoteSeenOn + .Select(accountId => new RemoteEncounter(clientLocation, accountId)) + .ToList(); + + return clientLocation; + }).ToList(); + await dbContext.Locations.AddRangeAsync(clientLocations, cancellationToken); + + _logger.LogInformation("Migrated {Count} locations", clientLocations.Count); + } + + private ClientLocation.EType MapJsonType(JsonMarker.EType type) + { + return type switch + { + JsonMarker.EType.Trap => ClientLocation.EType.Trap, + JsonMarker.EType.Hoard => ClientLocation.EType.Hoard, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } +#pragma warning restore CS0612 + } +} diff --git a/Pal.Client/DalamudPackager.targets b/Pal.Client/DalamudPackager.targets index 2f4ce0f..27e6884 100644 --- a/Pal.Client/DalamudPackager.targets +++ b/Pal.Client/DalamudPackager.targets @@ -6,7 +6,7 @@ OutputPath="$(OutputPath)" AssemblyName="$(AssemblyName)" MakeZip="false" - VersionComponents="2"/> + VersionComponents="2"/> @@ -15,6 +15,7 @@ OutputPath="$(OutputPath)" AssemblyName="$(AssemblyName)" MakeZip="true" - VersionComponents="2"/> + VersionComponents="2" + Exclude="ECommons.pdb;ECommons.xml"/> - \ No newline at end of file + diff --git a/Pal.Client/Database/Cleanup.cs b/Pal.Client/Database/Cleanup.cs new file mode 100644 index 0000000..40db00f --- /dev/null +++ b/Pal.Client/Database/Cleanup.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration; +using Pal.Common; + +namespace Pal.Client.Database +{ + internal sealed class Cleanup + { + private readonly ILogger _logger; + private readonly IPalacePalConfiguration _configuration; + + public Cleanup(ILogger logger, IPalacePalConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + public void Purge(PalClientContext dbContext) + { + var toDelete = dbContext.Locations + .Include(o => o.ImportedBy) + .Include(o => o.RemoteEncounters) + .AsSplitQuery() + .Where(DefaultPredicate()) + .Where(AnyRemoteEncounter()) + .ToList(); + _logger.LogInformation("Cleaning up {Count} outdated locations", toDelete.Count); + dbContext.Locations.RemoveRange(toDelete); + } + + public void Purge(PalClientContext dbContext, ETerritoryType territoryType) + { + var toDelete = dbContext.Locations + .Include(o => o.ImportedBy) + .Include(o => o.RemoteEncounters) + .AsSplitQuery() + .Where(o => o.TerritoryType == (ushort)territoryType) + .Where(DefaultPredicate()) + .Where(AnyRemoteEncounter()) + .ToList(); + _logger.LogInformation("Cleaning up {Count} outdated locations for territory {Territory}", toDelete.Count, + territoryType); + dbContext.Locations.RemoveRange(toDelete); + } + + private Expression> DefaultPredicate() + { + return o => !o.Seen && + o.ImportedBy.Count == 0 && + o.Source != ClientLocation.ESource.SeenLocally && + o.Source != ClientLocation.ESource.ExplodedLocally; + } + + private Expression> AnyRemoteEncounter() + { + if (_configuration.Mode == EMode.Offline) + return o => true; + else + // keep downloaded markers + return o => o.Source != ClientLocation.ESource.Download; + } + } +} diff --git a/Pal.Client/Database/ClientLocation.cs b/Pal.Client/Database/ClientLocation.cs new file mode 100644 index 0000000..ab748f5 --- /dev/null +++ b/Pal.Client/Database/ClientLocation.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Pal.Client.Database +{ + internal sealed class ClientLocation + { + [Key] public int LocalId { get; set; } + public ushort TerritoryType { get; set; } + public EType Type { get; set; } + public float X { get; set; } + public float Y { get; set; } + public float Z { get; set; } + + /// + /// Whether we have encountered the trap/coffer at this location in-game. + /// + public bool Seen { get; set; } + + /// + /// Which account ids this marker was seen. This is a list merely to support different remote endpoints + /// (where each server would assign you a different id). + /// + public List RemoteEncounters { get; set; } = new(); + + /// + /// To keep track of which markers were imported through a downloaded file, we save the associated import-id. + /// + /// Importing another file for the same remote server will remove the old import-id, and add the new import-id here. + /// + public List ImportedBy { get; set; } = new(); + + /// + /// Determines where this location is originally from. + /// + public ESource Source { get; set; } + + + /// + /// To make rollbacks of local data easier, keep track of the plugin version which was used to create this location initially. + /// + public string SinceVersion { get; set; } = "0.0"; + + public enum EType + { + Trap = 1, + Hoard = 2, + } + + public enum ESource + { + Unknown = 0, + SeenLocally = 1, + ExplodedLocally = 2, + Import = 3, + Download = 4, + } + } +} diff --git a/Pal.Client/Database/Compiled/ClientLocationEntityType.cs b/Pal.Client/Database/Compiled/ClientLocationEntityType.cs new file mode 100644 index 0000000..809cbc5 --- /dev/null +++ b/Pal.Client/Database/Compiled/ClientLocationEntityType.cs @@ -0,0 +1,123 @@ +// +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable enable + +namespace Pal.Client.Database.Compiled +{ + internal partial class ClientLocationEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType? baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Pal.Client.Database.ClientLocation", + typeof(ClientLocation), + baseEntityType); + + var localId = runtimeEntityType.AddProperty( + "LocalId", + typeof(int), + propertyInfo: typeof(ClientLocation).GetProperty("LocalId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw); + + var seen = runtimeEntityType.AddProperty( + "Seen", + typeof(bool), + propertyInfo: typeof(ClientLocation).GetProperty("Seen", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var sinceVersion = runtimeEntityType.AddProperty( + "SinceVersion", + typeof(string), + propertyInfo: typeof(ClientLocation).GetProperty("SinceVersion", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var source = runtimeEntityType.AddProperty( + "Source", + typeof(ClientLocation.ESource), + propertyInfo: typeof(ClientLocation).GetProperty("Source", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var territoryType = runtimeEntityType.AddProperty( + "TerritoryType", + typeof(ushort), + propertyInfo: typeof(ClientLocation).GetProperty("TerritoryType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var type = runtimeEntityType.AddProperty( + "Type", + typeof(ClientLocation.EType), + propertyInfo: typeof(ClientLocation).GetProperty("Type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var x = runtimeEntityType.AddProperty( + "X", + typeof(float), + propertyInfo: typeof(ClientLocation).GetProperty("X", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var y = runtimeEntityType.AddProperty( + "Y", + typeof(float), + propertyInfo: typeof(ClientLocation).GetProperty("Y", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var z = runtimeEntityType.AddProperty( + "Z", + typeof(float), + propertyInfo: typeof(ClientLocation).GetProperty("Z", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var key = runtimeEntityType.AddKey( + new[] { localId }); + runtimeEntityType.SetPrimaryKey(key); + + return runtimeEntityType; + } + + public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType declaringEntityType, RuntimeEntityType targetEntityType, RuntimeEntityType joinEntityType) + { + var skipNavigation = declaringEntityType.AddSkipNavigation( + "ImportedBy", + targetEntityType, + joinEntityType.FindForeignKey( + new[] { joinEntityType.FindProperty("ImportedLocationsLocalId")! }, + declaringEntityType.FindKey(new[] { declaringEntityType.FindProperty("LocalId")! })!, + declaringEntityType)!, + true, + false, + typeof(List), + propertyInfo: typeof(ClientLocation).GetProperty("ImportedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var inverse = targetEntityType.FindSkipNavigation("ImportedLocations"); + if (inverse != null) + { + skipNavigation.Inverse = inverse; + inverse.Inverse = skipNavigation; + } + + return skipNavigation; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "Locations"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/Pal.Client/Database/Compiled/ClientLocationImportHistoryEntityType.cs b/Pal.Client/Database/Compiled/ClientLocationImportHistoryEntityType.cs new file mode 100644 index 0000000..2b15bab --- /dev/null +++ b/Pal.Client/Database/Compiled/ClientLocationImportHistoryEntityType.cs @@ -0,0 +1,83 @@ +// +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable enable + +namespace Pal.Client.Database.Compiled +{ + internal partial class ClientLocationImportHistoryEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType? baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "ClientLocationImportHistory", + typeof(Dictionary), + baseEntityType, + sharedClrType: true, + indexerPropertyInfo: RuntimeEntityType.FindIndexerProperty(typeof(Dictionary)), + propertyBag: true); + + var importedById = runtimeEntityType.AddProperty( + "ImportedById", + typeof(Guid), + propertyInfo: runtimeEntityType.FindIndexerPropertyInfo(), + afterSaveBehavior: PropertySaveBehavior.Throw); + + var importedLocationsLocalId = runtimeEntityType.AddProperty( + "ImportedLocationsLocalId", + typeof(int), + propertyInfo: runtimeEntityType.FindIndexerPropertyInfo(), + afterSaveBehavior: PropertySaveBehavior.Throw); + + var key = runtimeEntityType.AddKey( + new[] { importedById, importedLocationsLocalId }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { importedLocationsLocalId }); + + return runtimeEntityType; + } + + public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("ImportedById")! }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("Id")! })!, + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + return runtimeForeignKey; + } + + public static RuntimeForeignKey CreateForeignKey2(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("ImportedLocationsLocalId")! }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("LocalId")! })!, + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + return runtimeForeignKey; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "LocationImports"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/Pal.Client/Database/Compiled/ImportHistoryEntityType.cs b/Pal.Client/Database/Compiled/ImportHistoryEntityType.cs new file mode 100644 index 0000000..c3f25d8 --- /dev/null +++ b/Pal.Client/Database/Compiled/ImportHistoryEntityType.cs @@ -0,0 +1,94 @@ +// +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable enable + +namespace Pal.Client.Database.Compiled +{ + internal partial class ImportHistoryEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType? baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Pal.Client.Database.ImportHistory", + typeof(ImportHistory), + baseEntityType); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(Guid), + propertyInfo: typeof(ImportHistory).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ImportHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw); + + var exportedAt = runtimeEntityType.AddProperty( + "ExportedAt", + typeof(DateTime), + propertyInfo: typeof(ImportHistory).GetProperty("ExportedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ImportHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var importedAt = runtimeEntityType.AddProperty( + "ImportedAt", + typeof(DateTime), + propertyInfo: typeof(ImportHistory).GetProperty("ImportedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ImportHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var remoteUrl = runtimeEntityType.AddProperty( + "RemoteUrl", + typeof(string), + propertyInfo: typeof(ImportHistory).GetProperty("RemoteUrl", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ImportHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + return runtimeEntityType; + } + + public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType declaringEntityType, RuntimeEntityType targetEntityType, RuntimeEntityType joinEntityType) + { + var skipNavigation = declaringEntityType.AddSkipNavigation( + "ImportedLocations", + targetEntityType, + joinEntityType.FindForeignKey( + new[] { joinEntityType.FindProperty("ImportedById")! }, + declaringEntityType.FindKey(new[] { declaringEntityType.FindProperty("Id")! })!, + declaringEntityType)!, + true, + false, + typeof(List), + propertyInfo: typeof(ImportHistory).GetProperty("ImportedLocations", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ImportHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var inverse = targetEntityType.FindSkipNavigation("ImportedBy"); + if (inverse != null) + { + skipNavigation.Inverse = inverse; + inverse.Inverse = skipNavigation; + } + + return skipNavigation; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "Imports"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/Pal.Client/Database/Compiled/PalClientContextModel.cs b/Pal.Client/Database/Compiled/PalClientContextModel.cs new file mode 100644 index 0000000..61cf3d0 --- /dev/null +++ b/Pal.Client/Database/Compiled/PalClientContextModel.cs @@ -0,0 +1,28 @@ +// +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable enable + +namespace Pal.Client.Database.Compiled +{ + [DbContext(typeof(PalClientContext))] + public partial class PalClientContextModel : RuntimeModel + { + static PalClientContextModel() + { + var model = new PalClientContextModel(); + model.Initialize(); + model.Customize(); + _instance = model; + } + + private static PalClientContextModel _instance; + public static IModel Instance => _instance; + + partial void Initialize(); + + partial void Customize(); + } +} diff --git a/Pal.Client/Database/Compiled/PalClientContextModelBuilder.cs b/Pal.Client/Database/Compiled/PalClientContextModelBuilder.cs new file mode 100644 index 0000000..81f2a22 --- /dev/null +++ b/Pal.Client/Database/Compiled/PalClientContextModelBuilder.cs @@ -0,0 +1,35 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable enable + +namespace Pal.Client.Database.Compiled +{ + public partial class PalClientContextModel + { + partial void Initialize() + { + var clientLocationImportHistory = ClientLocationImportHistoryEntityType.Create(this); + var clientLocation = ClientLocationEntityType.Create(this); + var importHistory = ImportHistoryEntityType.Create(this); + var remoteEncounter = RemoteEncounterEntityType.Create(this); + + ClientLocationImportHistoryEntityType.CreateForeignKey1(clientLocationImportHistory, importHistory); + ClientLocationImportHistoryEntityType.CreateForeignKey2(clientLocationImportHistory, clientLocation); + RemoteEncounterEntityType.CreateForeignKey1(remoteEncounter, clientLocation); + + ClientLocationEntityType.CreateSkipNavigation1(clientLocation, importHistory, clientLocationImportHistory); + ImportHistoryEntityType.CreateSkipNavigation1(importHistory, clientLocation, clientLocationImportHistory); + + ClientLocationImportHistoryEntityType.CreateAnnotations(clientLocationImportHistory); + ClientLocationEntityType.CreateAnnotations(clientLocation); + ImportHistoryEntityType.CreateAnnotations(importHistory); + RemoteEncounterEntityType.CreateAnnotations(remoteEncounter); + + AddAnnotation("ProductVersion", "7.0.3"); + } + } +} diff --git a/Pal.Client/Database/Compiled/RemoteEncounterEntityType.cs b/Pal.Client/Database/Compiled/RemoteEncounterEntityType.cs new file mode 100644 index 0000000..a2df98b --- /dev/null +++ b/Pal.Client/Database/Compiled/RemoteEncounterEntityType.cs @@ -0,0 +1,92 @@ +// +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable enable + +namespace Pal.Client.Database.Compiled +{ + internal partial class RemoteEncounterEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType? baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Pal.Client.Database.RemoteEncounter", + typeof(RemoteEncounter), + baseEntityType); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(int), + propertyInfo: typeof(RemoteEncounter).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RemoteEncounter).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw); + + var accountId = runtimeEntityType.AddProperty( + "AccountId", + typeof(string), + propertyInfo: typeof(RemoteEncounter).GetProperty("AccountId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RemoteEncounter).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 13); + + var clientLocationId = runtimeEntityType.AddProperty( + "ClientLocationId", + typeof(int), + propertyInfo: typeof(RemoteEncounter).GetProperty("ClientLocationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RemoteEncounter).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { clientLocationId }); + + return runtimeEntityType; + } + + public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("ClientLocationId")! }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("LocalId")! })!, + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + var clientLocation = declaringEntityType.AddNavigation("ClientLocation", + runtimeForeignKey, + onDependent: true, + typeof(ClientLocation), + propertyInfo: typeof(RemoteEncounter).GetProperty("ClientLocation", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RemoteEncounter).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var remoteEncounters = principalEntityType.AddNavigation("RemoteEncounters", + runtimeForeignKey, + onDependent: false, + typeof(List), + propertyInfo: typeof(ClientLocation).GetProperty("RemoteEncounters", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(ClientLocation).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + return runtimeForeignKey; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "RemoteEncounters"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/Pal.Client/Database/ImportHistory.cs b/Pal.Client/Database/ImportHistory.cs new file mode 100644 index 0000000..535b502 --- /dev/null +++ b/Pal.Client/Database/ImportHistory.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Pal.Client.Database +{ + internal sealed class ImportHistory + { + public Guid Id { get; set; } + public string? RemoteUrl { get; set; } + public DateTime ExportedAt { get; set; } + public DateTime ImportedAt { get; set; } + + public List ImportedLocations { get; set; } = new(); + } +} diff --git a/Pal.Client/Database/Migrations/20230216154417_AddImportHistory.Designer.cs b/Pal.Client/Database/Migrations/20230216154417_AddImportHistory.Designer.cs new file mode 100644 index 0000000..c65bf7e --- /dev/null +++ b/Pal.Client/Database/Migrations/20230216154417_AddImportHistory.Designer.cs @@ -0,0 +1,45 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Pal.Client.Database; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + [DbContext(typeof(PalClientContext))] + [Migration("20230216154417_AddImportHistory")] + partial class AddImportHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + + modelBuilder.Entity("Pal.Client.Database.ImportHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ExportedAt") + .HasColumnType("TEXT"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteUrl") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Imports"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Pal.Client/Database/Migrations/20230216154417_AddImportHistory.cs b/Pal.Client/Database/Migrations/20230216154417_AddImportHistory.cs new file mode 100644 index 0000000..065048a --- /dev/null +++ b/Pal.Client/Database/Migrations/20230216154417_AddImportHistory.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + /// + public partial class AddImportHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Imports", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RemoteUrl = table.Column(type: "TEXT", nullable: true), + ExportedAt = table.Column(type: "TEXT", nullable: false), + ImportedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Imports", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Imports"); + } + } +} diff --git a/Pal.Client/Database/Migrations/20230217160342_AddClientLocations.Designer.cs b/Pal.Client/Database/Migrations/20230217160342_AddClientLocations.Designer.cs new file mode 100644 index 0000000..a7b24d6 --- /dev/null +++ b/Pal.Client/Database/Migrations/20230217160342_AddClientLocations.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Pal.Client.Database; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + [DbContext(typeof(PalClientContext))] + [Migration("20230217160342_AddClientLocations")] + partial class AddClientLocations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.Property("ImportedById") + .HasColumnType("TEXT"); + + b.Property("ImportedLocationsLocalId") + .HasColumnType("INTEGER"); + + b.HasKey("ImportedById", "ImportedLocationsLocalId"); + + b.HasIndex("ImportedLocationsLocalId"); + + b.ToTable("LocationImports", (string)null); + }); + + modelBuilder.Entity("Pal.Client.Database.ClientLocation", b => + { + b.Property("LocalId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Seen") + .HasColumnType("INTEGER"); + + b.Property("TerritoryType") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("REAL"); + + b.HasKey("LocalId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Pal.Client.Database.ImportHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ExportedAt") + .HasColumnType("TEXT"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteUrl") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Imports"); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(13) + .HasColumnType("TEXT"); + + b.Property("ClientLocationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClientLocationId"); + + b.ToTable("RemoteEncounters"); + }); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.HasOne("Pal.Client.Database.ImportHistory", null) + .WithMany() + .HasForeignKey("ImportedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pal.Client.Database.ClientLocation", null) + .WithMany() + .HasForeignKey("ImportedLocationsLocalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.HasOne("Pal.Client.Database.ClientLocation", "ClientLocation") + .WithMany() + .HasForeignKey("ClientLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientLocation"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Pal.Client/Database/Migrations/20230217160342_AddClientLocations.cs b/Pal.Client/Database/Migrations/20230217160342_AddClientLocations.cs new file mode 100644 index 0000000..7f4580f --- /dev/null +++ b/Pal.Client/Database/Migrations/20230217160342_AddClientLocations.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + /// + public partial class AddClientLocations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Locations", + columns: table => new + { + LocalId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TerritoryType = table.Column(type: "INTEGER", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + X = table.Column(type: "REAL", nullable: false), + Y = table.Column(type: "REAL", nullable: false), + Z = table.Column(type: "REAL", nullable: false), + Seen = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Locations", x => x.LocalId); + }); + + migrationBuilder.CreateTable( + name: "LocationImports", + columns: table => new + { + ImportedById = table.Column(type: "TEXT", nullable: false), + ImportedLocationsLocalId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LocationImports", x => new { x.ImportedById, x.ImportedLocationsLocalId }); + table.ForeignKey( + name: "FK_LocationImports_Imports_ImportedById", + column: x => x.ImportedById, + principalTable: "Imports", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_LocationImports_Locations_ImportedLocationsLocalId", + column: x => x.ImportedLocationsLocalId, + principalTable: "Locations", + principalColumn: "LocalId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RemoteEncounters", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ClientLocationId = table.Column(type: "INTEGER", nullable: false), + AccountId = table.Column(type: "TEXT", maxLength: 13, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RemoteEncounters", x => x.Id); + table.ForeignKey( + name: "FK_RemoteEncounters_Locations_ClientLocationId", + column: x => x.ClientLocationId, + principalTable: "Locations", + principalColumn: "LocalId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_LocationImports_ImportedLocationsLocalId", + table: "LocationImports", + column: "ImportedLocationsLocalId"); + + migrationBuilder.CreateIndex( + name: "IX_RemoteEncounters_ClientLocationId", + table: "RemoteEncounters", + column: "ClientLocationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LocationImports"); + + migrationBuilder.DropTable( + name: "RemoteEncounters"); + + migrationBuilder.DropTable( + name: "Locations"); + } + } +} diff --git a/Pal.Client/Database/Migrations/20230218112804_AddImportedAndSinceVersionToClientLocation.Designer.cs b/Pal.Client/Database/Migrations/20230218112804_AddImportedAndSinceVersionToClientLocation.Designer.cs new file mode 100644 index 0000000..24dd296 --- /dev/null +++ b/Pal.Client/Database/Migrations/20230218112804_AddImportedAndSinceVersionToClientLocation.Designer.cs @@ -0,0 +1,148 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Pal.Client.Database; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + [DbContext(typeof(PalClientContext))] + [Migration("20230218112804_AddImportedAndSinceVersionToClientLocation")] + partial class AddImportedAndSinceVersionToClientLocation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.Property("ImportedById") + .HasColumnType("TEXT"); + + b.Property("ImportedLocationsLocalId") + .HasColumnType("INTEGER"); + + b.HasKey("ImportedById", "ImportedLocationsLocalId"); + + b.HasIndex("ImportedLocationsLocalId"); + + b.ToTable("LocationImports", (string)null); + }); + + modelBuilder.Entity("Pal.Client.Database.ClientLocation", b => + { + b.Property("LocalId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Imported") + .HasColumnType("INTEGER"); + + b.Property("Seen") + .HasColumnType("INTEGER"); + + b.Property("SinceVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TerritoryType") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("REAL"); + + b.HasKey("LocalId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Pal.Client.Database.ImportHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ExportedAt") + .HasColumnType("TEXT"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteUrl") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Imports"); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(13) + .HasColumnType("TEXT"); + + b.Property("ClientLocationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClientLocationId"); + + b.ToTable("RemoteEncounters"); + }); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.HasOne("Pal.Client.Database.ImportHistory", null) + .WithMany() + .HasForeignKey("ImportedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pal.Client.Database.ClientLocation", null) + .WithMany() + .HasForeignKey("ImportedLocationsLocalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.HasOne("Pal.Client.Database.ClientLocation", "ClientLocation") + .WithMany("RemoteEncounters") + .HasForeignKey("ClientLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientLocation"); + }); + + modelBuilder.Entity("Pal.Client.Database.ClientLocation", b => + { + b.Navigation("RemoteEncounters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Pal.Client/Database/Migrations/20230218112804_AddImportedAndSinceVersionToClientLocation.cs b/Pal.Client/Database/Migrations/20230218112804_AddImportedAndSinceVersionToClientLocation.cs new file mode 100644 index 0000000..130be35 --- /dev/null +++ b/Pal.Client/Database/Migrations/20230218112804_AddImportedAndSinceVersionToClientLocation.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + /// + public partial class AddImportedAndSinceVersionToClientLocation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Imported", + table: "Locations", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SinceVersion", + table: "Locations", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Imported", + table: "Locations"); + + migrationBuilder.DropColumn( + name: "SinceVersion", + table: "Locations"); + } + } +} diff --git a/Pal.Client/Database/Migrations/20230222191929_ChangeLocationImportedToSource.Designer.cs b/Pal.Client/Database/Migrations/20230222191929_ChangeLocationImportedToSource.Designer.cs new file mode 100644 index 0000000..0924e17 --- /dev/null +++ b/Pal.Client/Database/Migrations/20230222191929_ChangeLocationImportedToSource.Designer.cs @@ -0,0 +1,148 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Pal.Client.Database; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + [DbContext(typeof(PalClientContext))] + [Migration("20230222191929_ChangeLocationImportedToSource")] + partial class ChangeLocationImportedToSource + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.Property("ImportedById") + .HasColumnType("TEXT"); + + b.Property("ImportedLocationsLocalId") + .HasColumnType("INTEGER"); + + b.HasKey("ImportedById", "ImportedLocationsLocalId"); + + b.HasIndex("ImportedLocationsLocalId"); + + b.ToTable("LocationImports", (string)null); + }); + + modelBuilder.Entity("Pal.Client.Database.ClientLocation", b => + { + b.Property("LocalId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Seen") + .HasColumnType("INTEGER"); + + b.Property("SinceVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TerritoryType") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("REAL"); + + b.HasKey("LocalId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Pal.Client.Database.ImportHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ExportedAt") + .HasColumnType("TEXT"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteUrl") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Imports"); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(13) + .HasColumnType("TEXT"); + + b.Property("ClientLocationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClientLocationId"); + + b.ToTable("RemoteEncounters"); + }); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.HasOne("Pal.Client.Database.ImportHistory", null) + .WithMany() + .HasForeignKey("ImportedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pal.Client.Database.ClientLocation", null) + .WithMany() + .HasForeignKey("ImportedLocationsLocalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.HasOne("Pal.Client.Database.ClientLocation", "ClientLocation") + .WithMany("RemoteEncounters") + .HasForeignKey("ClientLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientLocation"); + }); + + modelBuilder.Entity("Pal.Client.Database.ClientLocation", b => + { + b.Navigation("RemoteEncounters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Pal.Client/Database/Migrations/20230222191929_ChangeLocationImportedToSource.cs b/Pal.Client/Database/Migrations/20230222191929_ChangeLocationImportedToSource.cs new file mode 100644 index 0000000..20cf1e0 --- /dev/null +++ b/Pal.Client/Database/Migrations/20230222191929_ChangeLocationImportedToSource.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + /// + public partial class ChangeLocationImportedToSource : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Imported", + table: "Locations", + newName: "Source"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Source", + table: "Locations", + newName: "Imported"); + } + } +} diff --git a/Pal.Client/Database/Migrations/PalClientContextModelSnapshot.cs b/Pal.Client/Database/Migrations/PalClientContextModelSnapshot.cs new file mode 100644 index 0000000..55e0dff --- /dev/null +++ b/Pal.Client/Database/Migrations/PalClientContextModelSnapshot.cs @@ -0,0 +1,145 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Pal.Client.Database; + +#nullable disable + +namespace Pal.Client.Database.Migrations +{ + [DbContext(typeof(PalClientContext))] + partial class PalClientContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.Property("ImportedById") + .HasColumnType("TEXT"); + + b.Property("ImportedLocationsLocalId") + .HasColumnType("INTEGER"); + + b.HasKey("ImportedById", "ImportedLocationsLocalId"); + + b.HasIndex("ImportedLocationsLocalId"); + + b.ToTable("LocationImports", (string)null); + }); + + modelBuilder.Entity("Pal.Client.Database.ClientLocation", b => + { + b.Property("LocalId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Seen") + .HasColumnType("INTEGER"); + + b.Property("SinceVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TerritoryType") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("REAL"); + + b.HasKey("LocalId"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Pal.Client.Database.ImportHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ExportedAt") + .HasColumnType("TEXT"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteUrl") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Imports"); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(13) + .HasColumnType("TEXT"); + + b.Property("ClientLocationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClientLocationId"); + + b.ToTable("RemoteEncounters"); + }); + + modelBuilder.Entity("ClientLocationImportHistory", b => + { + b.HasOne("Pal.Client.Database.ImportHistory", null) + .WithMany() + .HasForeignKey("ImportedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Pal.Client.Database.ClientLocation", null) + .WithMany() + .HasForeignKey("ImportedLocationsLocalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b => + { + b.HasOne("Pal.Client.Database.ClientLocation", "ClientLocation") + .WithMany("RemoteEncounters") + .HasForeignKey("ClientLocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientLocation"); + }); + + modelBuilder.Entity("Pal.Client.Database.ClientLocation", b => + { + b.Navigation("RemoteEncounters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Pal.Client/Database/PalClientContext.cs b/Pal.Client/Database/PalClientContext.cs new file mode 100644 index 0000000..8cbd6a6 --- /dev/null +++ b/Pal.Client/Database/PalClientContext.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; + +namespace Pal.Client.Database +{ + internal class PalClientContext : DbContext + { + public DbSet Locations { get; set; } = null!; + public DbSet Imports { get; set; } = null!; + public DbSet RemoteEncounters { get; set; } = null!; + + public PalClientContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(o => o.ImportedBy) + .WithMany(o => o.ImportedLocations) + .UsingEntity(o => o.ToTable("LocationImports")); + } + } +} diff --git a/Pal.Client/Database/PalClientContextFactory.cs b/Pal.Client/Database/PalClientContextFactory.cs new file mode 100644 index 0000000..30124b9 --- /dev/null +++ b/Pal.Client/Database/PalClientContextFactory.cs @@ -0,0 +1,20 @@ +#if EF +using System; +using System.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Pal.Client.Database +{ + internal sealed class PalClientContextFactory : IDesignTimeDbContextFactory + { + public PalClientContext CreateDbContext(string[] args) + { + var optionsBuilder = + new DbContextOptionsBuilder().UseSqlite( + $"Data Source={Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "XIVLauncher", "pluginConfigs", "Palace Pal", "palace-pal.data.sqlite3")}"); + return new PalClientContext(optionsBuilder.Options); + } + } +} +#endif diff --git a/Pal.Client/Database/RemoteEncounter.cs b/Pal.Client/Database/RemoteEncounter.cs new file mode 100644 index 0000000..0a0f0a1 --- /dev/null +++ b/Pal.Client/Database/RemoteEncounter.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using Pal.Client.Extensions; +using Pal.Client.Net; + +namespace Pal.Client.Database +{ + /// + /// To avoid sending too many requests to the server, we cache which locations have been seen + /// locally. These never expire, and locations which have been seen with a specific account + /// are never sent to the server again. + /// + /// To be marked as seen, it needs to be essentially processed by . + /// + internal sealed class RemoteEncounter + { + [Key] + public int Id { get; private set; } + + public int ClientLocationId { get; private set; } + public ClientLocation ClientLocation { get; private set; } = null!; + + /// + /// Partial account id. This is partially unique - however problems would (in theory) + /// only occur once you have two account-ids where the first 13 characters are equal. + /// + [MaxLength(13)] + public string AccountId { get; private set; } + + private RemoteEncounter(int clientLocationId, string accountId) + { + ClientLocationId = clientLocationId; + AccountId = accountId; + } + + public RemoteEncounter(ClientLocation clientLocation, string accountId) + { + ClientLocation = clientLocation; + AccountId = accountId.ToPartialId(); + } + } +} diff --git a/Pal.Client/DependencyContextInitializer.cs b/Pal.Client/DependencyContextInitializer.cs new file mode 100644 index 0000000..0723736 --- /dev/null +++ b/Pal.Client/DependencyContextInitializer.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Dalamud.Plugin; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Commands; +using Pal.Client.Configuration; +using Pal.Client.Configuration.Legacy; +using Pal.Client.Database; +using Pal.Client.DependencyInjection; +using Pal.Client.Floors; +using Pal.Client.Windows; + +namespace Pal.Client +{ + /// + /// Takes care of async plugin init - this is mostly everything that requires either the config or the database to + /// be available. + /// + internal sealed class DependencyContextInitializer + { + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public DependencyContextInitializer(ILogger logger, + IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + using IDisposable? logScope = _logger.BeginScope("AsyncInit"); + + _logger.LogInformation("Starting async init"); + + await RemoveOldBackups(); + await CreateBackups(); + cancellationToken.ThrowIfCancellationRequested(); + + await RunMigrations(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + await RunCleanup(); + cancellationToken.ThrowIfCancellationRequested(); + + // v1 migration: config migration for import history, json migration for markers + _serviceProvider.GetRequiredService().Migrate(); + await _serviceProvider.GetRequiredService().MigrateAsync(cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + // windows that have logic to open on startup + _serviceProvider.GetRequiredService(); + + // initialize components that are mostly self-contained/self-registered + _serviceProvider.GetRequiredService(); + _serviceProvider.GetRequiredService(); + _serviceProvider.GetRequiredService(); + + // eager load any commands to find errors now, not when running them + _serviceProvider.GetRequiredService(); + _serviceProvider.GetRequiredService(); + _serviceProvider.GetRequiredService(); + _serviceProvider.GetRequiredService(); + + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Async init complete"); + } + + private async Task RemoveOldBackups() + { + await using var scope = _serviceProvider.CreateAsyncScope(); + var pluginInterface = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + + var paths = Directory.GetFiles(pluginInterface.GetPluginConfigDirectory(), "backup-*.data.sqlite3", + new EnumerationOptions + { + IgnoreInaccessible = true, + RecurseSubdirectories = false, + MatchCasing = MatchCasing.CaseSensitive, + AttributesToSkip = FileAttributes.ReadOnly | FileAttributes.Hidden | FileAttributes.System, + ReturnSpecialDirectories = false, + }); + if (paths.Length == 0) + return; + + Regex backupRegex = new Regex(@"backup-([\d\-]{10})\.data\.sqlite3", RegexOptions.Compiled); + List<(DateTime Date, string Path)> backupFiles = new(); + foreach (string path in paths) + { + var match = backupRegex.Match(Path.GetFileName(path)); + if (!match.Success) + continue; + + if (DateTime.TryParseExact(match.Groups[1].Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out DateTime backupDate)) + { + backupFiles.Add((backupDate, path)); + } + } + + var toDelete = backupFiles.OrderByDescending(x => x.Date) + .Skip(configuration.Backups.MinimumBackupsToKeep) + .Where(x => (DateTime.Today.ToUniversalTime() - x.Date).Days > configuration.Backups.DaysToDeleteAfter) + .Select(x => x.Path); + foreach (var path in toDelete) + { + try + { + File.Delete(path); + _logger.LogInformation("Deleted old backup file '{Path}'", path); + } + catch (Exception e) + { + _logger.LogWarning(e, "Could not delete backup file '{Path}'", path); + } + } + } + + private async Task CreateBackups() + { + await using var scope = _serviceProvider.CreateAsyncScope(); + + var pluginInterface = scope.ServiceProvider.GetRequiredService(); + string backupPath = Path.Join(pluginInterface.GetPluginConfigDirectory(), + $"backup-{DateTime.Today.ToUniversalTime():yyyy-MM-dd}.data.sqlite3"); + string sourcePath = Path.Join(pluginInterface.GetPluginConfigDirectory(), + DependencyInjectionContext.DatabaseFileName); + if (File.Exists(sourcePath) && !File.Exists(backupPath)) + { + if (File.Exists(sourcePath + "-shm") || File.Exists(sourcePath + "-wal")) + { + _logger.LogWarning("Could not create backup, database is open in another program"); + return; + } + + _logger.LogInformation("Creating database backup '{Path}'", backupPath); + try + { + File.Copy(sourcePath, backupPath); + } + catch (Exception e) + { + _logger.LogError(e, "Could not create backup"); + } + } + else + _logger.LogInformation("Database backup in '{Path}' already exists", backupPath); + } + + private async Task RunMigrations(CancellationToken cancellationToken) + { + await using var scope = _serviceProvider.CreateAsyncScope(); + + _logger.LogInformation("Loading database & running migrations"); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + + // takes 2-3 seconds with initializing connections, loading driver etc. + await dbContext.Database.MigrateAsync(cancellationToken); + _logger.LogInformation("Completed database migrations"); + } + + private async Task RunCleanup() + { + await using var scope = _serviceProvider.CreateAsyncScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + var cleanup = scope.ServiceProvider.GetRequiredService(); + + cleanup.Purge(dbContext); + + await dbContext.SaveChangesAsync(); + } + } +} diff --git a/Pal.Client/DependencyInjection/Chat.cs b/Pal.Client/DependencyInjection/Chat.cs new file mode 100644 index 0000000..05a91e1 --- /dev/null +++ b/Pal.Client/DependencyInjection/Chat.cs @@ -0,0 +1,38 @@ +using Dalamud.Game.Gui; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Pal.Client.Properties; + +namespace Pal.Client.DependencyInjection +{ + internal sealed class Chat + { + private readonly ChatGui _chatGui; + + public Chat(ChatGui chatGui) + { + _chatGui = chatGui; + } + + public void Error(string e) + { + _chatGui.PrintChat(new XivChatEntry + { + Message = new SeStringBuilder() + .AddUiForeground($"[{Localization.Palace_Pal}] ", 16) + .AddText(e).Build(), + Type = XivChatType.Urgent + }); + } + + public void Message(string message) + { + _chatGui.Print(new SeStringBuilder() + .AddUiForeground($"[{Localization.Palace_Pal}] ", 57) + .AddText(message).Build()); + } + + public void UnformattedMessage(string message) + => _chatGui.Print(message); + } +} diff --git a/Pal.Client/DependencyInjection/ChatService.cs b/Pal.Client/DependencyInjection/ChatService.cs new file mode 100644 index 0000000..97ffaed --- /dev/null +++ b/Pal.Client/DependencyInjection/ChatService.cs @@ -0,0 +1,110 @@ +using System; +using System.Text.RegularExpressions; +using Dalamud.Data; +using Dalamud.Game.Gui; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Lumina.Excel.GeneratedSheets; +using Pal.Client.Configuration; +using Pal.Client.Floors; + +namespace Pal.Client.DependencyInjection +{ + internal sealed class ChatService : IDisposable + { + private readonly ChatGui _chatGui; + private readonly TerritoryState _territoryState; + private readonly IPalacePalConfiguration _configuration; + private readonly DataManager _dataManager; + private readonly LocalizedChatMessages _localizedChatMessages; + + public ChatService(ChatGui chatGui, TerritoryState territoryState, IPalacePalConfiguration configuration, + DataManager dataManager) + { + _chatGui = chatGui; + _territoryState = territoryState; + _configuration = configuration; + _dataManager = dataManager; + + _localizedChatMessages = LoadLanguageStrings(); + + _chatGui.ChatMessage += OnChatMessage; + } + + public void Dispose() + => _chatGui.ChatMessage -= OnChatMessage; + + private void OnChatMessage(XivChatType type, uint senderId, ref SeString sender, ref SeString seMessage, + ref bool isHandled) + { + if (_configuration.FirstUse) + return; + + if (type != (XivChatType)2105) + return; + + string message = seMessage.ToString(); + if (_localizedChatMessages.FloorChanged.IsMatch(message)) + { + _territoryState.PomanderOfSight = PomanderState.Inactive; + + if (_territoryState.PomanderOfIntuition == PomanderState.FoundOnCurrentFloor) + _territoryState.PomanderOfIntuition = PomanderState.Inactive; + } + else if (message.EndsWith(_localizedChatMessages.MapRevealed)) + { + _territoryState.PomanderOfSight = PomanderState.Active; + } + else if (message.EndsWith(_localizedChatMessages.AllTrapsRemoved)) + { + _territoryState.PomanderOfSight = PomanderState.PomanderOfSafetyUsed; + } + else if (message.EndsWith(_localizedChatMessages.HoardNotOnCurrentFloor) || + message.EndsWith(_localizedChatMessages.HoardOnCurrentFloor)) + { + // There is no functional difference between these - if you don't open the marked coffer, + // going to higher floors will keep the pomander active. + _territoryState.PomanderOfIntuition = PomanderState.Active; + } + else if (message.EndsWith(_localizedChatMessages.HoardCofferOpened)) + { + _territoryState.PomanderOfIntuition = PomanderState.FoundOnCurrentFloor; + } + } + + private LocalizedChatMessages LoadLanguageStrings() + { + return new LocalizedChatMessages + { + MapRevealed = GetLocalizedString(7256), + AllTrapsRemoved = GetLocalizedString(7255), + HoardOnCurrentFloor = GetLocalizedString(7272), + HoardNotOnCurrentFloor = GetLocalizedString(7273), + HoardCofferOpened = GetLocalizedString(7274), + FloorChanged = + new Regex("^" + GetLocalizedString(7270).Replace("\u0002 \u0003\ufffd\u0002\u0003", @"(\d+)") + + "$"), + }; + } + + private string GetLocalizedString(uint id) + { + return _dataManager.GetExcelSheet()?.GetRow(id)?.Text?.ToString() ?? "Unknown"; + } + + private sealed class LocalizedChatMessages + { + public string MapRevealed { get; init; } = "???"; //"The map for this floor has been revealed!"; + public string AllTrapsRemoved { get; init; } = "???"; // "All the traps on this floor have disappeared!"; + public string HoardOnCurrentFloor { get; init; } = "???"; // "You sense the Accursed Hoard calling you..."; + + public string HoardNotOnCurrentFloor { get; init; } = + "???"; // "You do not sense the call of the Accursed Hoard on this floor..."; + + public string HoardCofferOpened { get; init; } = "???"; // "You discover a piece of the Accursed Hoard!"; + + public Regex FloorChanged { get; init; } = + new(@"This isn't a game message, but will be replaced"); // new Regex(@"^Floor (\d+)$"); + } + } +} diff --git a/Pal.Client/DependencyInjection/DebugState.cs b/Pal.Client/DependencyInjection/DebugState.cs new file mode 100644 index 0000000..1fe7624 --- /dev/null +++ b/Pal.Client/DependencyInjection/DebugState.cs @@ -0,0 +1,15 @@ +using System; + +namespace Pal.Client.DependencyInjection +{ + internal sealed class DebugState + { + public string? DebugMessage { get; set; } + + public void SetFromException(Exception e) + => DebugMessage = $"{DateTime.Now}\n{e}"; + + public void Reset() + => DebugMessage = null; + } +} diff --git a/Pal.Client/Hooks.cs b/Pal.Client/DependencyInjection/GameHooks.cs similarity index 71% rename from Pal.Client/Hooks.cs rename to Pal.Client/DependencyInjection/GameHooks.cs index dca3228..7e7d868 100644 --- a/Pal.Client/Hooks.cs +++ b/Pal.Client/DependencyInjection/GameHooks.cs @@ -1,15 +1,22 @@ -using Dalamud.Game.ClientState.Objects.Types; +using System; +using System.Text; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Hooking; -using Dalamud.Logging; using Dalamud.Memory; using Dalamud.Utility.Signatures; -using System; -using System.Text; +using Microsoft.Extensions.Logging; +using Pal.Client.Floors; -namespace Pal.Client +namespace Pal.Client.DependencyInjection { - internal unsafe class Hooks + internal sealed unsafe class GameHooks : IDisposable { + private readonly ILogger _logger; + private readonly ObjectTable _objectTable; + private readonly TerritoryState _territoryState; + private readonly FrameworkService _frameworkService; + #pragma warning disable CS0649 private delegate nint ActorVfxCreateDelegate(char* a1, nint a2, nint a3, float a4, char a5, ushort a6, char a7); @@ -17,10 +24,18 @@ namespace Pal.Client private Hook ActorVfxCreateHook { get; init; } = null!; #pragma warning restore CS0649 - public Hooks() + public GameHooks(ILogger logger, ObjectTable objectTable, TerritoryState territoryState, FrameworkService frameworkService) { + _logger = logger; + _objectTable = objectTable; + _territoryState = territoryState; + _frameworkService = frameworkService; + + _logger.LogDebug("Initializing game hooks"); SignatureHelper.Initialise(this); ActorVfxCreateHook.Enable(); + + _logger.LogDebug("Game hooks initialized"); } /// @@ -55,35 +70,37 @@ namespace Pal.Client { try { - if (Service.Plugin.IsInDeepDungeon()) + if (_territoryState.IsInDeepDungeon()) { var vfxPath = MemoryHelper.ReadString(new nint(a1), Encoding.ASCII, 256); - var obj = Service.ObjectTable.CreateObjectReference(a2); + var obj = _objectTable.CreateObjectReference(a2); /* if (Service.Configuration.BetaKey == "VFX") - Service.Chat.Print($"{vfxPath} on {obj}"); + _chat.PalPrint($"{vfxPath} on {obj}"); */ if (obj is BattleChara bc && (bc.NameId == /* potd */ 5042 || bc.NameId == /* hoh */ 7395)) { if (vfxPath == "vfx/common/eff/dk05th_stdn0t.avfx" || vfxPath == "vfx/common/eff/dk05ht_ipws0t.avfx") { - Service.Plugin.NextUpdateObjects.Enqueue(obj.Address); + _logger.LogDebug("VFX '{Path}' playing at {Location}", vfxPath, obj.Position); + _frameworkService.NextUpdateObjects.Enqueue(obj.Address); } } } } catch (Exception e) { - PluginLog.Error(e, "VFX Create Hook failed"); + _logger.LogError(e, "VFX Create Hook failed"); } return ActorVfxCreateHook.Original(a1, a2, a3, a4, a5, a6, a7); } public void Dispose() { - ActorVfxCreateHook?.Dispose(); + _logger.LogDebug("Disposing game hooks"); + ActorVfxCreateHook.Dispose(); } } } diff --git a/Pal.Client/DependencyInjection/ImportService.cs b/Pal.Client/DependencyInjection/ImportService.cs new file mode 100644 index 0000000..d704afb --- /dev/null +++ b/Pal.Client/DependencyInjection/ImportService.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using Account; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Pal.Client.Database; +using Pal.Client.Floors; +using Pal.Client.Floors.Tasks; +using Pal.Common; + +namespace Pal.Client.DependencyInjection +{ + internal sealed class ImportService + { + private readonly IServiceProvider _serviceProvider; + private readonly FloorService _floorService; + private readonly Cleanup _cleanup; + + public ImportService( + IServiceProvider serviceProvider, + FloorService floorService, + Cleanup cleanup) + { + _serviceProvider = serviceProvider; + _floorService = floorService; + _cleanup = cleanup; + } + + public async Task FindLast(CancellationToken token = default) + { + await using var scope = _serviceProvider.CreateAsyncScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + + return await dbContext.Imports.OrderByDescending(x => x.ImportedAt).ThenBy(x => x.Id) + .FirstOrDefaultAsync(cancellationToken: token); + } + + public (int traps, int hoard) Import(ExportRoot import) + { + try + { + _floorService.SetToImportState(); + + using var scope = _serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Imports.RemoveRange(dbContext.Imports.Where(x => x.RemoteUrl == import.ServerUrl).ToList()); + dbContext.SaveChanges(); + + ImportHistory importHistory = new ImportHistory + { + Id = Guid.Parse(import.ExportId), + RemoteUrl = import.ServerUrl, + ExportedAt = import.CreatedAt.ToDateTime(), + ImportedAt = DateTime.UtcNow, + }; + dbContext.Imports.Add(importHistory); + + int traps = 0; + int hoard = 0; + foreach (var floor in import.Floors) + { + ETerritoryType territoryType = (ETerritoryType)floor.TerritoryType; + + List existingLocations = dbContext.Locations + .Where(loc => loc.TerritoryType == floor.TerritoryType) + .ToList() + .Select(LoadTerritory.ToMemoryLocation) + .ToList(); + foreach (var exportLocation in floor.Objects) + { + PersistentLocation persistentLocation = new PersistentLocation + { + Type = ToMemoryType(exportLocation.Type), + Position = new Vector3(exportLocation.X, exportLocation.Y, exportLocation.Z), + Source = ClientLocation.ESource.Unknown, + }; + + var existingLocation = existingLocations.FirstOrDefault(x => x == persistentLocation); + if (existingLocation != null) + { + var clientLoc = dbContext.Locations.FirstOrDefault(o => o.LocalId == existingLocation.LocalId); + clientLoc?.ImportedBy.Add(importHistory); + + continue; + } + + ClientLocation clientLocation = new ClientLocation + { + TerritoryType = (ushort)territoryType, + Type = ToClientLocationType(exportLocation.Type), + X = exportLocation.X, + Y = exportLocation.Y, + Z = exportLocation.Z, + Seen = false, + Source = ClientLocation.ESource.Import, + ImportedBy = new List { importHistory }, + SinceVersion = typeof(Plugin).Assembly.GetName().Version!.ToString(2), + }; + dbContext.Locations.Add(clientLocation); + + if (exportLocation.Type == ExportObjectType.Trap) + traps++; + else if (exportLocation.Type == ExportObjectType.Hoard) + hoard++; + } + } + + dbContext.SaveChanges(); + + _cleanup.Purge(dbContext); + dbContext.SaveChanges(); + + return (traps, hoard); + } + finally + { + _floorService.ResetAll(); + } + } + + private MemoryLocation.EType ToMemoryType(ExportObjectType exportLocationType) + { + return exportLocationType switch + { + ExportObjectType.Trap => MemoryLocation.EType.Trap, + ExportObjectType.Hoard => MemoryLocation.EType.Hoard, + _ => throw new ArgumentOutOfRangeException(nameof(exportLocationType), exportLocationType, null) + }; + } + + private ClientLocation.EType ToClientLocationType(ExportObjectType exportLocationType) + { + return exportLocationType switch + { + ExportObjectType.Trap => ClientLocation.EType.Trap, + ExportObjectType.Hoard => ClientLocation.EType.Hoard, + _ => throw new ArgumentOutOfRangeException(nameof(exportLocationType), exportLocationType, null) + }; + } + + public void RemoveById(Guid id) + { + try + { + _floorService.SetToImportState(); + using var scope = _serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.RemoveRange(dbContext.Imports.Where(x => x.Id == id)); + dbContext.SaveChanges(); + + _cleanup.Purge(dbContext); + dbContext.SaveChanges(); + } + finally + { + _floorService.ResetAll(); + } + } + } +} diff --git a/Pal.Client/DependencyInjection/Logging/DalamudLogger.cs b/Pal.Client/DependencyInjection/Logging/DalamudLogger.cs new file mode 100644 index 0000000..692c311 --- /dev/null +++ b/Pal.Client/DependencyInjection/Logging/DalamudLogger.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Text; +using Serilog.Events; + +namespace Pal.Client.DependencyInjection.Logging +{ + internal sealed class DalamudLogger : ILogger + { + private static readonly string AssemblyName = typeof(Plugin).Assembly.GetName().Name!; + private static readonly Serilog.ILogger PluginLogDelegate = Serilog.Log.ForContext("SourceContext", AssemblyName); + private readonly string _name; + private readonly IExternalScopeProvider? _scopeProvider; + + public DalamudLogger(string name, IExternalScopeProvider? scopeProvider) + { + _name = name; + _scopeProvider = scopeProvider; + } + + public IDisposable BeginScope(TState state) + where TState : notnull + => _scopeProvider?.Push(state) ?? NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && IsEnabled(ToSerilogLevel(logLevel)); + + private bool IsEnabled(LogEventLevel logEventLevel) => PluginLogDelegate.IsEnabled(logEventLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.None) + return; + + LogEventLevel logEventLevel = ToSerilogLevel(logLevel); + if (!IsEnabled(logEventLevel)) + return; + + if (formatter == null) + throw new ArgumentNullException(nameof(formatter)); + + StringBuilder sb = new StringBuilder(); + sb.Append('[').Append(AssemblyName).Append("] "); + _scopeProvider?.ForEachScope((scope, builder) => + { + if (scope is IEnumerable> properties) + { + foreach (KeyValuePair pair in properties) + { + builder.Append('<').Append(pair.Key).Append('=').Append(pair.Value) + .Append("> "); + } + } + else if (scope != null) + builder.Append('<').Append(scope).Append("> "); + }, + sb); + sb.Append(_name).Append(": ").Append(formatter(state, null)); + PluginLogDelegate.Write(logEventLevel, exception, sb.ToString()); + } + + private LogEventLevel ToSerilogLevel(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Critical => LogEventLevel.Fatal, + LogLevel.Error => LogEventLevel.Error, + LogLevel.Warning => LogEventLevel.Warning, + LogLevel.Information => LogEventLevel.Information, + LogLevel.Debug => LogEventLevel.Debug, + LogLevel.Trace => LogEventLevel.Verbose, + _ => throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null) + }; + } + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + private NullScope() + { + } + + public void Dispose() + { + } + } + } +} diff --git a/Pal.Client/DependencyInjection/Logging/DalamudLoggerProvider.cs b/Pal.Client/DependencyInjection/Logging/DalamudLoggerProvider.cs new file mode 100644 index 0000000..287239b --- /dev/null +++ b/Pal.Client/DependencyInjection/Logging/DalamudLoggerProvider.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using System; + +namespace Pal.Client.DependencyInjection.Logging +{ + internal sealed class DalamudLoggerProvider : ILoggerProvider, ISupportExternalScope + { + private IExternalScopeProvider? _scopeProvider; + + public ILogger CreateLogger(string categoryName) => new DalamudLogger(categoryName, _scopeProvider); + + /// + /// Manual logger creation, doesn't handle scopes. + /// + public ILogger CreateLogger(Type type) => CreateLogger(type.FullName ?? type.ToString()); + + /// + /// Manual logger creation, doesn't handle scopes. + /// + public ILogger CreateLogger() => CreateLogger(typeof(T)); + + public void SetScopeProvider(IExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } + + public void Dispose() + { + } + } +} diff --git a/Pal.Client/DependencyInjection/RepoVerification.cs b/Pal.Client/DependencyInjection/RepoVerification.cs new file mode 100644 index 0000000..07c41bc --- /dev/null +++ b/Pal.Client/DependencyInjection/RepoVerification.cs @@ -0,0 +1,26 @@ +using System; +using Dalamud.Game.Gui; +using Dalamud.Logging; +using Dalamud.Plugin; +using Microsoft.Extensions.Logging; +using Pal.Client.Extensions; +using Pal.Client.Properties; + +namespace Pal.Client.DependencyInjection +{ + internal sealed class RepoVerification + { + public RepoVerification(ILogger logger, DalamudPluginInterface pluginInterface, Chat chat) + { + logger.LogInformation("Install source: {Repo}", pluginInterface.SourceRepository); + if (!pluginInterface.IsDev + && !pluginInterface.SourceRepository.StartsWith("https://raw.githubusercontent.com/carvelli/") + && !pluginInterface.SourceRepository.StartsWith("https://github.com/carvelli/")) + { + chat.Error(string.Format(Localization.Error_WrongRepository, + "https://github.com/carvelli/Dalamud-Plugins")); + throw new InvalidOperationException(); + } + } + } +} diff --git a/Pal.Client/DependencyInjection/StatisticsService.cs b/Pal.Client/DependencyInjection/StatisticsService.cs new file mode 100644 index 0000000..2096ba8 --- /dev/null +++ b/Pal.Client/DependencyInjection/StatisticsService.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading.Tasks; +using Dalamud.Game.Gui; +using Grpc.Core; +using Pal.Client.Configuration; +using Pal.Client.Extensions; +using Pal.Client.Net; +using Pal.Client.Properties; +using Pal.Client.Windows; + +namespace Pal.Client.DependencyInjection +{ + internal sealed class StatisticsService + { + private readonly IPalacePalConfiguration _configuration; + private readonly RemoteApi _remoteApi; + private readonly StatisticsWindow _statisticsWindow; + private readonly Chat _chat; + + public StatisticsService(IPalacePalConfiguration configuration, RemoteApi remoteApi, + StatisticsWindow statisticsWindow, Chat chat) + { + _configuration = configuration; + _remoteApi = remoteApi; + _statisticsWindow = statisticsWindow; + _chat = chat; + } + + public void ShowGlobalStatistics() + { + Task.Run(async () => await FetchFloorStatistics()); + } + + private async Task FetchFloorStatistics() + { + if (!_configuration.HasRoleOnCurrentServer(RemoteApi.RemoteUrl, "statistics:view")) + { + _chat.Error(Localization.Command_pal_stats_CurrentFloor); + return; + } + + try + { + var (success, floorStatistics) = await _remoteApi.FetchStatistics(); + if (success) + { + _statisticsWindow.SetFloorData(floorStatistics); + _statisticsWindow.IsOpen = true; + } + else + { + _chat.Error(Localization.Command_pal_stats_UnableToFetchStatistics); + } + } + catch (RpcException e) when (e.StatusCode == StatusCode.PermissionDenied) + { + _chat.Error(Localization.Command_pal_stats_CurrentFloor); + } + catch (Exception e) + { + _chat.Error(e.ToString()); + } + } + } +} diff --git a/Pal.Client/DependencyInjectionContext.cs b/Pal.Client/DependencyInjectionContext.cs new file mode 100644 index 0000000..cc967cc --- /dev/null +++ b/Pal.Client/DependencyInjectionContext.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.Command; +using Dalamud.Game.Gui; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Commands; +using Pal.Client.Configuration; +using Pal.Client.Configuration.Legacy; +using Pal.Client.Database; +using Pal.Client.DependencyInjection; +using Pal.Client.DependencyInjection.Logging; +using Pal.Client.Floors; +using Pal.Client.Net; +using Pal.Client.Properties; +using Pal.Client.Rendering; +using Pal.Client.Scheduled; +using Pal.Client.Windows; + +namespace Pal.Client +{ + /// + /// DI-aware Plugin. + /// + internal sealed class DependencyInjectionContext : IDisposable + { + public const string DatabaseFileName = "palace-pal.data.sqlite3"; + public static DalamudLoggerProvider LoggerProvider { get; } = new(); + + /// + /// Initialized as temporary logger, will be overriden once context is ready with a logger that supports scopes. + /// + private ILogger _logger = LoggerProvider.CreateLogger(); + + private readonly string _sqliteConnectionString; + private readonly ServiceCollection _serviceCollection = new(); + private ServiceProvider? _serviceProvider; + + public string Name => Localization.Palace_Pal; + + public DependencyInjectionContext( + DalamudPluginInterface pluginInterface, + ClientState clientState, + GameGui gameGui, + ChatGui chatGui, + ObjectTable objectTable, + Framework framework, + Condition condition, + CommandManager commandManager, + DataManager dataManager, + Plugin plugin) + { + _logger.LogInformation("Building dalamud service container for {Assembly}", + typeof(DependencyInjectionContext).Assembly.FullName); + + // set up legacy services +#pragma warning disable CS0612 + JsonFloorState.SetContextProperties(pluginInterface.GetPluginConfigDirectory()); +#pragma warning restore CS0612 + + // set up logging + _serviceCollection.AddLogging(builder => + builder.AddFilter("Pal", LogLevel.Trace) + .AddFilter("Microsoft.EntityFrameworkCore.Database", LogLevel.Warning) + .AddFilter("Grpc", LogLevel.Debug) + .ClearProviders() + .AddProvider(LoggerProvider)); + + // dalamud + _serviceCollection.AddSingleton(plugin); + _serviceCollection.AddSingleton(pluginInterface); + _serviceCollection.AddSingleton(clientState); + _serviceCollection.AddSingleton(gameGui); + _serviceCollection.AddSingleton(chatGui); + _serviceCollection.AddSingleton(); + _serviceCollection.AddSingleton(objectTable); + _serviceCollection.AddSingleton(framework); + _serviceCollection.AddSingleton(condition); + _serviceCollection.AddSingleton(commandManager); + _serviceCollection.AddSingleton(dataManager); + _serviceCollection.AddSingleton(new WindowSystem(typeof(DependencyInjectionContext).AssemblyQualifiedName)); + + _sqliteConnectionString = + $"Data Source={Path.Join(pluginInterface.GetPluginConfigDirectory(), DatabaseFileName)}"; + } + + public IServiceProvider BuildServiceContainer() + { + _logger.LogInformation("Building async service container for {Assembly}", + typeof(DependencyInjectionContext).Assembly.FullName); + + // EF core + _serviceCollection.AddDbContext(o => o + .UseSqlite(_sqliteConnectionString) + .UseModel(Database.Compiled.PalClientContextModel.Instance)); + _serviceCollection.AddTransient(); + _serviceCollection.AddScoped(); + + // plugin-specific + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(sp => + sp.GetRequiredService().Load()); + _serviceCollection.AddTransient(); + + // commands + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + + // territory & marker related services + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + + // windows & related services + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + + // rendering + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + _serviceCollection.AddScoped(); + + // queue handling + _serviceCollection.AddTransient, QueuedImport.Handler>(); + _serviceCollection + .AddTransient, QueuedUndoImport.Handler>(); + _serviceCollection + .AddTransient, QueuedConfigUpdate.Handler>(); + _serviceCollection + .AddTransient, QueuedSyncResponse.Handler>(); + + // build + _serviceProvider = _serviceCollection.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true, + }); + + +#if RELEASE + // You're welcome to remove this code in your fork, but please make sure that: + // - none of the links accessible within FFXIV open the original repo (e.g. in the plugin installer), and + // - you host your own server instance + // + // This is mainly to avoid this plugin being included in 'mega-repos' that, for whatever reason, decide + // that collecting all plugins is a good idea (and break half in the process). + _serviceProvider.GetService(); +#endif + + // This is not ideal as far as loading the plugin goes, because there's no way to check for errors and + // tell Dalamud that no, the plugin isn't ready -- so the plugin will count as properly initialized, + // even if it's not. + // + // There's 2-3 seconds of slowdown primarily caused by the sqlite init, but that needs to happen for + // config stuff. + _logger = _serviceProvider.GetRequiredService>(); + _logger.LogInformation("Service container built"); + + return _serviceProvider; + } + + public void Dispose() + { + _logger.LogInformation("Disposing DI Context"); + _serviceProvider?.Dispose(); + + // ensure we're not keeping the file open longer than the plugin is loaded + using (SqliteConnection sqliteConnection = new(_sqliteConnectionString)) + SqliteConnection.ClearPool(sqliteConnection); + } + } +} diff --git a/Pal.Client/Extensions/ChatExtensions.cs b/Pal.Client/Extensions/ChatExtensions.cs deleted file mode 100644 index 31b39ef..0000000 --- a/Pal.Client/Extensions/ChatExtensions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Dalamud.Game.Gui; -using Pal.Client.Properties; - -namespace Pal.Client.Extensions -{ - public static class ChatExtensions - { - public static void PalError(this ChatGui chat, string e) - => chat.PrintError($"[{Localization.Palace_Pal}] {e}"); - } -} diff --git a/Pal.Client/Extensions/WindowSystemExtensions.cs b/Pal.Client/Extensions/WindowSystemExtensions.cs deleted file mode 100644 index 15809c2..0000000 --- a/Pal.Client/Extensions/WindowSystemExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Linq; -using Dalamud.Interface.Windowing; - -namespace Pal.Client.Extensions -{ - internal static class WindowSystemExtensions - { - public static T? GetWindow(this WindowSystem windowSystem) - where T : Window - { - return windowSystem.Windows.OfType().FirstOrDefault(); - } - } -} diff --git a/Pal.Client/Floors/EphemeralLocation.cs b/Pal.Client/Floors/EphemeralLocation.cs new file mode 100644 index 0000000..c4d8f20 --- /dev/null +++ b/Pal.Client/Floors/EphemeralLocation.cs @@ -0,0 +1,29 @@ +using System; + +namespace Pal.Client.Floors +{ + /// + /// This is a currently-visible marker. + /// + internal sealed class EphemeralLocation : MemoryLocation + { + public override bool Equals(object? obj) => obj is EphemeralLocation && base.Equals(obj); + + public override int GetHashCode() => base.GetHashCode(); + + public static bool operator ==(EphemeralLocation? a, object? b) + { + return Equals(a, b); + } + + public static bool operator !=(EphemeralLocation? a, object? b) + { + return !Equals(a, b); + } + + public override string ToString() + { + return $"EphemeralLocation(Position={Position}, Type={Type})"; + } + } +} diff --git a/Pal.Client/Floors/FloorService.cs b/Pal.Client/Floors/FloorService.cs new file mode 100644 index 0000000..bfb2b58 --- /dev/null +++ b/Pal.Client/Floors/FloorService.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Pal.Client.Configuration; +using Pal.Client.Database; +using Pal.Client.Extensions; +using Pal.Client.Floors.Tasks; +using Pal.Client.Net; +using Pal.Common; + +namespace Pal.Client.Floors +{ + internal sealed class FloorService + { + private readonly IPalacePalConfiguration _configuration; + private readonly Cleanup _cleanup; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IReadOnlyDictionary _territories; + + private ConcurrentBag _ephemeralLocations = new(); + + public FloorService(IPalacePalConfiguration configuration, Cleanup cleanup, + IServiceScopeFactory serviceScopeFactory) + { + _configuration = configuration; + _cleanup = cleanup; + _serviceScopeFactory = serviceScopeFactory; + _territories = Enum.GetValues().ToDictionary(o => o, o => new MemoryTerritory(o)); + } + + public IReadOnlyCollection EphemeralLocations => _ephemeralLocations; + public bool IsImportRunning { get; private set; } + + public void ChangeTerritory(ushort territoryType) + { + _ephemeralLocations = new ConcurrentBag(); + + if (typeof(ETerritoryType).IsEnumDefined(territoryType)) + ChangeTerritory((ETerritoryType)territoryType); + } + + private void ChangeTerritory(ETerritoryType newTerritory) + { + var territory = _territories[newTerritory]; + if (territory.ReadyState == MemoryTerritory.EReadyState.NotLoaded) + { + territory.ReadyState = MemoryTerritory.EReadyState.Loading; + new LoadTerritory(_serviceScopeFactory, _cleanup, territory).Start(); + } + } + + public MemoryTerritory? GetTerritoryIfReady(ushort territoryType) + { + if (typeof(ETerritoryType).IsEnumDefined(territoryType)) + return GetTerritoryIfReady((ETerritoryType)territoryType); + + return null; + } + + public MemoryTerritory? GetTerritoryIfReady(ETerritoryType territoryType) + { + var territory = _territories[territoryType]; + if (territory.ReadyState != MemoryTerritory.EReadyState.Ready) + return null; + + return territory; + } + + public bool IsReady(ushort territoryId) => GetTerritoryIfReady(territoryId) != null; + + public bool MergePersistentLocations( + ETerritoryType territoryType, + IReadOnlyList visibleLocations, + bool recreateLayout, + out List locationsToSync) + { + MemoryTerritory? territory = GetTerritoryIfReady(territoryType); + locationsToSync = new(); + if (territory == null) + return false; + + var partialAccountId = _configuration.FindAccount(RemoteApi.RemoteUrl)?.AccountId.ToPartialId(); + var persistentLocations = territory.Locations.ToList(); + + List markAsSeen = new(); + List newLocations = new(); + foreach (var visibleLocation in visibleLocations) + { + PersistentLocation? existingLocation = persistentLocations.SingleOrDefault(x => x == visibleLocation); + if (existingLocation != null) + { + if (existingLocation is { Seen: false, LocalId: { } }) + { + existingLocation.Seen = true; + markAsSeen.Add(existingLocation); + } + + // This requires you to have seen a trap/hoard marker once per floor to synchronize this for older local states, + // markers discovered afterwards are automatically marked seen. + if (partialAccountId != null && + existingLocation is { LocalId: { }, NetworkId: { }, RemoteSeenRequested: false } && + !existingLocation.RemoteSeenOn.Contains(partialAccountId)) + { + existingLocation.RemoteSeenRequested = true; + locationsToSync.Add(existingLocation); + } + + continue; + } + + territory.Locations.Add(visibleLocation); + newLocations.Add(visibleLocation); + recreateLayout = true; + } + + if (markAsSeen.Count > 0) + new MarkLocalSeen(_serviceScopeFactory, territory, markAsSeen).Start(); + + if (newLocations.Count > 0) + new SaveNewLocations(_serviceScopeFactory, territory, newLocations).Start(); + + return recreateLayout; + } + + /// Whether the locations have changed + public bool MergeEphemeralLocations(IReadOnlyList visibleLocations, bool recreate) + { + recreate |= _ephemeralLocations.Any(loc => visibleLocations.All(x => x != loc)); + recreate |= visibleLocations.Any(loc => _ephemeralLocations.All(x => x != loc)); + + if (!recreate) + return false; + + _ephemeralLocations.Clear(); + foreach (var visibleLocation in visibleLocations) + _ephemeralLocations.Add(visibleLocation); + + return true; + } + + public void ResetAll() + { + IsImportRunning = false; + foreach (var memoryTerritory in _territories.Values) + { + lock (memoryTerritory.LockObj) + memoryTerritory.Reset(); + } + } + + public void SetToImportState() + { + IsImportRunning = true; + foreach (var memoryTerritory in _territories.Values) + { + lock (memoryTerritory.LockObj) + memoryTerritory.ReadyState = MemoryTerritory.EReadyState.Importing; + } + } + } +} diff --git a/Pal.Client/Floors/FrameworkService.cs b/Pal.Client/Floors/FrameworkService.cs new file mode 100644 index 0000000..84fde6f --- /dev/null +++ b/Pal.Client/Floors/FrameworkService.cs @@ -0,0 +1,444 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.ClientState.Objects.Types; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration; +using Pal.Client.Database; +using Pal.Client.DependencyInjection; +using Pal.Client.Net; +using Pal.Client.Rendering; +using Pal.Client.Scheduled; +using Pal.Common; + +namespace Pal.Client.Floors +{ + internal sealed class FrameworkService : IDisposable + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly Framework _framework; + private readonly ConfigurationManager _configurationManager; + private readonly IPalacePalConfiguration _configuration; + private readonly ClientState _clientState; + private readonly TerritoryState _territoryState; + private readonly FloorService _floorService; + private readonly DebugState _debugState; + private readonly RenderAdapter _renderAdapter; + private readonly ObjectTable _objectTable; + private readonly RemoteApi _remoteApi; + + internal Queue EarlyEventQueue { get; } = new(); + internal Queue LateEventQueue { get; } = new(); + internal ConcurrentQueue NextUpdateObjects { get; } = new(); + + public FrameworkService( + IServiceProvider serviceProvider, + ILogger logger, + Framework framework, + ConfigurationManager configurationManager, + IPalacePalConfiguration configuration, + ClientState clientState, + TerritoryState territoryState, + FloorService floorService, + DebugState debugState, + RenderAdapter renderAdapter, + ObjectTable objectTable, + RemoteApi remoteApi) + { + _serviceProvider = serviceProvider; + _logger = logger; + _framework = framework; + _configurationManager = configurationManager; + _configuration = configuration; + _clientState = clientState; + _territoryState = territoryState; + _floorService = floorService; + _debugState = debugState; + _renderAdapter = renderAdapter; + _objectTable = objectTable; + _remoteApi = remoteApi; + + _framework.Update += OnUpdate; + _configurationManager.Saved += OnSaved; + } + + public void Dispose() + { + _framework.Update -= OnUpdate; + _configurationManager.Saved -= OnSaved; + } + + private void OnSaved(object? sender, IPalacePalConfiguration? config) + => EarlyEventQueue.Enqueue(new QueuedConfigUpdate()); + + private void OnUpdate(Framework framework) + { + if (_configuration.FirstUse) + return; + + try + { + bool recreateLayout = false; + + while (EarlyEventQueue.TryDequeue(out IQueueOnFrameworkThread? queued)) + HandleQueued(queued, ref recreateLayout); + + if (_territoryState.LastTerritory != _clientState.TerritoryType) + { + MemoryTerritory? oldTerritory = _floorService.GetTerritoryIfReady(_territoryState.LastTerritory); + if (oldTerritory != null) + oldTerritory.SyncState = ESyncState.NotAttempted; + + _territoryState.LastTerritory = _clientState.TerritoryType; + NextUpdateObjects.Clear(); + + _floorService.ChangeTerritory(_territoryState.LastTerritory); + _territoryState.PomanderOfSight = PomanderState.Inactive; + _territoryState.PomanderOfIntuition = PomanderState.Inactive; + recreateLayout = true; + _debugState.Reset(); + } + + if (!_territoryState.IsInDeepDungeon() || !_floorService.IsReady(_territoryState.LastTerritory)) + return; + + ETerritoryType territoryType = (ETerritoryType)_territoryState.LastTerritory; + MemoryTerritory memoryTerritory = _floorService.GetTerritoryIfReady(territoryType)!; + if (_configuration.Mode == EMode.Online && memoryTerritory.SyncState == ESyncState.NotAttempted) + { + memoryTerritory.SyncState = ESyncState.Started; + Task.Run(async () => await DownloadLocationsForTerritory(_territoryState.LastTerritory)); + } + + while (LateEventQueue.TryDequeue(out IQueueOnFrameworkThread? queued)) + HandleQueued(queued, ref recreateLayout); + + (IReadOnlyList visiblePersistentMarkers, + IReadOnlyList visibleEphemeralMarkers) = + GetRelevantGameObjects(); + + HandlePersistentLocations(territoryType, visiblePersistentMarkers, recreateLayout); + + if (_floorService.MergeEphemeralLocations(visibleEphemeralMarkers, recreateLayout)) + RecreateEphemeralLayout(); + } + catch (Exception e) + { + _debugState.SetFromException(e); + } + } + + #region Render Markers + + private void HandlePersistentLocations(ETerritoryType territoryType, + IReadOnlyList visiblePersistentMarkers, + bool recreateLayout) + { + bool recreatePersistentLocations = _floorService.MergePersistentLocations( + territoryType, + visiblePersistentMarkers, + recreateLayout, + out List locationsToSync); + recreatePersistentLocations |= CheckLocationsForPomanders(visiblePersistentMarkers); + if (locationsToSync.Count > 0) + { + Task.Run(async () => + await SyncSeenMarkersForTerritory(_territoryState.LastTerritory, locationsToSync)); + } + + UploadLocations(); + + if (recreatePersistentLocations) + RecreatePersistentLayout(visiblePersistentMarkers); + } + + private bool CheckLocationsForPomanders(IReadOnlyList visibleLocations) + { + MemoryTerritory? memoryTerritory = _floorService.GetTerritoryIfReady(_territoryState.LastTerritory); + if (memoryTerritory is { Locations.Count: > 0 } && + (_configuration.DeepDungeons.Traps.OnlyVisibleAfterPomander || + _configuration.DeepDungeons.HoardCoffers.OnlyVisibleAfterPomander)) + { + try + { + foreach (var location in memoryTerritory.Locations) + { + uint desiredColor = DetermineColor(location, visibleLocations); + if (location.RenderElement == null || !location.RenderElement.IsValid) + return true; + + if (location.RenderElement.Color != desiredColor) + location.RenderElement.Color = desiredColor; + } + } + catch (Exception e) + { + _debugState.SetFromException(e); + return true; + } + } + + return false; + } + + private void UploadLocations() + { + MemoryTerritory? memoryTerritory = _floorService.GetTerritoryIfReady(_territoryState.LastTerritory); + if (memoryTerritory == null || memoryTerritory.SyncState != ESyncState.Complete) + return; + + List locationsToUpload = memoryTerritory.Locations + .Where(loc => loc.NetworkId == null && loc.UploadRequested == false) + .ToList(); + if (locationsToUpload.Count > 0) + { + foreach (var location in locationsToUpload) + location.UploadRequested = true; + + Task.Run(async () => + await UploadLocationsForTerritory(_territoryState.LastTerritory, locationsToUpload)); + } + } + + private void RecreatePersistentLayout(IReadOnlyList visibleMarkers) + { + _renderAdapter.ResetLayer(ELayer.TrapHoard); + + MemoryTerritory? memoryTerritory = _floorService.GetTerritoryIfReady(_territoryState.LastTerritory); + if (memoryTerritory == null) + return; + + List elements = new(); + foreach (var location in memoryTerritory.Locations) + { + if (location.Type == MemoryLocation.EType.Trap) + { + CreateRenderElement(location, elements, DetermineColor(location, visibleMarkers), + _configuration.DeepDungeons.Traps); + } + else if (location.Type == MemoryLocation.EType.Hoard) + { + CreateRenderElement(location, elements, DetermineColor(location, visibleMarkers), + _configuration.DeepDungeons.HoardCoffers); + } + } + + if (elements.Count == 0) + return; + + _renderAdapter.SetLayer(ELayer.TrapHoard, elements); + } + + private void RecreateEphemeralLayout() + { + _renderAdapter.ResetLayer(ELayer.RegularCoffers); + + List elements = new(); + foreach (var location in _floorService.EphemeralLocations) + { + if (location.Type == MemoryLocation.EType.SilverCoffer && + _configuration.DeepDungeons.SilverCoffers.Show) + { + CreateRenderElement(location, elements, DetermineColor(location), + _configuration.DeepDungeons.SilverCoffers); + } + } + + if (elements.Count == 0) + return; + + _renderAdapter.SetLayer(ELayer.RegularCoffers, elements); + } + + private uint DetermineColor(PersistentLocation location, IReadOnlyList visibleLocations) + { + switch (location.Type) + { + case MemoryLocation.EType.Trap + when _territoryState.PomanderOfSight == PomanderState.Inactive || + !_configuration.DeepDungeons.Traps.OnlyVisibleAfterPomander || + visibleLocations.Any(x => x == location): + return _configuration.DeepDungeons.Traps.Color; + case MemoryLocation.EType.Hoard + when _territoryState.PomanderOfIntuition == PomanderState.Inactive || + !_configuration.DeepDungeons.HoardCoffers.OnlyVisibleAfterPomander || + visibleLocations.Any(x => x == location): + return _configuration.DeepDungeons.HoardCoffers.Color; + default: + return RenderData.ColorInvisible; + } + } + + private uint DetermineColor(EphemeralLocation location) + { + if (location.Type == MemoryLocation.EType.SilverCoffer) + return _configuration.DeepDungeons.SilverCoffers.Color; + + return RenderData.ColorInvisible; + } + + private void CreateRenderElement(MemoryLocation location, List elements, uint color, + MarkerConfiguration config) + { + if (!config.Show) + return; + + var element = _renderAdapter.CreateElement(location.Type, location.Position, color, config.Fill); + location.RenderElement = element; + elements.Add(element); + } + + #endregion + + #region Up-/Download + + private async Task DownloadLocationsForTerritory(ushort territoryId) + { + try + { + _logger.LogInformation("Downloading territory {Territory} from server", (ETerritoryType)territoryId); + var (success, downloadedMarkers) = await _remoteApi.DownloadRemoteMarkers(territoryId); + LateEventQueue.Enqueue(new QueuedSyncResponse + { + Type = SyncType.Download, + TerritoryType = territoryId, + Success = success, + Locations = downloadedMarkers + }); + } + catch (Exception e) + { + _debugState.SetFromException(e); + } + } + + private async Task UploadLocationsForTerritory(ushort territoryId, List locationsToUpload) + { + try + { + _logger.LogInformation("Uploading {Count} locations for territory {Territory} to server", + locationsToUpload.Count, (ETerritoryType)territoryId); + var (success, uploadedLocations) = await _remoteApi.UploadLocations(territoryId, locationsToUpload); + LateEventQueue.Enqueue(new QueuedSyncResponse + { + Type = SyncType.Upload, + TerritoryType = territoryId, + Success = success, + Locations = uploadedLocations + }); + } + catch (Exception e) + { + _debugState.SetFromException(e); + } + } + + private async Task SyncSeenMarkersForTerritory(ushort territoryId, + IReadOnlyList locationsToUpdate) + { + try + { + _logger.LogInformation("Syncing {Count} seen locations for territory {Territory} to server", + locationsToUpdate.Count, (ETerritoryType)territoryId); + var success = await _remoteApi.MarkAsSeen(territoryId, locationsToUpdate); + LateEventQueue.Enqueue(new QueuedSyncResponse + { + Type = SyncType.MarkSeen, + TerritoryType = territoryId, + Success = success, + Locations = locationsToUpdate, + }); + } + catch (Exception e) + { + _debugState.SetFromException(e); + } + } + + #endregion + + private (IReadOnlyList, IReadOnlyList) GetRelevantGameObjects() + { + List persistentLocations = new(); + List ephemeralLocations = new(); + for (int i = 246; i < _objectTable.Length; i++) + { + GameObject? obj = _objectTable[i]; + if (obj == null) + continue; + + switch ((uint)Marshal.ReadInt32(obj.Address + 128)) + { + case 2007182: + case 2007183: + case 2007184: + case 2007185: + case 2007186: + case 2009504: + persistentLocations.Add(new PersistentLocation + { + Type = MemoryLocation.EType.Trap, + Position = obj.Position, + Seen = true, + Source = ClientLocation.ESource.SeenLocally, + }); + break; + + case 2007542: + case 2007543: + persistentLocations.Add(new PersistentLocation + { + Type = MemoryLocation.EType.Hoard, + Position = obj.Position, + Seen = true, + Source = ClientLocation.ESource.SeenLocally, + }); + break; + + case 2007357: + ephemeralLocations.Add(new EphemeralLocation + { + Type = MemoryLocation.EType.SilverCoffer, + Position = obj.Position, + Seen = true, + }); + break; + } + } + + while (NextUpdateObjects.TryDequeue(out nint address)) + { + var obj = _objectTable.FirstOrDefault(x => x.Address == address); + if (obj != null && obj.Position.Length() > 0.1) + { + persistentLocations.Add(new PersistentLocation + { + Type = MemoryLocation.EType.Trap, + Position = obj.Position, + Seen = true, + Source = ClientLocation.ESource.ExplodedLocally, + + }); + } + } + + return (persistentLocations, ephemeralLocations); + } + + private void HandleQueued(IQueueOnFrameworkThread queued, ref bool recreateLayout) + { + Type handlerType = typeof(IQueueOnFrameworkThread.Handler<>).MakeGenericType(queued.GetType()); + var handler = (IQueueOnFrameworkThread.IHandler)_serviceProvider.GetRequiredService(handlerType); + + handler.RunIfCompatible(queued, ref recreateLayout); + } + } +} diff --git a/Pal.Client/Floors/MemoryLocation.cs b/Pal.Client/Floors/MemoryLocation.cs new file mode 100644 index 0000000..5b9a6ca --- /dev/null +++ b/Pal.Client/Floors/MemoryLocation.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Pal.Client.Rendering; +using Pal.Common; +using Palace; + +namespace Pal.Client.Floors +{ + /// + /// Base class for and . + /// + internal abstract class MemoryLocation + { + public required EType Type { get; init; } + public required Vector3 Position { get; init; } + public bool Seen { get; set; } + + public IRenderElement? RenderElement { get; set; } + + public enum EType + { + Unknown, + + Trap, + Hoard, + + SilverCoffer, + } + + public override bool Equals(object? obj) + { + return obj is MemoryLocation otherLocation && + Type == otherLocation.Type && + PalaceMath.IsNearlySamePosition(Position, otherLocation.Position); + } + + public override int GetHashCode() + { + return HashCode.Combine(Type, PalaceMath.GetHashCode(Position)); + } + } + + internal static class ETypeExtensions + { + public static MemoryLocation.EType ToMemoryType(this ObjectType objectType) + { + return objectType switch + { + ObjectType.Trap => MemoryLocation.EType.Trap, + ObjectType.Hoard => MemoryLocation.EType.Hoard, + _ => throw new ArgumentOutOfRangeException(nameof(objectType), objectType, null) + }; + } + + public static ObjectType ToObjectType(this MemoryLocation.EType type) + { + return type switch + { + MemoryLocation.EType.Trap => ObjectType.Trap, + MemoryLocation.EType.Hoard => ObjectType.Hoard, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + } +} diff --git a/Pal.Client/Floors/MemoryTerritory.cs b/Pal.Client/Floors/MemoryTerritory.cs new file mode 100644 index 0000000..e440bc8 --- /dev/null +++ b/Pal.Client/Floors/MemoryTerritory.cs @@ -0,0 +1,63 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Pal.Client.Configuration; +using Pal.Client.Scheduled; +using Pal.Common; + +namespace Pal.Client.Floors +{ + /// + /// A single set of floors loaded entirely in memory, can be e.g. POTD 51-60. + /// + internal sealed class MemoryTerritory + { + public MemoryTerritory(ETerritoryType territoryType) + { + TerritoryType = territoryType; + } + + public ETerritoryType TerritoryType { get; } + public EReadyState ReadyState { get; set; } = EReadyState.NotLoaded; + public ESyncState SyncState { get; set; } = ESyncState.NotAttempted; + + public ConcurrentBag Locations { get; } = new(); + public object LockObj { get; } = new(); + + public void Initialize(IEnumerable locations) + { + Locations.Clear(); + foreach (var location in locations) + Locations.Add(location); + + ReadyState = EReadyState.Ready; + } + + public void Reset() + { + Locations.Clear(); + SyncState = ESyncState.NotAttempted; + ReadyState = EReadyState.NotLoaded; + } + + public enum EReadyState + { + NotLoaded, + + /// + /// Currently loading from the database. + /// + Loading, + + /// + /// Locations loaded, no import running. + /// + Ready, + + /// + /// Import running, should probably not interact with this too much. + /// + Importing, + } + } +} diff --git a/Pal.Client/Floors/PersistentLocation.cs b/Pal.Client/Floors/PersistentLocation.cs new file mode 100644 index 0000000..e6f8ad6 --- /dev/null +++ b/Pal.Client/Floors/PersistentLocation.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Pal.Client.Database; + +namespace Pal.Client.Floors +{ + /// + /// A loaded in memory, with certain extra attributes as needed. + /// + internal sealed class PersistentLocation : MemoryLocation + { + /// + public int? LocalId { get; set; } + + /// + /// Network id for the server you're currently connected to. + /// + public Guid? NetworkId { get; set; } + + /// + /// For markers that the server you're connected to doesn't know: Whether this was requested to be uploaded, to avoid duplicate requests. + /// + public bool UploadRequested { get; set; } + + /// + /// + public List RemoteSeenOn { get; set; } = new(); + + /// + /// Whether this marker was requested to be seen, to avoid duplicate requests. + /// + public bool RemoteSeenRequested { get; set; } + + public ClientLocation.ESource Source { get; init; } + + public override bool Equals(object? obj) => obj is PersistentLocation && base.Equals(obj); + + public override int GetHashCode() => base.GetHashCode(); + + public static bool operator ==(PersistentLocation? a, object? b) + { + return Equals(a, b); + } + + public static bool operator !=(PersistentLocation? a, object? b) + { + return !Equals(a, b); + } + + public override string ToString() + { + return $"PersistentLocation(Position={Position}, Type={Type})"; + } + } +} diff --git a/Pal.Client/Floors/Tasks/DbTask.cs b/Pal.Client/Floors/Tasks/DbTask.cs new file mode 100644 index 0000000..64074fe --- /dev/null +++ b/Pal.Client/Floors/Tasks/DbTask.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Database; + +namespace Pal.Client.Floors.Tasks +{ + internal abstract class DbTask + where T : DbTask + { + private readonly IServiceScopeFactory _serviceScopeFactory; + + protected DbTask(IServiceScopeFactory serviceScopeFactory) + { + _serviceScopeFactory = serviceScopeFactory; + } + + public void Start() + { + Task.Run(() => + { + using var scope = _serviceScopeFactory.CreateScope(); + ILogger logger = scope.ServiceProvider.GetRequiredService>(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + Run(dbContext, logger); + }); + } + + protected abstract void Run(PalClientContext dbContext, ILogger logger); + } +} diff --git a/Pal.Client/Floors/Tasks/LoadTerritory.cs b/Pal.Client/Floors/Tasks/LoadTerritory.cs new file mode 100644 index 0000000..7e11b2f --- /dev/null +++ b/Pal.Client/Floors/Tasks/LoadTerritory.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Database; + +namespace Pal.Client.Floors.Tasks +{ + internal sealed class LoadTerritory : DbTask + { + private readonly Cleanup _cleanup; + private readonly MemoryTerritory _territory; + + public LoadTerritory(IServiceScopeFactory serviceScopeFactory, + Cleanup cleanup, + MemoryTerritory territory) + : base(serviceScopeFactory) + { + _cleanup = cleanup; + _territory = territory; + } + + protected override void Run(PalClientContext dbContext, ILogger logger) + { + lock (_territory.LockObj) + { + if (_territory.ReadyState != MemoryTerritory.EReadyState.Loading) + { + logger.LogInformation("Territory {Territory} is in state {State}", _territory.TerritoryType, + _territory.ReadyState); + return; + } + + logger.LogInformation("Loading territory {Territory}", _territory.TerritoryType); + + // purge outdated locations + _cleanup.Purge(dbContext, _territory.TerritoryType); + + // load good locations + List locations = dbContext.Locations + .Where(o => o.TerritoryType == (ushort)_territory.TerritoryType) + .Include(o => o.ImportedBy) + .Include(o => o.RemoteEncounters) + .AsSplitQuery() + .ToList(); + _territory.Initialize(locations.Select(ToMemoryLocation)); + + logger.LogInformation("Loaded {Count} locations for territory {Territory}", locations.Count, + _territory.TerritoryType); + } + } + + public static PersistentLocation ToMemoryLocation(ClientLocation location) + { + return new PersistentLocation + { + LocalId = location.LocalId, + Type = ToMemoryLocationType(location.Type), + Position = new Vector3(location.X, location.Y, location.Z), + Seen = location.Seen, + Source = location.Source, + RemoteSeenOn = location.RemoteEncounters.Select(o => o.AccountId).ToList(), + }; + } + + private static MemoryLocation.EType ToMemoryLocationType(ClientLocation.EType type) + { + return type switch + { + ClientLocation.EType.Trap => MemoryLocation.EType.Trap, + ClientLocation.EType.Hoard => MemoryLocation.EType.Hoard, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + } +} diff --git a/Pal.Client/Floors/Tasks/MarkLocalSeen.cs b/Pal.Client/Floors/Tasks/MarkLocalSeen.cs new file mode 100644 index 0000000..c2f4dd7 --- /dev/null +++ b/Pal.Client/Floors/Tasks/MarkLocalSeen.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Database; + +namespace Pal.Client.Floors.Tasks +{ + internal sealed class MarkLocalSeen : DbTask + { + private readonly MemoryTerritory _territory; + private readonly IReadOnlyList _locations; + + public MarkLocalSeen(IServiceScopeFactory serviceScopeFactory, MemoryTerritory territory, + IReadOnlyList locations) + : base(serviceScopeFactory) + { + _territory = territory; + _locations = locations; + } + + protected override void Run(PalClientContext dbContext, ILogger logger) + { + lock (_territory.LockObj) + { + logger.LogInformation("Marking {Count} locations as seen locally in territory {Territory}", _locations.Count, + _territory.TerritoryType); + dbContext.Locations + .Where(loc => _locations.Any(l => l.LocalId == loc.LocalId)) + .ExecuteUpdate(loc => loc.SetProperty(l => l.Seen, true)); + dbContext.SaveChanges(); + } + } + } +} diff --git a/Pal.Client/Floors/Tasks/MarkRemoteSeen.cs b/Pal.Client/Floors/Tasks/MarkRemoteSeen.cs new file mode 100644 index 0000000..7a63741 --- /dev/null +++ b/Pal.Client/Floors/Tasks/MarkRemoteSeen.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Database; + +namespace Pal.Client.Floors.Tasks +{ + internal sealed class MarkRemoteSeen : DbTask + { + private readonly MemoryTerritory _territory; + private readonly IReadOnlyList _locations; + private readonly string _accountId; + + public MarkRemoteSeen(IServiceScopeFactory serviceScopeFactory, + MemoryTerritory territory, + IReadOnlyList locations, + string accountId) + : base(serviceScopeFactory) + { + _territory = territory; + _locations = locations; + _accountId = accountId; + } + + protected override void Run(PalClientContext dbContext, ILogger logger) + { + lock (_territory.LockObj) + { + logger.LogInformation("Marking {Count} locations as seen remotely on {Account} in territory {Territory}", + _locations.Count, _accountId, _territory.TerritoryType); + + List locationIds = _locations.Select(x => x.LocalId).Where(x => x != null).Cast().ToList(); + List locationsToUpdate = + dbContext.Locations + .Include(x => x.RemoteEncounters) + .Where(x => locationIds.Contains(x.LocalId)) + .ToList() + .Where(x => x.RemoteEncounters.All(encounter => encounter.AccountId != _accountId)) + .ToList(); + foreach (var clientLocation in locationsToUpdate) + { + clientLocation.RemoteEncounters.Add(new RemoteEncounter(clientLocation, _accountId)); + } + + dbContext.SaveChanges(); + } + } + } +} diff --git a/Pal.Client/Floors/Tasks/SaveNewLocations.cs b/Pal.Client/Floors/Tasks/SaveNewLocations.cs new file mode 100644 index 0000000..345986a --- /dev/null +++ b/Pal.Client/Floors/Tasks/SaveNewLocations.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Database; +using Pal.Common; + +namespace Pal.Client.Floors.Tasks +{ + internal sealed class SaveNewLocations : DbTask + { + private readonly MemoryTerritory _territory; + private readonly List _newLocations; + + public SaveNewLocations(IServiceScopeFactory serviceScopeFactory, MemoryTerritory territory, + List newLocations) + : base(serviceScopeFactory) + { + _territory = territory; + _newLocations = newLocations; + } + + protected override void Run(PalClientContext dbContext, ILogger logger) + { + Run(_territory, dbContext, logger, _newLocations); + } + + public static void Run( + MemoryTerritory territory, + PalClientContext dbContext, + ILogger logger, + List locations) + { + lock (territory.LockObj) + { + logger.LogInformation("Saving {Count} new locations for territory {Territory}", locations.Count, + territory.TerritoryType); + + Dictionary mapping = + locations.ToDictionary(x => x, x => ToDatabaseLocation(x, territory.TerritoryType)); + dbContext.Locations.AddRange(mapping.Values); + dbContext.SaveChanges(); + + foreach ((PersistentLocation persistentLocation, ClientLocation clientLocation) in mapping) + { + persistentLocation.LocalId = clientLocation.LocalId; + } + } + } + + private static ClientLocation ToDatabaseLocation(PersistentLocation location, ETerritoryType territoryType) + { + return new ClientLocation + { + TerritoryType = (ushort)territoryType, + Type = ToDatabaseType(location.Type), + X = location.Position.X, + Y = location.Position.Y, + Z = location.Position.Z, + Seen = location.Seen, + Source = location.Source, + SinceVersion = typeof(Plugin).Assembly.GetName().Version!.ToString(2), + }; + } + + private static ClientLocation.EType ToDatabaseType(MemoryLocation.EType type) + { + return type switch + { + MemoryLocation.EType.Trap => ClientLocation.EType.Trap, + MemoryLocation.EType.Hoard => ClientLocation.EType.Hoard, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + } +} diff --git a/Pal.Client/Floors/TerritoryState.cs b/Pal.Client/Floors/TerritoryState.cs new file mode 100644 index 0000000..febff4c --- /dev/null +++ b/Pal.Client/Floors/TerritoryState.cs @@ -0,0 +1,36 @@ +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Conditions; +using Pal.Common; + +namespace Pal.Client.Floors +{ + public sealed class TerritoryState + { + private readonly ClientState _clientState; + private readonly Condition _condition; + + public TerritoryState(ClientState clientState, Condition condition) + { + _clientState = clientState; + _condition = condition; + } + + public ushort LastTerritory { get; set; } + public PomanderState PomanderOfSight { get; set; } = PomanderState.Inactive; + public PomanderState PomanderOfIntuition { get; set; } = PomanderState.Inactive; + + public bool IsInDeepDungeon() => + _clientState.IsLoggedIn + && _condition[ConditionFlag.InDeepDungeon] + && typeof(ETerritoryType).IsEnumDefined(_clientState.TerritoryType); + + } + + public enum PomanderState + { + Inactive, + Active, + FoundOnCurrentFloor, + PomanderOfSafetyUsed, + } +} diff --git a/Pal.Client/Marker.cs b/Pal.Client/Marker.cs deleted file mode 100644 index e20d0fe..0000000 --- a/Pal.Client/Marker.cs +++ /dev/null @@ -1,110 +0,0 @@ -using ECommons.SplatoonAPI; -using Pal.Client.Rendering; -using Pal.Common; -using Palace; -using System; -using System.Collections.Generic; -using System.Numerics; -using System.Text.Json.Serialization; - -namespace Pal.Client -{ - internal class Marker - { - public EType Type { get; set; } = EType.Unknown; - public Vector3 Position { get; set; } - - /// - /// Whether we have encountered the trap/coffer at this location in-game. - /// - public bool Seen { get; set; } - - /// - /// Network id for the server you're currently connected to. - /// - [JsonIgnore] - public Guid? NetworkId { get; set; } - - /// - /// For markers that the server you're connected to doesn't know: Whether this was requested to be uploaded, to avoid duplicate requests. - /// - [JsonIgnore] - public bool UploadRequested { get; set; } - - /// - /// Which account ids this marker was seen. This is a list merely to support different remote endpoints - /// (where each server would assign you a different id). - /// - public List RemoteSeenOn { get; set; } = new(); - - /// - /// Whether this marker was requested to be seen, to avoid duplicate requests. - /// - [JsonIgnore] - public bool RemoteSeenRequested { get; set; } - - /// - /// To keep track of which markers were imported through a downloaded file, we save the associated import-id. - /// - /// Importing another file for the same remote server will remove the old import-id, and add the new import-id here. - /// - public List Imports { get; set; } = new(); - - public bool WasImported { get; set; } - - /// - /// To make rollbacks of local data easier, keep track of the version which was used to write the marker initially. - /// - public string? SinceVersion { get; set; } - - [JsonIgnore] - public IRenderElement? RenderElement { get; set; } - - public Marker(EType type, Vector3 position, Guid? networkId = null) - { - Type = type; - Position = position; - NetworkId = networkId; - } - - public override int GetHashCode() - { - return HashCode.Combine(Type, PalaceMath.GetHashCode(Position)); - } - - public override bool Equals(object? obj) - { - return obj is Marker otherMarker && Type == otherMarker.Type && PalaceMath.IsNearlySamePosition(Position, otherMarker.Position); - } - - public static bool operator ==(Marker? a, object? b) - { - return Equals(a, b); - } - - public static bool operator !=(Marker? a, object? b) - { - return !Equals(a, b); - } - - - public bool IsPermanent() => Type == EType.Trap || Type == EType.Hoard; - - public enum EType - { - Unknown = ObjectType.Unknown, - - #region Permanent Markers - Trap = ObjectType.Trap, - Hoard = ObjectType.Hoard, - - [Obsolete] - Debug = 3, - #endregion - - # region Markers that only show up if they're currently visible - SilverCoffer = 100, - #endregion - } - } -} diff --git a/Pal.Client/Net/GrpcLogger.cs b/Pal.Client/Net/GrpcLogger.cs deleted file mode 100644 index 9c12149..0000000 --- a/Pal.Client/Net/GrpcLogger.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Dalamud.Logging; -using Microsoft.Extensions.Logging; -using System; -using System.Runtime.CompilerServices; - -namespace Pal.Client.Net -{ - internal class GrpcLogger : ILogger - { - private readonly string _name; - - public GrpcLogger(string name) - { - _name = name; - } - - public IDisposable BeginScope(TState state) - where TState : notnull - => NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - - [MethodImpl(MethodImplOptions.NoInlining)] // PluginLog detects the plugin name as `Microsoft.Extensions.Logging` if inlined - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (!IsEnabled(logLevel)) - return; - - if (formatter == null) - throw new ArgumentNullException(nameof(formatter)); - - string message = $"gRPC[{_name}] {formatter(state, null)}"; - if (string.IsNullOrEmpty(message)) - return; - -#pragma warning disable CS8604 // the nullability on PluginLog methods is wrong and allows nulls for exceptions, WriteLog even declares the parameter as `Exception? exception = null` - switch (logLevel) - { - case LogLevel.Critical: - PluginLog.Fatal(exception, message); - break; - - case LogLevel.Error: - PluginLog.Error(exception, message); - break; - - case LogLevel.Warning: - PluginLog.Warning(exception, message); - break; - - case LogLevel.Information: - PluginLog.Information(exception, message); - break; - - case LogLevel.Debug: - PluginLog.Debug(exception, message); - break; - - case LogLevel.Trace: - PluginLog.Verbose(exception, message); - break; - } -#pragma warning restore CS8604 - } - - private class NullScope : IDisposable - { - public static NullScope Instance { get; } = new(); - - private NullScope() - { - } - - public void Dispose() - { - } - } - } -} diff --git a/Pal.Client/Net/GrpcLoggerProvider.cs b/Pal.Client/Net/GrpcLoggerProvider.cs deleted file mode 100644 index bf702e5..0000000 --- a/Pal.Client/Net/GrpcLoggerProvider.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.Extensions.Logging; -using System; - -namespace Pal.Client.Net -{ - internal class GrpcLoggerProvider : ILoggerProvider - { - public ILogger CreateLogger(string categoryName) => new GrpcLogger(categoryName); - - public void Dispose() - { - } - } -} diff --git a/Pal.Client/Net/JwtClaims.cs b/Pal.Client/Net/JwtClaims.cs index 3247bba..5d8704b 100644 --- a/Pal.Client/Net/JwtClaims.cs +++ b/Pal.Client/Net/JwtClaims.cs @@ -8,7 +8,7 @@ using System.Threading.Tasks; namespace Pal.Client.Net { - internal class JwtClaims + internal sealed class JwtClaims { [JsonPropertyName("nameid")] public Guid NameId { get; set; } @@ -46,7 +46,7 @@ namespace Pal.Client.Net } } - internal class JwtRoleConverter : JsonConverter> + internal sealed class JwtRoleConverter : JsonConverter> { public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -78,9 +78,9 @@ namespace Pal.Client.Net public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) => throw new NotImplementedException(); } - public class JwtDateConverter : JsonConverter + public sealed class JwtDateConverter : JsonConverter { - static readonly DateTimeOffset Zero = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + static readonly DateTimeOffset Zero = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { diff --git a/Pal.Client/Net/RemoteApi.AccountService.cs b/Pal.Client/Net/RemoteApi.AccountService.cs index 9b3744f..c76554e 100644 --- a/Pal.Client/Net/RemoteApi.AccountService.cs +++ b/Pal.Client/Net/RemoteApi.AccountService.cs @@ -1,5 +1,4 @@ using Account; -using Dalamud.Logging; using Grpc.Core; using Grpc.Net.Client; using Microsoft.Extensions.Logging; @@ -11,24 +10,42 @@ using System.Threading; using System.Threading.Tasks; using Pal.Client.Extensions; using Pal.Client.Properties; +using Pal.Client.Configuration; namespace Pal.Client.Net { internal partial class RemoteApi { - private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken, ILoggerFactory? loggerFactory = null, bool retry = true) + private readonly SemaphoreSlim _connectLock = new(1, 1); + + private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken, + ILoggerFactory? loggerFactory = null, bool retry = true) { - if (Service.Configuration.Mode != Configuration.EMode.Online) + using IDisposable? logScope = _logger.BeginScope("TryConnect"); + + var result = await TryConnectImpl(cancellationToken, loggerFactory); + if (retry && result.ShouldRetry) + result = await TryConnectImpl(cancellationToken, loggerFactory); + + return (result.Success, result.Error); + } + + private async Task<(bool Success, string Error, bool ShouldRetry)> TryConnectImpl( + CancellationToken cancellationToken, + ILoggerFactory? loggerFactory) + { + if (_configuration.Mode != EMode.Online) { - PluginLog.Debug("TryConnect: Not Online, not attempting to establish a connection"); - return (false, Localization.ConnectionError_NotOnline); + _logger.LogDebug("Not Online, not attempting to establish a connection"); + return (false, Localization.ConnectionError_NotOnline, false); } - if (_channel == null || !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle)) + if (_channel == null || + !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle)) { Dispose(); - PluginLog.Information("TryConnect: Creating new gRPC channel"); + _logger.LogInformation("Creating new gRPC channel"); _channel = GrpcChannel.ForAddress(RemoteUrl, new GrpcChannelOptions { HttpHandler = new SocketsHttpHandler @@ -39,96 +56,126 @@ namespace Pal.Client.Net LoggerFactory = loggerFactory, }); - PluginLog.Information($"TryConnect: Connecting to upstream service at {RemoteUrl}"); + _logger.LogInformation("Connecting to upstream service at {Url}", RemoteUrl); await _channel.ConnectAsync(cancellationToken); } cancellationToken.ThrowIfCancellationRequested(); - var accountClient = new AccountService.AccountServiceClient(_channel); - if (AccountId == null) - { - PluginLog.Information($"TryConnect: No account information saved for {RemoteUrl}, creating new account"); - var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest(), headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); - if (createAccountReply.Success) - { - Account = new Configuration.AccountInfo - { - Id = Guid.Parse(createAccountReply.AccountId), - }; - PluginLog.Information($"TryConnect: Account created with id {FormattedPartialAccountId}"); + _logger.LogTrace("Acquiring connect lock"); + await _connectLock.WaitAsync(cancellationToken); + _logger.LogTrace("Obtained connect lock"); - Service.Configuration.Save(); - } - else + try + { + var accountClient = new AccountService.AccountServiceClient(_channel); + IAccountConfiguration? configuredAccount = _configuration.FindAccount(RemoteUrl); + if (configuredAccount == null) { - PluginLog.Error($"TryConnect: Account creation failed with error {createAccountReply.Error}"); - if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade) + _logger.LogInformation("No account information saved for {Url}, creating new account", RemoteUrl); + var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest(), + headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), + cancellationToken: cancellationToken); + if (createAccountReply.Success) { - Service.Chat.PalError(Localization.ConnectionError_OldVersion); - _warnedAboutUpgrade = true; + if (!Guid.TryParse(createAccountReply.AccountId, out Guid accountId)) + throw new InvalidOperationException("invalid account id returned"); + + configuredAccount = _configuration.CreateAccount(RemoteUrl, accountId); + _logger.LogInformation("Account created with id {AccountId}", accountId.ToPartialId()); + + _configurationManager.Save(_configuration); } - return (false, string.Format(Localization.ConnectionError_CreateAccountFailed, createAccountReply.Error)); - } - } - - cancellationToken.ThrowIfCancellationRequested(); - - if (AccountId == null) - { - PluginLog.Warning("TryConnect: No account id to login with"); - return (false, Localization.ConnectionError_CreateAccountReturnedNoId); - } - - if (!_loginInfo.IsValid) - { - PluginLog.Information($"TryConnect: Logging in with account id {FormattedPartialAccountId}"); - LoginReply loginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); - if (loginReply.Success) - { - PluginLog.Information($"TryConnect: Login successful with account id: {FormattedPartialAccountId}"); - _loginInfo = new LoginInfo(loginReply.AuthToken); - - var account = Account; - if (account != null) + else { - account.CachedRoles = _loginInfo.Claims?.Roles.ToList() ?? new List(); - Service.Configuration.Save(); - } - } - else - { - PluginLog.Error($"TryConnect: Login failed with error {loginReply.Error}"); - _loginInfo = new LoginInfo(null); - if (loginReply.Error == LoginError.InvalidAccountId) - { - Account = null; - Service.Configuration.Save(); - if (retry) + _logger.LogError("Account creation failed with error {Error}", createAccountReply.Error); + if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade) { - PluginLog.Information("TryConnect: Attempting connection retry without account id"); - return await TryConnect(cancellationToken, retry: false); + _chat.Error(Localization.ConnectionError_OldVersion); + _warnedAboutUpgrade = true; } - else - return (false, Localization.ConnectionError_InvalidAccountId); + + return (false, + string.Format(Localization.ConnectionError_CreateAccountFailed, createAccountReply.Error), + false); } - if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade) - { - Service.Chat.PalError(Localization.ConnectionError_OldVersion); - _warnedAboutUpgrade = true; - } - return (false, string.Format(Localization.ConnectionError_LoginFailed, loginReply.Error)); } - } - if (!_loginInfo.IsValid) + cancellationToken.ThrowIfCancellationRequested(); + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (configuredAccount == null) + { + _logger.LogWarning("No account to login with"); + return (false, Localization.ConnectionError_CreateAccountReturnedNoId, false); + } + + if (!_loginInfo.IsValid) + { + _logger.LogInformation("Logging in with account id {AccountId}", + configuredAccount.AccountId.ToPartialId()); + LoginReply loginReply = await accountClient.LoginAsync( + new LoginRequest { AccountId = configuredAccount.AccountId.ToString() }, + headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), + cancellationToken: cancellationToken); + + if (loginReply.Success) + { + _logger.LogInformation("Login successful with account id: {AccountId}", + configuredAccount.AccountId.ToPartialId()); + _loginInfo = new LoginInfo(loginReply.AuthToken); + + bool save = configuredAccount.EncryptIfNeeded(); + + List newRoles = _loginInfo.Claims?.Roles.ToList() ?? new(); + if (!newRoles.SequenceEqual(configuredAccount.CachedRoles)) + { + configuredAccount.CachedRoles = newRoles; + save = true; + } + + if (save) + _configurationManager.Save(_configuration); + } + else + { + _logger.LogError("Login failed with error {Error}", loginReply.Error); + _loginInfo = new LoginInfo(null); + if (loginReply.Error == LoginError.InvalidAccountId) + { + _configuration.RemoveAccount(RemoteUrl); + _configurationManager.Save(_configuration); + + _logger.LogInformation("Attempting connection retry without account id"); + return (false, Localization.ConnectionError_InvalidAccountId, true); + } + + if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade) + { + _chat.Error(Localization.ConnectionError_OldVersion); + _warnedAboutUpgrade = true; + } + + return (false, string.Format(Localization.ConnectionError_LoginFailed, loginReply.Error), + false); + } + } + + if (!_loginInfo.IsValid) + { + _logger.LogError("Login state is loggedIn={LoggedIn}, expired={Expired}", _loginInfo.IsLoggedIn, + _loginInfo.IsExpired); + return (false, Localization.ConnectionError_LoginReturnedNoToken, false); + } + + cancellationToken.ThrowIfCancellationRequested(); + return (true, string.Empty, false); + } + finally { - PluginLog.Error($"TryConnect: Login state is loggedIn={_loginInfo.IsLoggedIn}, expired={_loginInfo.IsExpired}"); - return (false, Localization.ConnectionError_LoginReturnedNoToken); + _logger.LogTrace("Releasing connectLock"); + _connectLock.Release(); } - - cancellationToken.ThrowIfCancellationRequested(); - return (true, string.Empty); } private async Task Connect(CancellationToken cancellationToken) @@ -139,21 +186,24 @@ namespace Pal.Client.Net public async Task VerifyConnection(CancellationToken cancellationToken = default) { + using IDisposable? logScope = _logger.BeginScope("VerifyConnection"); + _warnedAboutUpgrade = false; - var connectionResult = await TryConnect(cancellationToken, loggerFactory: _grpcToPluginLogLoggerFactory); + var connectionResult = await TryConnect(cancellationToken, loggerFactory: _loggerFactory); if (!connectionResult.Success) return string.Format(Localization.ConnectionError_CouldNotConnectToServer, connectionResult.Error); - PluginLog.Information("VerifyConnection: Connection established, trying to verify auth token"); + _logger.LogInformation("Connection established, trying to verify auth token"); var accountClient = new AccountService.AccountServiceClient(_channel); - await accountClient.VerifyAsync(new VerifyRequest(), headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); + await accountClient.VerifyAsync(new VerifyRequest(), headers: AuthorizedHeaders(), + deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); - PluginLog.Information("VerifyConnection: Verification returned no errors."); + _logger.LogInformation("Verification returned no errors."); return Localization.ConnectionSuccessful; } - internal class LoginInfo + internal sealed class LoginInfo { public LoginInfo(string? authToken) { @@ -170,7 +220,10 @@ namespace Pal.Client.Net public bool IsLoggedIn { get; } public string? AuthToken { get; } public JwtClaims? Claims { get; } - public DateTimeOffset ExpiresAt => Claims?.ExpiresAt.Subtract(TimeSpan.FromMinutes(5)) ?? DateTimeOffset.MinValue; + + private DateTimeOffset ExpiresAt => + Claims?.ExpiresAt.Subtract(TimeSpan.FromMinutes(5)) ?? DateTimeOffset.MinValue; + public bool IsExpired => ExpiresAt < DateTimeOffset.UtcNow; public bool IsValid => IsLoggedIn && !IsExpired; diff --git a/Pal.Client/Net/RemoteApi.PalaceService.cs b/Pal.Client/Net/RemoteApi.PalaceService.cs index cee5337..161b3f5 100644 --- a/Pal.Client/Net/RemoteApi.PalaceService.cs +++ b/Pal.Client/Net/RemoteApi.PalaceService.cs @@ -3,27 +3,28 @@ using System; using System.Collections.Generic; using System.Linq; using System.Numerics; -using System.Text; using System.Threading; using System.Threading.Tasks; +using Pal.Client.Database; +using Pal.Client.Floors; namespace Pal.Client.Net { internal partial class RemoteApi { - public async Task<(bool, List)> DownloadRemoteMarkers(ushort territoryId, CancellationToken cancellationToken = default) + public async Task<(bool, List)> DownloadRemoteMarkers(ushort territoryId, CancellationToken cancellationToken = default) { if (!await Connect(cancellationToken)) return (false, new()); var palaceClient = new PalaceService.PalaceServiceClient(_channel); var downloadReply = await palaceClient.DownloadFloorsAsync(new DownloadFloorsRequest { TerritoryType = territoryId }, headers: AuthorizedHeaders(), cancellationToken: cancellationToken); - return (downloadReply.Success, downloadReply.Objects.Select(CreateMarkerFromNetworkObject).ToList()); + return (downloadReply.Success, downloadReply.Objects.Select(CreateLocationFromNetworkObject).ToList()); } - public async Task<(bool, List)> UploadMarker(ushort territoryType, IList markers, CancellationToken cancellationToken = default) + public async Task<(bool, List)> UploadLocations(ushort territoryType, IReadOnlyList locations, CancellationToken cancellationToken = default) { - if (markers.Count == 0) + if (locations.Count == 0) return (true, new()); if (!await Connect(cancellationToken)) @@ -34,20 +35,20 @@ namespace Pal.Client.Net { TerritoryType = territoryType, }; - uploadRequest.Objects.AddRange(markers.Select(m => new PalaceObject + uploadRequest.Objects.AddRange(locations.Select(m => new PalaceObject { - Type = (ObjectType)m.Type, + Type = m.Type.ToObjectType(), X = m.Position.X, Y = m.Position.Y, Z = m.Position.Z })); var uploadReply = await palaceClient.UploadFloorsAsync(uploadRequest, headers: AuthorizedHeaders(), cancellationToken: cancellationToken); - return (uploadReply.Success, uploadReply.Objects.Select(CreateMarkerFromNetworkObject).ToList()); + return (uploadReply.Success, uploadReply.Objects.Select(CreateLocationFromNetworkObject).ToList()); } - public async Task MarkAsSeen(ushort territoryType, IList markers, CancellationToken cancellationToken = default) + public async Task MarkAsSeen(ushort territoryType, IReadOnlyList locations, CancellationToken cancellationToken = default) { - if (markers.Count == 0) + if (locations.Count == 0) return true; if (!await Connect(cancellationToken)) @@ -55,15 +56,23 @@ namespace Pal.Client.Net var palaceClient = new PalaceService.PalaceServiceClient(_channel); var seenRequest = new MarkObjectsSeenRequest { TerritoryType = territoryType }; - foreach (var marker in markers) + foreach (var marker in locations) seenRequest.NetworkIds.Add(marker.NetworkId.ToString()); var seenReply = await palaceClient.MarkObjectsSeenAsync(seenRequest, headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); return seenReply.Success; } - private Marker CreateMarkerFromNetworkObject(PalaceObject obj) => - new Marker((Marker.EType)obj.Type, new Vector3(obj.X, obj.Y, obj.Z), Guid.Parse(obj.NetworkId)); + private PersistentLocation CreateLocationFromNetworkObject(PalaceObject obj) + { + return new PersistentLocation + { + Type = obj.Type.ToMemoryType(), + Position = new Vector3(obj.X, obj.Y, obj.Z), + NetworkId = Guid.Parse(obj.NetworkId), + Source = ClientLocation.ESource.Download, + }; + } public async Task<(bool, List)> FetchStatistics(CancellationToken cancellationToken = default) { diff --git a/Pal.Client/Net/RemoteApi.Utils.cs b/Pal.Client/Net/RemoteApi.Utils.cs index 7dff878..83a1180 100644 --- a/Pal.Client/Net/RemoteApi.Utils.cs +++ b/Pal.Client/Net/RemoteApi.Utils.cs @@ -3,6 +3,7 @@ using Dalamud.Logging; using Grpc.Core; using System.Net.Security; using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; namespace Pal.Client.Net { @@ -40,7 +41,7 @@ namespace Pal.Client.Net throw new InvalidOperationException(); var certificate = new X509Certificate2(bytes, pass, X509KeyStorageFlags.DefaultKeySet); - PluginLog.Debug($"Using client certificate {certificate.GetCertHashString()}"); + _logger.LogDebug("Using client certificate {CertificateHash}", certificate.GetCertHashString()); return new SslClientAuthenticationOptions { ClientCertificates = new X509CertificateCollection() @@ -49,18 +50,9 @@ namespace Pal.Client.Net }, }; #else - PluginLog.Debug("Not using client certificate"); + _logger.LogDebug("Not using client certificate"); return null; #endif } - - public bool HasRoleOnCurrentServer(string role) - { - if (Service.Configuration.Mode != Configuration.EMode.Online) - return false; - - var account = Account; - return account == null || account.CachedRoles.Contains(role); - } } } diff --git a/Pal.Client/Net/RemoteApi.cs b/Pal.Client/Net/RemoteApi.cs index a25afbf..382f103 100644 --- a/Pal.Client/Net/RemoteApi.cs +++ b/Pal.Client/Net/RemoteApi.cs @@ -2,46 +2,49 @@ using Grpc.Net.Client; using Microsoft.Extensions.Logging; using System; -using Pal.Client.Extensions; +using Dalamud.Game.Gui; +using Pal.Client.Configuration; +using Pal.Client.DependencyInjection; namespace Pal.Client.Net { - internal partial class RemoteApi : IDisposable + internal sealed partial class RemoteApi : IDisposable { #if DEBUG - public static string RemoteUrl { get; } = "http://localhost:5145"; + public const string RemoteUrl = "http://localhost:5415"; #else - public static string RemoteUrl { get; } = "https://pal.μ.tv"; + public const string RemoteUrl = "https://pal.liza.sh"; #endif - private readonly string _userAgent = $"{typeof(RemoteApi).Assembly.GetName().Name?.Replace(" ", "")}/{typeof(RemoteApi).Assembly.GetName().Version?.ToString(2)}"; + private readonly string _userAgent = + $"{typeof(RemoteApi).Assembly.GetName().Name?.Replace(" ", "")}/{typeof(RemoteApi).Assembly.GetName().Version?.ToString(2)}"; - private readonly ILoggerFactory _grpcToPluginLogLoggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new GrpcLoggerProvider()).AddFilter("Grpc", LogLevel.Trace)); + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly Chat _chat; + private readonly ConfigurationManager _configurationManager; + private readonly IPalacePalConfiguration _configuration; private GrpcChannel? _channel; private LoginInfo _loginInfo = new(null); private bool _warnedAboutUpgrade; - public Configuration.AccountInfo? Account + public RemoteApi( + ILoggerFactory loggerFactory, + ILogger logger, + Chat chat, + ConfigurationManager configurationManager, + IPalacePalConfiguration configuration) { - get => Service.Configuration.Accounts.TryGetValue(RemoteUrl, out Configuration.AccountInfo? accountInfo) ? accountInfo : null; - set - { - if (value != null) - Service.Configuration.Accounts[RemoteUrl] = value; - else - Service.Configuration.Accounts.Remove(RemoteUrl); - } + _loggerFactory = loggerFactory; + _logger = logger; + _chat = chat; + _configurationManager = configurationManager; + _configuration = configuration; } - public Guid? AccountId => Account?.Id; - - public string? PartialAccountId => Account?.Id?.ToPartialId(); - - private string FormattedPartialAccountId => PartialAccountId ?? "[no account id]"; - public void Dispose() { - PluginLog.Debug("Disposing gRPC channel"); + _logger.LogDebug("Disposing gRPC channel"); _channel?.Dispose(); _channel = null; } diff --git a/Pal.Client/Pal.Client.csproj b/Pal.Client/Pal.Client.csproj index 717eaa7..10baf2e 100644 --- a/Pal.Client/Pal.Client.csproj +++ b/Pal.Client/Pal.Client.csproj @@ -4,6 +4,7 @@ net7.0-windows 11.0 enable + win-x64 @@ -12,7 +13,10 @@ Palace Pal true false + false true + portable + $(SolutionDir)=X:\ false false false @@ -21,20 +25,14 @@ dist - none - false - + - - - ResXFileCodeGenerator - Localization.Designer.cs - + @@ -48,8 +46,13 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + @@ -67,51 +70,62 @@ $(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll - false + false $(AppData)\XIVLauncher\addon\Hooks\dev\ImGui.NET.dll - false + false $(AppData)\XIVLauncher\addon\Hooks\dev\ImGuiScene.dll - false + false $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.dll - false + false $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll - false + false $(AppData)\XIVLauncher\addon\Hooks\dev\Newtonsoft.Json.dll - false + false $(AppData)\XIVLauncher\addon\Hooks\dev\FFXIVClientStructs.dll - false + false + + + $(AppData)\XIVLauncher\addon\Hooks\dev\Serilog.dll + false - - True - True - Localization.resx - + + ResXFileCodeGenerator + Localization.Designer.cs + + + True + True + Localization.resx + - + $(GitSemVerMajor).$(GitSemVerMinor) $(Version) - - + + + + + diff --git a/Pal.Client/Palace Pal.json b/Pal.Client/Palace Pal.json index 77ce336..79e7ff4 100644 --- a/Pal.Client/Palace Pal.json +++ b/Pal.Client/Palace Pal.json @@ -6,4 +6,4 @@ "RepoUrl": "https://github.com/carvelli/PalacePal", "IconUrl": "https://raw.githubusercontent.com/carvelli/Dalamud-Plugins/master/dist/Palace Pal.png", "Tags": [ "potd", "palace", "hoh", "splatoon" ] -} \ No newline at end of file +} diff --git a/Pal.Client/Plugin.cs b/Pal.Client/Plugin.cs index 4e5e893..0adafc0 100644 --- a/Pal.Client/Plugin.cs +++ b/Pal.Client/Plugin.cs @@ -1,699 +1,244 @@ -using Dalamud.Game; -using Dalamud.Game.ClientState.Conditions; -using Dalamud.Game.ClientState.Objects.Types; -using Dalamud.Game.Command; -using Dalamud.Game.Gui; -using Dalamud.Game.Text; -using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Interface.Windowing; -using Dalamud.Logging; +using Dalamud.Interface.Windowing; using Dalamud.Plugin; -using Grpc.Core; -using ImGuiNET; -using Lumina.Excel.GeneratedSheets; using Pal.Client.Rendering; -using Pal.Client.Scheduled; -using Pal.Client.Windows; -using Pal.Common; using System; -using System.Collections.Concurrent; -using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; -using Pal.Client.Extensions; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.Command; +using Dalamud.Game.Gui; using Pal.Client.Properties; using ECommons; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Commands; +using Pal.Client.Configuration; +using Pal.Client.DependencyInjection; namespace Pal.Client { - public class Plugin : IDalamudPlugin + /// + /// With all DI logic elsewhere, this plugin shell really only takes care of a few things around events that + /// need to be sent to different receivers depending on priority or configuration . + /// + /// + internal sealed class Plugin : IDalamudPlugin { - internal const uint ColorInvisible = 0; + private readonly CancellationTokenSource _initCts = new(); - private LocalizedChatMessages _localizedChatMessages = new(); + private readonly DalamudPluginInterface _pluginInterface; + private readonly CommandManager _commandManager; + private readonly ClientState _clientState; + private readonly ChatGui _chatGui; + private readonly Framework _framework; - internal ConcurrentDictionary FloorMarkers { get; } = new(); - internal ConcurrentBag EphemeralMarkers { get; set; } = new(); - internal ushort LastTerritory { get; set; } - internal SyncState TerritorySyncState { get; set; } - internal PomanderState PomanderOfSight { get; private set; } = PomanderState.Inactive; - internal PomanderState PomanderOfIntuition { get; private set; } = PomanderState.Inactive; - internal string? DebugMessage { get; set; } - internal Queue EarlyEventQueue { get; } = new(); - internal Queue LateEventQueue { get; } = new(); - internal ConcurrentQueue NextUpdateObjects { get; } = new(); - internal IRenderer Renderer { get; private set; } = null!; + private readonly TaskCompletionSource _rootScopeCompletionSource = new(); + private ELoadState _loadState = ELoadState.Initializing; - public string Name => Localization.Palace_Pal; + private DependencyInjectionContext? _dependencyInjectionContext; + private ILogger _logger = DependencyInjectionContext.LoggerProvider.CreateLogger(); + private WindowSystem? _windowSystem; + private IServiceScope? _rootScope; + private Action? _loginAction; - public Plugin(DalamudPluginInterface pluginInterface, ChatGui chat) + public Plugin( + DalamudPluginInterface pluginInterface, + CommandManager commandManager, + ClientState clientState, + ChatGui chatGui, + Framework framework) { - LanguageChanged(pluginInterface.UiLanguage); + _pluginInterface = pluginInterface; + _commandManager = commandManager; + _clientState = clientState; + _chatGui = chatGui; + _framework = framework; - PluginLog.Information($"Install source: {pluginInterface.SourceRepository}"); + // set up the current UI language before creating anything + Localization.Culture = new CultureInfo(_pluginInterface.UiLanguage); -#if RELEASE - // You're welcome to remove this code in your fork, as long as: - // - none of the links accessible within FFXIV open the original repo (e.g. in the plugin installer), and - // - you host your own server instance - if (!pluginInterface.IsDev - && !pluginInterface.SourceRepository.StartsWith("https://raw.githubusercontent.com/carvelli/") - && !pluginInterface.SourceRepository.StartsWith("https://github.com/carvelli/")) - { - chat.PalError(string.Format(Localization.Error_WrongRepository, "https://github.com/carvelli/Dalamud-Plugins")); - throw new InvalidOperationException(); - } -#endif - - pluginInterface.Create(); - Service.Plugin = this; - Service.Configuration = (Configuration?)pluginInterface.GetPluginConfig() ?? pluginInterface.Create()!; - Service.Configuration.Migrate(); - - ResetRenderer(); - - Service.Hooks = new Hooks(); - - var agreementWindow = pluginInterface.Create(); - if (agreementWindow is not null) - { - agreementWindow.IsOpen = Service.Configuration.FirstUse; - Service.WindowSystem.AddWindow(agreementWindow); - } - - var configWindow = pluginInterface.Create(); - if (configWindow is not null) - { - Service.WindowSystem.AddWindow(configWindow); - } - - var statisticsWindow = pluginInterface.Create(); - if (statisticsWindow is not null) - { - Service.WindowSystem.AddWindow(statisticsWindow); - } - - pluginInterface.UiBuilder.Draw += Draw; - pluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi; - pluginInterface.LanguageChanged += LanguageChanged; - Service.Framework.Update += OnFrameworkUpdate; - Service.Chat.ChatMessage += OnChatMessage; - Service.CommandManager.AddHandler("/pal", new CommandInfo(OnCommand) + _commandManager.AddHandler("/pal", new CommandInfo(OnCommand) { HelpMessage = Localization.Command_pal_HelpText }); - ReloadLanguageStrings(); + Task.Run(async () => await CreateDependencyContext()); } - private void OpenConfigUi() - { - Window? configWindow; - if (Service.Configuration.FirstUse) - configWindow = Service.WindowSystem.GetWindow(); - else - configWindow = Service.WindowSystem.GetWindow(); + public string Name => Localization.Palace_Pal; - if (configWindow != null) - configWindow.IsOpen = true; + private async Task CreateDependencyContext() + { + try + { + _dependencyInjectionContext = _pluginInterface.Create(this) + ?? throw new Exception("Could not create DI root context class"); + var serviceProvider = _dependencyInjectionContext.BuildServiceContainer(); + _initCts.Token.ThrowIfCancellationRequested(); + + _logger = serviceProvider.GetRequiredService>(); + _windowSystem = serviceProvider.GetRequiredService(); + _rootScope = serviceProvider.CreateScope(); + + var loader = _rootScope.ServiceProvider.GetRequiredService(); + await loader.InitializeAsync(_initCts.Token); + + await _framework.RunOnFrameworkThread(() => + { + _pluginInterface.UiBuilder.Draw += Draw; + _pluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi; + _pluginInterface.LanguageChanged += LanguageChanged; + _clientState.Login += Login; + }); + _rootScopeCompletionSource.SetResult(_rootScope); + _loadState = ELoadState.Loaded; + } + catch (ObjectDisposedException e) + { + _rootScopeCompletionSource.SetException(e); + _loadState = ELoadState.Error; + } + catch (OperationCanceledException e) + { + _rootScopeCompletionSource.SetException(e); + _loadState = ELoadState.Error; + } + catch (Exception e) + { + _rootScopeCompletionSource.SetException(e); + _logger.LogError(e, "Async load failed"); + ShowErrorOnLogin(() => + new Chat(_chatGui).Error(string.Format(Localization.Error_LoadFailed, + $"{e.GetType()} - {e.Message}"))); + + _loadState = ELoadState.Error; + } + } + + private void ShowErrorOnLogin(Action? loginAction) + { + if (_clientState.IsLoggedIn) + { + loginAction?.Invoke(); + _loginAction = null; + } + else + _loginAction = loginAction; + } + + private void Login(object? sender, EventArgs eventArgs) + { + _loginAction?.Invoke(); + _loginAction = null; } private void OnCommand(string command, string arguments) { - if (Service.Configuration.FirstUse) - { - Service.Chat.PalError(Localization.Error_FirstTimeSetupRequired); - return; - } + arguments = arguments.Trim(); - try + Task.Run(async () => { - arguments = arguments.Trim(); - switch (arguments) + IServiceScope rootScope; + try { - case "stats": - Task.Run(async () => await FetchFloorStatistics()); - break; - - case "test-connection": - case "tc": - var configWindow = Service.WindowSystem.GetWindow(); - if (configWindow == null) - return; - - configWindow.IsOpen = true; - configWindow.TestConnection(); - break; - -#if DEBUG - case "update-saves": - LocalState.UpdateAll(); - Service.Chat.Print(Localization.Command_pal_updatesaves); - break; -#endif - - case "": - case "config": - Service.WindowSystem.GetWindow()?.Toggle(); - break; - - case "near": - DebugNearest(_ => true); - break; - - case "tnear": - DebugNearest(m => m.Type == Marker.EType.Trap); - break; - - case "hnear": - DebugNearest(m => m.Type == Marker.EType.Hoard); - break; - - default: - Service.Chat.PalError(string.Format(Localization.Command_pal_UnknownSubcommand, arguments, command)); - break; + rootScope = await _rootScopeCompletionSource.Task; } - } - catch (Exception e) - { - Service.Chat.PalError(e.ToString()); - } - } - - #region IDisposable Support - protected virtual void Dispose(bool disposing) - { - if (!disposing) return; - - Service.CommandManager.RemoveHandler("/pal"); - Service.PluginInterface.UiBuilder.Draw -= Draw; - Service.PluginInterface.UiBuilder.OpenConfigUi -= OpenConfigUi; - Service.PluginInterface.LanguageChanged -= LanguageChanged; - Service.Framework.Update -= OnFrameworkUpdate; - Service.Chat.ChatMessage -= OnChatMessage; - - Service.WindowSystem.RemoveAllWindows(); - - Service.RemoteApi.Dispose(); - Service.Hooks.Dispose(); - - if (Renderer is IDisposable disposable) - disposable.Dispose(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - #endregion - - private void OnChatMessage(XivChatType type, uint senderId, ref SeString sender, ref SeString seMessage, ref bool isHandled) - { - if (Service.Configuration.FirstUse) - return; - - if (type != (XivChatType)2105) - return; - - string message = seMessage.ToString(); - if (_localizedChatMessages.FloorChanged.IsMatch(message)) - { - PomanderOfSight = PomanderState.Inactive; - - if (PomanderOfIntuition == PomanderState.FoundOnCurrentFloor) - PomanderOfIntuition = PomanderState.Inactive; - } - else if (message.EndsWith(_localizedChatMessages.MapRevealed)) - { - PomanderOfSight = PomanderState.Active; - } - else if (message.EndsWith(_localizedChatMessages.AllTrapsRemoved)) - { - PomanderOfSight = PomanderState.PomanderOfSafetyUsed; - } - else if (message.EndsWith(_localizedChatMessages.HoardNotOnCurrentFloor) || message.EndsWith(_localizedChatMessages.HoardOnCurrentFloor)) - { - // There is no functional difference between these - if you don't open the marked coffer, - // going to higher floors will keep the pomander active. - PomanderOfIntuition = PomanderState.Active; - } - else if (message.EndsWith(_localizedChatMessages.HoardCofferOpened)) - { - PomanderOfIntuition = PomanderState.FoundOnCurrentFloor; - } - } - - private void LanguageChanged(string langcode) - { - Localization.Culture = new CultureInfo(langcode); - Service.WindowSystem.Windows.OfType().Each(w => w.LanguageChanged()); - } - - private void OnFrameworkUpdate(Framework framework) - { - if (Service.Configuration.FirstUse) - return; - - try - { - bool recreateLayout = false; - bool saveMarkers = false; - - while (EarlyEventQueue.TryDequeue(out IQueueOnFrameworkThread? queued)) - queued.Run(this, ref recreateLayout, ref saveMarkers); - - if (LastTerritory != Service.ClientState.TerritoryType) + catch (Exception e) { - LastTerritory = Service.ClientState.TerritoryType; - TerritorySyncState = SyncState.NotAttempted; - NextUpdateObjects.Clear(); - - if (IsInDeepDungeon()) - GetFloorMarkers(LastTerritory); - EphemeralMarkers.Clear(); - PomanderOfSight = PomanderState.Inactive; - PomanderOfIntuition = PomanderState.Inactive; - recreateLayout = true; - DebugMessage = null; - } - - if (!IsInDeepDungeon()) + _logger.LogError(e, "Could not wait for command root scope"); return; - - if (Service.Configuration.Mode == Configuration.EMode.Online && TerritorySyncState == SyncState.NotAttempted) - { - TerritorySyncState = SyncState.Started; - Task.Run(async () => await DownloadMarkersForTerritory(LastTerritory)); } - while (LateEventQueue.TryDequeue(out IQueueOnFrameworkThread? queued)) - queued.Run(this, ref recreateLayout, ref saveMarkers); - - var currentFloor = GetFloorMarkers(LastTerritory); - - IList visibleMarkers = GetRelevantGameObjects(); - HandlePersistentMarkers(currentFloor, visibleMarkers.Where(x => x.IsPermanent()).ToList(), saveMarkers, recreateLayout); - HandleEphemeralMarkers(visibleMarkers.Where(x => !x.IsPermanent()).ToList(), recreateLayout); - } - catch (Exception e) - { - DebugMessage = $"{DateTime.Now}\n{e}"; - } - } - - internal LocalState GetFloorMarkers(ushort territoryType) - { - return FloorMarkers.GetOrAdd(territoryType, tt => LocalState.Load(tt) ?? new LocalState(tt)); - } - - #region Rendering markers - private void HandlePersistentMarkers(LocalState currentFloor, IList visibleMarkers, bool saveMarkers, bool recreateLayout) - { - var config = Service.Configuration; - var currentFloorMarkers = currentFloor.Markers; - - bool updateSeenMarkers = false; - var partialAccountId = Service.RemoteApi.PartialAccountId; - foreach (var visibleMarker in visibleMarkers) - { - Marker? knownMarker = currentFloorMarkers.SingleOrDefault(x => x == visibleMarker); - if (knownMarker != null) + IPalacePalConfiguration configuration = + rootScope.ServiceProvider.GetRequiredService(); + Chat chat = rootScope.ServiceProvider.GetRequiredService(); + if (configuration.FirstUse && arguments != "" && arguments != "config") { - if (!knownMarker.Seen) - { - knownMarker.Seen = true; - saveMarkers = true; - } - - // This requires you to have seen a trap/hoard marker once per floor to synchronize this for older local states, - // markers discovered afterwards are automatically marked seen. - if (partialAccountId != null && knownMarker is { NetworkId: { }, RemoteSeenRequested: false } && !knownMarker.RemoteSeenOn.Contains(partialAccountId)) - updateSeenMarkers = true; - - continue; + chat.Error(Localization.Error_FirstTimeSetupRequired); + return; } - currentFloorMarkers.Add(visibleMarker); - recreateLayout = true; - saveMarkers = true; - } - - if (!recreateLayout && currentFloorMarkers.Count > 0 && (config.OnlyVisibleTrapsAfterPomander || config.OnlyVisibleHoardAfterPomander)) - { - try { - foreach (var marker in currentFloorMarkers) - { - uint desiredColor = DetermineColor(marker, visibleMarkers); - if (marker.RenderElement == null || !marker.RenderElement.IsValid) - { - recreateLayout = true; - break; - } + var sp = rootScope.ServiceProvider; - if (marker.RenderElement.Color != desiredColor) - marker.RenderElement.Color = desiredColor; + switch (arguments) + { + case "": + case "config": + sp.GetRequiredService().Execute(); + break; + + case "stats": + sp.GetRequiredService().Execute(); + break; + + case "tc": + case "test-connection": + sp.GetRequiredService().Execute(); + break; + + case "near": + case "tnear": + case "hnear": + sp.GetRequiredService().Execute(arguments); + break; + + default: + chat.Error(string.Format(Localization.Command_pal_UnknownSubcommand, arguments, + command)); + break; } } catch (Exception e) { - DebugMessage = $"{DateTime.Now}\n{e}"; - recreateLayout = true; + chat.Error(e.ToString()); } - } - - if (updateSeenMarkers && partialAccountId != null) - { - var markersToUpdate = currentFloorMarkers.Where(x => x is { Seen: true, NetworkId: { }, RemoteSeenRequested: false } && !x.RemoteSeenOn.Contains(partialAccountId)).ToList(); - foreach (var marker in markersToUpdate) - marker.RemoteSeenRequested = true; - Task.Run(async () => await SyncSeenMarkersForTerritory(LastTerritory, markersToUpdate)); - } - - if (saveMarkers) - { - currentFloor.Save(); - - if (TerritorySyncState == SyncState.Complete) - { - var markersToUpload = currentFloorMarkers.Where(x => x.IsPermanent() && x.NetworkId == null && !x.UploadRequested).ToList(); - if (markersToUpload.Count > 0) - { - foreach (var marker in markersToUpload) - marker.UploadRequested = true; - Task.Run(async () => await UploadMarkersForTerritory(LastTerritory, markersToUpload)); - } - } - } - - if (recreateLayout) - { - Renderer.ResetLayer(ELayer.TrapHoard); - - List elements = new(); - foreach (var marker in currentFloorMarkers) - { - if (marker.Seen || config.Mode == Configuration.EMode.Online || marker is { WasImported: true, Imports.Count: > 0 }) - { - if (marker.Type == Marker.EType.Trap && config.ShowTraps) - { - CreateRenderElement(marker, elements, DetermineColor(marker, visibleMarkers)); - } - else if (marker.Type == Marker.EType.Hoard && config.ShowHoard) - { - CreateRenderElement(marker, elements, DetermineColor(marker, visibleMarkers)); - } - } - } - - if (elements.Count == 0) - return; - - Renderer.SetLayer(ELayer.TrapHoard, elements); - } + }); } - private void HandleEphemeralMarkers(IList visibleMarkers, bool recreateLayout) + private void OpenConfigUi() + => _rootScope!.ServiceProvider.GetRequiredService().Execute(); + + private void LanguageChanged(string languageCode) { - recreateLayout |= EphemeralMarkers.Any(existingMarker => visibleMarkers.All(x => x != existingMarker)); - recreateLayout |= visibleMarkers.Any(visibleMarker => EphemeralMarkers.All(x => x != visibleMarker)); + _logger.LogInformation("Language set to '{Language}'", languageCode); - if (recreateLayout) - { - Renderer.ResetLayer(ELayer.RegularCoffers); - EphemeralMarkers.Clear(); - - var config = Service.Configuration; - - List elements = new(); - foreach (var marker in visibleMarkers) - { - EphemeralMarkers.Add(marker); - - if (marker.Type == Marker.EType.SilverCoffer && config.ShowSilverCoffers) - { - CreateRenderElement(marker, elements, DetermineColor(marker, visibleMarkers), config.FillSilverCoffers); - } - } - - if (elements.Count == 0) - return; - - Renderer.SetLayer(ELayer.RegularCoffers, elements); - } - } - - private uint DetermineColor(Marker marker, IList visibleMarkers) - { - switch (marker.Type) - { - case Marker.EType.Trap when PomanderOfSight == PomanderState.Inactive || !Service.Configuration.OnlyVisibleTrapsAfterPomander || visibleMarkers.Any(x => x == marker): - return ImGui.ColorConvertFloat4ToU32(Service.Configuration.TrapColor); - case Marker.EType.Hoard when PomanderOfIntuition == PomanderState.Inactive || !Service.Configuration.OnlyVisibleHoardAfterPomander || visibleMarkers.Any(x => x == marker): - return ImGui.ColorConvertFloat4ToU32(Service.Configuration.HoardColor); - case Marker.EType.SilverCoffer: - return ImGui.ColorConvertFloat4ToU32(Service.Configuration.SilverCofferColor); - case Marker.EType.Trap: - case Marker.EType.Hoard: - return ColorInvisible; - default: - return ImGui.ColorConvertFloat4ToU32(new Vector4(1, 0.5f, 1, 0.4f)); - } - } - - private void CreateRenderElement(Marker marker, List elements, uint color, bool fill = false) - { - var element = Renderer.CreateElement(marker.Type, marker.Position, color, fill); - marker.RenderElement = element; - elements.Add(element); - } - #endregion - - #region Up-/Download - private async Task DownloadMarkersForTerritory(ushort territoryId) - { - try - { - var (success, downloadedMarkers) = await Service.RemoteApi.DownloadRemoteMarkers(territoryId); - LateEventQueue.Enqueue(new QueuedSyncResponse - { - Type = SyncType.Download, - TerritoryType = territoryId, - Success = success, - Markers = downloadedMarkers - }); - } - catch (Exception e) - { - DebugMessage = $"{DateTime.Now}\n{e}"; - } - } - - private async Task UploadMarkersForTerritory(ushort territoryId, List markersToUpload) - { - try - { - var (success, uploadedMarkers) = await Service.RemoteApi.UploadMarker(territoryId, markersToUpload); - LateEventQueue.Enqueue(new QueuedSyncResponse - { - Type = SyncType.Upload, - TerritoryType = territoryId, - Success = success, - Markers = uploadedMarkers - }); - } - catch (Exception e) - { - DebugMessage = $"{DateTime.Now}\n{e}"; - } - } - - private async Task SyncSeenMarkersForTerritory(ushort territoryId, List markersToUpdate) - { - try - { - var success = await Service.RemoteApi.MarkAsSeen(territoryId, markersToUpdate); - LateEventQueue.Enqueue(new QueuedSyncResponse - { - Type = SyncType.MarkSeen, - TerritoryType = territoryId, - Success = success, - Markers = markersToUpdate, - }); - } - catch (Exception e) - { - DebugMessage = $"{DateTime.Now}\n{e}"; - } - } - #endregion - - #region Command Handling - private async Task FetchFloorStatistics() - { - if (!Service.RemoteApi.HasRoleOnCurrentServer("statistics:view")) - { - Service.Chat.PalError(Localization.Command_pal_stats_CurrentFloor); - return; - } - - try - { - var (success, floorStatistics) = await Service.RemoteApi.FetchStatistics(); - if (success) - { - var statisticsWindow = Service.WindowSystem.GetWindow()!; - statisticsWindow.SetFloorData(floorStatistics); - statisticsWindow.IsOpen = true; - } - else - { - Service.Chat.PalError(Localization.Command_pal_stats_UnableToFetchStatistics); - } - } - catch (RpcException e) when (e.StatusCode == StatusCode.PermissionDenied) - { - Service.Chat.Print(Localization.Command_pal_stats_CurrentFloor); - } - catch (Exception e) - { - Service.Chat.PalError(e.ToString()); - } - } - - private void DebugNearest(Predicate predicate) - { - if (!IsInDeepDungeon()) - return; - - var state = GetFloorMarkers(Service.ClientState.TerritoryType); - var playerPosition = Service.ClientState.LocalPlayer?.Position; - if (playerPosition == null) - return; - Service.Chat.Print($"[Palace Pal] {playerPosition}"); - - var nearbyMarkers = state.Markers - .Where(m => predicate(m)) - .Where(m => m.RenderElement != null && m.RenderElement.Color != ColorInvisible) - .Select(m => new { m, distance = (playerPosition - m.Position)?.Length() ?? float.MaxValue }) - .OrderBy(m => m.distance) - .Take(5) - .ToList(); - foreach (var nearbyMarker in nearbyMarkers) - Service.Chat.Print($"{nearbyMarker.distance:F2} - {nearbyMarker.m.Type} {nearbyMarker.m.NetworkId?.ToPartialId(length: 8)} - {nearbyMarker.m.Position}"); - } - #endregion - - private IList GetRelevantGameObjects() - { - List result = new(); - for (int i = 246; i < Service.ObjectTable.Length; i++) - { - GameObject? obj = Service.ObjectTable[i]; - if (obj == null) - continue; - - switch ((uint)Marshal.ReadInt32(obj.Address + 128)) - { - case 2007182: - case 2007183: - case 2007184: - case 2007185: - case 2007186: - case 2009504: - result.Add(new Marker(Marker.EType.Trap, obj.Position) { Seen = true }); - break; - - case 2007542: - case 2007543: - result.Add(new Marker(Marker.EType.Hoard, obj.Position) { Seen = true }); - break; - - case 2007357: - result.Add(new Marker(Marker.EType.SilverCoffer, obj.Position) { Seen = true }); - break; - } - } - - while (NextUpdateObjects.TryDequeue(out nint address)) - { - var obj = Service.ObjectTable.FirstOrDefault(x => x.Address == address); - if (obj != null && obj.Position.Length() > 0.1) - result.Add(new Marker(Marker.EType.Trap, obj.Position) { Seen = true }); - } - - return result; - } - - internal bool IsInDeepDungeon() => - Service.ClientState.IsLoggedIn - && Service.Condition[ConditionFlag.InDeepDungeon] - && typeof(ETerritoryType).IsEnumDefined(Service.ClientState.TerritoryType); - - private void ReloadLanguageStrings() - { - _localizedChatMessages = new LocalizedChatMessages - { - MapRevealed = GetLocalizedString(7256), - AllTrapsRemoved = GetLocalizedString(7255), - HoardOnCurrentFloor = GetLocalizedString(7272), - HoardNotOnCurrentFloor = GetLocalizedString(7273), - HoardCofferOpened = GetLocalizedString(7274), - FloorChanged = new Regex("^" + GetLocalizedString(7270).Replace("\u0002 \u0003\ufffd\u0002\u0003", @"(\d+)") + "$"), - }; - } - - internal void ResetRenderer() - { - if (Renderer is SplatoonRenderer && Service.Configuration.Renderer == Configuration.ERenderer.Splatoon) - return; - else if (Renderer is SimpleRenderer && Service.Configuration.Renderer == Configuration.ERenderer.Simple) - return; - - if (Renderer is IDisposable disposable) - disposable.Dispose(); - - if (Service.Configuration.Renderer == Configuration.ERenderer.Splatoon) - Renderer = new SplatoonRenderer(Service.PluginInterface, this); - else - Renderer = new SimpleRenderer(); + Localization.Culture = new CultureInfo(languageCode); + _windowSystem!.Windows.OfType() + .Each(w => w.LanguageChanged()); } private void Draw() { - if (Renderer is SimpleRenderer sr) - sr.DrawLayers(); - - Service.WindowSystem.Draw(); + _rootScope!.ServiceProvider.GetRequiredService().DrawLayers(); + _windowSystem!.Draw(); } - private string GetLocalizedString(uint id) + public void Dispose() { - return Service.DataManager.GetExcelSheet()?.GetRow(id)?.Text?.ToString() ?? "Unknown"; + _commandManager.RemoveHandler("/pal"); + + if (_loadState == ELoadState.Loaded) + { + _pluginInterface.UiBuilder.Draw -= Draw; + _pluginInterface.UiBuilder.OpenConfigUi -= OpenConfigUi; + _pluginInterface.LanguageChanged -= LanguageChanged; + _clientState.Login -= Login; + } + + _initCts.Cancel(); + _rootScope?.Dispose(); } - public enum PomanderState + private enum ELoadState { - Inactive, - Active, - FoundOnCurrentFloor, - PomanderOfSafetyUsed, - } - - private class LocalizedChatMessages - { - public string MapRevealed { get; init; } = "???"; //"The map for this floor has been revealed!"; - public string AllTrapsRemoved { get; init; } = "???"; // "All the traps on this floor have disappeared!"; - public string HoardOnCurrentFloor { get; init; } = "???"; // "You sense the Accursed Hoard calling you..."; - public string HoardNotOnCurrentFloor { get; init; } = "???"; // "You do not sense the call of the Accursed Hoard on this floor..."; - public string HoardCofferOpened { get; init; } = "???"; // "You discover a piece of the Accursed Hoard!"; - public Regex FloorChanged { get; init; } = new(@"This isn't a game message, but will be replaced"); // new Regex(@"^Floor (\d+)$"); + Initializing, + Loaded, + Error } } } diff --git a/Pal.Client/Properties/Localization.Designer.cs b/Pal.Client/Properties/Localization.Designer.cs index f123400..2c95215 100644 --- a/Pal.Client/Properties/Localization.Designer.cs +++ b/Pal.Client/Properties/Localization.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -150,15 +149,6 @@ namespace Pal.Client.Properties { } } - /// - /// Looks up a localized string similar to Updated all locally cached marker files to latest version.. - /// - internal static string Command_pal_updatesaves { - get { - return ResourceManager.GetString("Command_pal_updatesaves", resourceCulture); - } - } - /// /// Looks up a localized string similar to You are NOT in a deep dungeon.. /// @@ -385,15 +375,6 @@ namespace Pal.Client.Properties { } } - /// - /// Looks up a localized string similar to Splatoon Test:. - /// - internal static string Config_Splatoon_Test { - get { - return ResourceManager.GetString("Config_Splatoon_Test", resourceCulture); - } - } - /// /// Looks up a localized string similar to Start Export. /// @@ -674,6 +655,15 @@ namespace Pal.Client.Properties { } } + /// + /// Looks up a localized string similar to Plugin could not be loaded: {0}. + /// + internal static string Error_LoadFailed { + get { + return ResourceManager.GetString("Error_LoadFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Please install this plugin from the official repository at {0} to continue using it.. /// diff --git a/Pal.Client/Properties/Localization.fr.resx b/Pal.Client/Properties/Localization.fr.resx index a213ab4..c634de2 100644 --- a/Pal.Client/Properties/Localization.fr.resx +++ b/Pal.Client/Properties/Localization.fr.resx @@ -61,10 +61,6 @@ Impossible de récupérer les statistiques. Shown when /pal stats produces a server-side error, and the statistics window can't be loaded. - - Mise à jour de tous les marqueurs du cache local vers la dernière version. - Shown after /pal update-saves was successful. - Connexion réussie. @@ -228,9 +224,6 @@ Il n'y a pas de synchronisation avec les autres joueurs ni de sauvegarde entre l Expérimental - - Test de Splatoon : - Dessiner les marqueurs des pièges et coffres autour de soi To test the Splatoon integration, you can draw markers around yourself. diff --git a/Pal.Client/Properties/Localization.ja.resx b/Pal.Client/Properties/Localization.ja.resx index 051b929..4c72606 100644 --- a/Pal.Client/Properties/Localization.ja.resx +++ b/Pal.Client/Properties/Localization.ja.resx @@ -61,10 +61,6 @@ 統計情報を取得できません。 Shown when /pal stats produces a server-side error, and the statistics window can't be loaded. - - 保存されたマーカーファイルを更新しました。 - Shown after /pal update-saves was successful. - 接続に成功しました。 @@ -227,9 +223,6 @@ 試験的機能 - - Splatoonのテスト: - 自分の周りにトラップと宝箱を表示する To test the Splatoon integration, you can draw markers around yourself. diff --git a/Pal.Client/Properties/Localization.resx b/Pal.Client/Properties/Localization.resx index 76cf1da..ddac1c3 100644 --- a/Pal.Client/Properties/Localization.resx +++ b/Pal.Client/Properties/Localization.resx @@ -46,6 +46,9 @@ Please finish the initial setup first. Before using any /pal command, the initial setup/agreeement needs to be completed. + + Plugin could not be loaded: {0} + Please install this plugin from the official repository at {0} to continue using it. @@ -66,11 +69,7 @@ Unable to fetch statistics. Shown when /pal stats produces a server-side error, and the statistics window can't be loaded. - - Updated all locally cached marker files to latest version. - Shown after /pal update-saves was successful. - - + Connection successful. @@ -239,9 +238,6 @@ This is not synchronized with other players and not saved between floors/runs. experimental - - Splatoon Test: - Draw trap & coffer circles around self To test the Splatoon integration, you can draw markers around yourself. @@ -325,6 +321,5 @@ This is not synchronized with other players and not saved between floors/runs. Import failed: Invalid file. - diff --git a/Pal.Client/README.md b/Pal.Client/README.md new file mode 100644 index 0000000..71ef729 --- /dev/null +++ b/Pal.Client/README.md @@ -0,0 +1,20 @@ +# Palace Pal + +## Client Build Notes + +### Database Migrations + +Since EF core needs all dll files to be present, including Dalamud ones, +there's a special `EF` configuration that exempts them from setting +`false` during the build. + +To use with `dotnet ef` commands, specify it as `-c EF`, for example: + +```shell +dotnet ef migrations add MigrationName --configuration EF +``` + +To rebuild the compiled model: +```shell +dotnet ef dbcontext optimize --output-dir Database/Compiled --namespace Pal.Client.Database.Compiled --configuration EF +``` diff --git a/Pal.Client/Rendering/ELayer.cs b/Pal.Client/Rendering/ELayer.cs index be7881e..1027f27 100644 --- a/Pal.Client/Rendering/ELayer.cs +++ b/Pal.Client/Rendering/ELayer.cs @@ -4,5 +4,6 @@ { TrapHoard, RegularCoffers, + Test, } } diff --git a/Pal.Client/Rendering/IDrawDebugItems.cs b/Pal.Client/Rendering/IDrawDebugItems.cs deleted file mode 100644 index e584545..0000000 --- a/Pal.Client/Rendering/IDrawDebugItems.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Numerics; - -namespace Pal.Client.Rendering -{ - internal interface IDrawDebugItems - { - void DrawDebugItems(Vector4 trapColor, Vector4 hoardColor); - } -} diff --git a/Pal.Client/Rendering/IRenderer.cs b/Pal.Client/Rendering/IRenderer.cs index 9ecf7d2..1856403 100644 --- a/Pal.Client/Rendering/IRenderer.cs +++ b/Pal.Client/Rendering/IRenderer.cs @@ -1,19 +1,20 @@ -using ImGuiNET; -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Generic; using System.Numerics; -using System.Text; -using System.Threading.Tasks; +using Pal.Client.Configuration; +using Pal.Client.Floors; namespace Pal.Client.Rendering { internal interface IRenderer { + ERenderer GetConfigValue(); + void SetLayer(ELayer layer, IReadOnlyList elements); void ResetLayer(ELayer layer); - IRenderElement CreateElement(Marker.EType type, Vector3 pos, uint color, bool fill = false); + IRenderElement CreateElement(MemoryLocation.EType type, Vector3 pos, uint color, bool fill = false); + + void DrawDebugItems(uint trapColor, uint hoardColor); } } diff --git a/Pal.Client/Rendering/MarkerConfig.cs b/Pal.Client/Rendering/MarkerConfig.cs index 58d3642..0bc25cf 100644 --- a/Pal.Client/Rendering/MarkerConfig.cs +++ b/Pal.Client/Rendering/MarkerConfig.cs @@ -1,20 +1,23 @@ using System.Collections.Generic; +using Pal.Client.Floors; namespace Pal.Client.Rendering { - internal class MarkerConfig + internal sealed class MarkerConfig { private static readonly MarkerConfig EmptyConfig = new(); - private static readonly Dictionary MarkerConfigs = new() + + private static readonly Dictionary MarkerConfigs = new() { - { Marker.EType.Trap, new MarkerConfig { Radius = 1.7f } }, - { Marker.EType.Hoard, new MarkerConfig { Radius = 1.7f, OffsetY = -0.03f } }, - { Marker.EType.SilverCoffer, new MarkerConfig { Radius = 1f } }, + { MemoryLocation.EType.Trap, new MarkerConfig { Radius = 1.7f } }, + { MemoryLocation.EType.Hoard, new MarkerConfig { Radius = 1.7f, OffsetY = -0.03f } }, + { MemoryLocation.EType.SilverCoffer, new MarkerConfig { Radius = 1f } }, }; - public float OffsetY { get; set; } - public float Radius { get; set; } = 0.25f; + public float OffsetY { get; private init; } + public float Radius { get; private init; } = 0.25f; - public static MarkerConfig ForType(Marker.EType type) => MarkerConfigs.GetValueOrDefault(type, EmptyConfig); + public static MarkerConfig ForType(MemoryLocation.EType type) => + MarkerConfigs.GetValueOrDefault(type, EmptyConfig); } } diff --git a/Pal.Client/Rendering/RenderAdapter.cs b/Pal.Client/Rendering/RenderAdapter.cs new file mode 100644 index 0000000..dfc2287 --- /dev/null +++ b/Pal.Client/Rendering/RenderAdapter.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration; +using Pal.Client.Floors; + +namespace Pal.Client.Rendering +{ + internal sealed class RenderAdapter : IRenderer, IDisposable + { + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; + private readonly IPalacePalConfiguration _configuration; + + private IServiceScope? _renderScope; + private IRenderer _implementation; + + public RenderAdapter(IServiceScopeFactory serviceScopeFactory, ILogger logger, + IPalacePalConfiguration configuration) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + _configuration = configuration; + + _implementation = Recreate(null); + } + + private IRenderer Recreate(ERenderer? currentRenderer) + { + ERenderer targetRenderer = _configuration.Renderer.SelectedRenderer; + if (targetRenderer == currentRenderer) + return _implementation; + + _renderScope?.Dispose(); + + _logger.LogInformation("Selected new renderer: {Renderer}", _configuration.Renderer.SelectedRenderer); + _renderScope = _serviceScopeFactory.CreateScope(); + if (targetRenderer == ERenderer.Splatoon) + return _renderScope.ServiceProvider.GetRequiredService(); + else + return _renderScope.ServiceProvider.GetRequiredService(); + } + + public void ConfigUpdated() + { + _implementation = Recreate(_implementation.GetConfigValue()); + } + + public void Dispose() + => _renderScope?.Dispose(); + + public void SetLayer(ELayer layer, IReadOnlyList elements) + => _implementation.SetLayer(layer, elements); + + public void ResetLayer(ELayer layer) + => _implementation.ResetLayer(layer); + + public IRenderElement CreateElement(MemoryLocation.EType type, Vector3 pos, uint color, bool fill = false) + => _implementation.CreateElement(type, pos, color, fill); + + public ERenderer GetConfigValue() + => throw new NotImplementedException(); + + public void DrawDebugItems(uint trapColor, uint hoardColor) + => _implementation.DrawDebugItems(trapColor, hoardColor); + + public void DrawLayers() + { + if (_implementation is SimpleRenderer sr) + sr.DrawLayers(); + } + } +} diff --git a/Pal.Client/Rendering/RenderData.cs b/Pal.Client/Rendering/RenderData.cs new file mode 100644 index 0000000..41c64ed --- /dev/null +++ b/Pal.Client/Rendering/RenderData.cs @@ -0,0 +1,8 @@ +namespace Pal.Client.Rendering +{ + internal static class RenderData + { + public static readonly uint ColorInvisible = 0; + public static readonly long TestLayerTimeout = 10_000; + } +} diff --git a/Pal.Client/Rendering/SimpleRenderer.cs b/Pal.Client/Rendering/SimpleRenderer.cs index c2c047b..3143def 100644 --- a/Pal.Client/Rendering/SimpleRenderer.cs +++ b/Pal.Client/Rendering/SimpleRenderer.cs @@ -1,15 +1,15 @@ -using Dalamud.Game.Gui; -using Dalamud.Interface; -using Dalamud.Plugin; -using ECommons.ExcelServices.TerritoryEnumeration; +using Dalamud.Interface; using ImGuiNET; -using Lumina.Excel.GeneratedSheets; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Numerics; -using System.Xml.Linq; +using Dalamud.Game.ClientState; +using Dalamud.Game.Gui; +using Pal.Client.Configuration; +using Pal.Client.DependencyInjection; +using Pal.Client.Floors; namespace Pal.Client.Rendering { @@ -20,15 +20,30 @@ namespace Pal.Client.Rendering /// remade into PalacePal (which is the third or fourth iteration on the same idea /// I made, just with a clear vision). /// - internal class SimpleRenderer : IRenderer, IDisposable + internal sealed class SimpleRenderer : IRenderer, IDisposable { + private const int SegmentCount = 20; + + private readonly ClientState _clientState; + private readonly GameGui _gameGui; + private readonly IPalacePalConfiguration _configuration; + private readonly TerritoryState _territoryState; private readonly ConcurrentDictionary _layers = new(); + public SimpleRenderer(ClientState clientState, GameGui gameGui, IPalacePalConfiguration configuration, + TerritoryState territoryState) + { + _clientState = clientState; + _gameGui = gameGui; + _configuration = configuration; + _territoryState = territoryState; + } + public void SetLayer(ELayer layer, IReadOnlyList elements) { _layers[layer] = new SimpleLayer { - TerritoryType = Service.ClientState.TerritoryType, + TerritoryType = _clientState.TerritoryType, Elements = elements.Cast().ToList() }; } @@ -39,7 +54,7 @@ namespace Pal.Client.Rendering l.Dispose(); } - public IRenderElement CreateElement(Marker.EType type, Vector3 pos, uint color, bool fill = false) + public IRenderElement CreateElement(MemoryLocation.EType type, Vector3 pos, uint color, bool fill = false) { var config = MarkerConfig.ForType(type); return new SimpleElement @@ -52,6 +67,26 @@ namespace Pal.Client.Rendering }; } + public void DrawDebugItems(uint trapColor, uint hoardColor) + { + _layers[ELayer.Test] = new SimpleLayer + { + TerritoryType = _clientState.TerritoryType, + Elements = new List + { + (SimpleElement)CreateElement( + MemoryLocation.EType.Trap, + _clientState.LocalPlayer?.Position ?? default, + trapColor), + (SimpleElement)CreateElement( + MemoryLocation.EType.Hoard, + _clientState.LocalPlayer?.Position ?? default, + hoardColor) + }, + ExpiresAt = Environment.TickCount64 + RenderData.TestLayerTimeout + }; + } + public void DrawLayers() { if (_layers.Count == 0) @@ -61,37 +96,93 @@ namespace Pal.Client.Rendering ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); ImGuiHelpers.SetNextWindowPosRelativeMainViewport(Vector2.Zero, ImGuiCond.None, Vector2.Zero); ImGui.SetNextWindowSize(ImGuiHelpers.MainViewport.Size); - if (ImGui.Begin("###PalacePalSimpleRender", ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.AlwaysUseWindowPadding)) + if (ImGui.Begin("###PalacePalSimpleRender", + ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground | + ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoSavedSettings | + ImGuiWindowFlags.AlwaysUseWindowPadding)) { - ushort territoryType = Service.ClientState.TerritoryType; + foreach (var layer in _layers.Values.Where(l => l.IsValid(_clientState))) + { + foreach (var e in layer.Elements) + Draw(e); + } - foreach (var layer in _layers.Values.Where(l => l.TerritoryType == territoryType)) - layer.Draw(); - - foreach (var key in _layers.Where(l => l.Value.TerritoryType != territoryType).Select(l => l.Key).ToList()) + foreach (var key in _layers.Where(l => !l.Value.IsValid(_clientState)) + .Select(l => l.Key) + .ToList()) ResetLayer(key); ImGui.End(); } + ImGui.PopStyleVar(); } + private void Draw(SimpleElement e) + { + if (e.Color == RenderData.ColorInvisible) + return; + + switch (e.Type) + { + case MemoryLocation.EType.Hoard: + // ignore distance if this is a found hoard coffer + if (_territoryState.PomanderOfIntuition == PomanderState.Active && + _configuration.DeepDungeons.HoardCoffers.OnlyVisibleAfterPomander) + break; + + goto case MemoryLocation.EType.Trap; + + case MemoryLocation.EType.Trap: + var playerPos = _clientState.LocalPlayer?.Position; + if (playerPos == null) + return; + + if ((playerPos.Value - e.Position).Length() > 65) + return; + break; + } + + bool onScreen = false; + for (int index = 0; index < 2 * SegmentCount; ++index) + { + onScreen |= _gameGui.WorldToScreen(new Vector3( + e.Position.X + e.Radius * (float)Math.Sin(Math.PI / SegmentCount * index), + e.Position.Y, + e.Position.Z + e.Radius * (float)Math.Cos(Math.PI / SegmentCount * index)), + out Vector2 vector2); + + ImGui.GetWindowDrawList().PathLineTo(vector2); + } + + if (onScreen) + { + if (e.Fill) + ImGui.GetWindowDrawList().PathFillConvex(e.Color); + else + ImGui.GetWindowDrawList().PathStroke(e.Color, ImDrawFlags.Closed, 2); + } + else + ImGui.GetWindowDrawList().PathClear(); + } + + public ERenderer GetConfigValue() + => ERenderer.Simple; + public void Dispose() { foreach (var l in _layers.Values) l.Dispose(); } - public class SimpleLayer : IDisposable + public sealed class SimpleLayer : IDisposable { public required ushort TerritoryType { get; init; } public required IReadOnlyList Elements { get; init; } + public long ExpiresAt { get; init; } = long.MaxValue; - public void Draw() - { - foreach (var element in Elements) - element.Draw(); - } + public bool IsValid(ClientState clientState) => + TerritoryType == clientState.TerritoryType && ExpiresAt >= Environment.TickCount64; public void Dispose() { @@ -100,63 +191,14 @@ namespace Pal.Client.Rendering } } - public class SimpleElement : IRenderElement + public sealed class SimpleElement : IRenderElement { - private const int SegmentCount = 20; - public bool IsValid { get; set; } = true; - public required Marker.EType Type { get; init; } + public required MemoryLocation.EType Type { get; init; } public required Vector3 Position { get; init; } public required uint Color { get; set; } public required float Radius { get; init; } public required bool Fill { get; init; } - - public void Draw() - { - if (Color == Plugin.ColorInvisible) - return; - - switch (Type) - { - case Marker.EType.Hoard: - // ignore distance if this is a found hoard coffer - if (Service.Plugin.PomanderOfIntuition == Plugin.PomanderState.Active && Service.Configuration.OnlyVisibleHoardAfterPomander) - break; - - goto case Marker.EType.Trap; - - case Marker.EType.Trap: - var playerPos = Service.ClientState.LocalPlayer?.Position; - if (playerPos == null) - return; - - if ((playerPos.Value - Position).Length() > 65) - return; - break; - } - - bool onScreen = false; - for (int index = 0; index < 2 * SegmentCount; ++index) - { - onScreen |= Service.GameGui.WorldToScreen(new Vector3( - Position.X + Radius * (float)Math.Sin(Math.PI / SegmentCount * index), - Position.Y, - Position.Z + Radius * (float)Math.Cos(Math.PI / SegmentCount * index)), - out Vector2 vector2); - - ImGui.GetWindowDrawList().PathLineTo(vector2); - } - - if (onScreen) - { - if (Fill) - ImGui.GetWindowDrawList().PathFillConvex(Color); - else - ImGui.GetWindowDrawList().PathStroke(Color, ImDrawFlags.Closed, 2); - } - else - ImGui.GetWindowDrawList().PathClear(); - } } } } diff --git a/Pal.Client/Rendering/SplatoonRenderer.cs b/Pal.Client/Rendering/SplatoonRenderer.cs index 2c07578..bdcee3d 100644 --- a/Pal.Client/Rendering/SplatoonRenderer.cs +++ b/Pal.Client/Rendering/SplatoonRenderer.cs @@ -1,31 +1,50 @@ -using Dalamud.Logging; -using Dalamud.Plugin; +using Dalamud.Plugin; using ECommons; using ECommons.Reflection; using ECommons.Schedulers; using ECommons.SplatoonAPI; -using ImGuiNET; using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Reflection; -using System.Text; -using System.Threading.Tasks; +using Dalamud.Game.ClientState; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration; +using Pal.Client.DependencyInjection; +using Pal.Client.Floors; namespace Pal.Client.Rendering { - internal class SplatoonRenderer : IRenderer, IDrawDebugItems, IDisposable + internal sealed class SplatoonRenderer : IRenderer, IDisposable { private const long OnTerritoryChange = -2; - private bool IsDisposed { get; set; } - public SplatoonRenderer(DalamudPluginInterface pluginInterface, IDalamudPlugin plugin) + private readonly ILogger _logger; + private readonly DebugState _debugState; + private readonly ClientState _clientState; + private readonly Chat _chat; + + public SplatoonRenderer( + ILogger logger, + DalamudPluginInterface pluginInterface, + IDalamudPlugin dalamudPlugin, + DebugState debugState, + ClientState clientState, + Chat chat) { - ECommonsMain.Init(pluginInterface, plugin, ECommons.Module.SplatoonAPI); + _logger = logger; + _debugState = debugState; + _clientState = clientState; + _chat = chat; + + _logger.LogInformation("Initializing splatoon"); + ECommonsMain.Init(pluginInterface, dalamudPlugin, ECommons.Module.SplatoonAPI); } + private bool IsDisposed { get; set; } + public void SetLayer(ELayer layer, IReadOnlyList elements) { // we need to delay this, as the current framework update could be before splatoon's, in which case it would immediately delete the layout @@ -33,12 +52,15 @@ namespace Pal.Client.Rendering { try { - Splatoon.AddDynamicElements(ToLayerName(layer), elements.Cast().Select(x => x.Delegate).ToArray(), new[] { Environment.TickCount64 + 60 * 60 * 1000, OnTerritoryChange }); + Splatoon.AddDynamicElements(ToLayerName(layer), + elements.Cast().Select(x => x.Delegate).ToArray(), + new[] { Environment.TickCount64 + 60 * 60 * 1000, OnTerritoryChange }); } catch (Exception e) { - PluginLog.Error(e, $"Could not create splatoon layer {layer} with {elements.Count} elements"); - Service.Plugin.DebugMessage = $"{DateTime.Now}\n{e}"; + _logger.LogError(e, "Could not create splatoon layer {Layer} with {Count} elements", layer, + elements.Count); + _debugState.SetFromException(e); } }); } @@ -51,14 +73,14 @@ namespace Pal.Client.Rendering } catch (Exception e) { - PluginLog.Error(e, $"Could not reset splatoon layer {layer}"); + _logger.LogError(e, "Could not reset splatoon layer {Layer}", layer); } } private string ToLayerName(ELayer layer) => $"PalacePal.{layer}"; - public IRenderElement CreateElement(Marker.EType type, Vector3 pos, uint color, bool fill = false) + public IRenderElement CreateElement(MemoryLocation.EType type, Vector3 pos, uint color, bool fill = false) { MarkerConfig config = MarkerConfig.ForType(type); Element element = new Element(ElementType.CircleAtFixedCoordinates) @@ -78,22 +100,26 @@ namespace Pal.Client.Rendering return new SplatoonElement(this, element); } - public void DrawDebugItems(Vector4 trapColor, Vector4 hoardColor) + public void DrawDebugItems(uint trapColor, uint hoardColor) { try { - Vector3? pos = Service.ClientState.LocalPlayer?.Position; + Vector3? pos = _clientState.LocalPlayer?.Position; if (pos != null) { + ResetLayer(ELayer.Test); + var elements = new List { - CreateElement(Marker.EType.Trap, pos.Value, ImGui.ColorConvertFloat4ToU32(trapColor)), - CreateElement(Marker.EType.Hoard, pos.Value, ImGui.ColorConvertFloat4ToU32(hoardColor)), + CreateElement(MemoryLocation.EType.Trap, pos.Value, trapColor), + CreateElement(MemoryLocation.EType.Hoard, pos.Value, hoardColor), }; - if (!Splatoon.AddDynamicElements("PalacePal.Test", elements.Cast().Select(x => x.Delegate).ToArray(), new[] { Environment.TickCount64 + 10000 })) + if (!Splatoon.AddDynamicElements(ToLayerName(ELayer.Test), + elements.Cast().Select(x => x.Delegate).ToArray(), + new[] { Environment.TickCount64 + RenderData.TestLayerTimeout })) { - Service.Chat.PrintError("Could not draw markers :("); + _chat.Message("Could not draw markers :("); } } } @@ -102,37 +128,51 @@ namespace Pal.Client.Rendering try { var pluginManager = DalamudReflector.GetPluginManager(); - IList installedPlugins = pluginManager.GetType().GetProperty("InstalledPlugins")?.GetValue(pluginManager) as IList ?? new List(); + IList installedPlugins = + pluginManager.GetType().GetProperty("InstalledPlugins")?.GetValue(pluginManager) as IList ?? + new List(); foreach (var t in installedPlugins) { - AssemblyName? assemblyName = (AssemblyName?)t.GetType().GetProperty("AssemblyName")?.GetValue(t); + AssemblyName? assemblyName = + (AssemblyName?)t.GetType().GetProperty("AssemblyName")?.GetValue(t); string? pluginName = (string?)t.GetType().GetProperty("Name")?.GetValue(t); if (assemblyName?.Name == "Splatoon" && pluginName != "Splatoon") { - Service.Chat.PrintError($"[Palace Pal] Splatoon is installed under the plugin name '{pluginName}', which is incompatible with the Splatoon API."); - Service.Chat.Print("[Palace Pal] You need to install Splatoon from the official repository at https://github.com/NightmareXIV/MyDalamudPlugins."); + _chat.Error( + $"Splatoon is installed under the plugin name '{pluginName}', which is incompatible with the Splatoon API."); + _chat.Message( + "You need to install Splatoon from the official repository at https://github.com/NightmareXIV/MyDalamudPlugins."); return; } } } - catch (Exception) { } + catch (Exception) + { + // not relevant + } - Service.Chat.PrintError("Could not draw markers, is Splatoon installed and enabled?"); + _chat.Error("Could not draw markers, is Splatoon installed and enabled?"); } } + public ERenderer GetConfigValue() + => ERenderer.Splatoon; + public void Dispose() { + _logger.LogInformation("Disposing splatoon"); + IsDisposed = true; ResetLayer(ELayer.TrapHoard); ResetLayer(ELayer.RegularCoffers); + ResetLayer(ELayer.Test); ECommonsMain.Dispose(); } - public class SplatoonElement : IRenderElement + private sealed class SplatoonElement : IRenderElement { private readonly SplatoonRenderer _renderer; @@ -145,6 +185,7 @@ namespace Pal.Client.Rendering public Element Delegate { get; } public bool IsValid => !_renderer.IsDisposed && Delegate.IsValid(); + public uint Color { get => Delegate.color; diff --git a/Pal.Client/Scheduled/IQueueOnFrameworkThread.cs b/Pal.Client/Scheduled/IQueueOnFrameworkThread.cs index bf02b9d..5397be3 100644 --- a/Pal.Client/Scheduled/IQueueOnFrameworkThread.cs +++ b/Pal.Client/Scheduled/IQueueOnFrameworkThread.cs @@ -1,13 +1,40 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Reflection.Metadata; +using Dalamud.Logging; +using Microsoft.Extensions.Logging; namespace Pal.Client.Scheduled { internal interface IQueueOnFrameworkThread { - void Run(Plugin plugin, ref bool recreateLayout, ref bool saveMarkers); + internal interface IHandler + { + void RunIfCompatible(IQueueOnFrameworkThread queued, ref bool recreateLayout); + } + + internal abstract class Handler : IHandler + where T : IQueueOnFrameworkThread + { + protected readonly ILogger> _logger; + + protected Handler(ILogger> logger) + { + _logger = logger; + } + + protected abstract void Run(T queued, ref bool recreateLayout); + + public void RunIfCompatible(IQueueOnFrameworkThread queued, ref bool recreateLayout) + { + if (queued is T t) + { + _logger.LogDebug("Handling {QueuedType}", queued.GetType()); + Run(t, ref recreateLayout); + } + else + { + _logger.LogError("Could not use queue handler {QueuedType}", queued.GetType()); + } + } + } } } diff --git a/Pal.Client/Scheduled/QueuedConfigUpdate.cs b/Pal.Client/Scheduled/QueuedConfigUpdate.cs index d816ec1..9810f94 100644 --- a/Pal.Client/Scheduled/QueuedConfigUpdate.cs +++ b/Pal.Client/Scheduled/QueuedConfigUpdate.cs @@ -1,21 +1,30 @@ -namespace Pal.Client.Scheduled -{ - internal class QueuedConfigUpdate : IQueueOnFrameworkThread - { - public void Run(Plugin plugin, ref bool recreateLayout, ref bool saveMarkers) - { - if (Service.Configuration.Mode == Configuration.EMode.Offline) - { - LocalState.UpdateAll(); - plugin.FloorMarkers.Clear(); - plugin.EphemeralMarkers.Clear(); - plugin.LastTerritory = 0; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration; +using Pal.Client.DependencyInjection; +using Pal.Client.Floors; +using Pal.Client.Rendering; - recreateLayout = true; - saveMarkers = true; +namespace Pal.Client.Scheduled +{ + internal sealed class QueuedConfigUpdate : IQueueOnFrameworkThread + { + internal sealed class Handler : IQueueOnFrameworkThread.Handler + { + private readonly RenderAdapter _renderAdapter; + + public Handler( + ILogger logger, + RenderAdapter renderAdapter) + : base(logger) + { + _renderAdapter = renderAdapter; } - plugin.ResetRenderer(); + protected override void Run(QueuedConfigUpdate queued, ref bool recreateLayout) + { + // TODO filter stuff if offline + _renderAdapter.ConfigUpdated(); + } } } } diff --git a/Pal.Client/Scheduled/QueuedImport.cs b/Pal.Client/Scheduled/QueuedImport.cs index 890de80..8c07149 100644 --- a/Pal.Client/Scheduled/QueuedImport.cs +++ b/Pal.Client/Scheduled/QueuedImport.cs @@ -1,114 +1,122 @@ using Account; -using Dalamud.Logging; using Pal.Common; using System; -using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Numerics; -using Pal.Client.Extensions; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Database; +using Pal.Client.DependencyInjection; using Pal.Client.Properties; +using Pal.Client.Windows; namespace Pal.Client.Scheduled { - internal class QueuedImport : IQueueOnFrameworkThread + internal sealed class QueuedImport : IQueueOnFrameworkThread { - private readonly ExportRoot _export; - private Guid _exportId; - private int _importedTraps; - private int _importedHoardCoffers; + private ExportRoot Export { get; } + private Guid ExportId { get; set; } + private int ImportedTraps { get; set; } + private int ImportedHoardCoffers { get; set; } public QueuedImport(string sourcePath) { using var input = File.OpenRead(sourcePath); - _export = ExportRoot.Parser.ParseFrom(input); + Export = ExportRoot.Parser.ParseFrom(input); } - public void Run(Plugin plugin, ref bool recreateLayout, ref bool saveMarkers) + internal sealed class Handler : IQueueOnFrameworkThread.Handler { - try + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly Chat _chat; + private readonly ImportService _importService; + private readonly ConfigWindow _configWindow; + + public Handler( + ILogger logger, + IServiceScopeFactory serviceScopeFactory, + Chat chat, + ImportService importService, + ConfigWindow configWindow) + : base(logger) { - if (!Validate()) - return; - - var config = Service.Configuration; - var oldExportIds = string.IsNullOrEmpty(_export.ServerUrl) ? config.ImportHistory.Where(x => x.RemoteUrl == _export.ServerUrl).Select(x => x.Id).Where(x => x != Guid.Empty).ToList() : new List(); - - foreach (var remoteFloor in _export.Floors) - { - ushort territoryType = (ushort)remoteFloor.TerritoryType; - var localState = plugin.GetFloorMarkers(territoryType); - - localState.UndoImport(oldExportIds); - ImportFloor(remoteFloor, localState); - - localState.Save(); - } - - config.ImportHistory.RemoveAll(hist => oldExportIds.Contains(hist.Id) || hist.Id == _exportId); - config.ImportHistory.Add(new Configuration.ImportHistoryEntry - { - Id = _exportId, - RemoteUrl = _export.ServerUrl, - ExportedAt = _export.CreatedAt.ToDateTime(), - ImportedAt = DateTime.UtcNow, - }); - config.Save(); + _serviceScopeFactory = serviceScopeFactory; + _chat = chat; + _importService = importService; + _configWindow = configWindow; + } + protected override void Run(QueuedImport import, ref bool recreateLayout) + { recreateLayout = true; - saveMarkers = true; - Service.Chat.Print(string.Format(Localization.ImportCompleteStatistics, _importedTraps, _importedHoardCoffers)); - } - catch (Exception e) - { - PluginLog.Error(e, "Import failed"); - Service.Chat.PalError(string.Format(Localization.Error_ImportFailed, e)); - } - } - - private bool Validate() - { - if (_export.ExportVersion != ExportConfig.ExportVersion) - { - Service.Chat.PrintError(Localization.Error_ImportFailed_IncompatibleVersion); - return false; - } - - if (!Guid.TryParse(_export.ExportId, out _exportId) || _exportId == Guid.Empty) - { - Service.Chat.PrintError(Localization.Error_ImportFailed_InvalidFile); - return false; - } - - if (string.IsNullOrEmpty(_export.ServerUrl)) - { - // If we allow for backups as import/export, this should be removed - Service.Chat.PrintError(Localization.Error_ImportFailed_InvalidFile); - return false; - } - - return true; - } - - private void ImportFloor(ExportFloor remoteFloor, LocalState localState) - { - var remoteMarkers = remoteFloor.Objects.Select(m => new Marker((Marker.EType)m.Type, new Vector3(m.X, m.Y, m.Z)) { WasImported = true }); - foreach (var remoteMarker in remoteMarkers) - { - Marker? localMarker = localState.Markers.SingleOrDefault(x => x == remoteMarker); - if (localMarker == null) + try { - localState.Markers.Add(remoteMarker); - localMarker = remoteMarker; + if (!Validate(import)) + return; - if (localMarker.Type == Marker.EType.Trap) - _importedTraps++; - else if (localMarker.Type == Marker.EType.Hoard) - _importedHoardCoffers++; + Task.Run(() => + { + try + { + using (var scope = _serviceScopeFactory.CreateScope()) + { + using var dbContext = scope.ServiceProvider.GetRequiredService(); + (import.ImportedTraps, import.ImportedHoardCoffers) = + _importService.Import(import.Export); + } + + _configWindow.UpdateLastImport(); + + _logger.LogInformation( + "Imported {ExportId} for {Traps} traps, {Hoard} hoard coffers", import.ExportId, + import.ImportedTraps, import.ImportedHoardCoffers); + _chat.Message(string.Format(Localization.ImportCompleteStatistics, import.ImportedTraps, + import.ImportedHoardCoffers)); + } + catch (Exception e) + { + _logger.LogError(e, "Import failed in inner task"); + _chat.Error(string.Format(Localization.Error_ImportFailed, e)); + } + }); + } + catch (Exception e) + { + _logger.LogError(e, "Import failed"); + _chat.Error(string.Format(Localization.Error_ImportFailed, e)); + } + } + + private bool Validate(QueuedImport import) + { + if (import.Export.ExportVersion != ExportConfig.ExportVersion) + { + _logger.LogError( + "Import: Different version in export file, {ExportVersion} != {ConfiguredVersion}", + import.Export.ExportVersion, ExportConfig.ExportVersion); + _chat.Error(Localization.Error_ImportFailed_IncompatibleVersion); + return false; } - remoteMarker.Imports.Add(_exportId); + if (!Guid.TryParse(import.Export.ExportId, out Guid exportId) || exportId == Guid.Empty) + { + _logger.LogError("Import: Invalid export id '{Id}'", import.Export.ExportId); + _chat.Error(Localization.Error_ImportFailed_InvalidFile); + return false; + } + + import.ExportId = exportId; + + if (string.IsNullOrEmpty(import.Export.ServerUrl)) + { + // If we allow for backups as import/export, this should be removed + _logger.LogError("Import: No server URL"); + _chat.Error(Localization.Error_ImportFailed_InvalidFile); + return false; + } + + return true; } } } diff --git a/Pal.Client/Scheduled/QueuedSyncResponse.cs b/Pal.Client/Scheduled/QueuedSyncResponse.cs index 871ed47..519f5f1 100644 --- a/Pal.Client/Scheduled/QueuedSyncResponse.cs +++ b/Pal.Client/Scheduled/QueuedSyncResponse.cs @@ -1,85 +1,149 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static Pal.Client.Plugin; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration; +using Pal.Client.DependencyInjection; +using Pal.Client.Extensions; +using Pal.Client.Floors; +using Pal.Client.Floors.Tasks; +using Pal.Client.Net; +using Pal.Common; namespace Pal.Client.Scheduled { - internal class QueuedSyncResponse : IQueueOnFrameworkThread + internal sealed class QueuedSyncResponse : IQueueOnFrameworkThread { public required SyncType Type { get; init; } public required ushort TerritoryType { get; init; } public required bool Success { get; init; } - public required List Markers { get; init; } + public required IReadOnlyList Locations { get; init; } - public void Run(Plugin plugin, ref bool recreateLayout, ref bool saveMarkers) + internal sealed class Handler : IQueueOnFrameworkThread.Handler { - recreateLayout = true; - saveMarkers = true; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IPalacePalConfiguration _configuration; + private readonly FloorService _floorService; + private readonly TerritoryState _territoryState; + private readonly DebugState _debugState; - try + public Handler( + ILogger logger, + IServiceScopeFactory serviceScopeFactory, + IPalacePalConfiguration configuration, + FloorService floorService, + TerritoryState territoryState, + DebugState debugState) + : base(logger) { - var remoteMarkers = Markers; - var currentFloor = plugin.GetFloorMarkers(TerritoryType); - if (Service.Configuration.Mode == Configuration.EMode.Online && Success && remoteMarkers.Count > 0) + _serviceScopeFactory = serviceScopeFactory; + _configuration = configuration; + _floorService = floorService; + _territoryState = territoryState; + _debugState = debugState; + } + + protected override void Run(QueuedSyncResponse queued, ref bool recreateLayout) + { + recreateLayout = true; + + _logger.LogDebug( + "Sync response for territory {Territory} of type {Type}, success = {Success}, response objects = {Count}", + (ETerritoryType)queued.TerritoryType, queued.Type, queued.Success, queued.Locations.Count); + var memoryTerritory = _floorService.GetTerritoryIfReady(queued.TerritoryType); + if (memoryTerritory == null) { - switch (Type) + _logger.LogWarning("Discarding sync response for territory {Territory} as it isn't ready", + (ETerritoryType)queued.TerritoryType); + return; + } + + try + { + var remoteMarkers = queued.Locations; + if (_configuration.Mode == EMode.Online && queued.Success && remoteMarkers.Count > 0) { - case SyncType.Download: - case SyncType.Upload: - foreach (var remoteMarker in remoteMarkers) - { - // Both uploads and downloads return the network id to be set, but only the downloaded marker is new as in to-be-saved. - Marker? localMarker = currentFloor.Markers.SingleOrDefault(x => x == remoteMarker); - if (localMarker != null) + switch (queued.Type) + { + case SyncType.Download: + case SyncType.Upload: + List newLocations = new(); + foreach (var remoteMarker in remoteMarkers) { - localMarker.NetworkId = remoteMarker.NetworkId; - continue; + // Both uploads and downloads return the network id to be set, but only the downloaded marker is new as in to-be-saved. + PersistentLocation? localLocation = + memoryTerritory.Locations.SingleOrDefault(x => x == remoteMarker); + if (localLocation != null) + { + localLocation.NetworkId = remoteMarker.NetworkId; + continue; + } + + if (queued.Type == SyncType.Download) + { + memoryTerritory.Locations.Add(remoteMarker); + newLocations.Add(remoteMarker); + } } - if (Type == SyncType.Download) - currentFloor.Markers.Add(remoteMarker); - } - break; + if (newLocations.Count > 0) + new SaveNewLocations(_serviceScopeFactory, memoryTerritory, newLocations).Start(); - case SyncType.MarkSeen: - var partialAccountId = Service.RemoteApi.PartialAccountId; - if (partialAccountId == null) break; - foreach (var remoteMarker in remoteMarkers) - { - Marker? localMarker = currentFloor.Markers.SingleOrDefault(x => x == remoteMarker); - if (localMarker != null) - localMarker.RemoteSeenOn.Add(partialAccountId); - } - break; + + case SyncType.MarkSeen: + var partialAccountId = + _configuration.FindAccount(RemoteApi.RemoteUrl)?.AccountId.ToPartialId(); + if (partialAccountId == null) + break; + + List locationsToUpdate = new(); + foreach (var remoteMarker in remoteMarkers) + { + PersistentLocation? localLocation = + memoryTerritory.Locations.SingleOrDefault(x => x == remoteMarker); + if (localLocation != null) + { + localLocation.RemoteSeenOn.Add(partialAccountId); + locationsToUpdate.Add(localLocation); + } + } + + if (locationsToUpdate.Count > 0) + { + new MarkRemoteSeen(_serviceScopeFactory, memoryTerritory, locationsToUpdate, + partialAccountId).Start(); + } + + break; + } + } + + // don't modify state for outdated floors + if (_territoryState.LastTerritory != queued.TerritoryType) + return; + + if (queued.Type == SyncType.Download) + { + if (queued.Success) + memoryTerritory.SyncState = ESyncState.Complete; + else + memoryTerritory.SyncState = ESyncState.Failed; } } - - // don't modify state for outdated floors - if (plugin.LastTerritory != TerritoryType) - return; - - if (Type == SyncType.Download) + catch (Exception e) { - if (Success) - plugin.TerritorySyncState = SyncState.Complete; - else - plugin.TerritorySyncState = SyncState.Failed; + _logger.LogError(e, "Sync failed for territory {Territory}", (ETerritoryType)queued.TerritoryType); + _debugState.SetFromException(e); + if (queued.Type == SyncType.Download) + memoryTerritory.SyncState = ESyncState.Failed; } } - catch (Exception e) - { - plugin.DebugMessage = $"{DateTime.Now}\n{e}"; - if (Type == SyncType.Download) - plugin.TerritorySyncState = SyncState.Failed; - } } } - public enum SyncState + public enum ESyncState { NotAttempted, NotNeeded, diff --git a/Pal.Client/Scheduled/QueuedUndoImport.cs b/Pal.Client/Scheduled/QueuedUndoImport.cs index 1c0163e..f5b0d3c 100644 --- a/Pal.Client/Scheduled/QueuedUndoImport.cs +++ b/Pal.Client/Scheduled/QueuedUndoImport.cs @@ -1,35 +1,42 @@ -using ECommons.Configuration; -using Pal.Common; -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Pal.Client.Configuration; +using Pal.Client.DependencyInjection; +using Pal.Client.Floors; +using Pal.Client.Windows; +using Pal.Common; namespace Pal.Client.Scheduled { - internal class QueuedUndoImport : IQueueOnFrameworkThread + internal sealed class QueuedUndoImport : IQueueOnFrameworkThread { - private readonly Guid _exportId; - public QueuedUndoImport(Guid exportId) { - _exportId = exportId; + ExportId = exportId; } - public void Run(Plugin plugin, ref bool recreateLayout, ref bool saveMarkers) - { - recreateLayout = true; - saveMarkers = true; + private Guid ExportId { get; } - foreach (ETerritoryType territoryType in typeof(ETerritoryType).GetEnumValues()) + internal sealed class Handler : IQueueOnFrameworkThread.Handler + { + private readonly ImportService _importService; + private readonly ConfigWindow _configWindow; + + public Handler(ILogger logger, ImportService importService, ConfigWindow configWindow) + : base(logger) { - var localState = plugin.GetFloorMarkers((ushort)territoryType); - localState.UndoImport(new List { _exportId }); - localState.Save(); + _importService = importService; + _configWindow = configWindow; } - Service.Configuration.ImportHistory.RemoveAll(hist => hist.Id == _exportId); + protected override void Run(QueuedUndoImport queued, ref bool recreateLayout) + { + recreateLayout = true; + + _importService.RemoveById(queued.ExportId); + _configWindow.UpdateLastImport(); + } } } } diff --git a/Pal.Client/Service.cs b/Pal.Client/Service.cs deleted file mode 100644 index 0b0d90d..0000000 --- a/Pal.Client/Service.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Dalamud.Data; -using Dalamud.Game; -using Dalamud.Game.ClientState; -using Dalamud.Game.ClientState.Conditions; -using Dalamud.Game.ClientState.Objects; -using Dalamud.Game.Command; -using Dalamud.Game.Gui; -using Dalamud.Interface.Windowing; -using Dalamud.IoC; -using Dalamud.Plugin; -using Pal.Client.Net; - -namespace Pal.Client -{ - public class Service - { - [PluginService] public static DalamudPluginInterface PluginInterface { get; private set; } = null!; - [PluginService] public static ClientState ClientState { get; set; } = null!; - [PluginService] public static ChatGui Chat { get; private set; } = null!; - [PluginService] public static ObjectTable ObjectTable { get; private set; } = null!; - [PluginService] public static Framework Framework { get; set; } = null!; - [PluginService] public static Condition Condition { get; set; } = null!; - [PluginService] public static CommandManager CommandManager { get; set; } = null!; - [PluginService] public static DataManager DataManager { get; set; } = null!; - [PluginService] public static GameGui GameGui { get; set; } = null!; - - internal static Plugin Plugin { get; set; } = null!; - internal static WindowSystem WindowSystem { get; } = new(typeof(Service).AssemblyQualifiedName); - internal static RemoteApi RemoteApi { get; } = new(); - internal static Configuration Configuration { get; set; } = null!; - internal static Hooks Hooks { get; set; } = null!; - } -} diff --git a/Pal.Client/Windows/AgreementWindow.cs b/Pal.Client/Windows/AgreementWindow.cs index 377ac64..6442805 100644 --- a/Pal.Client/Windows/AgreementWindow.cs +++ b/Pal.Client/Windows/AgreementWindow.cs @@ -1,20 +1,33 @@ -using Dalamud.Interface.Colors; +using System; +using Dalamud.Interface.Colors; using Dalamud.Interface.Windowing; using ECommons; using ImGuiNET; using System.Numerics; +using Pal.Client.Configuration; using Pal.Client.Extensions; using Pal.Client.Properties; namespace Pal.Client.Windows { - internal class AgreementWindow : Window, ILanguageChanged + internal sealed class AgreementWindow : Window, IDisposable, ILanguageChanged { private const string WindowId = "###PalPalaceAgreement"; + private readonly WindowSystem _windowSystem; + private readonly ConfigurationManager _configurationManager; + private readonly IPalacePalConfiguration _configuration; private int _choice; - public AgreementWindow() : base(WindowId) + public AgreementWindow( + WindowSystem windowSystem, + ConfigurationManager configurationManager, + IPalacePalConfiguration configuration) + : base(WindowId) { + _windowSystem = windowSystem; + _configurationManager = configurationManager; + _configuration = configuration; + LanguageChanged(); Flags = ImGuiWindowFlags.NoCollapse; @@ -28,8 +41,14 @@ namespace Pal.Client.Windows MinimumSize = new Vector2(500, 500), MaximumSize = new Vector2(2000, 2000), }; + + IsOpen = configuration.FirstUse; + _windowSystem.AddWindow(this); } + public void Dispose() + => _windowSystem.RemoveWindow(this); + public void LanguageChanged() => WindowName = $"{Localization.Palace_Pal}{WindowId}"; @@ -40,8 +59,6 @@ namespace Pal.Client.Windows public override void Draw() { - var config = Service.Configuration; - ImGui.TextWrapped(Localization.Explanation_1); ImGui.TextWrapped(Localization.Explanation_2); @@ -50,8 +67,10 @@ namespace Pal.Client.Windows ImGui.TextWrapped(Localization.Explanation_3); ImGui.TextWrapped(Localization.Explanation_4); - PalImGui.RadioButtonWrapped(Localization.Config_UploadMyDiscoveries_ShowOtherTraps, ref _choice, (int)Configuration.EMode.Online); - PalImGui.RadioButtonWrapped(Localization.Config_NeverUploadDiscoveries_ShowMyTraps, ref _choice, (int)Configuration.EMode.Offline); + PalImGui.RadioButtonWrapped(Localization.Config_UploadMyDiscoveries_ShowOtherTraps, ref _choice, + (int)EMode.Online); + PalImGui.RadioButtonWrapped(Localization.Config_NeverUploadDiscoveries_ShowMyTraps, ref _choice, + (int)EMode.Offline); ImGui.Separator(); @@ -68,12 +87,13 @@ namespace Pal.Client.Windows ImGui.BeginDisabled(_choice == -1); if (ImGui.Button(Localization.Agreement_UsingThisOnMyOwnRisk)) { - config.Mode = (Configuration.EMode)_choice; - config.FirstUse = false; - config.Save(); + _configuration.Mode = (EMode)_choice; + _configuration.FirstUse = false; + _configurationManager.Save(_configuration); IsOpen = false; } + ImGui.EndDisabled(); ImGui.Separator(); diff --git a/Pal.Client/Windows/ConfigWindow.cs b/Pal.Client/Windows/ConfigWindow.cs index dbf0623..c3b279b 100644 --- a/Pal.Client/Windows/ConfigWindow.cs +++ b/Pal.Client/Windows/ConfigWindow.cs @@ -3,7 +3,6 @@ using Dalamud.Interface; using Dalamud.Interface.Components; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Windowing; -using Dalamud.Logging; using ECommons; using Google.Protobuf; using ImGuiNET; @@ -14,29 +13,40 @@ using System; using System.IO; using System.Linq; using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Pal.Client.Extensions; using Pal.Client.Properties; +using Pal.Client.Configuration; +using Pal.Client.Database; +using Pal.Client.DependencyInjection; +using Pal.Client.Floors; namespace Pal.Client.Windows { - internal class ConfigWindow : Window, ILanguageChanged + internal sealed class ConfigWindow : Window, ILanguageChanged, IDisposable { private const string WindowId = "###PalPalaceConfig"; + + private readonly ILogger _logger; + private readonly WindowSystem _windowSystem; + private readonly ConfigurationManager _configurationManager; + private readonly IPalacePalConfiguration _configuration; + private readonly RenderAdapter _renderAdapter; + private readonly TerritoryState _territoryState; + private readonly FrameworkService _frameworkService; + private readonly FloorService _floorService; + private readonly DebugState _debugState; + private readonly Chat _chat; + private readonly RemoteApi _remoteApi; + private readonly ImportService _importService; + private int _mode; private int _renderer; - private bool _showTraps; - private Vector4 _trapColor; - private bool _onlyVisibleTrapsAfterPomander; - private bool _showHoard; - private Vector4 _hoardColor; - private bool _onlyVisibleHoardAfterPomander; - private bool _showSilverCoffers; - private Vector4 _silverCofferColor; - private bool _fillSilverCoffers; + private ConfigurableMarker _trapConfig = new(); + private ConfigurableMarker _hoardConfig = new(); + private ConfigurableMarker _silverConfig = new(); private string? _connectionText; private bool _switchToCommunityTab; @@ -46,11 +56,39 @@ namespace Pal.Client.Windows private string? _saveExportDialogStartPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); private readonly FileDialogManager _importDialog; private readonly FileDialogManager _exportDialog; + private ImportHistory? _lastImport; private CancellationTokenSource? _testConnectionCts; + private CancellationTokenSource? _lastImportCts; - public ConfigWindow() : base(WindowId) + public ConfigWindow( + ILogger logger, + WindowSystem windowSystem, + ConfigurationManager configurationManager, + IPalacePalConfiguration configuration, + RenderAdapter renderAdapter, + TerritoryState territoryState, + FrameworkService frameworkService, + FloorService floorService, + DebugState debugState, + Chat chat, + RemoteApi remoteApi, + ImportService importService) + : base(WindowId) { + _logger = logger; + _windowSystem = windowSystem; + _configurationManager = configurationManager; + _configuration = configuration; + _renderAdapter = renderAdapter; + _territoryState = territoryState; + _frameworkService = frameworkService; + _floorService = floorService; + _debugState = debugState; + _chat = chat; + _remoteApi = remoteApi; + _importService = importService; + LanguageChanged(); Size = new Vector2(500, 400); @@ -58,8 +96,19 @@ namespace Pal.Client.Windows Position = new Vector2(300, 300); PositionCondition = ImGuiCond.FirstUseEver; - _importDialog = new FileDialogManager { AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking }; - _exportDialog = new FileDialogManager { AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking }; + _importDialog = new FileDialogManager + { AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking }; + _exportDialog = new FileDialogManager + { AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking }; + + _windowSystem.AddWindow(this); + } + + public void Dispose() + { + _windowSystem.RemoveWindow(this); + _lastImportCts?.Cancel(); + _testConnectionCts?.Cancel(); } public void LanguageChanged() @@ -70,19 +119,14 @@ namespace Pal.Client.Windows public override void OnOpen() { - var config = Service.Configuration; - _mode = (int)config.Mode; - _renderer = (int)config.Renderer; - _showTraps = config.ShowTraps; - _trapColor = config.TrapColor; - _onlyVisibleTrapsAfterPomander = config.OnlyVisibleTrapsAfterPomander; - _showHoard = config.ShowHoard; - _hoardColor = config.HoardColor; - _onlyVisibleHoardAfterPomander = config.OnlyVisibleHoardAfterPomander; - _showSilverCoffers = config.ShowSilverCoffers; - _silverCofferColor = config.SilverCofferColor; - _fillSilverCoffers = config.FillSilverCoffers; + _mode = (int)_configuration.Mode; + _renderer = (int)_configuration.Renderer.SelectedRenderer; + _trapConfig = new ConfigurableMarker(_configuration.DeepDungeons.Traps); + _hoardConfig = new ConfigurableMarker(_configuration.DeepDungeons.HoardCoffers); + _silverConfig = new ConfigurableMarker(_configuration.DeepDungeons.SilverCoffers); _connectionText = null; + + UpdateLastImport(); } public override void OnClose() @@ -113,19 +157,13 @@ namespace Pal.Client.Windows if (save || saveAndClose) { - var config = Service.Configuration; - config.Mode = (Configuration.EMode)_mode; - config.Renderer = (Configuration.ERenderer)_renderer; - config.ShowTraps = _showTraps; - config.TrapColor = _trapColor; - config.OnlyVisibleTrapsAfterPomander = _onlyVisibleTrapsAfterPomander; - config.ShowHoard = _showHoard; - config.HoardColor = _hoardColor; - config.OnlyVisibleHoardAfterPomander = _onlyVisibleHoardAfterPomander; - config.ShowSilverCoffers = _showSilverCoffers; - config.SilverCofferColor = _silverCofferColor; - config.FillSilverCoffers = _fillSilverCoffers; - config.Save(); + _configuration.Mode = (EMode)_mode; + _configuration.Renderer.SelectedRenderer = (ERenderer)_renderer; + _configuration.DeepDungeons.Traps = _trapConfig.Build(); + _configuration.DeepDungeons.HoardCoffers = _hoardConfig.Build(); + _configuration.DeepDungeons.SilverCoffers = _silverConfig.Build(); + + _configurationManager.Save(_configuration); if (saveAndClose) IsOpen = false; @@ -136,12 +174,12 @@ namespace Pal.Client.Windows { if (ImGui.BeginTabItem($"{Localization.ConfigTab_DeepDungeons}###TabDeepDungeons")) { - ImGui.Checkbox(Localization.Config_Traps_Show, ref _showTraps); + ImGui.Checkbox(Localization.Config_Traps_Show, ref _trapConfig.Show); ImGui.Indent(); - ImGui.BeginDisabled(!_showTraps); + ImGui.BeginDisabled(!_trapConfig.Show); ImGui.Spacing(); - ImGui.ColorEdit4(Localization.Config_Traps_Color, ref _trapColor, ImGuiColorEditFlags.NoInputs); - ImGui.Checkbox(Localization.Config_Traps_HideImpossible, ref _onlyVisibleTrapsAfterPomander); + ImGui.ColorEdit4(Localization.Config_Traps_Color, ref _trapConfig.Color, ImGuiColorEditFlags.NoInputs); + ImGui.Checkbox(Localization.Config_Traps_HideImpossible, ref _trapConfig.OnlyVisibleAfterPomander); ImGui.SameLine(); ImGuiComponents.HelpMarker(Localization.Config_Traps_HideImpossible_ToolTip); ImGui.EndDisabled(); @@ -149,12 +187,14 @@ namespace Pal.Client.Windows ImGui.Separator(); - ImGui.Checkbox(Localization.Config_HoardCoffers_Show, ref _showHoard); + ImGui.Checkbox(Localization.Config_HoardCoffers_Show, ref _hoardConfig.Show); ImGui.Indent(); - ImGui.BeginDisabled(!_showHoard); + ImGui.BeginDisabled(!_hoardConfig.Show); ImGui.Spacing(); - ImGui.ColorEdit4(Localization.Config_HoardCoffers_Color, ref _hoardColor, ImGuiColorEditFlags.NoInputs); - ImGui.Checkbox(Localization.Config_HoardCoffers_HideImpossible, ref _onlyVisibleHoardAfterPomander); + ImGui.ColorEdit4(Localization.Config_HoardCoffers_Color, ref _hoardConfig.Color, + ImGuiColorEditFlags.NoInputs); + ImGui.Checkbox(Localization.Config_HoardCoffers_HideImpossible, + ref _hoardConfig.OnlyVisibleAfterPomander); ImGui.SameLine(); ImGuiComponents.HelpMarker(Localization.Config_HoardCoffers_HideImpossible_ToolTip); ImGui.EndDisabled(); @@ -162,13 +202,14 @@ namespace Pal.Client.Windows ImGui.Separator(); - ImGui.Checkbox(Localization.Config_SilverCoffer_Show, ref _showSilverCoffers); + ImGui.Checkbox(Localization.Config_SilverCoffer_Show, ref _silverConfig.Show); ImGuiComponents.HelpMarker(Localization.Config_SilverCoffers_ToolTip); ImGui.Indent(); - ImGui.BeginDisabled(!_showSilverCoffers); + ImGui.BeginDisabled(!_silverConfig.Show); ImGui.Spacing(); - ImGui.ColorEdit4(Localization.Config_SilverCoffer_Color, ref _silverCofferColor, ImGuiColorEditFlags.NoInputs); - ImGui.Checkbox(Localization.Config_SilverCoffer_Filled, ref _fillSilverCoffers); + ImGui.ColorEdit4(Localization.Config_SilverCoffer_Color, ref _silverConfig.Color, + ImGuiColorEditFlags.NoInputs); + ImGui.Checkbox(Localization.Config_SilverCoffer_Filled, ref _silverConfig.Fill); ImGui.EndDisabled(); ImGui.Unindent(); @@ -184,20 +225,23 @@ namespace Pal.Client.Windows private void DrawCommunityTab(ref bool saveAndClose) { - if (PalImGui.BeginTabItemWithFlags($"{Localization.ConfigTab_Community}###TabCommunity", _switchToCommunityTab ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None)) + if (PalImGui.BeginTabItemWithFlags($"{Localization.ConfigTab_Community}###TabCommunity", + _switchToCommunityTab ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None)) { _switchToCommunityTab = false; ImGui.TextWrapped(Localization.Explanation_3); ImGui.TextWrapped(Localization.Explanation_4); - PalImGui.RadioButtonWrapped(Localization.Config_UploadMyDiscoveries_ShowOtherTraps, ref _mode, (int)Configuration.EMode.Online); - PalImGui.RadioButtonWrapped(Localization.Config_NeverUploadDiscoveries_ShowMyTraps, ref _mode, (int)Configuration.EMode.Offline); + PalImGui.RadioButtonWrapped(Localization.Config_UploadMyDiscoveries_ShowOtherTraps, ref _mode, + (int)EMode.Online); + PalImGui.RadioButtonWrapped(Localization.Config_NeverUploadDiscoveries_ShowMyTraps, ref _mode, + (int)EMode.Offline); saveAndClose = ImGui.Button(Localization.SaveAndClose); ImGui.Separator(); - ImGui.BeginDisabled(Service.Configuration.Mode != Configuration.EMode.Online); + ImGui.BeginDisabled(_configuration.Mode != EMode.Online); if (ImGui.Button(Localization.Config_TestConnection)) TestConnection(); @@ -217,7 +261,8 @@ namespace Pal.Client.Windows ImGui.TextWrapped(Localization.Config_ImportExplanation2); ImGui.TextWrapped(Localization.Config_ImportExplanation3); ImGui.Separator(); - ImGui.TextWrapped(string.Format(Localization.Config_ImportDownloadLocation, "https://github.com/carvelli/PalacePal/releases/")); + ImGui.TextWrapped(string.Format(Localization.Config_ImportDownloadLocation, + "https://github.com/carvelli/PalacePal/releases/")); if (ImGui.Button(Localization.Config_Import_VisitGitHub)) GenericHelpers.ShellStart("https://github.com/carvelli/PalacePal/releases/latest"); ImGui.Separator(); @@ -227,29 +272,37 @@ namespace Pal.Client.Windows ImGui.SameLine(); if (ImGuiComponents.IconButton(FontAwesomeIcon.Search)) { - _importDialog.OpenFileDialog(Localization.Palace_Pal, $"{Localization.Palace_Pal} (*.pal) {{.pal}}", (success, paths) => - { - if (success && paths.Count == 1) + _importDialog.OpenFileDialog(Localization.Palace_Pal, $"{Localization.Palace_Pal} (*.pal) {{.pal}}", + (success, paths) => { - _openImportPath = paths.First(); - } - }, selectionCountMax: 1, startPath: _openImportDialogStartPath, isModal: false); - _openImportDialogStartPath = null; // only use this once, FileDialogManager will save path between calls + if (success && paths.Count == 1) + { + _openImportPath = paths.First(); + } + }, selectionCountMax: 1, startPath: _openImportDialogStartPath, isModal: false); + _openImportDialogStartPath = + null; // only use this once, FileDialogManager will save path between calls } - ImGui.BeginDisabled(string.IsNullOrEmpty(_openImportPath) || !File.Exists(_openImportPath)); + ImGui.BeginDisabled(string.IsNullOrEmpty(_openImportPath) || !File.Exists(_openImportPath) || _floorService.IsImportRunning); if (ImGui.Button(Localization.Config_StartImport)) DoImport(_openImportPath); ImGui.EndDisabled(); - var importHistory = Service.Configuration.ImportHistory.OrderByDescending(x => x.ImportedAt).ThenBy(x => x.Id).FirstOrDefault(); + ImportHistory? importHistory = _lastImport; if (importHistory != null) { ImGui.Separator(); - ImGui.TextWrapped(string.Format(Localization.Config_UndoImportExplanation1, importHistory.ImportedAt, importHistory.RemoteUrl, importHistory.ExportedAt)); + ImGui.TextWrapped(string.Format(Localization.Config_UndoImportExplanation1, + importHistory.ImportedAt.ToLocalTime(), + importHistory.RemoteUrl, + importHistory.ExportedAt.ToUniversalTime())); ImGui.TextWrapped(Localization.Config_UndoImportExplanation2); + + ImGui.BeginDisabled(_floorService.IsImportRunning); if (ImGui.Button(Localization.Config_UndoImport)) UndoImport(importHistory.Id); + ImGui.EndDisabled(); } ImGui.EndTabItem(); @@ -258,7 +311,8 @@ namespace Pal.Client.Windows private void DrawExportTab() { - if (Service.RemoteApi.HasRoleOnCurrentServer("export:run") && ImGui.BeginTabItem($"{Localization.ConfigTab_Export}###TabExport")) + if (_configuration.HasRoleOnCurrentServer(RemoteApi.RemoteUrl, "export:run") && + ImGui.BeginTabItem($"{Localization.ConfigTab_Export}###TabExport")) { string todaysFileName = $"export-{DateTime.Today:yyyy-MM-dd}.pal"; if (string.IsNullOrEmpty(_saveExportPath) && !string.IsNullOrEmpty(_saveExportDialogStartPath)) @@ -271,14 +325,16 @@ namespace Pal.Client.Windows ImGui.SameLine(); if (ImGuiComponents.IconButton(FontAwesomeIcon.Search)) { - _importDialog.SaveFileDialog(Localization.Palace_Pal, $"{Localization.Palace_Pal} (*.pal) {{.pal}}", todaysFileName, "pal", (success, path) => - { - if (success && !string.IsNullOrEmpty(path)) + _importDialog.SaveFileDialog(Localization.Palace_Pal, $"{Localization.Palace_Pal} (*.pal) {{.pal}}", + todaysFileName, "pal", (success, path) => { - _saveExportPath = path; - } - }, startPath: _saveExportDialogStartPath, isModal: false); - _saveExportDialogStartPath = null; // only use this once, FileDialogManager will save path between calls + if (success && !string.IsNullOrEmpty(path)) + { + _saveExportPath = path; + } + }, startPath: _saveExportDialogStartPath, isModal: false); + _saveExportDialogStartPath = + null; // only use this once, FileDialogManager will save path between calls } ImGui.BeginDisabled(string.IsNullOrEmpty(_saveExportPath) || File.Exists(_saveExportPath)); @@ -295,8 +351,11 @@ namespace Pal.Client.Windows if (ImGui.BeginTabItem($"{Localization.ConfigTab_Renderer}###TabRenderer")) { ImGui.Text(Localization.Config_SelectRenderBackend); - ImGui.RadioButton($"{Localization.Config_Renderer_Splatoon} ({Localization.Config_Renderer_Splatoon_Hint})", ref _renderer, (int)Configuration.ERenderer.Splatoon); - ImGui.RadioButton($"{Localization.Config_Renderer_Simple} ({Localization.Config_Renderer_Simple_Hint})", ref _renderer, (int)Configuration.ERenderer.Simple); + ImGui.RadioButton( + $"{Localization.Config_Renderer_Splatoon} ({Localization.Config_Renderer_Splatoon_Hint})", + ref _renderer, (int)ERenderer.Splatoon); + ImGui.RadioButton($"{Localization.Config_Renderer_Simple} ({Localization.Config_Renderer_Simple_Hint})", + ref _renderer, (int)ERenderer.Simple); ImGui.Separator(); @@ -305,11 +364,9 @@ namespace Pal.Client.Windows saveAndClose = ImGui.Button(Localization.SaveAndClose); ImGui.Separator(); - ImGui.Text(Localization.Config_Splatoon_Test); - ImGui.BeginDisabled(!(Service.Plugin.Renderer is IDrawDebugItems)); if (ImGui.Button(Localization.Config_Splatoon_DrawCircles)) - (Service.Plugin.Renderer as IDrawDebugItems)?.DrawDebugItems(_trapColor, _hoardColor); - ImGui.EndDisabled(); + _renderAdapter.DrawDebugItems(ImGui.ColorConvertFloat4ToU32(_trapConfig.Color), + ImGui.ColorConvertFloat4ToU32(_hoardConfig.Color)); ImGui.EndTabItem(); } @@ -319,39 +376,47 @@ namespace Pal.Client.Windows { if (ImGui.BeginTabItem($"{Localization.ConfigTab_Debug}###TabDebug")) { - var plugin = Service.Plugin; - if (plugin.IsInDeepDungeon()) + if (_territoryState.IsInDeepDungeon()) { - ImGui.Text($"You are in a deep dungeon, territory type {plugin.LastTerritory}."); - ImGui.Text($"Sync State = {plugin.TerritorySyncState}"); - ImGui.Text($"{plugin.DebugMessage}"); + MemoryTerritory? memoryTerritory = _floorService.GetTerritoryIfReady(_territoryState.LastTerritory); + ImGui.Text($"You are in a deep dungeon, territory type {_territoryState.LastTerritory}."); + ImGui.Text($"Sync State = {memoryTerritory?.SyncState.ToString() ?? "Unknown"}"); + ImGui.Text($"{_debugState.DebugMessage}"); ImGui.Indent(); - if (plugin.FloorMarkers.TryGetValue(plugin.LastTerritory, out var currentFloor)) + if (memoryTerritory != null) { - if (_showTraps) + if (_trapConfig.Show) { - int traps = currentFloor.Markers.Count(x => x.Type == Marker.EType.Trap); + int traps = memoryTerritory.Locations.Count(x => x.Type == MemoryLocation.EType.Trap); ImGui.Text($"{traps} known trap{(traps == 1 ? "" : "s")}"); } - if (_showHoard) + + if (_hoardConfig.Show) { - int hoardCoffers = currentFloor.Markers.Count(x => x.Type == Marker.EType.Hoard); + int hoardCoffers = + memoryTerritory.Locations.Count(x => x.Type == MemoryLocation.EType.Hoard); ImGui.Text($"{hoardCoffers} known hoard coffer{(hoardCoffers == 1 ? "" : "s")}"); } - if (_showSilverCoffers) + + if (_silverConfig.Show) { - int silverCoffers = plugin.EphemeralMarkers.Count(x => x.Type == Marker.EType.SilverCoffer); - ImGui.Text($"{silverCoffers} silver coffer{(silverCoffers == 1 ? "" : "s")} visible on current floor"); + int silverCoffers = + _floorService.EphemeralLocations.Count(x => + x.Type == MemoryLocation.EType.SilverCoffer); + ImGui.Text( + $"{silverCoffers} silver coffer{(silverCoffers == 1 ? "" : "s")} visible on current floor"); } - ImGui.Text($"Pomander of Sight: {plugin.PomanderOfSight}"); - ImGui.Text($"Pomander of Intuition: {plugin.PomanderOfIntuition}"); + ImGui.Text($"Pomander of Sight: {_territoryState.PomanderOfSight}"); + ImGui.Text($"Pomander of Intuition: {_territoryState.PomanderOfIntuition}"); } else ImGui.Text("Could not query current trap/coffer count."); + ImGui.Unindent(); - ImGui.TextWrapped("Traps and coffers may not be discovered even after using a pomander if they're far away (around 1,5-2 rooms)."); + ImGui.TextWrapped( + "Traps and coffers may not be discovered even after using a pomander if they're far away (around 1,5-2 rooms)."); } else ImGui.Text(Localization.Config_Debug_NotInADeepDungeon); @@ -375,29 +440,39 @@ namespace Pal.Client.Windows try { - _connectionText = await Service.RemoteApi.VerifyConnection(cts.Token); + _connectionText = await _remoteApi.VerifyConnection(cts.Token); } catch (Exception e) { if (cts == _testConnectionCts) { - PluginLog.Error(e, "Could not establish remote connection"); + _logger.LogError(e, "Could not establish remote connection"); _connectionText = e.ToString(); } else - PluginLog.Warning(e, "Could not establish a remote connection, but user also clicked 'test connection' again so not updating UI"); + _logger.LogWarning(e, + "Could not establish a remote connection, but user also clicked 'test connection' again so not updating UI"); } }); } private void DoImport(string sourcePath) { - Service.Plugin.EarlyEventQueue.Enqueue(new QueuedImport(sourcePath)); + _frameworkService.EarlyEventQueue.Enqueue(new QueuedImport(sourcePath)); } private void UndoImport(Guid importId) { - Service.Plugin.EarlyEventQueue.Enqueue(new QueuedUndoImport(importId)); + _frameworkService.EarlyEventQueue.Enqueue(new QueuedUndoImport(importId)); + } + + internal void UpdateLastImport() + { + _lastImportCts?.Cancel(); + CancellationTokenSource cts = new CancellationTokenSource(); + _lastImportCts = cts; + + Task.Run(async () => { _lastImport = await _importService.FindLast(cts.Token); }, cts.Token); } private void DoExport(string destinationPath) @@ -406,25 +481,56 @@ namespace Pal.Client.Windows { try { - (bool success, ExportRoot export) = await Service.RemoteApi.DoExport(); + (bool success, ExportRoot export) = await _remoteApi.DoExport(); if (success) { await using var output = File.Create(destinationPath); export.WriteTo(output); - Service.Chat.Print($"Export saved as {destinationPath}."); + _chat.Message($"Export saved as {destinationPath}."); } else { - Service.Chat.PrintError("Export failed due to server error."); + _chat.Error("Export failed due to server error."); } } catch (Exception e) { - PluginLog.Error(e, "Export failed"); - Service.Chat.PrintError($"Export failed: {e}"); + _logger.LogError(e, "Export failed"); + _chat.Error($"Export failed: {e}"); } }); } + + private sealed class ConfigurableMarker + { + public bool Show; + public Vector4 Color; + public bool OnlyVisibleAfterPomander; + public bool Fill; + + public ConfigurableMarker() + { + } + + public ConfigurableMarker(MarkerConfiguration config) + { + Show = config.Show; + Color = ImGui.ColorConvertU32ToFloat4(config.Color); + OnlyVisibleAfterPomander = config.OnlyVisibleAfterPomander; + Fill = config.Fill; + } + + public MarkerConfiguration Build() + { + return new MarkerConfiguration + { + Show = Show, + Color = ImGui.ColorConvertFloat4ToU32(Color), + OnlyVisibleAfterPomander = OnlyVisibleAfterPomander, + Fill = Fill + }; + } + } } } diff --git a/Pal.Client/Windows/StatisticsWindow.cs b/Pal.Client/Windows/StatisticsWindow.cs index 03eabdc..05da234 100644 --- a/Pal.Client/Windows/StatisticsWindow.cs +++ b/Pal.Client/Windows/StatisticsWindow.cs @@ -7,19 +7,20 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; using Pal.Client.Properties; -using System.ComponentModel.DataAnnotations; -using System.Runtime.CompilerServices; -using System.Reflection; namespace Pal.Client.Windows { - internal class StatisticsWindow : Window, ILanguageChanged + internal sealed class StatisticsWindow : Window, IDisposable, ILanguageChanged { private const string WindowId = "###PalacePalStats"; + private readonly WindowSystem _windowSystem; private readonly SortedDictionary _territoryStatistics = new(); - public StatisticsWindow() : base(WindowId) + public StatisticsWindow(WindowSystem windowSystem) + : base(WindowId) { + _windowSystem = windowSystem; + LanguageChanged(); Size = new Vector2(500, 500); @@ -30,8 +31,13 @@ namespace Pal.Client.Windows { _territoryStatistics[territory] = new TerritoryStatistics(territory.ToString()); } + + _windowSystem.AddWindow(this); } + public void Dispose() + => _windowSystem.RemoveWindow(this); + public void LanguageChanged() => WindowName = $"{Localization.Palace_Pal} - {Localization.Statistics}{WindowId}"; @@ -39,8 +45,10 @@ namespace Pal.Client.Windows { if (ImGui.BeginTabBar("Tabs")) { - DrawDungeonStats("Palace of the Dead", Localization.PalaceOfTheDead, ETerritoryType.Palace_1_10, ETerritoryType.Palace_191_200); - DrawDungeonStats("Heaven on High", Localization.HeavenOnHigh, ETerritoryType.HeavenOnHigh_1_10, ETerritoryType.HeavenOnHigh_91_100); + DrawDungeonStats("Palace of the Dead", Localization.PalaceOfTheDead, ETerritoryType.Palace_1_10, + ETerritoryType.Palace_191_200); + DrawDungeonStats("Heaven on High", Localization.HeavenOnHigh, ETerritoryType.HeavenOnHigh_1_10, + ETerritoryType.HeavenOnHigh_91_100); } } @@ -48,7 +56,8 @@ namespace Pal.Client.Windows { if (ImGui.BeginTabItem($"{name}###{id}")) { - if (ImGui.BeginTable($"TrapHoardStatistics{id}", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.Resizable)) + if (ImGui.BeginTable($"TrapHoardStatistics{id}", 4, + ImGuiTableFlags.Borders | ImGuiTableFlags.Resizable)) { ImGui.TableSetupColumn(Localization.Statistics_TerritoryId); ImGui.TableSetupColumn(Localization.Statistics_InstanceName); @@ -56,7 +65,9 @@ namespace Pal.Client.Windows ImGui.TableSetupColumn(Localization.Statistics_HoardCoffers); ImGui.TableHeadersRow(); - foreach (var (territoryType, stats) in _territoryStatistics.Where(x => x.Key >= minTerritory && x.Key <= maxTerritory).OrderBy(x => x.Key.GetOrder() ?? (int)x.Key)) + foreach (var (territoryType, stats) in _territoryStatistics + .Where(x => x.Key >= minTerritory && x.Key <= maxTerritory) + .OrderBy(x => x.Key.GetOrder() ?? (int)x.Key)) { ImGui.TableNextRow(); if (ImGui.TableNextColumn()) @@ -71,8 +82,10 @@ namespace Pal.Client.Windows if (ImGui.TableNextColumn()) ImGui.Text(stats.HoardCofferCount?.ToString() ?? "-"); } + ImGui.EndTable(); } + ImGui.EndTabItem(); } } @@ -87,7 +100,8 @@ namespace Pal.Client.Windows foreach (var floor in floorStatistics) { - if (_territoryStatistics.TryGetValue((ETerritoryType)floor.TerritoryType, out TerritoryStatistics? territoryStatistics)) + if (_territoryStatistics.TryGetValue((ETerritoryType)floor.TerritoryType, + out TerritoryStatistics? territoryStatistics)) { territoryStatistics.TrapCount = floor.TrapCount; territoryStatistics.HoardCofferCount = floor.HoardCount; @@ -95,9 +109,9 @@ namespace Pal.Client.Windows } } - private class TerritoryStatistics + private sealed class TerritoryStatistics { - public string TerritoryName { get; set; } + public string TerritoryName { get; } public uint? TrapCount { get; set; } public uint? HoardCofferCount { get; set; } diff --git a/Pal.Common/ETerritoryType.cs b/Pal.Common/ETerritoryType.cs index 9796f86..99e3ce6 100644 --- a/Pal.Common/ETerritoryType.cs +++ b/Pal.Common/ETerritoryType.cs @@ -1,7 +1,10 @@ using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; namespace Pal.Common { + [SuppressMessage("ReSharper", "UnusedMember.Global")] + [SuppressMessage("ReSharper", "InconsistentNaming")] public enum ETerritoryType : ushort { Palace_1_10 = 561, diff --git a/Pal.Common/ExportConfig.cs b/Pal.Common/ExportConfig.cs index e664ee2..408b6f2 100644 --- a/Pal.Common/ExportConfig.cs +++ b/Pal.Common/ExportConfig.cs @@ -8,6 +8,6 @@ namespace Pal.Common { public static class ExportConfig { - public static int ExportVersion => 1; + public static int ExportVersion => 2; } } diff --git a/Pal.Common/Pal.Common.csproj b/Pal.Common/Pal.Common.csproj index 22d5a35..720fa9b 100644 --- a/Pal.Common/Pal.Common.csproj +++ b/Pal.Common/Pal.Common.csproj @@ -5,5 +5,7 @@ 11.0 enable enable + portable + $(SolutionDir)=X:\