New config and database structures (#8)

This commit is contained in:
Liza 2023-02-23 00:51:17 +01:00
commit 98bc4887d6
98 changed files with 5794 additions and 1692 deletions

View File

@ -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();
}
}
}

View File

@ -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<PersistentLocation> 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}");
}
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}
}

View File

@ -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<AccountConfigurationV7>();
[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;
/// <summary>
/// Depending on <see cref="Format"/>, this is either a Guid as string or a base64 encoded byte array.
/// </summary>
[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<string> 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,
/// <summary>
/// Used for filtering: We don't want to overwrite any entries of this value using DPAPI, ever.
/// This is mostly a wine fallback.
/// </summary>
ProtectedDataUnsupported = 3,
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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<ConfigurationManager> _logger;
private readonly DalamudPluginInterface _pluginInterface;
private readonly IServiceProvider _serviceProvider;
public event EventHandler<IPalacePalConfiguration>? Saved;
public ConfigurationManager(ILogger<ConfigurationManager> 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<ConfigurationV7>(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<ConfigurationV1>(
File.ReadAllText(_pluginInterface.ConfigFile.FullName)) ?? new ConfigurationV1();
configurationV1.Migrate(_pluginInterface, _serviceProvider.GetRequiredService<ILogger<ConfigurationV1>>());
configurationV1.Save(_pluginInterface);
var v7 = MigrateToV7(configurationV1);
Save(v7, queue: false);
using (var scope = _serviceProvider.CreateScope())
{
using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
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
}
}

View File

@ -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<AccountConfigurationV7> 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);
}
}
}

View File

@ -0,0 +1,15 @@
namespace Pal.Client.Configuration
{
public enum EMode
{
/// <summary>
/// Fetches trap locations from remote server.
/// </summary>
Online = 1,
/// <summary>
/// Only shows traps found by yourself uisng a pomander of sight.
/// </summary>
Offline = 2,
}
}

View File

@ -0,0 +1,11 @@
namespace Pal.Client.Configuration
{
public enum ERenderer
{
/// <see cref="Rendering.SimpleRenderer"/>
Simple = 0,
/// <see cref="Rendering.SplatoonRenderer"/>
Splatoon = 1,
}
}

View File

@ -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; }
/// <summary>
/// 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.
/// </summary>
List<string> CachedRoles { get; set; }
bool EncryptIfNeeded();
}
public class BackupConfiguration
{
public int MinimumBackupsToKeep { get; set; } = 3;
public int DaysToDeleteAfter { get; set; } = 21;
}
}

View File

@ -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<ConfigurationV1> 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<Marker>(s.Markers.Where(m => m.SinceVersion != "0.0" || m.Type == Marker.EType.Hoard || m.WasImported));
s.Markers = new ConcurrentBag<JsonMarker>(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
{
/// <summary>
/// Fetches trap locations from remote server.
/// </summary>
Online = 1,
/// <summary>
/// Only shows traps found by yourself uisng a pomander of sight.
/// </summary>
Offline = 2,
}
public enum ERenderer
{
/// <see cref="Rendering.SimpleRenderer"/>
Simple = 0,
/// <see cref="Rendering.SplatoonRenderer"/>
Splatoon = 1,
}
public class AccountInfo
{
[JsonConverter(typeof(AccountIdConverter))]
public Guid? Id { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? Id { get; set; }
public List<string> 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; }

View File

@ -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
{
/// <summary>
/// JSON for a single floor set (e.g. 51-60).
/// Legacy JSON file for marker locations.
/// </summary>
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<Marker> 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<JsonMarker> Markers { get; set; } = new();
public JsonFloorState(ushort territoryType)
{
TerritoryType = territoryType;
}
private void ApplyFilters()
{
if (Service.Configuration.Mode == Configuration.EMode.Offline)
Markers = new ConcurrentBag<Marker>(Markers.Where(x => x.Seen || (x.WasImported && x.Imports.Count > 0)));
if (_mode == EMode.Offline)
Markers = new ConcurrentBag<JsonMarker>(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<Marker>(Markers.Where(x => x.Seen || !x.WasImported || x.Imports.Count > 0));
Markers = new ConcurrentBag<JsonMarker>(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<Marker>(JsonSerializer.Deserialize<HashSet<Marker>>(content, JsonSerializerOptions) ?? new()),
Markers = new ConcurrentBag<JsonMarker>(JsonSerializer.Deserialize<HashSet<JsonMarker>>(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<Marker>(save.Markers.Where(o => o.Type == Marker.EType.Trap || o.Type == Marker.EType.Hoard)),
Markers = new ConcurrentBag<JsonMarker>(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<Marker>(Markers)
Markers = new HashSet<JsonMarker>(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<LocalState> action)
public static void ForEach(Action<JsonFloorState> 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<Marker> Markers { get; set; } = new();
public HashSet<JsonMarker> Markers { get; set; } = new();
}
}
}

View File

@ -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<string> RemoteSeenOn { get; set; } = new();
public List<Guid> 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,
}
}
}

View File

@ -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
{
/// <summary>
/// Imports legacy territoryType.json files into the database if it exists, and no markers for that territory exist.
/// </summary>
internal sealed class JsonMigration
{
private readonly ILogger<JsonMigration> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly DalamudPluginInterface _pluginInterface;
public JsonMigration(ILogger<JsonMigration> logger, IServiceScopeFactory serviceScopeFactory,
DalamudPluginInterface pluginInterface)
{
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_pluginInterface = pluginInterface;
}
#pragma warning disable CS0612
public async Task MigrateAsync(CancellationToken cancellationToken)
{
List<JsonFloorState> 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<PalClientContext>();
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<Guid, ImportHistory> 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());
}
/// <returns>Whether to archive this file once complete</returns>
private async Task MigrateFloor(
PalClientContext dbContext,
JsonFloorState floorToMigrate,
IReadOnlyDictionary<Guid, ImportHistory> 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<ClientLocation> 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<ImportHistory>()
.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
}
}

View File

@ -15,6 +15,7 @@
OutputPath="$(OutputPath)"
AssemblyName="$(AssemblyName)"
MakeZip="true"
VersionComponents="2"/>
VersionComponents="2"
Exclude="ECommons.pdb;ECommons.xml"/>
</Target>
</Project>

View File

@ -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<Cleanup> _logger;
private readonly IPalacePalConfiguration _configuration;
public Cleanup(ILogger<Cleanup> 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<Func<ClientLocation, bool>> DefaultPredicate()
{
return o => !o.Seen &&
o.ImportedBy.Count == 0 &&
o.Source != ClientLocation.ESource.SeenLocally &&
o.Source != ClientLocation.ESource.ExplodedLocally;
}
private Expression<Func<ClientLocation, bool>> AnyRemoteEncounter()
{
if (_configuration.Mode == EMode.Offline)
return o => true;
else
// keep downloaded markers
return o => o.Source != ClientLocation.ESource.Download;
}
}
}

View File

@ -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; }
/// <summary>
/// Whether we have encountered the trap/coffer at this location in-game.
/// </summary>
public bool Seen { get; set; }
/// <summary>
/// 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).
/// </summary>
public List<RemoteEncounter> RemoteEncounters { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
public List<ImportHistory> ImportedBy { get; set; } = new();
/// <summary>
/// Determines where this location is originally from.
/// </summary>
public ESource Source { get; set; }
/// <summary>
/// To make rollbacks of local data easier, keep track of the plugin version which was used to create this location initially.
/// </summary>
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,
}
}
}

View File

@ -0,0 +1,123 @@
// <auto-generated />
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("<LocalId>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("<Seen>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("<SinceVersion>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("<Source>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("<TerritoryType>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("<Type>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("<X>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("<Y>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("<Z>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<ImportHistory>),
propertyInfo: typeof(ClientLocation).GetProperty("ImportedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientLocation).GetField("<ImportedBy>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);
}
}

View File

@ -0,0 +1,83 @@
// <auto-generated />
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<string, object>),
baseEntityType,
sharedClrType: true,
indexerPropertyInfo: RuntimeEntityType.FindIndexerProperty(typeof(Dictionary<string, object>)),
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);
}
}

View File

@ -0,0 +1,94 @@
// <auto-generated />
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("<Id>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("<ExportedAt>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("<ImportedAt>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("<RemoteUrl>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<ClientLocation>),
propertyInfo: typeof(ImportHistory).GetProperty("ImportedLocations", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ImportHistory).GetField("<ImportedLocations>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);
}
}

View File

@ -0,0 +1,28 @@
// <auto-generated />
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();
}
}

View File

@ -0,0 +1,35 @@
// <auto-generated />
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");
}
}
}

View File

@ -0,0 +1,92 @@
// <auto-generated />
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("<Id>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("<AccountId>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("<ClientLocationId>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("<ClientLocation>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
var remoteEncounters = principalEntityType.AddNavigation("RemoteEncounters",
runtimeForeignKey,
onDependent: false,
typeof(List<RemoteEncounter>),
propertyInfo: typeof(ClientLocation).GetProperty("RemoteEncounters", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientLocation).GetField("<RemoteEncounters>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);
}
}

View File

@ -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<ClientLocation> ImportedLocations { get; set; } = new();
}
}

View File

@ -0,0 +1,45 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("ExportedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedAt")
.HasColumnType("TEXT");
b.Property<string>("RemoteUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Imports");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Pal.Client.Database.Migrations
{
/// <inheritdoc />
public partial class AddImportHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Imports",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
RemoteUrl = table.Column<string>(type: "TEXT", nullable: true),
ExportedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
ImportedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Imports", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Imports");
}
}
}

View File

@ -0,0 +1,136 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("ClientLocationImportHistory", b =>
{
b.Property<Guid>("ImportedById")
.HasColumnType("TEXT");
b.Property<int>("ImportedLocationsLocalId")
.HasColumnType("INTEGER");
b.HasKey("ImportedById", "ImportedLocationsLocalId");
b.HasIndex("ImportedLocationsLocalId");
b.ToTable("LocationImports", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.ClientLocation", b =>
{
b.Property<int>("LocalId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Seen")
.HasColumnType("INTEGER");
b.Property<ushort>("TerritoryType")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<float>("X")
.HasColumnType("REAL");
b.Property<float>("Y")
.HasColumnType("REAL");
b.Property<float>("Z")
.HasColumnType("REAL");
b.HasKey("LocalId");
b.ToTable("Locations");
});
modelBuilder.Entity("Pal.Client.Database.ImportHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("ExportedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedAt")
.HasColumnType("TEXT");
b.Property<string>("RemoteUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Imports");
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("TEXT");
b.Property<int>("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
}
}
}

View File

@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Pal.Client.Database.Migrations
{
/// <inheritdoc />
public partial class AddClientLocations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Locations",
columns: table => new
{
LocalId = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TerritoryType = table.Column<ushort>(type: "INTEGER", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
X = table.Column<float>(type: "REAL", nullable: false),
Y = table.Column<float>(type: "REAL", nullable: false),
Z = table.Column<float>(type: "REAL", nullable: false),
Seen = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Locations", x => x.LocalId);
});
migrationBuilder.CreateTable(
name: "LocationImports",
columns: table => new
{
ImportedById = table.Column<Guid>(type: "TEXT", nullable: false),
ImportedLocationsLocalId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ClientLocationId = table.Column<int>(type: "INTEGER", nullable: false),
AccountId = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LocationImports");
migrationBuilder.DropTable(
name: "RemoteEncounters");
migrationBuilder.DropTable(
name: "Locations");
}
}
}

View File

@ -0,0 +1,148 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("ClientLocationImportHistory", b =>
{
b.Property<Guid>("ImportedById")
.HasColumnType("TEXT");
b.Property<int>("ImportedLocationsLocalId")
.HasColumnType("INTEGER");
b.HasKey("ImportedById", "ImportedLocationsLocalId");
b.HasIndex("ImportedLocationsLocalId");
b.ToTable("LocationImports", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.ClientLocation", b =>
{
b.Property<int>("LocalId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Imported")
.HasColumnType("INTEGER");
b.Property<bool>("Seen")
.HasColumnType("INTEGER");
b.Property<string>("SinceVersion")
.IsRequired()
.HasColumnType("TEXT");
b.Property<ushort>("TerritoryType")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<float>("X")
.HasColumnType("REAL");
b.Property<float>("Y")
.HasColumnType("REAL");
b.Property<float>("Z")
.HasColumnType("REAL");
b.HasKey("LocalId");
b.ToTable("Locations");
});
modelBuilder.Entity("Pal.Client.Database.ImportHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("ExportedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedAt")
.HasColumnType("TEXT");
b.Property<string>("RemoteUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Imports");
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("TEXT");
b.Property<int>("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
}
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Pal.Client.Database.Migrations
{
/// <inheritdoc />
public partial class AddImportedAndSinceVersionToClientLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Imported",
table: "Locations",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "SinceVersion",
table: "Locations",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Imported",
table: "Locations");
migrationBuilder.DropColumn(
name: "SinceVersion",
table: "Locations");
}
}
}

View File

@ -0,0 +1,148 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("ClientLocationImportHistory", b =>
{
b.Property<Guid>("ImportedById")
.HasColumnType("TEXT");
b.Property<int>("ImportedLocationsLocalId")
.HasColumnType("INTEGER");
b.HasKey("ImportedById", "ImportedLocationsLocalId");
b.HasIndex("ImportedLocationsLocalId");
b.ToTable("LocationImports", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.ClientLocation", b =>
{
b.Property<int>("LocalId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Seen")
.HasColumnType("INTEGER");
b.Property<string>("SinceVersion")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.Property<ushort>("TerritoryType")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<float>("X")
.HasColumnType("REAL");
b.Property<float>("Y")
.HasColumnType("REAL");
b.Property<float>("Z")
.HasColumnType("REAL");
b.HasKey("LocalId");
b.ToTable("Locations");
});
modelBuilder.Entity("Pal.Client.Database.ImportHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("ExportedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedAt")
.HasColumnType("TEXT");
b.Property<string>("RemoteUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Imports");
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("TEXT");
b.Property<int>("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
}
}
}

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Pal.Client.Database.Migrations
{
/// <inheritdoc />
public partial class ChangeLocationImportedToSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Imported",
table: "Locations",
newName: "Source");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Source",
table: "Locations",
newName: "Imported");
}
}
}

View File

@ -0,0 +1,145 @@
// <auto-generated />
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<Guid>("ImportedById")
.HasColumnType("TEXT");
b.Property<int>("ImportedLocationsLocalId")
.HasColumnType("INTEGER");
b.HasKey("ImportedById", "ImportedLocationsLocalId");
b.HasIndex("ImportedLocationsLocalId");
b.ToTable("LocationImports", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.ClientLocation", b =>
{
b.Property<int>("LocalId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Seen")
.HasColumnType("INTEGER");
b.Property<string>("SinceVersion")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.Property<ushort>("TerritoryType")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<float>("X")
.HasColumnType("REAL");
b.Property<float>("Y")
.HasColumnType("REAL");
b.Property<float>("Z")
.HasColumnType("REAL");
b.HasKey("LocalId");
b.ToTable("Locations");
});
modelBuilder.Entity("Pal.Client.Database.ImportHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("ExportedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedAt")
.HasColumnType("TEXT");
b.Property<string>("RemoteUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Imports");
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("TEXT");
b.Property<int>("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
}
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
namespace Pal.Client.Database
{
internal class PalClientContext : DbContext
{
public DbSet<ClientLocation> Locations { get; set; } = null!;
public DbSet<ImportHistory> Imports { get; set; } = null!;
public DbSet<RemoteEncounter> RemoteEncounters { get; set; } = null!;
public PalClientContext(DbContextOptions<PalClientContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClientLocation>()
.HasMany(o => o.ImportedBy)
.WithMany(o => o.ImportedLocations)
.UsingEntity(o => o.ToTable("LocationImports"));
}
}
}

View File

@ -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<PalClientContext>
{
public PalClientContext CreateDbContext(string[] args)
{
var optionsBuilder =
new DbContextOptionsBuilder<PalClientContext>().UseSqlite(
$"Data Source={Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "XIVLauncher", "pluginConfigs", "Palace Pal", "palace-pal.data.sqlite3")}");
return new PalClientContext(optionsBuilder.Options);
}
}
}
#endif

View File

@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using Pal.Client.Extensions;
using Pal.Client.Net;
namespace Pal.Client.Database
{
/// <summary>
/// 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 <see cref="RemoteApi.MarkAsSeen"/>.
/// </summary>
internal sealed class RemoteEncounter
{
[Key]
public int Id { get; private set; }
public int ClientLocationId { get; private set; }
public ClientLocation ClientLocation { get; private set; } = null!;
/// <summary>
/// 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.
/// </summary>
[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();
}
}
}

View File

@ -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
{
/// <summary>
/// Takes care of async plugin init - this is mostly everything that requires either the config or the database to
/// be available.
/// </summary>
internal sealed class DependencyContextInitializer
{
private readonly ILogger<DependencyContextInitializer> _logger;
private readonly IServiceProvider _serviceProvider;
public DependencyContextInitializer(ILogger<DependencyContextInitializer> 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<ConfigurationManager>().Migrate();
await _serviceProvider.GetRequiredService<JsonMigration>().MigrateAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
// windows that have logic to open on startup
_serviceProvider.GetRequiredService<AgreementWindow>();
// initialize components that are mostly self-contained/self-registered
_serviceProvider.GetRequiredService<GameHooks>();
_serviceProvider.GetRequiredService<FrameworkService>();
_serviceProvider.GetRequiredService<ChatService>();
// eager load any commands to find errors now, not when running them
_serviceProvider.GetRequiredService<PalConfigCommand>();
_serviceProvider.GetRequiredService<PalNearCommand>();
_serviceProvider.GetRequiredService<PalStatsCommand>();
_serviceProvider.GetRequiredService<PalTestConnectionCommand>();
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Async init complete");
}
private async Task RemoveOldBackups()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var pluginInterface = scope.ServiceProvider.GetRequiredService<DalamudPluginInterface>();
var configuration = scope.ServiceProvider.GetRequiredService<IPalacePalConfiguration>();
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<DalamudPluginInterface>();
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<PalClientContext>();
// 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<PalClientContext>();
var cleanup = scope.ServiceProvider.GetRequiredService<Cleanup>();
cleanup.Purge(dbContext);
await dbContext.SaveChangesAsync();
}
}
}

View File

@ -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);
}
}

View File

@ -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<LogMessage>()?.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+)$");
}
}
}

View File

@ -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;
}
}

View File

@ -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<GameHooks> _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<ActorVfxCreateDelegate> ActorVfxCreateHook { get; init; } = null!;
#pragma warning restore CS0649
public Hooks()
public GameHooks(ILogger<GameHooks> 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");
}
/// <summary>
@ -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();
}
}
}

View File

@ -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<ImportHistory?> FindLast(CancellationToken token = default)
{
await using var scope = _serviceProvider.CreateAsyncScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
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<PalClientContext>();
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<PersistentLocation> 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> { 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<PalClientContext>();
dbContext.RemoveRange(dbContext.Imports.Where(x => x.Id == id));
dbContext.SaveChanges();
_cleanup.Purge(dbContext);
dbContext.SaveChanges();
}
finally
{
_floorService.ResetAll();
}
}
}
}

View File

@ -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>(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<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> 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<KeyValuePair<string, object>> properties)
{
foreach (KeyValuePair<string, object> 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()
{
}
}
}
}

View File

@ -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);
/// <summary>
/// Manual logger creation, doesn't handle scopes.
/// </summary>
public ILogger CreateLogger(Type type) => CreateLogger(type.FullName ?? type.ToString());
/// <summary>
/// Manual logger creation, doesn't handle scopes.
/// </summary>
public ILogger CreateLogger<T>() => CreateLogger(typeof(T));
public void SetScopeProvider(IExternalScopeProvider scopeProvider)
{
_scopeProvider = scopeProvider;
}
public void Dispose()
{
}
}
}

View File

@ -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<RepoVerification> 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();
}
}
}
}

View File

@ -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());
}
}
}
}

View File

@ -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
{
/// <summary>
/// DI-aware Plugin.
/// </summary>
internal sealed class DependencyInjectionContext : IDisposable
{
public const string DatabaseFileName = "palace-pal.data.sqlite3";
public static DalamudLoggerProvider LoggerProvider { get; } = new();
/// <summary>
/// Initialized as temporary logger, will be overriden once context is ready with a logger that supports scopes.
/// </summary>
private ILogger _logger = LoggerProvider.CreateLogger<DependencyInjectionContext>();
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<IDalamudPlugin>(plugin);
_serviceCollection.AddSingleton(pluginInterface);
_serviceCollection.AddSingleton(clientState);
_serviceCollection.AddSingleton(gameGui);
_serviceCollection.AddSingleton(chatGui);
_serviceCollection.AddSingleton<Chat>();
_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<PalClientContext>(o => o
.UseSqlite(_sqliteConnectionString)
.UseModel(Database.Compiled.PalClientContextModel.Instance));
_serviceCollection.AddTransient<JsonMigration>();
_serviceCollection.AddScoped<Cleanup>();
// plugin-specific
_serviceCollection.AddScoped<DependencyContextInitializer>();
_serviceCollection.AddScoped<DebugState>();
_serviceCollection.AddScoped<GameHooks>();
_serviceCollection.AddScoped<RemoteApi>();
_serviceCollection.AddScoped<ConfigurationManager>();
_serviceCollection.AddScoped<IPalacePalConfiguration>(sp =>
sp.GetRequiredService<ConfigurationManager>().Load());
_serviceCollection.AddTransient<RepoVerification>();
// commands
_serviceCollection.AddScoped<PalConfigCommand>();
_serviceCollection.AddScoped<PalNearCommand>();
_serviceCollection.AddScoped<PalStatsCommand>();
_serviceCollection.AddScoped<PalTestConnectionCommand>();
// territory & marker related services
_serviceCollection.AddScoped<TerritoryState>();
_serviceCollection.AddScoped<FrameworkService>();
_serviceCollection.AddScoped<ChatService>();
_serviceCollection.AddScoped<FloorService>();
_serviceCollection.AddScoped<ImportService>();
// windows & related services
_serviceCollection.AddScoped<AgreementWindow>();
_serviceCollection.AddScoped<ConfigWindow>();
_serviceCollection.AddScoped<StatisticsService>();
_serviceCollection.AddScoped<StatisticsWindow>();
// rendering
_serviceCollection.AddScoped<SimpleRenderer>();
_serviceCollection.AddScoped<SplatoonRenderer>();
_serviceCollection.AddScoped<RenderAdapter>();
// queue handling
_serviceCollection.AddTransient<IQueueOnFrameworkThread.Handler<QueuedImport>, QueuedImport.Handler>();
_serviceCollection
.AddTransient<IQueueOnFrameworkThread.Handler<QueuedUndoImport>, QueuedUndoImport.Handler>();
_serviceCollection
.AddTransient<IQueueOnFrameworkThread.Handler<QueuedConfigUpdate>, QueuedConfigUpdate.Handler>();
_serviceCollection
.AddTransient<IQueueOnFrameworkThread.Handler<QueuedSyncResponse>, 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<RepoVerification>();
#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<ILogger<DependencyInjectionContext>>();
_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);
}
}
}

View File

@ -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}");
}
}

View File

@ -1,14 +0,0 @@
using System.Linq;
using Dalamud.Interface.Windowing;
namespace Pal.Client.Extensions
{
internal static class WindowSystemExtensions
{
public static T? GetWindow<T>(this WindowSystem windowSystem)
where T : Window
{
return windowSystem.Windows.OfType<T>().FirstOrDefault();
}
}
}

View File

@ -0,0 +1,29 @@
using System;
namespace Pal.Client.Floors
{
/// <summary>
/// This is a currently-visible marker.
/// </summary>
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})";
}
}
}

View File

@ -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<ETerritoryType, MemoryTerritory> _territories;
private ConcurrentBag<EphemeralLocation> _ephemeralLocations = new();
public FloorService(IPalacePalConfiguration configuration, Cleanup cleanup,
IServiceScopeFactory serviceScopeFactory)
{
_configuration = configuration;
_cleanup = cleanup;
_serviceScopeFactory = serviceScopeFactory;
_territories = Enum.GetValues<ETerritoryType>().ToDictionary(o => o, o => new MemoryTerritory(o));
}
public IReadOnlyCollection<EphemeralLocation> EphemeralLocations => _ephemeralLocations;
public bool IsImportRunning { get; private set; }
public void ChangeTerritory(ushort territoryType)
{
_ephemeralLocations = new ConcurrentBag<EphemeralLocation>();
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<PersistentLocation> visibleLocations,
bool recreateLayout,
out List<PersistentLocation> 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<PersistentLocation> markAsSeen = new();
List<PersistentLocation> 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;
}
/// <returns>Whether the locations have changed</returns>
public bool MergeEphemeralLocations(IReadOnlyList<EphemeralLocation> 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;
}
}
}
}

View File

@ -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<FrameworkService> _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<IQueueOnFrameworkThread> EarlyEventQueue { get; } = new();
internal Queue<IQueueOnFrameworkThread> LateEventQueue { get; } = new();
internal ConcurrentQueue<nint> NextUpdateObjects { get; } = new();
public FrameworkService(
IServiceProvider serviceProvider,
ILogger<FrameworkService> 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<PersistentLocation> visiblePersistentMarkers,
IReadOnlyList<EphemeralLocation> 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<PersistentLocation> visiblePersistentMarkers,
bool recreateLayout)
{
bool recreatePersistentLocations = _floorService.MergePersistentLocations(
territoryType,
visiblePersistentMarkers,
recreateLayout,
out List<PersistentLocation> 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<PersistentLocation> 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<PersistentLocation> 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<PersistentLocation> visibleMarkers)
{
_renderAdapter.ResetLayer(ELayer.TrapHoard);
MemoryTerritory? memoryTerritory = _floorService.GetTerritoryIfReady(_territoryState.LastTerritory);
if (memoryTerritory == null)
return;
List<IRenderElement> 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<IRenderElement> 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<PersistentLocation> 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<IRenderElement> 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<PersistentLocation> 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<PersistentLocation> 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<PersistentLocation>, IReadOnlyList<EphemeralLocation>) GetRelevantGameObjects()
{
List<PersistentLocation> persistentLocations = new();
List<EphemeralLocation> 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);
}
}
}

View File

@ -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
{
/// <summary>
/// Base class for <see cref="MemoryLocation"/> and <see cref="EphemeralLocation"/>.
/// </summary>
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)
};
}
}
}

View File

@ -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
{
/// <summary>
/// A single set of floors loaded entirely in memory, can be e.g. POTD 51-60.
/// </summary>
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<PersistentLocation> Locations { get; } = new();
public object LockObj { get; } = new();
public void Initialize(IEnumerable<PersistentLocation> 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,
/// <summary>
/// Currently loading from the database.
/// </summary>
Loading,
/// <summary>
/// Locations loaded, no import running.
/// </summary>
Ready,
/// <summary>
/// Import running, should probably not interact with this too much.
/// </summary>
Importing,
}
}
}

View File

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using Pal.Client.Database;
namespace Pal.Client.Floors
{
/// <summary>
/// A <see cref="ClientLocation"/> loaded in memory, with certain extra attributes as needed.
/// </summary>
internal sealed class PersistentLocation : MemoryLocation
{
/// <see cref="ClientLocation.LocalId"/>
public int? LocalId { get; set; }
/// <summary>
/// Network id for the server you're currently connected to.
/// </summary>
public Guid? NetworkId { get; set; }
/// <summary>
/// For markers that the server you're connected to doesn't know: Whether this was requested to be uploaded, to avoid duplicate requests.
/// </summary>
public bool UploadRequested { get; set; }
/// <see cref="ClientLocation.RemoteEncounters"/>
///
public List<string> RemoteSeenOn { get; set; } = new();
/// <summary>
/// Whether this marker was requested to be seen, to avoid duplicate requests.
/// </summary>
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})";
}
}
}

View File

@ -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<T>
where T : DbTask<T>
{
private readonly IServiceScopeFactory _serviceScopeFactory;
protected DbTask(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}
public void Start()
{
Task.Run(() =>
{
using var scope = _serviceScopeFactory.CreateScope();
ILogger<T> logger = scope.ServiceProvider.GetRequiredService<ILogger<T>>();
using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
Run(dbContext, logger);
});
}
protected abstract void Run(PalClientContext dbContext, ILogger<T> logger);
}
}

View File

@ -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<LoadTerritory>
{
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<LoadTerritory> 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<ClientLocation> 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)
};
}
}
}

View File

@ -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<MarkLocalSeen>
{
private readonly MemoryTerritory _territory;
private readonly IReadOnlyList<PersistentLocation> _locations;
public MarkLocalSeen(IServiceScopeFactory serviceScopeFactory, MemoryTerritory territory,
IReadOnlyList<PersistentLocation> locations)
: base(serviceScopeFactory)
{
_territory = territory;
_locations = locations;
}
protected override void Run(PalClientContext dbContext, ILogger<MarkLocalSeen> 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();
}
}
}
}

View File

@ -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<MarkRemoteSeen>
{
private readonly MemoryTerritory _territory;
private readonly IReadOnlyList<PersistentLocation> _locations;
private readonly string _accountId;
public MarkRemoteSeen(IServiceScopeFactory serviceScopeFactory,
MemoryTerritory territory,
IReadOnlyList<PersistentLocation> locations,
string accountId)
: base(serviceScopeFactory)
{
_territory = territory;
_locations = locations;
_accountId = accountId;
}
protected override void Run(PalClientContext dbContext, ILogger<MarkRemoteSeen> logger)
{
lock (_territory.LockObj)
{
logger.LogInformation("Marking {Count} locations as seen remotely on {Account} in territory {Territory}",
_locations.Count, _accountId, _territory.TerritoryType);
List<int> locationIds = _locations.Select(x => x.LocalId).Where(x => x != null).Cast<int>().ToList();
List<ClientLocation> 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();
}
}
}
}

View File

@ -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<SaveNewLocations>
{
private readonly MemoryTerritory _territory;
private readonly List<PersistentLocation> _newLocations;
public SaveNewLocations(IServiceScopeFactory serviceScopeFactory, MemoryTerritory territory,
List<PersistentLocation> newLocations)
: base(serviceScopeFactory)
{
_territory = territory;
_newLocations = newLocations;
}
protected override void Run(PalClientContext dbContext, ILogger<SaveNewLocations> logger)
{
Run(_territory, dbContext, logger, _newLocations);
}
public static void Run<T>(
MemoryTerritory territory,
PalClientContext dbContext,
ILogger<T> logger,
List<PersistentLocation> locations)
{
lock (territory.LockObj)
{
logger.LogInformation("Saving {Count} new locations for territory {Territory}", locations.Count,
territory.TerritoryType);
Dictionary<PersistentLocation, ClientLocation> 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)
};
}
}
}

View File

@ -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,
}
}

View File

@ -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; }
/// <summary>
/// Whether we have encountered the trap/coffer at this location in-game.
/// </summary>
public bool Seen { get; set; }
/// <summary>
/// Network id for the server you're currently connected to.
/// </summary>
[JsonIgnore]
public Guid? NetworkId { get; set; }
/// <summary>
/// For markers that the server you're connected to doesn't know: Whether this was requested to be uploaded, to avoid duplicate requests.
/// </summary>
[JsonIgnore]
public bool UploadRequested { get; set; }
/// <summary>
/// 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).
/// </summary>
public List<string> RemoteSeenOn { get; set; } = new();
/// <summary>
/// Whether this marker was requested to be seen, to avoid duplicate requests.
/// </summary>
[JsonIgnore]
public bool RemoteSeenRequested { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<Guid> Imports { get; set; } = new();
public bool WasImported { get; set; }
/// <summary>
/// To make rollbacks of local data easier, keep track of the version which was used to write the marker initially.
/// </summary>
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
}
}
}

View File

@ -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>(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<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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()
{
}
}
}
}

View File

@ -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()
{
}
}
}

View File

@ -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<List<string>>
internal sealed class JwtRoleConverter : JsonConverter<List<string>>
{
public override List<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
@ -78,9 +78,9 @@ namespace Pal.Client.Net
public override void Write(Utf8JsonWriter writer, List<string> value, JsonSerializerOptions options) => throw new NotImplementedException();
}
public class JwtDateConverter : JsonConverter<DateTimeOffset>
public sealed class JwtDateConverter : JsonConverter<DateTimeOffset>
{
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)
{

View File

@ -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)
{
PluginLog.Debug("TryConnect: Not Online, not attempting to establish a connection");
return (false, Localization.ConnectionError_NotOnline);
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);
}
if (_channel == null || !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle))
private async Task<(bool Success, string Error, bool ShouldRetry)> TryConnectImpl(
CancellationToken cancellationToken,
ILoggerFactory? loggerFactory)
{
if (_configuration.Mode != EMode.Online)
{
_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))
{
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)
_logger.LogTrace("Acquiring connect lock");
await _connectLock.WaitAsync(cancellationToken);
_logger.LogTrace("Obtained connect lock");
try
{
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);
var accountClient = new AccountService.AccountServiceClient(_channel);
IAccountConfiguration? configuredAccount = _configuration.FindAccount(RemoteUrl);
if (configuredAccount == null)
{
_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)
{
Account = new Configuration.AccountInfo
{
Id = Guid.Parse(createAccountReply.AccountId),
};
PluginLog.Information($"TryConnect: Account created with id {FormattedPartialAccountId}");
if (!Guid.TryParse(createAccountReply.AccountId, out Guid accountId))
throw new InvalidOperationException("invalid account id returned");
Service.Configuration.Save();
configuredAccount = _configuration.CreateAccount(RemoteUrl, accountId);
_logger.LogInformation("Account created with id {AccountId}", accountId.ToPartialId());
_configurationManager.Save(_configuration);
}
else
{
PluginLog.Error($"TryConnect: Account creation failed with error {createAccountReply.Error}");
_logger.LogError("Account creation failed with error {Error}", createAccountReply.Error);
if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade)
{
Service.Chat.PalError(Localization.ConnectionError_OldVersion);
_chat.Error(Localization.ConnectionError_OldVersion);
_warnedAboutUpgrade = true;
}
return (false, string.Format(Localization.ConnectionError_CreateAccountFailed, createAccountReply.Error));
return (false,
string.Format(Localization.ConnectionError_CreateAccountFailed, createAccountReply.Error),
false);
}
}
cancellationToken.ThrowIfCancellationRequested();
if (AccountId == null)
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (configuredAccount == null)
{
PluginLog.Warning("TryConnect: No account id to login with");
return (false, Localization.ConnectionError_CreateAccountReturnedNoId);
_logger.LogWarning("No account to login with");
return (false, Localization.ConnectionError_CreateAccountReturnedNoId, false);
}
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);
_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)
{
PluginLog.Information($"TryConnect: Login successful with account id: {FormattedPartialAccountId}");
_logger.LogInformation("Login successful with account id: {AccountId}",
configuredAccount.AccountId.ToPartialId());
_loginInfo = new LoginInfo(loginReply.AuthToken);
var account = Account;
if (account != null)
bool save = configuredAccount.EncryptIfNeeded();
List<string> newRoles = _loginInfo.Claims?.Roles.ToList() ?? new();
if (!newRoles.SequenceEqual(configuredAccount.CachedRoles))
{
account.CachedRoles = _loginInfo.Claims?.Roles.ToList() ?? new List<string>();
Service.Configuration.Save();
configuredAccount.CachedRoles = newRoles;
save = true;
}
if (save)
_configurationManager.Save(_configuration);
}
else
{
PluginLog.Error($"TryConnect: Login failed with error {loginReply.Error}");
_logger.LogError("Login failed with error {Error}", loginReply.Error);
_loginInfo = new LoginInfo(null);
if (loginReply.Error == LoginError.InvalidAccountId)
{
Account = null;
Service.Configuration.Save();
if (retry)
{
PluginLog.Information("TryConnect: Attempting connection retry without account id");
return await TryConnect(cancellationToken, retry: false);
}
else
return (false, Localization.ConnectionError_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)
{
Service.Chat.PalError(Localization.ConnectionError_OldVersion);
_chat.Error(Localization.ConnectionError_OldVersion);
_warnedAboutUpgrade = true;
}
return (false, string.Format(Localization.ConnectionError_LoginFailed, loginReply.Error));
return (false, string.Format(Localization.ConnectionError_LoginFailed, loginReply.Error),
false);
}
}
if (!_loginInfo.IsValid)
{
PluginLog.Error($"TryConnect: Login state is loggedIn={_loginInfo.IsLoggedIn}, expired={_loginInfo.IsExpired}");
return (false, Localization.ConnectionError_LoginReturnedNoToken);
_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);
return (true, string.Empty, false);
}
finally
{
_logger.LogTrace("Releasing connectLock");
_connectLock.Release();
}
}
private async Task<bool> Connect(CancellationToken cancellationToken)
@ -139,21 +186,24 @@ namespace Pal.Client.Net
public async Task<string> 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;

View File

@ -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<Marker>)> DownloadRemoteMarkers(ushort territoryId, CancellationToken cancellationToken = default)
public async Task<(bool, List<PersistentLocation>)> 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<Marker>)> UploadMarker(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
public async Task<(bool, List<PersistentLocation>)> UploadLocations(ushort territoryType, IReadOnlyList<PersistentLocation> 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<bool> MarkAsSeen(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
public async Task<bool> MarkAsSeen(ushort territoryType, IReadOnlyList<PersistentLocation> 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<FloorStatistics>)> FetchStatistics(CancellationToken cancellationToken = default)
{

View File

@ -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);
}
}
}

View File

@ -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<RemoteApi> _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<RemoteApi> 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;
}

View File

@ -4,6 +4,7 @@
<TargetFramework>net7.0-windows</TargetFramework>
<LangVersion>11.0</LangVersion>
<Nullable>enable</Nullable>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<PropertyGroup>
@ -12,7 +13,10 @@
<AssemblyName>Palace Pal</AssemblyName>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<DebugType>portable</DebugType>
<PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
<GitVersion>false</GitVersion>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
@ -21,8 +25,6 @@
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<OutputPath>dist</OutputPath>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Release' And Exists('Certificate.pfx')">
@ -31,10 +33,6 @@
<ItemGroup Condition="'$(Configuration)' == 'Release' And Exists('Certificate.pfx')">
<EmbeddedResource Include="Certificate.pfx" />
<EmbeddedResource Update="Properties\Localization.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Localization.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
@ -48,8 +46,13 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="7.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="7.0.1" />
</ItemGroup>
<ItemGroup>
@ -67,35 +70,43 @@
<!--You may need to adjust these paths yourself. These point to a Dalamud assembly in AppData.-->
<Reference Include="Dalamud">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll</HintPath>
<Private>false</Private>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\ImGui.NET.dll</HintPath>
<Private>false</Private>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\ImGuiScene.dll</HintPath>
<Private>false</Private>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.dll</HintPath>
<Private>false</Private>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll</HintPath>
<Private>false</Private>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="Serilog">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Serilog.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Localization.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Localization.Designer.cs</LastGenOutput>
</EmbeddedResource>
<Compile Update="Properties\Localization.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
@ -110,8 +121,11 @@
</PropertyGroup>
</Target>
<Target Name="RenameLatestZip" AfterTargets="PackagePlugin">
<Target Name="RenameLatestZip" AfterTargets="PackagePlugin" Condition="'$(Configuration)' == 'Release'">
<Exec Command="rename &quot;$(OutDir)$(AssemblyName)\latest.zip&quot; &quot;$(AssemblyName)-$(Version).zip&quot;" />
</Target>
<Target Name="Clean">
<RemoveDir Directories="dist" />
</Target>
</Project>

View File

@ -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
/// <summary>
/// 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 .
/// </summary>
/// <see cref="DependencyInjectionContext"/>
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<ushort, LocalState> FloorMarkers { get; } = new();
internal ConcurrentBag<Marker> 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<IQueueOnFrameworkThread> EarlyEventQueue { get; } = new();
internal Queue<IQueueOnFrameworkThread> LateEventQueue { get; } = new();
internal ConcurrentQueue<nint> NextUpdateObjects { get; } = new();
internal IRenderer Renderer { get; private set; } = null!;
private readonly TaskCompletionSource<IServiceScope> _rootScopeCompletionSource = new();
private ELoadState _loadState = ELoadState.Initializing;
public string Name => Localization.Palace_Pal;
private DependencyInjectionContext? _dependencyInjectionContext;
private ILogger _logger = DependencyInjectionContext.LoggerProvider.CreateLogger<Plugin>();
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>();
Service.Plugin = this;
Service.Configuration = (Configuration?)pluginInterface.GetPluginConfig() ?? pluginInterface.Create<Configuration>()!;
Service.Configuration.Migrate();
ResetRenderer();
Service.Hooks = new Hooks();
var agreementWindow = pluginInterface.Create<AgreementWindow>();
if (agreementWindow is not null)
{
agreementWindow.IsOpen = Service.Configuration.FirstUse;
Service.WindowSystem.AddWindow(agreementWindow);
}
var configWindow = pluginInterface.Create<ConfigWindow>();
if (configWindow is not null)
{
Service.WindowSystem.AddWindow(configWindow);
}
var statisticsWindow = pluginInterface.Create<StatisticsWindow>();
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<AgreementWindow>();
else
configWindow = Service.WindowSystem.GetWindow<ConfigWindow>();
public string Name => Localization.Palace_Pal;
if (configWindow != null)
configWindow.IsOpen = true;
private async Task CreateDependencyContext()
{
try
{
_dependencyInjectionContext = _pluginInterface.Create<DependencyInjectionContext>(this)
?? throw new Exception("Could not create DI root context class");
var serviceProvider = _dependencyInjectionContext.BuildServiceContainer();
_initCts.Token.ThrowIfCancellationRequested();
_logger = serviceProvider.GetRequiredService<ILogger<Plugin>>();
_windowSystem = serviceProvider.GetRequiredService<WindowSystem>();
_rootScope = serviceProvider.CreateScope();
var loader = _rootScope.ServiceProvider.GetRequiredService<DependencyContextInitializer>();
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)
arguments = arguments.Trim();
Task.Run(async () =>
{
Service.Chat.PalError(Localization.Error_FirstTimeSetupRequired);
IServiceScope rootScope;
try
{
rootScope = await _rootScopeCompletionSource.Task;
}
catch (Exception e)
{
_logger.LogError(e, "Could not wait for command root scope");
return;
}
IPalacePalConfiguration configuration =
rootScope.ServiceProvider.GetRequiredService<IPalacePalConfiguration>();
Chat chat = rootScope.ServiceProvider.GetRequiredService<Chat>();
if (configuration.FirstUse && arguments != "" && arguments != "config")
{
chat.Error(Localization.Error_FirstTimeSetupRequired);
return;
}
try
{
arguments = arguments.Trim();
var sp = rootScope.ServiceProvider;
switch (arguments)
{
case "stats":
Task.Run(async () => await FetchFloorStatistics());
break;
case "test-connection":
case "tc":
var configWindow = Service.WindowSystem.GetWindow<ConfigWindow>();
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<ConfigWindow>()?.Toggle();
sp.GetRequiredService<PalConfigCommand>().Execute();
break;
case "stats":
sp.GetRequiredService<PalStatsCommand>().Execute();
break;
case "tc":
case "test-connection":
sp.GetRequiredService<PalTestConnectionCommand>().Execute();
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);
sp.GetRequiredService<PalNearCommand>().Execute(arguments);
break;
default:
Service.Chat.PalError(string.Format(Localization.Command_pal_UnknownSubcommand, arguments, command));
chat.Error(string.Format(Localization.Command_pal_UnknownSubcommand, arguments,
command));
break;
}
}
catch (Exception e)
{
Service.Chat.PalError(e.ToString());
chat.Error(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<ILanguageChanged>().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)
{
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())
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<Marker> 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<Marker> 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)
{
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;
}
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;
}
if (marker.RenderElement.Color != desiredColor)
marker.RenderElement.Color = desiredColor;
}
}
catch (Exception e)
{
DebugMessage = $"{DateTime.Now}\n{e}";
recreateLayout = true;
}
}
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<IRenderElement> 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<Marker> visibleMarkers, bool recreateLayout)
{
recreateLayout |= EphemeralMarkers.Any(existingMarker => visibleMarkers.All(x => x != existingMarker));
recreateLayout |= visibleMarkers.Any(visibleMarker => EphemeralMarkers.All(x => x != visibleMarker));
if (recreateLayout)
{
Renderer.ResetLayer(ELayer.RegularCoffers);
EphemeralMarkers.Clear();
var config = Service.Configuration;
List<IRenderElement> 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<Marker> 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<IRenderElement> 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<Marker> 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 void OpenConfigUi()
=> _rootScope!.ServiceProvider.GetRequiredService<PalConfigCommand>().Execute();
private async Task SyncSeenMarkersForTerritory(ushort territoryId, List<Marker> markersToUpdate)
private void LanguageChanged(string languageCode)
{
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
_logger.LogInformation("Language set to '{Language}'", languageCode);
#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>()!;
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<Marker> 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<Marker> GetRelevantGameObjects()
{
List<Marker> 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<ILanguageChanged>()
.Each(w => w.LanguageChanged());
}
private void Draw()
{
if (Renderer is SimpleRenderer sr)
sr.DrawLayers();
Service.WindowSystem.Draw();
_rootScope!.ServiceProvider.GetRequiredService<RenderAdapter>().DrawLayers();
_windowSystem!.Draw();
}
private string GetLocalizedString(uint id)
public void Dispose()
{
return Service.DataManager.GetExcelSheet<LogMessage>()?.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;
}
public enum PomanderState
{
Inactive,
Active,
FoundOnCurrentFloor,
PomanderOfSafetyUsed,
_initCts.Cancel();
_rootScope?.Dispose();
}
private class LocalizedChatMessages
private enum ELoadState
{
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
}
}
}

View File

@ -1,7 +1,6 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 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 {
}
}
/// <summary>
/// Looks up a localized string similar to Updated all locally cached marker files to latest version..
/// </summary>
internal static string Command_pal_updatesaves {
get {
return ResourceManager.GetString("Command_pal_updatesaves", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You are NOT in a deep dungeon..
/// </summary>
@ -385,15 +375,6 @@ namespace Pal.Client.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Splatoon Test:.
/// </summary>
internal static string Config_Splatoon_Test {
get {
return ResourceManager.GetString("Config_Splatoon_Test", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Start Export.
/// </summary>
@ -674,6 +655,15 @@ namespace Pal.Client.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Plugin could not be loaded: {0}.
/// </summary>
internal static string Error_LoadFailed {
get {
return ResourceManager.GetString("Error_LoadFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please install this plugin from the official repository at {0} to continue using it..
/// </summary>

View File

@ -61,10 +61,6 @@
<value>Impossible de récupérer les statistiques.</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data>
<data name="Command_pal_updatesaves" xml:space="preserve">
<value>Mise à jour de tous les marqueurs du cache local vers la dernière version.</value>
<comment>Shown after /pal update-saves was successful.</comment>
</data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
<value>Connexion réussie.</value>
@ -228,9 +224,6 @@ Il n'y a pas de synchronisation avec les autres joueurs ni de sauvegarde entre l
<data name="Config_Renderer_Simple_Hint" xml:space="preserve">
<value>Expérimental</value>
</data>
<data name="Config_Splatoon_Test" xml:space="preserve">
<value>Test de Splatoon :</value>
</data>
<data name="Config_Splatoon_DrawCircles" xml:space="preserve">
<value>Dessiner les marqueurs des pièges et coffres autour de soi</value>
<comment>To test the Splatoon integration, you can draw markers around yourself.</comment>

View File

@ -61,10 +61,6 @@
<value>統計情報を取得できません。</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data>
<data name="Command_pal_updatesaves" xml:space="preserve">
<value>保存されたマーカーファイルを更新しました。</value>
<comment>Shown after /pal update-saves was successful.</comment>
</data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
<value>接続に成功しました。</value>
@ -227,9 +223,6 @@
<data name="Config_Renderer_Simple_Hint" xml:space="preserve">
<value>試験的機能</value>
</data>
<data name="Config_Splatoon_Test" xml:space="preserve">
<value>Splatoonのテスト:</value>
</data>
<data name="Config_Splatoon_DrawCircles" xml:space="preserve">
<value>自分の周りにトラップと宝箱を表示する</value>
<comment>To test the Splatoon integration, you can draw markers around yourself.</comment>

View File

@ -46,6 +46,9 @@
<value>Please finish the initial setup first.</value>
<comment>Before using any /pal command, the initial setup/agreeement needs to be completed.</comment>
</data>
<data name="Error_LoadFailed" xml:space="preserve">
<value>Plugin could not be loaded: {0}</value>
</data>
<data name="Error_WrongRepository" xml:space="preserve">
<value>Please install this plugin from the official repository at {0} to continue using it.</value>
</data>
@ -66,10 +69,6 @@
<value>Unable to fetch statistics.</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data>
<data name="Command_pal_updatesaves" xml:space="preserve">
<value>Updated all locally cached marker files to latest version.</value>
<comment>Shown after /pal update-saves was successful.</comment>
</data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
@ -239,9 +238,6 @@ This is not synchronized with other players and not saved between floors/runs.</
<data name="Config_Renderer_Simple_Hint" xml:space="preserve">
<value>experimental</value>
</data>
<data name="Config_Splatoon_Test" xml:space="preserve">
<value>Splatoon Test:</value>
</data>
<data name="Config_Splatoon_DrawCircles" xml:space="preserve">
<value>Draw trap &amp; coffer circles around self</value>
<comment>To test the Splatoon integration, you can draw markers around yourself.</comment>
@ -325,6 +321,5 @@ This is not synchronized with other players and not saved between floors/runs.</
<data name="Error_ImportFailed_InvalidFile" xml:space="preserve">
<value>Import failed: Invalid file.</value>
</data>
<!-- Other -->
</root>

20
Pal.Client/README.md Normal file
View File

@ -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
`<Private>false</Private>` 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
```

View File

@ -4,5 +4,6 @@
{
TrapHoard,
RegularCoffers,
Test,
}
}

View File

@ -1,9 +0,0 @@
using System.Numerics;
namespace Pal.Client.Rendering
{
internal interface IDrawDebugItems
{
void DrawDebugItems(Vector4 trapColor, Vector4 hoardColor);
}
}

View File

@ -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<IRenderElement> 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);
}
}

View File

@ -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<Marker.EType, MarkerConfig> MarkerConfigs = new()
private static readonly Dictionary<MemoryLocation.EType, MarkerConfig> 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);
}
}

View File

@ -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<RenderAdapter> _logger;
private readonly IPalacePalConfiguration _configuration;
private IServiceScope? _renderScope;
private IRenderer _implementation;
public RenderAdapter(IServiceScopeFactory serviceScopeFactory, ILogger<RenderAdapter> 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<SplatoonRenderer>();
else
return _renderScope.ServiceProvider.GetRequiredService<SimpleRenderer>();
}
public void ConfigUpdated()
{
_implementation = Recreate(_implementation.GetConfigValue());
}
public void Dispose()
=> _renderScope?.Dispose();
public void SetLayer(ELayer layer, IReadOnlyList<IRenderElement> 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();
}
}
}

View File

@ -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;
}
}

View File

@ -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).
/// </summary>
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<ELayer, SimpleLayer> _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<IRenderElement> elements)
{
_layers[layer] = new SimpleLayer
{
TerritoryType = Service.ClientState.TerritoryType,
TerritoryType = _clientState.TerritoryType,
Elements = elements.Cast<SimpleElement>().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>
{
(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<SimpleElement> 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();
}
}
}
}

View File

@ -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<SplatoonRenderer> _logger;
private readonly DebugState _debugState;
private readonly ClientState _clientState;
private readonly Chat _chat;
public SplatoonRenderer(
ILogger<SplatoonRenderer> 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<IRenderElement> 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<SplatoonElement>().Select(x => x.Delegate).ToArray(), new[] { Environment.TickCount64 + 60 * 60 * 1000, OnTerritoryChange });
Splatoon.AddDynamicElements(ToLayerName(layer),
elements.Cast<SplatoonElement>().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<IRenderElement>
{
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<SplatoonElement>().Select(x => x.Delegate).ToArray(), new[] { Environment.TickCount64 + 10000 }))
if (!Splatoon.AddDynamicElements(ToLayerName(ELayer.Test),
elements.Cast<SplatoonElement>().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<object>();
IList installedPlugins =
pluginManager.GetType().GetProperty("InstalledPlugins")?.GetValue(pluginManager) as IList ??
new List<object>();
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;

View File

@ -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<T> : IHandler
where T : IQueueOnFrameworkThread
{
protected readonly ILogger<Handler<T>> _logger;
protected Handler(ILogger<Handler<T>> 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());
}
}
}
}
}

View File

@ -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<QueuedConfigUpdate>
{
private readonly RenderAdapter _renderAdapter;
public Handler(
ILogger<Handler> 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();
}
}
}
}

View File

@ -1,115 +1,123 @@
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<QueuedImport>
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly Chat _chat;
private readonly ImportService _importService;
private readonly ConfigWindow _configWindow;
public Handler(
ILogger<Handler> logger,
IServiceScopeFactory serviceScopeFactory,
Chat chat,
ImportService importService,
ConfigWindow configWindow)
: base(logger)
{
_serviceScopeFactory = serviceScopeFactory;
_chat = chat;
_importService = importService;
_configWindow = configWindow;
}
protected override void Run(QueuedImport import, ref bool recreateLayout)
{
recreateLayout = true;
try
{
if (!Validate(import))
return;
Task.Run(() =>
{
try
{
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<Guid>();
foreach (var remoteFloor in _export.Floors)
using (var scope = _serviceScopeFactory.CreateScope())
{
ushort territoryType = (ushort)remoteFloor.TerritoryType;
var localState = plugin.GetFloorMarkers(territoryType);
localState.UndoImport(oldExportIds);
ImportFloor(remoteFloor, localState);
localState.Save();
using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
(import.ImportedTraps, import.ImportedHoardCoffers) =
_importService.Import(import.Export);
}
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();
_configWindow.UpdateLastImport();
recreateLayout = true;
saveMarkers = true;
Service.Chat.Print(string.Format(Localization.ImportCompleteStatistics, _importedTraps, _importedHoardCoffers));
_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)
{
PluginLog.Error(e, "Import failed");
Service.Chat.PalError(string.Format(Localization.Error_ImportFailed, 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()
private bool Validate(QueuedImport import)
{
if (_export.ExportVersion != ExportConfig.ExportVersion)
if (import.Export.ExportVersion != ExportConfig.ExportVersion)
{
Service.Chat.PrintError(Localization.Error_ImportFailed_IncompatibleVersion);
_logger.LogError(
"Import: Different version in export file, {ExportVersion} != {ConfiguredVersion}",
import.Export.ExportVersion, ExportConfig.ExportVersion);
_chat.Error(Localization.Error_ImportFailed_IncompatibleVersion);
return false;
}
if (!Guid.TryParse(_export.ExportId, out _exportId) || _exportId == Guid.Empty)
if (!Guid.TryParse(import.Export.ExportId, out Guid exportId) || exportId == Guid.Empty)
{
Service.Chat.PrintError(Localization.Error_ImportFailed_InvalidFile);
_logger.LogError("Import: Invalid export id '{Id}'", import.Export.ExportId);
_chat.Error(Localization.Error_ImportFailed_InvalidFile);
return false;
}
if (string.IsNullOrEmpty(_export.ServerUrl))
import.ExportId = exportId;
if (string.IsNullOrEmpty(import.Export.ServerUrl))
{
// If we allow for backups as import/export, this should be removed
Service.Chat.PrintError(Localization.Error_ImportFailed_InvalidFile);
_logger.LogError("Import: No server URL");
_chat.Error(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)
{
localState.Markers.Add(remoteMarker);
localMarker = remoteMarker;
if (localMarker.Type == Marker.EType.Trap)
_importedTraps++;
else if (localMarker.Type == Marker.EType.Hoard)
_importedHoardCoffers++;
}
remoteMarker.Imports.Add(_exportId);
}
}
}
}

View File

@ -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<Marker> Markers { get; init; }
public required IReadOnlyList<PersistentLocation> Locations { get; init; }
public void Run(Plugin plugin, ref bool recreateLayout, ref bool saveMarkers)
internal sealed class Handler : IQueueOnFrameworkThread.Handler<QueuedSyncResponse>
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IPalacePalConfiguration _configuration;
private readonly FloorService _floorService;
private readonly TerritoryState _territoryState;
private readonly DebugState _debugState;
public Handler(
ILogger<Handler> logger,
IServiceScopeFactory serviceScopeFactory,
IPalacePalConfiguration configuration,
FloorService floorService,
TerritoryState territoryState,
DebugState debugState)
: base(logger)
{
_serviceScopeFactory = serviceScopeFactory;
_configuration = configuration;
_floorService = floorService;
_territoryState = territoryState;
_debugState = debugState;
}
protected override void Run(QueuedSyncResponse queued, ref bool recreateLayout)
{
recreateLayout = true;
saveMarkers = 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)
{
_logger.LogWarning("Discarding sync response for territory {Territory} as it isn't ready",
(ETerritoryType)queued.TerritoryType);
return;
}
try
{
var remoteMarkers = Markers;
var currentFloor = plugin.GetFloorMarkers(TerritoryType);
if (Service.Configuration.Mode == Configuration.EMode.Online && Success && remoteMarkers.Count > 0)
var remoteMarkers = queued.Locations;
if (_configuration.Mode == EMode.Online && queued.Success && remoteMarkers.Count > 0)
{
switch (Type)
switch (queued.Type)
{
case SyncType.Download:
case SyncType.Upload:
List<PersistentLocation> newLocations = new();
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)
PersistentLocation? localLocation =
memoryTerritory.Locations.SingleOrDefault(x => x == remoteMarker);
if (localLocation != null)
{
localMarker.NetworkId = remoteMarker.NetworkId;
localLocation.NetworkId = remoteMarker.NetworkId;
continue;
}
if (Type == SyncType.Download)
currentFloor.Markers.Add(remoteMarker);
if (queued.Type == SyncType.Download)
{
memoryTerritory.Locations.Add(remoteMarker);
newLocations.Add(remoteMarker);
}
}
if (newLocations.Count > 0)
new SaveNewLocations(_serviceScopeFactory, memoryTerritory, newLocations).Start();
break;
case SyncType.MarkSeen:
var partialAccountId = Service.RemoteApi.PartialAccountId;
var partialAccountId =
_configuration.FindAccount(RemoteApi.RemoteUrl)?.AccountId.ToPartialId();
if (partialAccountId == null)
break;
List<PersistentLocation> locationsToUpdate = new();
foreach (var remoteMarker in remoteMarkers)
{
Marker? localMarker = currentFloor.Markers.SingleOrDefault(x => x == remoteMarker);
if (localMarker != null)
localMarker.RemoteSeenOn.Add(partialAccountId);
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 (plugin.LastTerritory != TerritoryType)
if (_territoryState.LastTerritory != queued.TerritoryType)
return;
if (Type == SyncType.Download)
if (queued.Type == SyncType.Download)
{
if (Success)
plugin.TerritorySyncState = SyncState.Complete;
if (queued.Success)
memoryTerritory.SyncState = ESyncState.Complete;
else
plugin.TerritorySyncState = SyncState.Failed;
memoryTerritory.SyncState = ESyncState.Failed;
}
}
catch (Exception e)
{
plugin.DebugMessage = $"{DateTime.Now}\n{e}";
if (Type == SyncType.Download)
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;
}
}
}
}
public enum SyncState
public enum ESyncState
{
NotAttempted,
NotNeeded,

View File

@ -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)
private Guid ExportId { get; }
internal sealed class Handler : IQueueOnFrameworkThread.Handler<QueuedUndoImport>
{
private readonly ImportService _importService;
private readonly ConfigWindow _configWindow;
public Handler(ILogger<Handler> logger, ImportService importService, ConfigWindow configWindow)
: base(logger)
{
_importService = importService;
_configWindow = configWindow;
}
protected override void Run(QueuedUndoImport queued, ref bool recreateLayout)
{
recreateLayout = true;
saveMarkers = true;
foreach (ETerritoryType territoryType in typeof(ETerritoryType).GetEnumValues())
{
var localState = plugin.GetFloorMarkers((ushort)territoryType);
localState.UndoImport(new List<Guid> { _exportId });
localState.Save();
}
Service.Configuration.ImportHistory.RemoveAll(hist => hist.Id == _exportId);
_importService.RemoveById(queued.ExportId);
_configWindow.UpdateLastImport();
}
}
}
}

View File

@ -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!;
}
}

View File

@ -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();

View File

@ -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<ConfigWindow> _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<ConfigWindow> 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) =>
_importDialog.OpenFileDialog(Localization.Palace_Pal, $"{Localization.Palace_Pal} (*.pal) {{.pal}}",
(success, paths) =>
{
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
_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) =>
_importDialog.SaveFileDialog(Localization.Palace_Pal, $"{Localization.Palace_Pal} (*.pal) {{.pal}}",
todaysFileName, "pal", (success, path) =>
{
if (success && !string.IsNullOrEmpty(path))
{
_saveExportPath = path;
}
}, startPath: _saveExportDialogStartPath, isModal: false);
_saveExportDialogStartPath = null; // only use this once, FileDialogManager will save path between calls
_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
};
}
}
}
}

View File

@ -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<ETerritoryType, TerritoryStatistics> _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; }

View File

@ -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,

View File

@ -8,6 +8,6 @@ namespace Pal.Common
{
public static class ExportConfig
{
public static int ExportVersion => 1;
public static int ExportVersion => 2;
}
}

View File

@ -5,5 +5,7 @@
<LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DebugType>portable</DebugType>
<PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
</PropertyGroup>
</Project>