Compare commits

...

132 Commits

Author SHA1 Message Date
d239a6e4b0
API 10 2024-07-06 13:26:39 +02:00
9d5e4a797b
Update Dockerfile 2024-07-06 13:20:17 +02:00
3e2f917c14
API 10 2024-07-06 13:14:59 +02:00
2fdf9b1e4d
Remove GitInfo dependency 2024-01-16 11:41:13 +01:00
18ffd66086
Update icon URL 2024-01-16 07:03:56 +01:00
64e576a004
Render: Ensure RenderElement is null if we don't recreate it 2023-11-30 14:01:59 +01:00
0d1882d97f
Render: Performance fix 2023-11-26 22:14:04 +01:00
964119cfd2
Render: Use enabled instead of color for showing/hiding elements 2023-11-26 20:13:17 +01:00
309edfcd17
Bump version/recompile 2023-11-14 20:21:40 +01:00
8fbd3fbc0d
Update docker build 2023-11-11 01:56:09 +01:00
ce9be45e8f
Update docker build 2023-11-11 01:54:43 +01:00
8def5e28a4
Update dependencies 2023-11-11 01:41:15 +01:00
ba06576c2d
Update dependencies 2023-11-11 01:39:17 +01:00
f259f03534
Update server submodule structure 2023-11-11 01:39:10 +01:00
cf3a24afe8
Update header icon logic for Dalamud changes 2023-11-09 11:32:32 +01:00
2a180345eb
Update submodule URL 2023-10-14 00:02:21 +02:00
345c9c59a3
Add LLib, remove outdated info, add config window constraints 2023-10-11 03:56:18 +02:00
5c4e9f30e0
API 9 2023-10-03 22:05:19 +02:00
d337413b82 Update docker-build.sh 2023-10-03 17:54:53 +00:00
5a5d7fecfd
Change nuget lib path 2023-10-03 11:10:43 +02:00
1ee86d378f
API 9 2023-10-03 11:08:38 +02:00
cd52192d15
Setup server/test submodules 2023-09-17 22:28:37 +02:00
6260f35b61 Update repo location 2023-07-31 02:58:24 +02:00
79ad7fbc39 Update README 2023-07-30 23:53:47 +02:00
1bdcc7179c Update image location 2023-07-30 23:26:15 +02:00
5ba18477b4 Update repo url 2023-07-30 23:22:33 +02:00
5f506bda8a Update server url 2023-07-30 22:32:39 +02:00
bbec57c3ad Update actions 2023-07-30 21:49:14 +02:00
58404b9728 Fix floor change regex (for pomanders)
Not sure how or why that even broke, the string doesn't seem to have changed in the LogMessage sheet.

Fixes #18.
2023-04-13 09:17:06 +02:00
8042aee951 Use file-scoped namespaces 2023-03-30 22:02:51 +02:00
0b07e8b8f6 Code cleanup 2023-03-27 20:00:30 +02:00
7e73430179 Add palace service tests, remove standalone client 2023-03-27 18:02:18 +02:00
cec3597629 Update dependencies 2023-03-26 15:57:50 +02:00
ae29eaa52f Update ECommons 2023-03-26 15:49:52 +02:00
b81ced33e8 Code style tweaks 2023-03-26 15:47:43 +02:00
87806397d1 Configure GHA caching 2023-03-26 14:26:12 +02:00
d645aa9ea7 Configure GHA dotnet test timeout 2023-03-26 13:55:30 +02:00
4288440e6c Add export tests 2023-03-26 03:11:01 +02:00
36b328c29e Add simple account test 2023-03-25 19:14:32 +01:00
232ab164be Update DE localization 2023-03-25 02:47:21 +01:00
a4890c0159 Fix duplicate seen location errors 2023-03-25 02:39:54 +01:00
7bdf97411c Hide occasional FileLoadException (temporary error) 2023-03-11 01:50:26 +01:00
3e9f14419c Update gRPC packages 2023-03-10 03:08:44 +01:00
a21e9335aa GHA: Add workflow_dispatch to docker build 2023-03-10 02:44:04 +01:00
1b415d7a7f Improve repo exception handling 2023-03-08 01:27:10 +01:00
c5acb2ca54 Eureka Orthos: Add owlet trap to pomander of sight 2023-03-07 23:28:58 +01:00
7a514fad2a Add debug feature for object table 2023-03-07 21:55:50 +01:00
125b687a9c Eureka Orthos: Territory types 2023-03-07 12:58:10 +01:00
07ed62cd9a Update JP & DE localization 2023-03-02 02:00:05 +01:00
7179d41e59 Fix local seen 2023-02-26 17:54:15 +01:00
8279bfe9bf Fix config changes to trap/coffer locations not being applied when saving 2023-02-26 17:43:20 +01:00
e45b72a655 Add gold coffers (#10) 2023-02-26 17:31:37 +01:00
cbdcf58063 Move logging into nuget package 2023-02-25 15:32:26 +01:00
d25e21619d Merge branch 'signtool-fix' 2023-02-25 01:35:26 +01:00
2bfbeacdd0 fix CI build 2023-02-25 01:32:39 +01:00
88ec7a00da Improve error handling if a command fails with an exception 2023-02-25 01:15:19 +01:00
0c922c1695 Use PalConfigCommand for the config UI button (partial revert of previous commit) 2023-02-25 01:07:10 +01:00
e1dc8cafd9 Fix /pal test-connection not working if using Simple renderer 2023-02-25 01:06:33 +01:00
e79e8de6dc Use ISubCommand for subcommands 2023-02-25 00:55:48 +01:00
f140fb870c Signing test 2023-02-24 16:21:38 +01:00
3663bbb241 Improve error handling 2023-02-24 11:18:03 +01:00
17f7dcdf12 Db: Make backups work for open databases 2023-02-23 16:13:46 +01:00
f02aeffb26 Fix missing config file breaking plugin load 2023-02-23 09:39:09 +01:00
b658956c4b Fix cleanup not properly closing db 2023-02-23 01:12:05 +01:00
98bc4887d6 New config and database structures (#8) 2023-02-23 00:51:17 +01:00
efeb30331c Db: Use precompiled model 2023-02-23 00:38:52 +01:00
8a27eca8b3 DI: Just use file.copy for backups 2023-02-23 00:09:49 +01:00
0d8f655936 Reset remote url 2023-02-23 00:02:31 +01:00
26b3a54ebd DI: Update namespaces 2023-02-22 23:58:05 +01:00
8d17c02186 DI: Load entire DI container in the background 2023-02-22 22:20:50 +01:00
dbe6abd1db Net: Change retry logic 2023-02-22 21:54:33 +01:00
7bccec0bae Db: lmport 2023-02-22 20:30:26 +01:00
d5dc55a0c4 Db: Fix various things around local persistence/net interactions 2023-02-22 17:21:48 +01:00
802e0c4cde Db: Backups 2023-02-21 17:32:13 +01:00
e0d4a5d676 Namespace cleanup 2023-02-21 16:38:27 +01:00
810aa30cf9 Merge branch 'master' into new-config-and-data
# Conflicts:
#	Pal.Client/Pal.Client.csproj
#	Pal.Client/Windows/AgreementWindow.cs
#	Pal.Client/Windows/ConfigWindow.cs
2023-02-21 16:30:50 +01:00
ff02b1f03c Downgrade GitInfo to avoid extra dependency dll in dist 2023-02-21 16:14:56 +01:00
d1fdd1ce12 Determine client build version automatically from git tag 2023-02-21 16:04:51 +01:00
7b5bb3ee3a Use textwrap for radio buttons 2023-02-21 14:58:30 +01:00
7c968fa9d4 Update README 2023-02-21 14:07:52 +01:00
94f3fa2ede Db: Move Markers into database 2023-02-18 21:12:36 +01:00
f63e70b0c4 DI: Build root scope async while still registering events/commands during plugin init 2023-02-18 04:34:49 +01:00
adddbc452c DI: Support debug items in SimpleRenderer, remove IDrawDebugItems 2023-02-17 19:31:43 +01:00
5419f51942 DI: Only initialize one renderer at once 2023-02-17 19:12:44 +01:00
8986b368c7 Log: Use Serilog directly 2023-02-17 18:36:55 +01:00
e624c5b628 Db: Migrate markers to db 2023-02-17 18:36:22 +01:00
57a5be7938 Config/Db: Move V1 json layouts to legacy folders 2023-02-17 16:30:20 +01:00
870f29f0c6 DI: Add Chat instead of using ChatExtensions 2023-02-17 15:51:45 +01:00
8b6dd52b54 Logging: Use underlying serilog logger directly 2023-02-17 13:57:12 +01:00
3954c839fb Merge branch 'master' into new-config-and-data 2023-02-17 01:14:12 +01:00
f1171d6ccd Update french localization 2023-02-17 01:08:46 +01:00
0bb7301ca1 Logging: Update PluginLog to use Microsoft.Extensions.Logging instead 2023-02-17 00:54:23 +01:00
a5456a54a0 DI: Cleanup 2023-02-16 22:09:29 +01:00
29342264c0 Tweaks for pdb files 2023-02-16 20:40:32 +01:00
c7d5aa1eaa Db: Migrate ImportHistory to sqlite 2023-02-16 19:51:54 +01:00
7e2ccd3b42 Config: Remove Pal.Client.Net dependency for HasRole check 2023-02-16 13:27:28 +01:00
5c82382161 DI: Remove Service class 2023-02-16 13:17:55 +01:00
e27f5a3201 Export: Version bump 2023-02-16 10:51:25 +01:00
3d560fad7f Make chat messages/errors consistent 2023-02-16 10:46:49 +01:00
e3459a0182 DI: Remove WindowSystemExtensions 2023-02-16 10:29:17 +01:00
7d04cd7575 DI: Split QueueHandler into multiple classes 2023-02-16 10:25:33 +01:00
29aefee135 DI: Fix migration, cleanup 2023-02-15 23:51:35 +01:00
c52341eb0d DI: Initial Draft 2023-02-15 23:17:52 +01:00
faa35feade Config: Few JsonRequired attributes 2023-02-15 19:02:57 +01:00
e7c2cd426b Config: Clean up 2023-02-15 13:34:44 +01:00
d1cb7e08f2 Config: Make DPAPI support optional 2023-02-15 13:27:41 +01:00
550fa92a53 Config: Improve account model 2023-02-15 13:00:00 +01:00
16a17e0dcf Config: Add EF Core 2023-02-15 10:46:04 +01:00
4f8deea8e0 Config: account tweaks, UTF-8 fix, update server url 2023-02-15 10:27:00 +01:00
6412afbfbb Merge branch 'master' into new-config-and-data 2023-02-15 02:40:58 +01:00
d3b8001a61 Fix build workflow ignore path 2023-02-15 02:40:15 +01:00
b0de113ad2 New configuration format 2023-02-15 02:38:04 +01:00
4be0bdf637 Update solution items 2023-02-13 22:44:17 +01:00
c586ced5bd Clicking on 'Test Connection' again cancels previous connection attempt 2023-02-13 22:43:30 +01:00
019a204862 Add build.yml as solution item 2023-02-13 17:38:17 +01:00
b81575de84 Update version 2023-02-13 00:22:04 +01:00
2a32cf5c8d Update workflows 2023-02-12 22:45:57 +01:00
8857b93a19 Update workflows 2023-02-12 22:40:43 +01:00
f9cbf0494a Fix HoH 31-90 display names 2023-02-12 22:33:30 +01:00
a39eaa11f1 Add build workflow 2023-02-12 22:31:34 +01:00
9c0699bf2c Update JP localization 2023-02-12 21:00:05 +01:00
3c3ebba645 Simplify English text a bit 2023-02-12 20:58:25 +01:00
ea9feb9599 Update Version 2023-02-12 14:09:53 +01:00
58e0e8d44c Add FR localization 2023-02-12 14:08:58 +01:00
16bc4fc34f Fix agreement window title 2023-02-12 00:41:48 +01:00
2bfcfee753 Code cleanup bits 2023-02-11 21:10:45 +01:00
12de110b04 Update config tab names to include stable ids, add more localizable text 2023-02-11 14:40:22 +01:00
0bec8cf759 Update window titles on language change 2023-02-11 14:31:43 +01:00
c7b8e69732 Update StatisticsWindow to use locale-independent ids 2023-02-11 14:17:26 +01:00
3cd75ff9a5 Update AgreementWindow to be resizeable/have more word wrapping 2023-02-11 14:08:16 +01:00
f7d707073b Add JP localization 2023-02-11 13:47:54 +01:00
a180309815 Color fix 2023-02-10 21:25:28 +01:00
127 changed files with 8532 additions and 3015 deletions

View File

@ -4,4 +4,6 @@
.gitignore .gitignore
Dockerfile Dockerfile
.dockerignore .dockerignore
docker-build.sh
.vs/ .vs/
.idea/

31
.editorconfig Normal file
View File

@ -0,0 +1,31 @@
root = true
[*]
indent_style = space
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[{*.cs}]
charset = utf-8-bom
indent_size = 4
dotnet_sort_system_directives_first = true
csharp_style_implicit_object_creation_when_type_is_apparent = true
csharp_trailing_comma_in_multiline_lists = true
csharp_place_simple_embedded_block_on_same_line = false
csharp_place_attribute_on_same_line = false
resharper_indent_text = ZeroIndent
# methods
csharp_style_expression_bodied_methods = true
# namespaces
csharp_style_namespace_declarations = file_scoped
# braces
csharp_prefer_braces = when_multiline
[{*.resx,*.Designer.cs,packages.lock.json}]
generated_code = true
ij_formatter_enabled = false

View File

@ -1,28 +0,0 @@
name: Server
on:
push:
branches:
- master
paths:
- 'Pal.Common/**'
- 'Pal.Server/**'
permissions:
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Login to GitHub Package Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
push: true
tags: ghcr.io/${{ github.repository_owner }}/palace-pal:latest

View File

@ -1,24 +0,0 @@
name: Upload to Crowdin
on:
push:
branches:
- master
paths:
- 'Pal.Client/Properties/*.resx'
- 'crowdin.yml'
workflow_dispatch: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Crowdin Action
uses: crowdin/github-action@1.4.9
with:
upload_sources: true
upload_translations: true
download_translations: false
crowdin_branch_name: ${{ github.ref_name }}
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

6
.gitmodules vendored
View File

@ -1,3 +1,9 @@
[submodule "vendor/ECommons"] [submodule "vendor/ECommons"]
path = vendor/ECommons path = vendor/ECommons
url = https://github.com/NightmareXIV/ECommons url = https://github.com/NightmareXIV/ECommons
[submodule "vendor/LLib"]
path = vendor/LLib
url = https://git.carvel.li/liza/LLib.git
[submodule "Server"]
path = Server
url = https://git.carvel.li/liza/PalacePal.Server.git

10
Directory.Build.targets Normal file
View File

@ -0,0 +1,10 @@
<Project>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<WindowsKitsRoot Condition="'$(WindowsKitsRoot)' == ''">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows Kits\Installed Roots', 'KitsRoot10', null, RegistryView.Registry32, RegistryView.Default))</WindowsKitsRoot>
<SignToolPath Condition="'$(WindowsKitsRoot)' != '' And '$(SignToolPath)' == '' And exists('$(WindowsKitsRoot)bin\10.0.19041.0\')">$(WindowsKitsRoot)bin\10.0.19041.0\x86\</SignToolPath>
</PropertyGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent" Condition="'$(SignToolPath)' != '' And Exists('$(SolutionDir)codesigning.pfx')">
<Exec Command="&quot;$(SignToolPath)signtool.exe&quot; sign /f $(SolutionDir)codesigning.pfx /t http://timestamp.digicert.com /fd SHA256 &quot;$(TargetPath)&quot;"/>
</Target>
</Project>

View File

@ -1,13 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
ARG TARGETARCH
WORKDIR /build WORKDIR /build
COPY Pal.Common/Pal.Common.csproj Pal.Common/ COPY Pal.Common/Pal.Common.csproj Pal.Common/
COPY Pal.Server/Pal.Server.csproj Pal.Server/ COPY Server/Server/Pal.Server.csproj Server/Server/
RUN dotnet restore Pal.Server/Pal.Server.csproj RUN dotnet restore Server/Server/Pal.Server.csproj -a $TARGETARCH
COPY . ./ COPY . ./
RUN dotnet publish Pal.Server/Pal.Server.csproj -c Release -o /dist RUN dotnet publish Server/Server/Pal.Server.csproj -a $TARGETARCH --no-restore -o /dist
FROM mcr.microsoft.com/dotnet/aspnet:8.0
# fix later
ENV DOTNET_ROLL_FORWARD=Major
ENV DOTNET_ROLL_FORWARD_PRE_RELEASE=1
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS runtime
EXPOSE 5415 EXPOSE 5415
ENV DOTNET_ENVIRONMENT=Production ENV DOTNET_ENVIRONMENT=Production
ENV ASPNETCORE_URLS= ENV ASPNETCORE_URLS=

View File

@ -0,0 +1,9 @@
using System;
using System.Collections.Generic;
namespace Pal.Client.Commands;
public interface ISubCommand
{
IReadOnlyDictionary<string, Action<string>> GetHandlers();
}

View File

@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using Pal.Client.Configuration;
using Pal.Client.Windows;
namespace Pal.Client.Commands;
internal class PalConfigCommand : ISubCommand
{
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 IReadOnlyDictionary<string, Action<string>> GetHandlers()
=> new Dictionary<string, Action<string>>
{
{ "config", _ => Execute() },
{ "", _ => Execute() }
};
public void Execute()
{
if (_configuration.FirstUse)
_agreementWindow.IsOpen = true;
else
_configWindow.Toggle();
}
}

View File

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Plugin.Services;
using Pal.Client.DependencyInjection;
using Pal.Client.Extensions;
using Pal.Client.Floors;
using Pal.Client.Rendering;
namespace Pal.Client.Commands;
internal sealed class PalNearCommand : ISubCommand
{
private readonly Chat _chat;
private readonly IClientState _clientState;
private readonly TerritoryState _territoryState;
private readonly FloorService _floorService;
public PalNearCommand(Chat chat, IClientState clientState, TerritoryState territoryState,
FloorService floorService)
{
_chat = chat;
_clientState = clientState;
_territoryState = territoryState;
_floorService = floorService;
}
public IReadOnlyDictionary<string, Action<string>> GetHandlers()
=> new Dictionary<string, Action<string>>
{
{ "near", _ => DebugNearest(_ => true) },
{ "tnear", _ => DebugNearest(m => m.Type == MemoryLocation.EType.Trap) },
{ "hnear", _ => DebugNearest(m => m.Type == MemoryLocation.EType.Hoard) },
};
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($"Your position: {playerPosition}");
var nearbyMarkers = state.Locations
.Where(m => predicate(m))
.Where(m => m.RenderElement != null && m.RenderElement.Enabled)
.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,24 @@
using System;
using System.Collections.Generic;
using Pal.Client.DependencyInjection;
namespace Pal.Client.Commands;
internal sealed class PalStatsCommand : ISubCommand
{
private readonly StatisticsService _statisticsService;
public PalStatsCommand(StatisticsService statisticsService)
{
_statisticsService = statisticsService;
}
public IReadOnlyDictionary<string, Action<string>> GetHandlers()
=> new Dictionary<string, Action<string>>
{
{ "stats", _ => Execute() },
};
private void Execute()
=> _statisticsService.ShowGlobalStatistics();
}

View File

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using ECommons.Schedulers;
using Pal.Client.Windows;
namespace Pal.Client.Commands;
internal sealed class PalTestConnectionCommand : ISubCommand
{
private readonly ConfigWindow _configWindow;
public PalTestConnectionCommand(ConfigWindow configWindow)
{
_configWindow = configWindow;
}
public IReadOnlyDictionary<string, Action<string>> GetHandlers()
=> new Dictionary<string, Action<string>>
{
{ "test-connection", _ => Execute() },
{ "tc", _ => Execute() },
};
private void Execute()
{
_configWindow.IsOpen = true;
var _ = new TickScheduler(() => _configWindow.TestConnection());
}
}

View File

@ -1,257 +0,0 @@
using Dalamud.Configuration;
using Dalamud.Logging;
using ECommons.Schedulers;
using Newtonsoft.Json;
using Pal.Client.Scheduled;
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;
namespace Pal.Client
{
public class Configuration : IPluginConfiguration
{
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
public bool FirstUse { get; set; } = true;
public EMode Mode { get; set; } = EMode.Offline;
public ERenderer Renderer { get; set; } = ERenderer.Splatoon;
[Obsolete]
public string? DebugAccountId { private get; set; }
[Obsolete]
public string? AccountId { private get; set; }
[Obsolete]
public Dictionary<string, Guid> AccountIds { private get; set; } = new();
public Dictionary<string, AccountInfo> Accounts { get; set; } = new();
public List<ImportHistoryEntry> ImportHistory { get; set; } = new();
public bool ShowTraps { get; set; } = true;
public Vector4 TrapColor { get; set; } = new Vector4(1, 0, 0, 0.4f);
public bool OnlyVisibleTrapsAfterPomander { get; set; } = true;
public bool ShowHoard { get; set; } = true;
public Vector4 HoardColor { get; set; } = new Vector4(0, 1, 1, 0.4f);
public bool OnlyVisibleHoardAfterPomander { get; set; } = true;
public bool ShowSilverCoffers { get; set; } = false;
public Vector4 SilverCofferColor { get; set; } = new Vector4(1, 1, 1, 0.4f);
public bool FillSilverCoffers { get; set; } = true;
/// <summary>
/// Needs to be manually set.
/// </summary>
public string BetaKey { get; set; } = "";
#endregion
#pragma warning disable CS0612 // Type or member is obsolete
public void Migrate()
{
if (Version == 1)
{
PluginLog.Information("Updating config to version 2");
if (DebugAccountId != null && Guid.TryParse(DebugAccountId, out Guid debugAccountId))
AccountIds["http://localhost:5145"] = debugAccountId;
if (AccountId != null && Guid.TryParse(AccountId, out Guid accountId))
AccountIds["https://pal.μ.tv"] = accountId;
Version = 2;
Save();
}
if (Version == 2)
{
PluginLog.Information("Updating config to version 3");
Accounts = AccountIds.ToDictionary(x => x.Key, x => new AccountInfo
{
Id = x.Value
});
Version = 3;
Save();
}
if (Version == 3)
{
Version = 4;
Save();
}
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 =>
{
foreach (var marker in s.Markers)
marker.SinceVersion = "0.0";
var lastModified = File.GetLastWriteTimeUtc(s.GetSaveLocation());
if (lastModified >= new DateTime(2023, 2, 3, 0, 0, 0, DateTimeKind.Utc))
{
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.Save();
changedAnyFile = true;
}
else
{
// just add version information, nothing else
s.Save();
}
});
// Only notify offline users - we can just re-download the backup markers from the server seamlessly.
if (Mode == EMode.Offline && changedAnyFile)
{
new TickScheduler(delegate
{
Service.Chat.PalError("Due to a bug, some coffers were accidentally saved as traps. To fix the related display issue, locally cached data was cleaned up.");
Service.Chat.PrintError($"If you have any backup tools installed, please restore the contents of '{Service.PluginInterface.GetPluginConfigDirectory()}' to any backup from February 2, 2023 or before.");
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();
}
if (Version == 5)
{
LocalState.UpdateAll();
Version = 6;
Save();
}
}
#pragma warning restore CS0612 // Type or member is obsolete
public void Save()
{
Service.PluginInterface.SavePluginConfig(this);
Service.Plugin.EarlyEventQueue.Enqueue(new QueuedConfigUpdate());
}
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,
}
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 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 Guid Id { get; set; }
public string? RemoteUrl { get; set; }
public DateTime ExportedAt { get; set; }
/// <summary>
/// Set when the file is imported locally.
/// </summary>
public DateTime ImportedAt { get; set; }
}
}
}

View File

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace Pal.Client.Configuration;
public sealed class AccountConfigurationV7 : IAccountConfiguration
{
private const int DefaultEntropyLength = 16;
[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)
{
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,40 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace Pal.Client.Configuration;
internal static class ConfigurationData
{
[Obsolete("for V1 import")]
internal static readonly byte[] FixedV1Entropy = { 0x22, 0x4b, 0xe7, 0x21, 0x44, 0x83, 0x69, 0x55, 0x80, 0x38 };
public const string ConfigFileName = "palace-pal.config.json";
private static bool? _supportsDpapi;
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;
}
}
return _supportsDpapi.Value;
}
}
}

View File

@ -0,0 +1,170 @@
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 IDalamudPluginInterface _pluginInterface;
private readonly IServiceProvider _serviceProvider;
public event EventHandler<IPalacePalConfiguration>? Saved;
public ConfigurationManager(ILogger<ConfigurationManager> logger, IDalamudPluginInterface pluginInterface,
IServiceProvider serviceProvider)
{
_logger = logger;
_pluginInterface = pluginInterface;
_serviceProvider = serviceProvider;
}
private string ConfigPath =>
Path.Join(_pluginInterface.GetPluginConfigDirectory(), ConfigurationData.ConfigFileName);
public IPalacePalConfiguration Load()
{
if (!File.Exists(ConfigPath))
{
_logger.LogInformation("No config file exists, creating one");
Save(new ConfigurationV7(), false);
}
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")
.Replace("pal.liza.sh", "connect.palacepal.com"),
ExportedAt = importHistory.ExportedAt,
ImportedAt = importHistory.ImportedAt
});
}
dbContext.SaveChanges();
}
File.Move(_pluginInterface.ConfigFile.FullName, _pluginInterface.ConfigFile.FullName + ".old", true);
}
IPalacePalConfiguration? currentConfig = Load();
IAccountConfiguration? legacyAccount = currentConfig?.FindAccount("https://pal.liza.sh");
if (currentConfig != null && legacyAccount != null)
{
IAccountConfiguration newAccount = currentConfig.CreateAccount("https://connect.palacepal.com", legacyAccount.AccountId);
newAccount.CachedRoles = legacyAccount.CachedRoles;
currentConfig.RemoveAccount(legacyAccount.Server);
Save(currentConfig, false);
}
}
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")
.Replace("pal.liza.sh", "connect.palacepal.com");
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,53 @@
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,14 @@
namespace Pal.Client.Configuration;
public enum EMode
{
/// <summary>
/// Fetches trap locations from remote server.
/// </summary>
Online = 1,
/// <summary>
/// Only shows traps found by yourself using a pomander of sight.
/// </summary>
Offline = 2,
}

View File

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

View File

@ -0,0 +1,110 @@
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; }
bool HasBetaFeature(string feature) => BetaKey.Contains(feature);
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 MarkerConfiguration GoldCoffers { get; set; } = new()
{
Show = false,
Color = ImGui.ColorConvertFloat4ToU32(new Vector4(1, 1, 0, 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

@ -0,0 +1,166 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using Dalamud.Plugin;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Pal.Client.Configuration.Legacy;
[Obsolete]
public sealed class ConfigurationV1
{
public int Version { get; set; } = 6;
#region Saved configuration values
public bool FirstUse { get; set; } = true;
public EMode Mode { get; set; } = EMode.Offline;
public ERenderer Renderer { get; set; } = ERenderer.Splatoon;
[Obsolete]
public string? DebugAccountId { private get; set; }
[Obsolete]
public string? AccountId { private get; set; }
[Obsolete]
public Dictionary<string, Guid> AccountIds { private get; set; } = new();
public Dictionary<string, AccountInfo> Accounts { get; set; } = new();
public List<ImportHistoryEntry> ImportHistory { get; set; } = new();
public bool ShowTraps { get; set; } = true;
public Vector4 TrapColor { get; set; } = new(1, 0, 0, 0.4f);
public bool OnlyVisibleTrapsAfterPomander { get; set; } = true;
public bool ShowHoard { get; set; } = true;
public Vector4 HoardColor { get; set; } = new(0, 1, 1, 0.4f);
public bool OnlyVisibleHoardAfterPomander { get; set; } = true;
public bool ShowSilverCoffers { get; set; }
public Vector4 SilverCofferColor { get; set; } = new(1, 1, 1, 0.4f);
public bool FillSilverCoffers { get; set; } = true;
/// <summary>
/// Needs to be manually set.
/// </summary>
public string BetaKey { get; set; } = "";
#endregion
public void Migrate(IDalamudPluginInterface pluginInterface, ILogger<ConfigurationV1> logger)
{
if (Version == 1)
{
logger.LogInformation("Updating config to version 2");
if (DebugAccountId != null && Guid.TryParse(DebugAccountId, out Guid debugAccountId))
AccountIds["http://localhost:5145"] = debugAccountId;
if (AccountId != null && Guid.TryParse(AccountId, out Guid accountId))
AccountIds["https://pal.μ.tv"] = accountId;
Version = 2;
Save(pluginInterface);
}
if (Version == 2)
{
logger.LogInformation("Updating config to version 3");
Accounts = AccountIds.ToDictionary(x => x.Key, x => new AccountInfo
{
Id = x.Value.ToString() // encryption happens in V7 migration at latest
});
Version = 3;
Save(pluginInterface);
}
if (Version == 3)
{
Version = 4;
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;
JsonFloorState.ForEach(s =>
{
foreach (var marker in s.Markers)
marker.SinceVersion = "0.0";
var lastModified = File.GetLastWriteTimeUtc(s.GetSaveLocation());
if (lastModified >= new DateTime(2023, 2, 3, 0, 0, 0, DateTimeKind.Utc))
{
s.Backup(suffix: "bak");
s.Markers = new ConcurrentBag<JsonMarker>(s.Markers.Where(m => m.SinceVersion != "0.0" || m.Type == JsonMarker.EType.Hoard || m.WasImported));
s.Save();
//changedAnyFile = true;
}
else
{
// just add version information, nothing else
s.Save();
}
});
/*
// Only notify offline users - we can just re-download the backup markers from the server seamlessly.
if (Mode == EMode.Offline && changedAnyFile)
{
_ = new TickScheduler(delegate
{
Service.Chat.PalError("Due to a bug, some coffers were accidentally saved as traps. To fix the related display issue, locally cached data was cleaned up.");
Service.Chat.PrintError($"If you have any backup tools installed, please restore the contents of '{Service.PluginInterface.GetPluginConfigDirectory()}' to any backup from February 2, 2023 or before.");
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(pluginInterface);
}
if (Version == 5)
{
JsonFloorState.UpdateAll();
Version = 6;
Save(pluginInterface);
}
}
public void Save(IDalamudPluginInterface pluginInterface)
{
File.WriteAllText(pluginInterface.ConfigFile.FullName, JsonConvert.SerializeObject(this, Formatting.Indented, new JsonSerializerSettings
{
TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple,
TypeNameHandling = TypeNameHandling.Objects
}));
}
public sealed class AccountInfo
{
public string? Id { get; set; }
public List<string> CachedRoles { get; set; } = new();
}
public sealed class ImportHistoryEntry
{
public Guid Id { get; set; }
public string? RemoteUrl { get; set; }
public DateTime ExportedAt { get; set; }
/// <summary>
/// Set when the file is imported locally.
/// </summary>
public DateTime ImportedAt { get; set; }
}
}

View File

@ -0,0 +1,161 @@
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.Configuration.Legacy;
/// <summary>
/// Legacy JSON file for marker locations.
/// </summary>
[Obsolete]
public sealed class JsonFloorState
{
private static readonly JsonSerializerOptions JsonSerializerOptions = new() { IncludeFields = true };
private const int CurrentVersion = 4;
private static string _pluginConfigDirectory = null!;
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()
{
Markers = new ConcurrentBag<JsonMarker>(Markers.Where(x => x.Seen || !x.WasImported || x.Imports.Count > 0));
}
public static JsonFloorState? Load(ushort territoryType)
{
string path = GetSaveLocation(territoryType);
if (!File.Exists(path))
return null;
string content = File.ReadAllText(path);
if (content.Length == 0)
return null;
JsonFloorState localState;
int version = 1;
if (content[0] == '[')
{
// v1 only had a list of markers, not a JSON object as root
localState = new JsonFloorState(territoryType)
{
Markers = new ConcurrentBag<JsonMarker>(JsonSerializer.Deserialize<HashSet<JsonMarker>>(content, JsonSerializerOptions) ?? new()),
};
}
else
{
var save = JsonSerializer.Deserialize<SaveFile>(content, JsonSerializerOptions);
if (save == null)
return null;
localState = new JsonFloorState(territoryType)
{
Markers = new ConcurrentBag<JsonMarker>(save.Markers.Where(o => o.Type == JsonMarker.EType.Trap || o.Type == JsonMarker.EType.Hoard)),
};
version = save.Version;
}
localState.ApplyFilters();
if (version <= 3)
{
foreach (var marker in localState.Markers)
marker.RemoteSeenOn = marker.RemoteSeenOn.Select(x => x.ToPartialId()).ToList();
}
if (version < CurrentVersion)
localState.Save();
return localState;
}
public void Save()
{
string path = GetSaveLocation(TerritoryType);
ApplyFilters();
SaveImpl(path);
}
public void Backup(string suffix)
{
string path = $"{GetSaveLocation(TerritoryType)}.{suffix}";
if (!File.Exists(path))
{
SaveImpl(path);
}
}
private void SaveImpl(string path)
{
foreach (var marker in Markers)
{
if (string.IsNullOrEmpty(marker.SinceVersion))
marker.SinceVersion = typeof(Plugin).Assembly.GetName().Version!.ToString(2);
}
if (Markers.Count == 0)
File.Delete(path);
else
{
File.WriteAllText(path, JsonSerializer.Serialize(new SaveFile
{
Version = CurrentVersion,
Markers = new HashSet<JsonMarker>(Markers)
}, JsonSerializerOptions));
}
}
public string GetSaveLocation() => GetSaveLocation(TerritoryType);
private static string GetSaveLocation(uint territoryType) => Path.Join(_pluginConfigDirectory, $"{territoryType}.json");
public static void ForEach(Action<JsonFloorState> action)
{
foreach (ETerritoryType territory in typeof(ETerritoryType).GetEnumValues())
{
// 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);
}
}
public static void UpdateAll()
{
ForEach(s => s.Save());
}
public void UndoImport(List<Guid> importIds)
{
// When saving a floor state, any markers not seen, not remote seen, and not having an import id are removed;
// so it is possible to remove "wrong" markers by not having them be in the current import.
foreach (var marker in Markers)
marker.Imports.RemoveAll(importIds.Contains);
}
public sealed class SaveFile
{
public int Version { get; set; }
public HashSet<JsonMarker> Markers { get; set; } = new();
}
}

View File

@ -0,0 +1,25 @@
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,146 @@
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 IDalamudPluginInterface _pluginInterface;
public JsonMigration(ILogger<JsonMigration> logger, IServiceScopeFactory serviceScopeFactory,
IDalamudPluginInterface 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

@ -1,20 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project> <Project>
<Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'"> <Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'">
<DalamudPackager <DalamudPackager
ProjectDir="$(ProjectDir)" ProjectDir="$(ProjectDir)"
OutputPath="$(OutputPath)" OutputPath="$(OutputPath)"
AssemblyName="$(AssemblyName)" AssemblyName="$(AssemblyName)"
MakeZip="false" MakeZip="false"
VersionComponents="2"/> VersionComponents="2"/>
</Target> </Target>
<Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'"> <Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
<DalamudPackager <DalamudPackager
ProjectDir="$(ProjectDir)" ProjectDir="$(ProjectDir)"
OutputPath="$(OutputPath)" OutputPath="$(OutputPath)"
AssemblyName="$(AssemblyName)" AssemblyName="$(AssemblyName)"
MakeZip="true" MakeZip="true"
VersionComponents="2"/> VersionComponents="2"
</Target> Exclude="ECommons.pdb;ECommons.xml"/>
</Target>
</Project> </Project>

View File

@ -0,0 +1,66 @@
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,58 @@
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,14 @@
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,23 @@
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,40 @@
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,195 @@
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 CreateBackup();
cancellationToken.ThrowIfCancellationRequested();
await RunMigrations(cancellationToken);
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();
await RunCleanup();
cancellationToken.ThrowIfCancellationRequested();
await RemoveOldBackups();
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<IEnumerable<ISubCommand>>();
cancellationToken.ThrowIfCancellationRequested();
if (_serviceProvider.GetRequiredService<IPalacePalConfiguration>().HasBetaFeature(ObjectTableDebug.FeatureName))
_serviceProvider.GetRequiredService<ObjectTableDebug>();
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Async init complete");
}
private async Task RemoveOldBackups()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var pluginInterface = scope.ServiceProvider.GetRequiredService<IDalamudPluginInterface>();
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.Now.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 CreateBackup()
{
await using var scope = _serviceProvider.CreateAsyncScope();
var pluginInterface = scope.ServiceProvider.GetRequiredService<IDalamudPluginInterface>();
string backupPath = Path.Join(pluginInterface.GetPluginConfigDirectory(),
$"backup-{DateTime.Now.ToUniversalTime():yyyy-MM-dd}.data.sqlite3");
string sourcePath = Path.Join(pluginInterface.GetPluginConfigDirectory(),
DependencyInjectionContext.DatabaseFileName);
if (File.Exists(sourcePath) && !File.Exists(backupPath))
{
try
{
if (File.Exists(sourcePath + "-shm") || File.Exists(sourcePath + "-wal"))
{
_logger.LogInformation("Creating database backup '{Path}' (open db)", backupPath);
await using var db = scope.ServiceProvider.GetRequiredService<PalClientContext>();
await using SqliteConnection source = new(db.Database.GetConnectionString());
await source.OpenAsync();
await using SqliteConnection backup = new($"Data Source={backupPath}");
source.BackupDatabase(backup);
SqliteConnection.ClearPool(backup);
}
else
{
_logger.LogInformation("Creating database backup '{Path}' (file copy)", backupPath);
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.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.Services;
using ECommons.DalamudServices.Legacy;
using Pal.Client.Properties;
namespace Pal.Client.DependencyInjection;
internal sealed class Chat
{
private readonly IChatGui _chatGui;
public Chat(IChatGui 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,116 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.Services;
using Lumina.Excel.GeneratedSheets;
using Pal.Client.Configuration;
using Pal.Client.Floors;
namespace Pal.Client.DependencyInjection;
internal sealed class ChatService : IDisposable
{
private readonly IChatGui _chatGui;
private readonly TerritoryState _territoryState;
private readonly IPalacePalConfiguration _configuration;
private readonly IDataManager _dataManager;
private readonly LocalizedChatMessages _localizedChatMessages;
public ChatService(IChatGui chatGui, TerritoryState territoryState, IPalacePalConfiguration configuration,
IDataManager 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, int 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, true).Replace("\u0002 \u0003\ufffd\u0002\u0003", @"(\d+)") +
"$"),
};
}
private string GetLocalizedString(uint id, bool asRawData = false)
{
var text = _dataManager.GetExcelSheet<LogMessage>()?.GetRow(id)?.Text;
if (text == null)
return "Unknown";
if (asRawData)
return Encoding.UTF8.GetString(text.RawData);
else
return text.ToString();
}
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,14 @@
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

@ -0,0 +1,106 @@
using System;
using System.Text;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Hooking;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using Microsoft.Extensions.Logging;
using Pal.Client.Floors;
namespace Pal.Client.DependencyInjection;
internal sealed unsafe class GameHooks : IDisposable
{
private readonly ILogger<GameHooks> _logger;
private readonly IObjectTable _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);
[Signature("40 53 55 56 57 48 81 EC ?? ?? ?? ?? 0F 29 B4 24 ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 0F B6 AC 24 ?? ?? ?? ?? 0F 28 F3 49 8B F8", DetourName = nameof(ActorVfxCreate))]
private Hook<ActorVfxCreateDelegate> ActorVfxCreateHook { get; init; } = null!;
#pragma warning restore CS0649
public GameHooks(ILogger<GameHooks> logger, IObjectTable objectTable, TerritoryState territoryState, FrameworkService frameworkService, IGameInteropProvider gameInteropProvider)
{
_logger = logger;
_objectTable = objectTable;
_territoryState = territoryState;
_frameworkService = frameworkService;
_logger.LogDebug("Initializing game hooks");
gameInteropProvider.InitializeFromAttributes(this);
ActorVfxCreateHook.Enable();
_logger.LogDebug("Game hooks initialized");
}
/// <summary>
/// Even with a pomander of sight, the BattleChara's position for the trap remains at {0, 0, 0} until it is activated.
/// Upon exploding, the trap's position is moved to the exact location that the pomander of sight would have revealed.
///
/// That exact position appears to be used for VFX playing when you walk into it - even if you barely walk into the
/// outer ring of an otter/luring/impeding/landmine trap, the VFX plays at the exact center and not at your character's
/// location.
///
/// Especially at higher floors, you're more likely to walk into an undiscovered trap compared to e.g. 51-60,
/// and you probably don't want to/can't use sight on every floor - yet the trap location is still useful information.
///
/// Some (but not all) chests also count as BattleChara named 'Trap', however the effect upon opening isn't played via
/// ActorVfxCreate even if they explode (but probably as a Vfx with static location, doesn't matter for here).
///
/// Landmines and luring traps also don't play a VFX attached to their BattleChara.
///
/// otter: vfx/common/eff/dk05th_stdn0t.avfx <br/>
/// toading: vfx/common/eff/dk05th_stdn0t.avfx <br/>
/// enfeebling: vfx/common/eff/dk05th_stdn0t.avfx <br/>
/// landmine: none <br/>
/// luring: none <br/>
/// impeding: vfx/common/eff/dk05ht_ipws0t.avfx (one of silence/pacification) <br/>
/// impeding: vfx/common/eff/dk05ht_slet0t.avfx (the other of silence/pacification) <br/>
///
/// It is of course annoying that, when testing, almost all traps are landmines.
/// There's also vfx/common/eff/dk01gd_inv0h.avfx for e.g. impeding when you're invulnerable, but not sure if that
/// has other trigger conditions.
/// </summary>
public nint ActorVfxCreate(char* a1, nint a2, nint a3, float a4, char a5, ushort a6, char a7)
{
try
{
if (_territoryState.IsInDeepDungeon())
{
var vfxPath = MemoryHelper.ReadString(new nint(a1), Encoding.ASCII, 256);
var obj = _objectTable.CreateObjectReference(a2);
/*
if (Service.Configuration.BetaKey == "VFX")
_chat.PalPrint($"{vfxPath} on {obj}");
*/
if (obj is IBattleChara bc && (bc.NameId == /* potd */ 5042 || bc.NameId == /* hoh */ 7395))
{
if (vfxPath == "vfx/common/eff/dk05th_stdn0t.avfx" || vfxPath == "vfx/common/eff/dk05ht_ipws0t.avfx")
{
_logger.LogDebug("VFX '{Path}' playing at {Location}", vfxPath, obj.Position);
_frameworkService.NextUpdateObjects.Enqueue(obj.Address);
}
}
}
}
catch (Exception e)
{
_logger.LogError(e, "VFX Create Hook failed");
}
return ActorVfxCreateHook.Original(a1, a2, a3, a4, a5, a6, a7);
}
public void Dispose()
{
_logger.LogDebug("Disposing game hooks");
ActorVfxCreateHook.Dispose();
}
}

View File

@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
using Export;
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,27 @@
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, IDalamudPluginInterface pluginInterface, Chat chat)
{
logger.LogInformation("Install source: {Repo}", pluginInterface.SourceRepository);
if (!pluginInterface.IsDev && pluginInterface.SourceRepository.TrimEnd('/') != "https://plugins.carvel.li")
{
chat.Error(string.Format(Localization.Error_WrongRepository,
"https://plugins.carvel.li"));
throw new RepoVerificationFailedException();
}
}
internal sealed class RepoVerificationFailedException : Exception
{
}
}

View File

@ -0,0 +1,74 @@
using System;
using System.Threading.Tasks;
using Dalamud.Game.Gui;
using Grpc.Core;
using Microsoft.Extensions.Logging;
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 ILogger<StatisticsService> _logger;
private readonly RemoteApi _remoteApi;
private readonly StatisticsWindow _statisticsWindow;
private readonly Chat _chat;
public StatisticsService(
IPalacePalConfiguration configuration,
ILogger<StatisticsService> logger,
RemoteApi remoteApi,
StatisticsWindow statisticsWindow,
Chat chat)
{
_configuration = configuration;
_logger = logger;
_remoteApi = remoteApi;
_statisticsWindow = statisticsWindow;
_chat = chat;
}
public void ShowGlobalStatistics()
{
Task.Run(async () => await FetchFloorStatistics());
}
private async Task FetchFloorStatistics()
{
try
{
if (!_configuration.HasRoleOnCurrentServer(RemoteApi.RemoteUrl, "statistics:view"))
{
_chat.Error(Localization.Command_pal_stats_CurrentFloor);
return;
}
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)
{
_logger.LogWarning(e, "Access denied while fetching floor statistics");
_chat.Error(Localization.Command_pal_stats_CurrentFloor);
}
catch (Exception e)
{
_logger.LogError(e, "Could not fetch floor statistics");
_chat.Error(string.Format(Localization.Error_CommandFailed,
$"{e.GetType()} - {e.Message}"));
}
}
}

View File

@ -0,0 +1,194 @@
using System;
using System.IO;
using Dalamud.Data;
using Dalamud.Extensions.MicrosoftLogging;
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 Dalamud.Plugin.Services;
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.Net;
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";
/// <summary>
/// Initialized as temporary logger, will be overriden once context is ready with a logger that supports scopes.
/// </summary>
private ILogger _logger;
private readonly string _sqliteConnectionString;
private readonly ServiceCollection _serviceCollection = new();
private ServiceProvider? _serviceProvider;
public DependencyInjectionContext(
IDalamudPluginInterface pluginInterface,
IClientState clientState,
IGameGui gameGui,
IChatGui chatGui,
IObjectTable objectTable,
IFramework framework,
ICondition condition,
ICommandManager commandManager,
IDataManager dataManager,
IGameInteropProvider gameInteropProvider,
IPluginLog pluginLog,
Plugin plugin)
{
var loggerProvider = new DalamudLoggerProvider(pluginLog);
_logger = loggerProvider.CreateLogger<DependencyInjectionContext>();
_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()
.AddDalamudLogger(pluginLog));
// 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(gameInteropProvider);
_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<ISubCommand, PalConfigCommand>();
_serviceCollection.AddScoped<ISubCommand, PalNearCommand>();
_serviceCollection.AddScoped<ISubCommand, PalStatsCommand>();
_serviceCollection.AddScoped<ISubCommand, PalTestConnectionCommand>();
// territory & marker related services
_serviceCollection.AddScoped<TerritoryState>();
_serviceCollection.AddScoped<FrameworkService>();
_serviceCollection.AddScoped<ChatService>();
_serviceCollection.AddScoped<FloorService>();
_serviceCollection.AddScoped<ImportService>();
_serviceCollection.AddScoped<ObjectTableDebug>();
// 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,10 +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

@ -0,0 +1,12 @@
using System;
namespace Pal.Client.Extensions;
public static class GuidExtensions
{
public static string ToPartialId(this Guid g, int length = 13)
=> g.ToString().ToPartialId();
public static string ToPartialId(this string s, int length = 13)
=> s.PadRight(length + 1).Substring(0, length);
}

View File

@ -0,0 +1,35 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using ImGuiNET;
namespace Pal.Client.Extensions;
internal static class PalImGui
{
/// <summary>
/// None of the default BeginTabItem methods allow using flags without making the tab have a close button for some reason.
/// </summary>
internal static unsafe bool BeginTabItemWithFlags(string label, ImGuiTabItemFlags flags)
{
int labelLength = Encoding.UTF8.GetByteCount(label);
byte* labelPtr = stackalloc byte[labelLength + 1];
byte[] labelBytes = Encoding.UTF8.GetBytes(label);
Marshal.Copy(labelBytes, 0, (IntPtr)labelPtr, labelLength);
labelPtr[labelLength] = 0;
return ImGuiNative.igBeginTabItem(labelPtr, null, flags) != 0;
}
public static void RadioButtonWrapped(string label, ref int choice, int value)
{
ImGui.BeginGroup();
ImGui.RadioButton($"##radio{value}", value == choice);
ImGui.SameLine();
ImGui.TextWrapped(label);
ImGui.EndGroup();
if (ImGui.IsItemClicked())
choice = value;
}
}

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,28 @@
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,162 @@
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,464 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services;
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 IFramework _framework;
private readonly ConfigurationManager _configurationManager;
private readonly IPalacePalConfiguration _configuration;
private readonly IClientState _clientState;
private readonly TerritoryState _territoryState;
private readonly FloorService _floorService;
private readonly DebugState _debugState;
private readonly RenderAdapter _renderAdapter;
private readonly IObjectTable _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,
IFramework framework,
ConfigurationManager configurationManager,
IPalacePalConfiguration configuration,
IClientState clientState,
TerritoryState territoryState,
FloorService floorService,
DebugState debugState,
RenderAdapter renderAdapter,
IObjectTable 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(IFramework 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;
if (_renderAdapter.RequireRedraw)
{
recreateLayout = true;
_renderAdapter.RequireRedraw = false;
}
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)
{
bool isEnabled = DetermineVisibility(location, visibleLocations);
if (location.RenderElement == null)
{
if (isEnabled)
return true;
else
continue;
}
if (!location.RenderElement.IsValid)
return true;
if (location.RenderElement.Enabled != isEnabled)
location.RenderElement.Enabled = isEnabled;
}
}
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, DetermineVisibility(location, visibleMarkers),
_configuration.DeepDungeons.Traps);
}
else if (location.Type == MemoryLocation.EType.Hoard)
{
CreateRenderElement(location, elements, DetermineVisibility(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, true, _configuration.DeepDungeons.SilverCoffers);
}
else if (location.Type == MemoryLocation.EType.GoldCoffer &&
_configuration.DeepDungeons.GoldCoffers.Show)
{
CreateRenderElement(location, elements, true, _configuration.DeepDungeons.GoldCoffers);
}
}
if (elements.Count == 0)
return;
_renderAdapter.SetLayer(ELayer.RegularCoffers, elements);
}
private bool DetermineVisibility(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 true;
case MemoryLocation.EType.Hoard
when _territoryState.PomanderOfIntuition == PomanderState.Inactive ||
!_configuration.DeepDungeons.HoardCoffers.OnlyVisibleAfterPomander ||
visibleLocations.Any(x => x == location):
return true;
default:
return false;
}
}
private void CreateRenderElement(MemoryLocation location, List<IRenderElement> elements, bool enabled,
MarkerConfiguration config)
{
if (!config.Show)
{
location.RenderElement = null;
return;
}
var element =
_renderAdapter.CreateElement(location.Type, location.Position, enabled, config.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++)
{
IGameObject? 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:
case 2013284:
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;
case 2007358:
ephemeralLocations.Add(new EphemeralLocation
{
Type = MemoryLocation.EType.GoldCoffer,
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,
GoldCoffer,
}
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,62 @@
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,99 @@
using System;
using System.Numerics;
using System.Runtime.InteropServices;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using ImGuiNET;
namespace Pal.Client.Floors;
/// <summary>
/// This isn't very useful for running deep dungeons normally, but it is for plugin dev.
///
/// Needs the corresponding beta feature to be enabled.
/// </summary>
internal sealed class ObjectTableDebug : IDisposable
{
public const string FeatureName = nameof(ObjectTableDebug);
private readonly IDalamudPluginInterface _pluginInterface;
private readonly IObjectTable _objectTable;
private readonly IGameGui _gameGui;
private readonly IClientState _clientState;
public ObjectTableDebug(IDalamudPluginInterface pluginInterface, IObjectTable objectTable, IGameGui gameGui,
IClientState clientState)
{
_pluginInterface = pluginInterface;
_objectTable = objectTable;
_gameGui = gameGui;
_clientState = clientState;
_pluginInterface.UiBuilder.Draw += Draw;
}
private void Draw()
{
int index = 0;
foreach (IGameObject obj in _objectTable)
{
if (obj is IEventObj eventObj && string.IsNullOrEmpty(eventObj.Name.ToString()))
{
++index;
int model = Marshal.ReadInt32(obj.Address + 128);
if (_gameGui.WorldToScreen(obj.Position, out var screenCoords))
{
// So, while WorldToScreen will return false if the point is off of game client screen, to
// to avoid performance issues, we have to manually determine if creating a window would
// produce a new viewport, and skip rendering it if so
float distance = DistanceToPlayer(obj.Position);
var objectText =
$"{obj.Address.ToInt64():X}:{obj.EntityId:X}[{index}]\nkind: {obj.ObjectKind} sub: {obj.SubKind}\nmodel: {model}\nname: {obj.Name}\ndata id: {obj.DataId}";
var screenPos = ImGui.GetMainViewport().Pos;
var screenSize = ImGui.GetMainViewport().Size;
var windowSize = ImGui.CalcTextSize(objectText);
// Add some extra safety padding
windowSize.X += ImGui.GetStyle().WindowPadding.X + 10;
windowSize.Y += ImGui.GetStyle().WindowPadding.Y + 10;
if (screenCoords.X + windowSize.X > screenPos.X + screenSize.X ||
screenCoords.Y + windowSize.Y > screenPos.Y + screenSize.Y)
continue;
if (distance > 50f)
continue;
ImGui.SetNextWindowPos(new Vector2(screenCoords.X, screenCoords.Y));
ImGui.SetNextWindowBgAlpha(Math.Max(1f - (distance / 50f), 0.2f));
if (ImGui.Begin(
$"PalacePal_{nameof(ObjectTableDebug)}_{index}",
ImGuiWindowFlags.NoDecoration |
ImGuiWindowFlags.AlwaysAutoResize |
ImGuiWindowFlags.NoSavedSettings |
ImGuiWindowFlags.NoMove |
ImGuiWindowFlags.NoMouseInputs |
ImGuiWindowFlags.NoDocking |
ImGuiWindowFlags.NoFocusOnAppearing |
ImGuiWindowFlags.NoNav))
ImGui.Text(objectText);
ImGui.End();
}
}
}
}
private float DistanceToPlayer(Vector3 center)
=> Vector3.Distance(_clientState.LocalPlayer?.Position ?? Vector3.Zero, center);
public void Dispose()
{
_pluginInterface.UiBuilder.Draw -= Draw;
}
}

View File

@ -0,0 +1,54 @@
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,46 @@
using System;
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(() =>
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
ILogger<T> logger = scope.ServiceProvider.GetRequiredService<ILogger<T>>();
try
{
using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
Run(dbContext, logger);
}
catch (Exception e)
{
logger.LogError(e, "Failed to run DbTask");
}
}
catch (Exception)
{
// nothing we can do here but catch it, if we don't we crash the game
}
});
}
protected abstract void Run(PalClientContext dbContext, ILogger<T> logger);
}

View File

@ -0,0 +1,78 @@
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,37 @@
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);
List<int> localIds = _locations.Select(l => l.LocalId).Where(x => x != null).Cast<int>().ToList();
dbContext.Locations
.Where(loc => localIds.Contains(loc.LocalId))
.ExecuteUpdate(loc => loc.SetProperty(l => l.Seen, true));
dbContext.SaveChanges();
}
}
}

View File

@ -0,0 +1,50 @@
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,76 @@
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,34 @@
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Plugin.Services;
using Pal.Common;
namespace Pal.Client.Floors;
public sealed class TerritoryState
{
private readonly IClientState _clientState;
private readonly ICondition _condition;
public TerritoryState(IClientState clientState, ICondition 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,89 +0,0 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Hooking;
using Dalamud.Logging;
using Dalamud.Memory;
using Dalamud.Utility.Signatures;
using System;
using System.Text;
namespace Pal.Client
{
internal unsafe class Hooks
{
#pragma warning disable CS0649
private delegate nint ActorVfxCreateDelegate(char* a1, nint a2, nint a3, float a4, char a5, ushort a6, char a7);
[Signature("40 53 55 56 57 48 81 EC ?? ?? ?? ?? 0F 29 B4 24 ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 0F B6 AC 24 ?? ?? ?? ?? 0F 28 F3 49 8B F8", DetourName = nameof(ActorVfxCreate))]
private Hook<ActorVfxCreateDelegate> ActorVfxCreateHook { get; init; } = null!;
#pragma warning restore CS0649
public Hooks()
{
SignatureHelper.Initialise(this);
ActorVfxCreateHook.Enable();
}
/// <summary>
/// Even with a pomander of sight, the BattleChara's position for the trap remains at {0, 0, 0} until it is activated.
/// Upon exploding, the trap's position is moved to the exact location that the pomander of sight would have revealed.
///
/// That exact position appears to be used for VFX playing when you walk into it - even if you barely walk into the
/// outer ring of an otter/luring/impeding/landmine trap, the VFX plays at the exact center and not at your character's
/// location.
///
/// Especially at higher floors, you're more likely to walk into an undiscovered trap compared to e.g. 51-60,
/// and you probably don't want to/can't use sight on every floor - yet the trap location is still useful information.
///
/// Some (but not all) chests also count as BattleChara named 'Trap', however the effect upon opening isn't played via
/// ActorVfxCreate even if they explode (but probably as a Vfx with static location, doesn't matter for here).
///
/// Landmines and luring traps also don't play a VFX attached to their BattleChara.
///
/// otter: vfx/common/eff/dk05th_stdn0t.avfx <br/>
/// toading: vfx/common/eff/dk05th_stdn0t.avfx <br/>
/// enfeebling: vfx/common/eff/dk05th_stdn0t.avfx <br/>
/// landmine: none <br/>
/// luring: none <br/>
/// impeding: vfx/common/eff/dk05ht_ipws0t.avfx (one of silence/pacification) <br/>
/// impeding: vfx/common/eff/dk05ht_slet0t.avfx (the other of silence/pacification) <br/>
///
/// It is of course annoying that, when testing, almost all traps are landmines.
/// There's also vfx/common/eff/dk01gd_inv0h.avfx for e.g. impeding when you're invulnerable, but not sure if that
/// has other trigger conditions.
/// </summary>
public nint ActorVfxCreate(char* a1, nint a2, nint a3, float a4, char a5, ushort a6, char a7)
{
try
{
if (Service.Plugin.IsInDeepDungeon())
{
var vfxPath = MemoryHelper.ReadString(new nint(a1), Encoding.ASCII, 256);
var obj = Service.ObjectTable.CreateObjectReference(a2);
/*
if (Service.Configuration.BetaKey == "VFX")
Service.Chat.Print($"{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);
}
}
}
}
catch (Exception e)
{
PluginLog.Error(e, "VFX Create Hook failed");
}
return ActorVfxCreateHook.Original(a1, a2, a3, a4, a5, a6, a7);
}
public void Dispose()
{
ActorVfxCreateHook?.Dispose();
}
}
}

View File

@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Pal.Client;
internal interface ILanguageChanged
{
void LanguageChanged();
}

View File

@ -1,154 +0,0 @@
using Pal.Common;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace Pal.Client
{
/// <summary>
/// JSON for a single floor set (e.g. 51-60).
/// </summary>
internal class LocalState
{
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();
public LocalState(uint 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)));
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));
}
public static LocalState? Load(uint territoryType)
{
string path = GetSaveLocation(territoryType);
if (!File.Exists(path))
return null;
string content = File.ReadAllText(path);
if (content.Length == 0)
return null;
LocalState localState;
int version = 1;
if (content[0] == '[')
{
// v1 only had a list of markers, not a JSON object as root
localState = new LocalState(territoryType)
{
Markers = new ConcurrentBag<Marker>(JsonSerializer.Deserialize<HashSet<Marker>>(content, JsonSerializerOptions) ?? new()),
};
}
else
{
var save = JsonSerializer.Deserialize<SaveFile>(content, JsonSerializerOptions);
if (save == null)
return null;
localState = new LocalState(territoryType)
{
Markers = new ConcurrentBag<Marker>(save.Markers.Where(o => o.Type == Marker.EType.Trap || o.Type == Marker.EType.Hoard)),
};
version = save.Version;
}
localState.ApplyFilters();
if (version <= 3)
{
foreach (var marker in localState.Markers)
marker.RemoteSeenOn = marker.RemoteSeenOn.Select(x => x.PadRight(14).Substring(0, 13)).ToList();
}
if (version < CurrentVersion)
localState.Save();
return localState;
}
public void Save()
{
string path = GetSaveLocation(TerritoryType);
ApplyFilters();
SaveImpl(path);
}
public void Backup(string suffix)
{
string path = $"{GetSaveLocation(TerritoryType)}.{suffix}";
if (!File.Exists(path))
{
SaveImpl(path);
}
}
private void SaveImpl(string path)
{
foreach (var marker in Markers)
{
if (string.IsNullOrEmpty(marker.SinceVersion))
marker.SinceVersion = typeof(Plugin).Assembly.GetName().Version!.ToString(2);
}
if (Markers.Count == 0)
File.Delete(path);
else
{
File.WriteAllText(path, JsonSerializer.Serialize(new SaveFile
{
Version = CurrentVersion,
Markers = new HashSet<Marker>(Markers)
}, JsonSerializerOptions));
}
}
public string GetSaveLocation() => GetSaveLocation(TerritoryType);
private static string GetSaveLocation(uint territoryType) => Path.Join(Service.PluginInterface.GetPluginConfigDirectory(), $"{territoryType}.json");
public static void ForEach(Action<LocalState> action)
{
foreach (ETerritoryType territory in typeof(ETerritoryType).GetEnumValues())
{
LocalState? localState = Load((ushort)territory);
if (localState != null)
action(localState);
}
}
public static void UpdateAll()
{
ForEach(s => s.Save());
}
public void UndoImport(List<Guid> importIds)
{
// When saving a floor state, any markers not seen, not remote seen, and not having an import id are removed;
// so it is possible to remove "wrong" markers by not having them be in the current import.
foreach (var marker in Markers)
marker.Imports.RemoveAll(importIds.Contains);
}
public class SaveFile
{
public int Version { get; set; }
public HashSet<Marker> Markers { get; set; } = new();
}
}
}

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

@ -1,95 +1,79 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text; using System.Text;
using System.Text.Json.Serialization;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Text.Json.Serialization;
namespace Pal.Client.Net namespace Pal.Client.Net;
internal sealed class JwtClaims
{ {
internal class JwtClaims [JsonPropertyName("nameid")]
public Guid NameId { get; set; }
[JsonPropertyName("role")]
[JsonConverter(typeof(JwtRoleConverter))]
public List<string> Roles { get; set; } = new();
[JsonPropertyName("nbf")]
[JsonConverter(typeof(JwtDateConverter))]
public DateTimeOffset NotBefore { get; set; }
[JsonPropertyName("exp")]
[JsonConverter(typeof(JwtDateConverter))]
public DateTimeOffset ExpiresAt { get; set; }
public static JwtClaims FromAuthToken(string authToken)
{ {
[JsonPropertyName("nameid")] if (string.IsNullOrEmpty(authToken))
public Guid NameId { get; set; } throw new ArgumentException("Server sent no auth token", nameof(authToken));
[JsonPropertyName("role")] string[] parts = authToken.Split('.');
[JsonConverter(typeof(JwtRoleConverter))] if (parts.Length != 3)
public List<string> Roles { get; set; } = new(); throw new ArgumentException("Unsupported token type", nameof(authToken));
[JsonPropertyName("nbf")] // fix padding manually
[JsonConverter(typeof(JwtDateConverter))] string payload = parts[1].Replace(",", "=").Replace("-", "+").Replace("/", "_");
public DateTimeOffset NotBefore { get; set; } if (payload.Length % 4 == 2)
payload += "==";
else if (payload.Length % 4 == 3)
payload += "=";
[JsonPropertyName("exp")] string content = Encoding.UTF8.GetString(Convert.FromBase64String(payload));
[JsonConverter(typeof(JwtDateConverter))] return JsonSerializer.Deserialize<JwtClaims>(content) ??
public DateTimeOffset ExpiresAt { get; set; } throw new InvalidOperationException("token deserialization returned null");
public static JwtClaims FromAuthToken(string authToken)
{
if (string.IsNullOrEmpty(authToken))
throw new ArgumentException("Server sent no auth token", nameof(authToken));
string[] parts = authToken.Split('.');
if (parts.Length != 3)
throw new ArgumentException("Unsupported token type", nameof(authToken));
// fix padding manually
string payload = parts[1].Replace(",", "=").Replace("-", "+").Replace("/", "_");
if (payload.Length % 4 == 2)
payload += "==";
else if (payload.Length % 4 == 3)
payload += "=";
string content = Encoding.UTF8.GetString(Convert.FromBase64String(payload));
return JsonSerializer.Deserialize<JwtClaims>(content) ?? throw new InvalidOperationException("token deserialization returned null");
}
}
internal class JwtRoleConverter : JsonConverter<List<string>>
{
public override List<string>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
return new List<string> { reader.GetString() ?? throw new JsonException("no value present") };
else if (reader.TokenType == JsonTokenType.StartArray)
{
List<string> result = new();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
result.Sort();
return result;
}
if (reader.TokenType != JsonTokenType.String)
throw new JsonException("string expected");
result.Add(reader.GetString() ?? throw new JsonException("no value present"));
}
throw new JsonException("read to end of document");
}
else
throw new JsonException("bad token type");
}
public override void Write(Utf8JsonWriter writer, List<string> value, JsonSerializerOptions options) => throw new NotImplementedException();
}
public class JwtDateConverter : JsonConverter<DateTimeOffset>
{
static readonly DateTimeOffset Zero = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.Number)
throw new JsonException("bad token type");
return Zero.AddSeconds(reader.GetInt64());
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => throw new NotImplementedException();
} }
} }
internal sealed class JwtRoleConverter : JsonConverter<List<string>>
{
public override List<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
return new List<string> { reader.GetString() ?? throw new JsonException("no value present") };
else if (reader.TokenType == JsonTokenType.StartArray)
{
List<string> result = new();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
result.Sort();
return result;
}
if (reader.TokenType != JsonTokenType.String)
throw new JsonException("string expected");
result.Add(reader.GetString() ?? throw new JsonException("no value present"));
}
throw new JsonException("read to end of document");
}
else
throw new JsonException("bad token type");
}
public override void Write(Utf8JsonWriter writer, List<string> value, JsonSerializerOptions options) =>
throw new NotImplementedException();
}

View File

@ -0,0 +1,21 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Pal.Client.Net;
public sealed class JwtDateConverter : JsonConverter<DateTimeOffset>
{
static readonly DateTimeOffset Zero = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.Number)
throw new JsonException("bad token type");
return Zero.AddSeconds(reader.GetInt64());
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) =>
throw new NotImplementedException();
}

View File

@ -1,174 +1,248 @@
using Account; using System;
using Dalamud.Logging;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Account;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using Pal.Client.Configuration;
using Pal.Client.Extensions; using Pal.Client.Extensions;
using Pal.Client.Properties; using Pal.Client.Properties;
using Version = System.Version;
namespace Pal.Client.Net namespace Pal.Client.Net;
internal partial class RemoteApi
{ {
internal partial class RemoteApi private static readonly Version PluginVersion = typeof(Plugin).Assembly.GetName().Version!;
private readonly SemaphoreSlim _connectLock = new(1, 1);
private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken,
ILoggerFactory? loggerFactory = null, bool retry = true)
{ {
private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken, ILoggerFactory? loggerFactory = null, bool retry = true) using IDisposable? logScope = _logger.BeginScope("TryConnect");
var result = await TryConnectImpl(cancellationToken, loggerFactory);
if (retry && result.ShouldRetry)
result = await TryConnectImpl(cancellationToken, loggerFactory);
return (result.Success, result.Error);
}
private async Task<(bool Success, string Error, bool ShouldRetry)> TryConnectImpl(
CancellationToken cancellationToken,
ILoggerFactory? loggerFactory)
{
if (_configuration.Mode != EMode.Online)
{ {
if (Service.Configuration.Mode != Configuration.EMode.Online) _logger.LogDebug("Not Online, not attempting to establish a connection");
{ return (false, Localization.ConnectionError_NotOnline, false);
PluginLog.Debug("TryConnect: Not Online, not attempting to establish a connection"); }
return (false, Localization.ConnectionError_NotOnline);
}
if (_channel == null || !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle)) if (_channel == null ||
{ !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle))
Dispose(); {
Dispose();
PluginLog.Information("TryConnect: Creating new gRPC channel"); _logger.LogInformation("Creating new gRPC channel");
_channel = GrpcChannel.ForAddress(RemoteUrl, new GrpcChannelOptions _channel = GrpcChannel.ForAddress(RemoteUrl, new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{ {
HttpHandler = new SocketsHttpHandler ConnectTimeout = TimeSpan.FromSeconds(5),
{ SslOptions = GetSslClientAuthenticationOptions(),
ConnectTimeout = TimeSpan.FromSeconds(5), },
SslOptions = GetSslClientAuthenticationOptions(), LoggerFactory = loggerFactory,
}, });
LoggerFactory = loggerFactory,
});
PluginLog.Information($"TryConnect: Connecting to upstream service at {RemoteUrl}"); _logger.LogInformation("Connecting to upstream service at {Url}", RemoteUrl);
await _channel.ConnectAsync(cancellationToken); await _channel.ConnectAsync(cancellationToken);
} }
cancellationToken.ThrowIfCancellationRequested();
_logger.LogTrace("Acquiring connect lock");
await _connectLock.WaitAsync(cancellationToken);
_logger.LogTrace("Obtained connect lock");
try
{
var accountClient = new AccountService.AccountServiceClient(_channel); var accountClient = new AccountService.AccountServiceClient(_channel);
if (AccountId == null) IAccountConfiguration? configuredAccount = _configuration.FindAccount(RemoteUrl);
if (configuredAccount == null)
{ {
PluginLog.Information($"TryConnect: No account information saved for {RemoteUrl}, creating new account"); _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); var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest
{
Version = new()
{
Major = PluginVersion.Major,
Minor = PluginVersion.Minor,
},
},
headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10),
cancellationToken: cancellationToken);
if (createAccountReply.Success) if (createAccountReply.Success)
{ {
Account = new Configuration.AccountInfo if (!Guid.TryParse(createAccountReply.AccountId, out Guid accountId))
{ throw new InvalidOperationException("invalid account id returned");
Id = Guid.Parse(createAccountReply.AccountId),
};
PluginLog.Information($"TryConnect: Account created with id {FormattedPartialAccountId}");
Service.Configuration.Save(); configuredAccount = _configuration.CreateAccount(RemoteUrl, accountId);
_logger.LogInformation("Account created with id {AccountId}", accountId.ToPartialId());
_configurationManager.Save(_configuration);
} }
else 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) if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade)
{ {
Service.Chat.PalError(Localization.ConnectionError_OldVersion); _chat.Error(Localization.ConnectionError_OldVersion);
_warnedAboutUpgrade = true; _warnedAboutUpgrade = true;
} }
return (false, string.Format(Localization.ConnectionError_CreateAccountFailed, createAccountReply.Error));
return (false,
string.Format(Localization.ConnectionError_CreateAccountFailed, createAccountReply.Error),
false);
} }
} }
if (AccountId == null) cancellationToken.ThrowIfCancellationRequested();
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (configuredAccount == null)
{ {
PluginLog.Warning("TryConnect: No account id to login with"); _logger.LogWarning("No account to login with");
return (false, Localization.ConnectionError_CreateAccountReturnedNoId); return (false, Localization.ConnectionError_CreateAccountReturnedNoId, false);
} }
if (!_loginInfo.IsValid) if (!_loginInfo.IsValid)
{ {
PluginLog.Information($"TryConnect: Logging in with account id {FormattedPartialAccountId}"); _logger.LogInformation("Logging in with account id {AccountId}",
LoginReply loginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); configuredAccount.AccountId.ToPartialId());
LoginReply loginReply = await accountClient.LoginAsync(
new LoginRequest
{
AccountId = configuredAccount.AccountId.ToString(),
Version = new()
{
Major = PluginVersion.Major,
Minor = PluginVersion.Minor,
},
},
headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10),
cancellationToken: cancellationToken);
if (loginReply.Success) 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); _loginInfo = new LoginInfo(loginReply.AuthToken);
var account = Account; bool save = configuredAccount.EncryptIfNeeded();
if (account != null)
List<string> newRoles = _loginInfo.Claims?.Roles.ToList() ?? new();
if (!newRoles.SequenceEqual(configuredAccount.CachedRoles))
{ {
account.CachedRoles = _loginInfo.Claims?.Roles?.ToList() ?? new List<string>(); configuredAccount.CachedRoles = newRoles;
Service.Configuration.Save(); save = true;
} }
if (save)
_configurationManager.Save(_configuration);
} }
else else
{ {
PluginLog.Error($"TryConnect: Login failed with error {loginReply.Error}"); _logger.LogError("Login failed with error {Error}", loginReply.Error);
_loginInfo = new LoginInfo(null); _loginInfo = new LoginInfo(null);
if (loginReply.Error == LoginError.InvalidAccountId) if (loginReply.Error == LoginError.InvalidAccountId)
{ {
Account = null; _configuration.RemoveAccount(RemoteUrl);
Service.Configuration.Save(); _configurationManager.Save(_configuration);
if (retry)
{ _logger.LogInformation("Attempting connection retry without account id");
PluginLog.Information("TryConnect: Attempting connection retry without account id"); return (false, Localization.ConnectionError_InvalidAccountId, true);
return await TryConnect(cancellationToken, retry: false);
}
else
return (false, Localization.ConnectionError_InvalidAccountId);
} }
if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade) if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade)
{ {
Service.Chat.PalError(Localization.ConnectionError_OldVersion); _chat.Error(Localization.ConnectionError_OldVersion);
_warnedAboutUpgrade = true; _warnedAboutUpgrade = true;
} }
return (false, string.Format(Localization.ConnectionError_LoginFailed, loginReply.Error));
return (false, string.Format(Localization.ConnectionError_LoginFailed, loginReply.Error),
false);
} }
} }
if (!_loginInfo.IsValid) if (!_loginInfo.IsValid)
{ {
PluginLog.Error($"TryConnect: Login state is loggedIn={_loginInfo.IsLoggedIn}, expired={_loginInfo.IsExpired}"); _logger.LogError("Login state is loggedIn={LoggedIn}, expired={Expired}", _loginInfo.IsLoggedIn,
return (false, Localization.ConnectionError_LoginReturnedNoToken); _loginInfo.IsExpired);
return (false, Localization.ConnectionError_LoginReturnedNoToken, false);
} }
return (true, string.Empty); cancellationToken.ThrowIfCancellationRequested();
return (true, string.Empty, false);
} }
finally
private async Task<bool> Connect(CancellationToken cancellationToken)
{ {
var result = await TryConnect(cancellationToken); _logger.LogTrace("Releasing connectLock");
return result.Success; _connectLock.Release();
}
public async Task<string> VerifyConnection(CancellationToken cancellationToken = default)
{
_warnedAboutUpgrade = false;
var connectionResult = await TryConnect(cancellationToken, loggerFactory: _grpcToPluginLogLoggerFactory);
if (!connectionResult.Success)
return string.Format(Localization.ConnectionError_CouldNotConnectToServer, connectionResult.Error);
PluginLog.Information("VerifyConnection: 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);
PluginLog.Information("VerifyConnection: Verification returned no errors.");
return Localization.ConnectionSuccessful;
}
internal class LoginInfo
{
public LoginInfo(string? authToken)
{
if (!string.IsNullOrEmpty(authToken))
{
IsLoggedIn = true;
AuthToken = authToken;
Claims = JwtClaims.FromAuthToken(authToken!);
}
else
IsLoggedIn = false;
}
public bool IsLoggedIn { get; }
public string? AuthToken { get; }
public JwtClaims? Claims { get; }
public DateTimeOffset ExpiresAt => Claims?.ExpiresAt.Subtract(TimeSpan.FromMinutes(5)) ?? DateTimeOffset.MinValue;
public bool IsExpired => ExpiresAt < DateTimeOffset.UtcNow;
public bool IsValid => IsLoggedIn && !IsExpired;
} }
} }
private async Task<bool> Connect(CancellationToken cancellationToken)
{
var result = await TryConnect(cancellationToken);
return result.Success;
}
public async Task<string> VerifyConnection(CancellationToken cancellationToken = default)
{
using IDisposable? logScope = _logger.BeginScope("VerifyConnection");
_warnedAboutUpgrade = false;
var connectionResult = await TryConnect(cancellationToken, loggerFactory: _loggerFactory);
if (!connectionResult.Success)
return string.Format(Localization.ConnectionError_CouldNotConnectToServer, connectionResult.Error);
_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);
_logger.LogInformation("Verification returned no errors.");
return Localization.ConnectionSuccessful;
}
internal sealed class LoginInfo
{
public LoginInfo(string? authToken)
{
if (!string.IsNullOrEmpty(authToken))
{
IsLoggedIn = true;
AuthToken = authToken;
Claims = JwtClaims.FromAuthToken(authToken);
}
else
IsLoggedIn = false;
}
public bool IsLoggedIn { get; }
public string? AuthToken { get; }
public JwtClaims? Claims { get; }
private DateTimeOffset ExpiresAt =>
Claims?.ExpiresAt.Subtract(TimeSpan.FromMinutes(5)) ?? DateTimeOffset.MinValue;
public bool IsExpired => ExpiresAt < DateTimeOffset.UtcNow;
public bool IsValid => IsLoggedIn && !IsExpired;
}
} }

View File

@ -1,23 +1,23 @@
using Account; using System;
using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Export;
namespace Pal.Client.Net namespace Pal.Client.Net;
internal partial class RemoteApi
{ {
internal partial class RemoteApi public async Task<(bool, ExportRoot)> DoExport(CancellationToken cancellationToken = default)
{ {
public async Task<(bool, ExportRoot)> DoExport(CancellationToken cancellationToken = default) if (!await Connect(cancellationToken))
{ return new(false, new());
if (!await Connect(cancellationToken))
return new(false, new());
var exportClient = new ExportService.ExportServiceClient(_channel); var exportClient = new ExportService.ExportServiceClient(_channel);
var exportReply = await exportClient.ExportAsync(new ExportRequest var exportReply = await exportClient.ExportAsync(new ExportRequest
{ {
ServerUrl = RemoteUrl, ServerUrl = RemoteUrl,
}, headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(120), cancellationToken: cancellationToken); }, headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(120),
return (exportReply.Success, exportReply.Data); cancellationToken: cancellationToken);
} return (exportReply.Success, exportReply.Data);
} }
} }

View File

@ -1,78 +1,95 @@
using Palace; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Pal.Client.Database;
using Pal.Client.Floors;
using Palace;
namespace Pal.Client.Net namespace Pal.Client.Net;
internal partial class RemoteApi
{ {
internal partial class RemoteApi public async Task<(bool, List<PersistentLocation>)> DownloadRemoteMarkers(ushort territoryId,
CancellationToken cancellationToken = default)
{ {
public async Task<(bool, List<Marker>)> 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(CreateLocationFromNetworkObject).ToList());
}
public async Task<(bool, List<PersistentLocation>)> UploadLocations(ushort territoryType,
IReadOnlyList<PersistentLocation> locations, CancellationToken cancellationToken = default)
{
if (locations.Count == 0)
return (true, new());
if (!await Connect(cancellationToken))
return (false, new());
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
var uploadRequest = new UploadFloorsRequest
{ {
if (!await Connect(cancellationToken)) TerritoryType = territoryType,
return (false, new()); };
uploadRequest.Objects.AddRange(locations.Select(m => new PalaceObject
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(o => CreateMarkerFromNetworkObject(o)).ToList());
}
public async Task<(bool, List<Marker>)> UploadMarker(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
{ {
if (markers.Count == 0) Type = m.Type.ToObjectType(),
return (true, new()); 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(CreateLocationFromNetworkObject).ToList());
}
if (!await Connect(cancellationToken)) public async Task<bool> MarkAsSeen(ushort territoryType, IReadOnlyList<PersistentLocation> locations,
return (false, new()); CancellationToken cancellationToken = default)
{
if (locations.Count == 0)
return true;
var palaceClient = new PalaceService.PalaceServiceClient(_channel); if (!await Connect(cancellationToken))
var uploadRequest = new UploadFloorsRequest return false;
{
TerritoryType = territoryType,
};
uploadRequest.Objects.AddRange(markers.Select(m => new PalaceObject
{
Type = (ObjectType)m.Type,
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(o => CreateMarkerFromNetworkObject(o)).ToList());
}
public async Task<bool> MarkAsSeen(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default) var palaceClient = new PalaceService.PalaceServiceClient(_channel);
var seenRequest = new MarkObjectsSeenRequest { TerritoryType = territoryType };
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 PersistentLocation CreateLocationFromNetworkObject(PalaceObject obj)
{
return new PersistentLocation
{ {
if (markers.Count == 0) Type = obj.Type.ToMemoryType(),
return true; Position = new Vector3(obj.X, obj.Y, obj.Z),
NetworkId = Guid.Parse(obj.NetworkId),
Source = ClientLocation.ESource.Download,
};
}
if (!await Connect(cancellationToken)) public async Task<(bool, List<FloorStatistics>)> FetchStatistics(CancellationToken cancellationToken = default)
return false; {
if (!await Connect(cancellationToken))
return new(false, new List<FloorStatistics>());
var palaceClient = new PalaceService.PalaceServiceClient(_channel); var palaceClient = new PalaceService.PalaceServiceClient(_channel);
var seenRequest = new MarkObjectsSeenRequest { TerritoryType = territoryType }; var statisticsReply = await palaceClient.FetchStatisticsAsync(new StatisticsRequest(),
foreach (var marker in markers) headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(30),
seenRequest.NetworkIds.Add(marker.NetworkId.ToString()); cancellationToken: cancellationToken);
return (statisticsReply.Success, statisticsReply.FloorStatistics.ToList());
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));
public async Task<(bool, List<FloorStatistics>)> FetchStatistics(CancellationToken cancellationToken = default)
{
if (!await Connect(cancellationToken))
return new(false, new List<FloorStatistics>());
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
var statisticsReply = await palaceClient.FetchStatisticsAsync(new StatisticsRequest(), headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(30), cancellationToken: cancellationToken);
return (statisticsReply.Success, statisticsReply.FloorStatistics.ToList());
}
} }
} }

View File

@ -1,63 +1,57 @@
using Dalamud.Logging; using System;
using Grpc.Core;
using System.Net.Security; using System.Net.Security;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Dalamud.Logging;
using Grpc.Core;
using Microsoft.Extensions.Logging;
namespace Pal.Client.Net namespace Pal.Client.Net;
internal partial class RemoteApi
{ {
internal partial class RemoteApi private Metadata UnauthorizedHeaders() => new()
{ {
private Metadata UnauthorizedHeaders() => new Metadata { "User-Agent", _userAgent },
{ };
{ "User-Agent", _userAgent },
};
private Metadata AuthorizedHeaders() => new Metadata private Metadata AuthorizedHeaders() => new()
{ {
{ "Authorization", $"Bearer {_loginInfo?.AuthToken}" }, { "Authorization", $"Bearer {_loginInfo.AuthToken}" },
{ "User-Agent", _userAgent }, { "User-Agent", _userAgent },
}; };
private SslClientAuthenticationOptions? GetSslClientAuthenticationOptions() private SslClientAuthenticationOptions? GetSslClientAuthenticationOptions()
{ {
#if !DEBUG #if !DEBUG
var secrets = typeof(RemoteApi).Assembly.GetType("Pal.Client.Secrets"); var secrets = typeof(RemoteApi).Assembly.GetType("Pal.Client.Secrets");
if (secrets == null) if (secrets == null)
return null;
var pass = secrets.GetProperty("CertPassword")?.GetValue(null) as string;
if (pass == null)
return null;
var manifestResourceStream = typeof(RemoteApi).Assembly.GetManifestResourceStream("Pal.Client.Certificate.pfx");
if (manifestResourceStream == null)
return null;
var bytes = new byte[manifestResourceStream.Length];
manifestResourceStream.Read(bytes, 0, bytes.Length);
var certificate = new X509Certificate2(bytes, pass, X509KeyStorageFlags.DefaultKeySet);
PluginLog.Debug($"Using client certificate {certificate.GetCertHashString()}");
return new SslClientAuthenticationOptions
{
ClientCertificates = new X509CertificateCollection()
{
certificate,
},
};
#else
PluginLog.Debug("Not using client certificate");
return null; return null;
#endif
}
public bool HasRoleOnCurrentServer(string role) var pass = secrets.GetProperty("CertPassword")?.GetValue(null) as string;
if (pass == null)
return null;
var manifestResourceStream = typeof(RemoteApi).Assembly.GetManifestResourceStream("Pal.Client.Certificate.pfx");
if (manifestResourceStream == null)
return null;
var bytes = new byte[manifestResourceStream.Length];
int read = manifestResourceStream.Read(bytes, 0, bytes.Length);
if (read != bytes.Length)
throw new InvalidOperationException();
var certificate = new X509Certificate2(bytes, pass, X509KeyStorageFlags.DefaultKeySet);
_logger.LogDebug("Using client certificate {CertificateHash}", certificate.GetCertHashString());
return new SslClientAuthenticationOptions
{ {
if (Service.Configuration.Mode != Configuration.EMode.Online) ClientCertificates = new X509CertificateCollection()
return false; {
certificate,
var account = Account; },
return account == null || account.CachedRoles.Contains(role); };
} #else
_logger.LogDebug("Not using client certificate");
return null;
#endif
} }
} }

View File

@ -1,48 +1,51 @@
using Dalamud.Logging; using System;
using Dalamud.Game.Gui;
using Dalamud.Logging;
using Grpc.Net.Client; using Grpc.Net.Client;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System; using Pal.Client.Configuration;
using Pal.Client.DependencyInjection;
namespace Pal.Client.Net namespace Pal.Client.Net;
internal sealed partial class RemoteApi : IDisposable
{ {
internal partial class RemoteApi : IDisposable
{
#if DEBUG #if DEBUG
public static string RemoteUrl { get; } = "http://localhost:5145"; public const string RemoteUrl = "http://localhost:5415";
#else #else
public static string RemoteUrl { get; } = "https://pal.μ.tv"; public const string RemoteUrl = "https://connect.palacepal.com";
#endif #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 GrpcChannel? _channel;
private LoginInfo _loginInfo = new(null); private LoginInfo _loginInfo = new(null);
private bool _warnedAboutUpgrade = false; private bool _warnedAboutUpgrade;
public Configuration.AccountInfo? Account public RemoteApi(
{ ILoggerFactory loggerFactory,
get => Service.Configuration.Accounts.TryGetValue(RemoteUrl, out Configuration.AccountInfo? accountInfo) ? accountInfo : null; ILogger<RemoteApi> logger,
set Chat chat,
{ ConfigurationManager configurationManager,
if (value != null) IPalacePalConfiguration configuration)
Service.Configuration.Accounts[RemoteUrl] = value; {
else _loggerFactory = loggerFactory;
Service.Configuration.Accounts.Remove(RemoteUrl); _logger = logger;
} _chat = chat;
} _configurationManager = configurationManager;
_configuration = configuration;
}
public Guid? AccountId => Account?.Id; public void Dispose()
{
public string? PartialAccountId => Account?.Id?.ToString()?.PadRight(14).Substring(0, 13); _logger.LogDebug("Disposing gRPC channel");
_channel?.Dispose();
private string FormattedPartialAccountId => PartialAccountId ?? "[no account id]"; _channel = null;
public void Dispose()
{
PluginLog.Debug("Disposing gRPC channel");
_channel?.Dispose();
_channel = null;
}
} }
} }

View File

@ -1,104 +1,69 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Dalamud.NET.Sdk/9.0.2">
<PropertyGroup>
<Version>6.0</Version>
<AssemblyName>Palace Pal</AssemblyName>
<PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup> <Import Project="..\vendor\LLib\LLib.targets"/>
<TargetFramework>net7.0-windows</TargetFramework> <Import Project="..\vendor\LLib\RenameZip.targets"/>
<LangVersion>11.0</LangVersion>
<Version>2.10</Version>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup> <PropertyGroup Condition="'$(Configuration)' == 'Release'">
<ProduceReferenceAssembly>false</ProduceReferenceAssembly> <OutputPath>dist</OutputPath>
<PlatformTarget>x64</PlatformTarget> </PropertyGroup>
<AssemblyName>Palace Pal</AssemblyName>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'"> <ItemGroup Condition="'$(Configuration)' == 'Release' And Exists('Certificate.pfx')">
<OutputPath>dist</OutputPath> <None Remove="Certificate.pfx"/>
<DebugType>none</DebugType> </ItemGroup>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Release' And Exists('Certificate.pfx')"> <ItemGroup Condition="'$(Configuration)' == 'Release' And Exists('Certificate.pfx')">
<None Remove="Certificate.pfx" /> <EmbeddedResource Include="Certificate.pfx"/>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Release' And Exists('Certificate.pfx')"> <ItemGroup>
<EmbeddedResource Include="Certificate.pfx" /> <PackageReference Include="Dalamud.Extensions.MicrosoftLogging" Version="4.0.1"/>
<EmbeddedResource Update="Properties\Localization.resx"> <PackageReference Include="Google.Protobuf" Version="3.27.2" />
<Generator>ResXFileCodeGenerator</Generator> <PackageReference Include="Grpc.Net.Client" Version="2.63.0"/>
<LastGenOutput>Localization.Designer.cs</LastGenOutput> <PackageReference Include="Grpc.Tools" Version="2.64.0">
</EmbeddedResource> <PrivateAssets>all</PrivateAssets>
</ItemGroup> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6" Condition="'$(Configuration)' == 'EF'">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0"/>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DalamudPackager" Version="2.1.10" /> <ProjectReference Include="..\Pal.Common\Pal.Common.csproj"/>
<PackageReference Include="Google.Protobuf" Version="3.21.12" /> <ProjectReference Include="..\vendor\ECommons\ECommons\ECommons.csproj"/>
<PackageReference Include="Grpc.Net.Client" Version="2.51.0" /> <ProjectReference Include="..\vendor\LLib\LLib.csproj" />
<PackageReference Include="Grpc.Tools" Version="2.51.0"> </ItemGroup>
<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" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Pal.Common\Pal.Common.csproj" /> <Protobuf Include="..\Pal.Common\Protos\account.proto" Link="Protos\account.proto" GrpcServices="Client" Access="Internal"/>
<ProjectReference Include="..\vendor\ECommons\ECommons\ECommons.csproj" /> <Protobuf Include="..\Pal.Common\Protos\palace.proto" Link="Protos\palace.proto" GrpcServices="Client" Access="Internal"/>
</ItemGroup> <Protobuf Include="..\Pal.Common\Protos\export.proto" Link="Protos\export.proto" GrpcServices="Client" Access="Internal"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Protobuf Include="..\Pal.Common\Protos\account.proto" Link="Protos\account.proto" GrpcServices="Client" Access="Internal" /> <EmbeddedResource Update="Properties\Localization.resx">
<Protobuf Include="..\Pal.Common\Protos\palace.proto" Link="Protos\palace.proto" GrpcServices="Client" Access="Internal" /> <Generator>ResXFileCodeGenerator</Generator>
<Protobuf Include="..\Pal.Common\Protos\export.proto" Link="Protos\export.proto" GrpcServices="Client" Access="Internal" /> <LastGenOutput>Localization.Designer.cs</LastGenOutput>
</ItemGroup> </EmbeddedResource>
<Compile Update="Properties\Localization.Designer.cs">
<ItemGroup> <DesignTime>True</DesignTime>
<!--You may need to adjust these paths yourself. These point to a Dalamud assembly in AppData.--> <AutoGen>True</AutoGen>
<Reference Include="Dalamud"> <DependentUpon>Localization.resx</DependentUpon>
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll</HintPath> </Compile>
<Private>false</Private> </ItemGroup>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\ImGuiScene.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\dev\FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Localization.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Localization.resx</DependentUpon>
</Compile>
</ItemGroup>
<Target Name="RenameLatestZip" AfterTargets="PackagePlugin">
<Exec Command="rename &quot;$(OutDir)$(AssemblyName)\latest.zip&quot; &quot;$(AssemblyName)-$(Version).zip&quot;" />
</Target>
<Target Name="Clean">
<RemoveDir Directories="dist"/>
</Target>
</Project> </Project>

View File

@ -2,8 +2,13 @@
"Name": "Palace Pal", "Name": "Palace Pal",
"Author": "Liza Carvelli", "Author": "Liza Carvelli",
"Punchline": "Shows possible trap & hoard coffer locations in Palace of the Dead & Heaven on High.", "Punchline": "Shows possible trap & hoard coffer locations in Palace of the Dead & Heaven on High.",
"Description": "Shows possible trap & hoard coffer locations in Palace of the Dead & Heaven on High. Requires Splatoon to be installed.", "Description": "Shows possible trap & hoard coffer locations in Palace of the Dead & Heaven on High.\n\nThe default configuration requires Splatoon to be installed. If you do not wish to install Splatoon, you can switch to the experimental 'Simple' renderer in the configuration.",
"RepoUrl": "https://github.com/carvelli/PalacePal", "RepoUrl": "https://git.carvel.li/liza/PalacePal",
"IconUrl": "https://raw.githubusercontent.com/carvelli/Dalamud-Plugins/master/dist/Palace Pal.png", "IconUrl": "https://plugins.carvel.li/icons/PalacePal.png",
"Tags": [ "potd", "palace", "hoh", "splatoon" ] "Tags": [
"potd",
"palace",
"hoh",
"splatoon"
]
} }

View File

@ -1,696 +1,235 @@
using Dalamud.Game; using System;
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.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.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Numerics; using System.Threading;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Pal.Client.Extensions; using Dalamud.Extensions.MicrosoftLogging;
using Dalamud.Game.Command;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using ECommons;
using ECommons.DalamudServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Pal.Client.Commands;
using Pal.Client.Configuration;
using Pal.Client.DependencyInjection;
using Pal.Client.Properties; using Pal.Client.Properties;
using Pal.Client.Rendering;
namespace Pal.Client namespace Pal.Client;
/// <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
{ {
public class Plugin : IDalamudPlugin private readonly CancellationTokenSource _initCts = new();
private readonly IDalamudPluginInterface _pluginInterface;
private readonly ICommandManager _commandManager;
private readonly IClientState _clientState;
private readonly IChatGui _chatGui;
private readonly IFramework _framework;
private readonly TaskCompletionSource<IServiceScope> _rootScopeCompletionSource = new();
private ELoadState _loadState = ELoadState.Initializing;
private DependencyInjectionContext? _dependencyInjectionContext;
private ILogger _logger;
private WindowSystem? _windowSystem;
private IServiceScope? _rootScope;
private Action? _loginAction;
public Plugin(
IDalamudPluginInterface pluginInterface,
ICommandManager commandManager,
IClientState clientState,
IChatGui chatGui,
IFramework framework,
IPluginLog pluginLog)
{ {
internal const uint ColorInvisible = 0; _pluginInterface = pluginInterface;
_commandManager = commandManager;
_clientState = clientState;
_chatGui = chatGui;
_framework = framework;
_logger = new DalamudLoggerProvider(pluginLog).CreateLogger<Plugin>();
private LocalizedChatMessages _localizedChatMessages = new(); // set up the current UI language before creating anything
Localization.Culture = new CultureInfo(_pluginInterface.UiLanguage);
internal ConcurrentDictionary<ushort, LocalState> FloorMarkers { get; } = new(); _commandManager.AddHandler("/pal", new CommandInfo(OnCommand)
internal ConcurrentBag<Marker> EphemeralMarkers { get; set; } = new();
internal ushort LastTerritory { get; set; }
public SyncState TerritorySyncState { get; set; }
public PomanderState PomanderOfSight { get; set; } = PomanderState.Inactive;
public PomanderState PomanderOfIntuition { get; set; } = PomanderState.Inactive;
public 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!;
public string Name => Localization.Palace_Pal;
public Plugin(DalamudPluginInterface pluginInterface, ChatGui chat)
{ {
LanguageChanged(pluginInterface.UiLanguage); HelpMessage = Localization.Command_pal_HelpText
});
PluginLog.Information($"Install source: {pluginInterface.SourceRepository}"); // Using TickScheduler requires ECommons to at least be partially initialized
// ECommonsMain.Dispose leaves this untouched.
Svc.Init(pluginInterface);
#if RELEASE Task.Run(async () => await CreateDependencyContext());
// 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 private async Task CreateDependencyContext()
if (!pluginInterface.IsDev {
&& !pluginInterface.SourceRepository.StartsWith("https://raw.githubusercontent.com/carvelli/") try
&& !pluginInterface.SourceRepository.StartsWith("https://github.com/carvelli/")) {
_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(() =>
{ {
chat.PalError(string.Format(Localization.Error_WrongRepository, "https://github.com/carvelli/Dalamud-Plugins")); _pluginInterface.UiBuilder.Draw += Draw;
throw new InvalidOperationException(); _pluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi;
} _pluginInterface.LanguageChanged += LanguageChanged;
#endif _clientState.Login += Login;
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 += OnOpenConfigUi;
pluginInterface.LanguageChanged += LanguageChanged;
Service.Framework.Update += OnFrameworkUpdate;
Service.Chat.ChatMessage += OnChatMessage;
Service.CommandManager.AddHandler("/pal", new CommandInfo(OnCommand)
{
HelpMessage = Localization.Command_pal_HelpText
}); });
_rootScopeCompletionSource.SetResult(_rootScope);
ReloadLanguageStrings(); _loadState = ELoadState.Loaded;
} }
catch (Exception e) when (e is ObjectDisposedException
public void OnOpenConfigUi() or OperationCanceledException
or RepoVerification.RepoVerificationFailedException
|| (e is FileLoadException && _pluginInterface.IsDev))
{ {
Window? configWindow; _rootScopeCompletionSource.SetException(e);
if (Service.Configuration.FirstUse) _loadState = ELoadState.Error;
configWindow = Service.WindowSystem.GetWindow<AgreementWindow>();
else
configWindow = Service.WindowSystem.GetWindow<ConfigWindow>();
if (configWindow != null)
configWindow.IsOpen = true;
} }
catch (Exception e)
private void OnCommand(string command, string arguments)
{ {
if (Service.Configuration.FirstUse) _rootScopeCompletionSource.SetException(e);
{ _logger.LogError(e, "Async load failed");
Service.Chat.PalError(Localization.Error_FirstTimeSetupRequired); ShowErrorOnLogin(() =>
return; new Chat(_chatGui).Error(string.Format(Localization.Error_LoadFailed,
} $"{e.GetType()} - {e.Message}")));
try _loadState = ELoadState.Error;
{
arguments = arguments.Trim();
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();
break;
case "near":
DebugNearest(m => true);
break;
case "tnear":
DebugNearest(m => m.Type == Marker.EType.Trap);
break;
case "hnear":
DebugNearest(m => m.Type == Marker.EType.Hoard);
break;
default:
Service.Chat.PalError(string.Format(Localization.Command_pal_UnknownSubcommand, arguments, command));
break;
}
}
catch (Exception e)
{
Service.Chat.PalError(e.ToString());
}
}
#region IDisposable Support
protected virtual void Dispose(bool disposing)
{
if (!disposing) return;
Service.CommandManager.RemoveHandler("/pal");
Service.PluginInterface.UiBuilder.Draw -= Draw;
Service.PluginInterface.UiBuilder.OpenConfigUi -= OnOpenConfigUi;
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;
}
else
return;
}
private void LanguageChanged(string langcode)
=> Localization.Culture = new CultureInfo(langcode);
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.NetworkId != null && !knownMarker.RemoteSeenRequested && !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.Seen && x.NetworkId != null && !x.RemoteSeenRequested && !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.WasImported && marker.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:
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 async Task SyncSeenMarkersForTerritory(ushort territoryId, List<Marker> markersToUpdate)
{
try
{
var success = await Service.RemoteApi.MarkAsSeen(territoryId, markersToUpdate);
LateEventQueue.Enqueue(new QueuedSyncResponse
{
Type = SyncType.MarkSeen,
TerritoryType = territoryId,
Success = success,
Markers = markersToUpdate,
});
}
catch (Exception e)
{
DebugMessage = $"{DateTime.Now}\n{e}";
}
}
#endregion
#region Command Handling
private async Task FetchFloorStatistics()
{
if (!Service.RemoteApi.HasRoleOnCurrentServer("statistics:view"))
{
Service.Chat.PalError(Localization.Command_pal_stats_CurrentFloor);
return;
}
try
{
var (success, floorStatistics) = await Service.RemoteApi.FetchStatistics();
if (success)
{
var statisticsWindow = Service.WindowSystem.GetWindow<StatisticsWindow>()!;
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 = 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?.ToString()?.Substring(0, 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();
}
private void Draw()
{
if (Renderer is SimpleRenderer sr)
sr.DrawLayers();
Service.WindowSystem.Draw();
}
private string GetLocalizedString(uint id)
{
return Service.DataManager.GetExcelSheet<LogMessage>()?.GetRow(id)?.Text?.ToString() ?? "Unknown";
}
public enum PomanderState
{
Inactive,
Active,
FoundOnCurrentFloor,
PomanderOfSafetyUsed,
}
private class LocalizedChatMessages
{
public string MapRevealed { get; set; } = "???"; //"The map for this floor has been revealed!";
public string AllTrapsRemoved { get; set; } = "???"; // "All the traps on this floor have disappeared!";
public string HoardOnCurrentFloor { get; set; } = "???"; // "You sense the Accursed Hoard calling you...";
public string HoardNotOnCurrentFloor { get; set; } = "???"; // "You do not sense the call of the Accursed Hoard on this floor...";
public string HoardCofferOpened { get; set; } = "???"; // "You discover a piece of the Accursed Hoard!";
public Regex FloorChanged { get; set; } = new Regex(@"This isn't a game message, but will be replaced"); // new Regex(@"^Floor (\d+)$");
} }
} }
private void ShowErrorOnLogin(Action? loginAction)
{
if (_clientState.IsLoggedIn)
{
loginAction?.Invoke();
_loginAction = null;
}
else
_loginAction = loginAction;
}
private void Login()
{
_loginAction?.Invoke();
_loginAction = null;
}
private void OnCommand(string command, string arguments)
{
arguments = arguments.Trim();
Task.Run(async () =>
{
IServiceScope rootScope;
Chat chat;
try
{
rootScope = await _rootScopeCompletionSource.Task;
chat = rootScope.ServiceProvider.GetRequiredService<Chat>();
}
catch (Exception e)
{
_logger.LogError(e, "Could not wait for command root scope");
return;
}
try
{
IPalacePalConfiguration configuration =
rootScope.ServiceProvider.GetRequiredService<IPalacePalConfiguration>();
if (configuration.FirstUse && arguments != "" && arguments != "config")
{
chat.Error(Localization.Error_FirstTimeSetupRequired);
return;
}
Action<string> commandHandler = rootScope.ServiceProvider
.GetRequiredService<IEnumerable<ISubCommand>>()
.SelectMany(cmd => cmd.GetHandlers())
.Where(cmd => cmd.Key == arguments.ToLowerInvariant())
.Select(cmd => cmd.Value)
.SingleOrDefault(missingCommand =>
{
chat.Error(string.Format(Localization.Command_pal_UnknownSubcommand, missingCommand,
command));
});
commandHandler.Invoke(arguments);
}
catch (Exception e)
{
_logger.LogError(e, "Could not execute command '{Command}' with arguments '{Arguments}'", command,
arguments);
chat.Error(string.Format(Localization.Error_CommandFailed,
$"{e.GetType()} - {e.Message}"));
}
});
}
private void OpenConfigUi()
=> _rootScope!.ServiceProvider.GetRequiredService<PalConfigCommand>().Execute();
private void LanguageChanged(string languageCode)
{
_logger.LogInformation("Language set to '{Language}'", languageCode);
Localization.Culture = new CultureInfo(languageCode);
_windowSystem!.Windows.OfType<ILanguageChanged>()
.Each(w => w.LanguageChanged());
}
private void Draw()
{
_rootScope!.ServiceProvider.GetRequiredService<RenderAdapter>().DrawLayers();
_windowSystem!.Draw();
}
public void Dispose()
{
_commandManager.RemoveHandler("/pal");
if (_loadState == ELoadState.Loaded)
{
_pluginInterface.UiBuilder.Draw -= Draw;
_pluginInterface.UiBuilder.OpenConfigUi -= OpenConfigUi;
_pluginInterface.LanguageChanged -= LanguageChanged;
_clientState.Login -= Login;
}
_initCts.Cancel();
_rootScope?.Dispose();
_dependencyInjectionContext?.Dispose();
}
private enum ELoadState
{
Initializing,
Loaded,
Error
}
} }

View File

@ -18,7 +18,7 @@ namespace Pal.Client.Properties {
// class via a tool like ResGen or Visual Studio. // class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen // To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project. // with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Localization { internal class Localization {
@ -114,7 +114,7 @@ namespace Pal.Client.Properties {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to Open the configuration/debug window. /// Looks up a localized string similar to Open the configuration window.
/// </summary> /// </summary>
internal static string Command_pal_HelpText { internal static string Command_pal_HelpText {
get { get {
@ -149,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> /// <summary>
/// Looks up a localized string similar to You are NOT in a deep dungeon.. /// Looks up a localized string similar to You are NOT in a deep dungeon..
/// </summary> /// </summary>
@ -185,6 +176,43 @@ namespace Pal.Client.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Gold Coffer color.
/// </summary>
internal static string Config_GoldCoffer_Color {
get {
return ResourceManager.GetString("Config_GoldCoffer_Color", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Draw filled.
/// </summary>
internal static string Config_GoldCoffer_Filled {
get {
return ResourceManager.GetString("Config_GoldCoffer_Filled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show gold coffers on current floor.
/// </summary>
internal static string Config_GoldCoffer_Show {
get {
return ResourceManager.GetString("Config_GoldCoffer_Show", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Shows nearby gold coffers (containing pomanders) on the current floor.
///This is not synchronized with other players and not saved between floors/runs..
/// </summary>
internal static string Config_GoldCoffers_ToolTip {
get {
return ResourceManager.GetString("Config_GoldCoffers_ToolTip", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Hoard Coffer color. /// Looks up a localized string similar to Hoard Coffer color.
/// </summary> /// </summary>
@ -320,6 +348,15 @@ namespace Pal.Client.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Path to *.pal file.
/// </summary>
internal static string Config_SelectImportFile_Hint {
get {
return ResourceManager.GetString("Config_SelectImportFile_Hint", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Select which render backend to use for markers:. /// Looks up a localized string similar to Select which render backend to use for markers:.
/// </summary> /// </summary>
@ -357,7 +394,7 @@ namespace Pal.Client.Properties {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to Shows all the silver coffers visible to you on the current floor. /// Looks up a localized string similar to Shows nearby silver coffers (gear upgrades and magicites) on the current floor.
///This is not synchronized with other players and not saved between floors/runs.. ///This is not synchronized with other players and not saved between floors/runs..
/// </summary> /// </summary>
internal static string Config_SilverCoffers_ToolTip { internal static string Config_SilverCoffers_ToolTip {
@ -375,15 +412,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> /// <summary>
/// Looks up a localized string similar to Start Export. /// Looks up a localized string similar to Start Export.
/// </summary> /// </summary>
@ -411,6 +439,15 @@ namespace Pal.Client.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Testing....
/// </summary>
internal static string Config_TestConnection_Connecting {
get {
return ResourceManager.GetString("Config_TestConnection_Connecting", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Trap color. /// Looks up a localized string similar to Trap color.
/// </summary> /// </summary>
@ -620,7 +657,16 @@ namespace Pal.Client.Properties {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to Please finish the first-time setup first.. /// Looks up a localized string similar to Command could not be executed: {0}.
/// </summary>
internal static string Error_CommandFailed {
get {
return ResourceManager.GetString("Error_CommandFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please finish the initial setup first..
/// </summary> /// </summary>
internal static string Error_FirstTimeSetupRequired { internal static string Error_FirstTimeSetupRequired {
get { get {
@ -655,6 +701,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> /// <summary>
/// Looks up a localized string similar to Please install this plugin from the official repository at {0} to continue using it.. /// Looks up a localized string similar to Please install this plugin from the official repository at {0} to continue using it..
/// </summary> /// </summary>

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!-- Common -->
<data name="PalaceOfTheDead" xml:space="preserve">
<value>Palast der Toten</value>
</data>
<data name="HeavenOnHigh" xml:space="preserve">
<value>Himmelssäule</value>
</data>
<data name="Save" xml:space="preserve">
<value>Speichern</value>
</data>
<data name="SaveAndClose" xml:space="preserve">
<value>Speichern &amp; Schließen</value>
</data>
<!-- Generic Errors -->
<!-- /pal commands -->
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
<value>Verbindung erfolgreich.</value>
</data>
<data name="ConnectionError_NotOnline" xml:space="preserve">
<value>Sie sind nicht online.</value>
<comment>Shown if you attempt to connect to the server while you have selected 'never upload discoveries, only show traps and coffers i found myself'</comment>
</data>
<data name="ConnectionError_OldVersion" xml:space="preserve">
<value>Ihre Version von Palace Pal ist veraltet, bitte aktualisieren Sie das Plugin mit Hilfe des Plugin Installers.</value>
<comment>Shown if the version is too old to create an account or log in.</comment>
</data>
<!-- Config Window: Deep Dungeons -->
<data name="ConfigTab_DeepDungeons" xml:space="preserve">
<value>Tiefe Gewölbe</value>
</data>
<data name="Config_Traps_Show" xml:space="preserve">
<value>Fallen anzeigen</value>
</data>
<!-- Config Window: Community -->
<!-- Config Window: Import -->
<!-- Config Window: Export -->
<!-- Config Window: Renderer -->
<!-- Config Window: Debug -->
<!-- Statistics Window -->
<!-- Agreement Window -->
<!-- Import (chat messages) -->
<!-- Other -->
</root>

View File

@ -0,0 +1,307 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!-- Common -->
<data name="Palace_Pal" xml:space="preserve">
<value>Palace Pal</value>
<comment>Plugin Name</comment>
</data>
<data name="PalaceOfTheDead" xml:space="preserve">
<value>Palais des morts</value>
</data>
<data name="HeavenOnHigh" xml:space="preserve">
<value>Palais des Cieux</value>
</data>
<data name="EurekaOrthos" xml:space="preserve">
<value>Eurêka Orthos</value>
</data>
<data name="Save" xml:space="preserve">
<value>Enregistrer</value>
</data>
<data name="SaveAndClose" xml:space="preserve">
<value>Enregistrer et Fermer</value>
</data>
<!-- Generic Errors -->
<data name="Error_FirstTimeSetupRequired" xml:space="preserve">
<value>Veuillez s'il vous plaît terminer la configuration initiale.</value>
<comment>Before using any /pal command, the initial setup/agreeement needs to be completed.</comment>
</data>
<data name="Error_WrongRepository" xml:space="preserve">
<value>Veuillez installer ce plugin depuis le dépôt officiel {0} pour continuer à l'utiliser.</value>
</data>
<!-- /pal commands -->
<data name="Command_pal_HelpText" xml:space="preserve">
<value>Ouvrir la fenêtre de configuration / débogage</value>
<comment>Help text for the /pal command, shown in the Plugin Installer</comment>
</data>
<data name="Command_pal_UnknownSubcommand" xml:space="preserve">
<value>Paramètre inconnu "{0} pour "{1}".</value>
<comment>Error shown when using '/pal xxx' with an unknown argument 'xxx'.</comment>
</data>
<data name="Command_pal_stats_CurrentFloor" xml:space="preserve">
<value>Vous pouvez voir les statistiques de l'étage actuel en ouvrant l'onglet "Débogage" dans la fenêtre de configuration.</value>
</data>
<data name="Command_pal_stats_UnableToFetchStatistics" xml:space="preserve">
<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>
<!-- 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>
</data>
<data name="ConnectionError_NotOnline" xml:space="preserve">
<value>Vous n'êtes pas connecté.</value>
<comment>Shown if you attempt to connect to the server while you have selected 'never upload discoveries, only show traps and coffers i found myself'</comment>
</data>
<data name="ConnectionError_OldVersion" xml:space="preserve">
<value>Votre version de Palace Pal est dépréciée, veuillez faire la mise à jour via l'installateur de plugin.</value>
<comment>Shown if the version is too old to create an account or log in.</comment>
</data>
<data name="ConnectionError_CreateAccountFailed" xml:space="preserve">
<value>Échec de la création de compte ({0}).</value>
<comment>Creating an account failed with an error code, but there is no (translated) error message for it.</comment>
</data>
<data name="ConnectionError_CreateAccountReturnedNoId" xml:space="preserve">
<value>Pas d'ID de compte suite à la tentative de création d'un compte.</value>
<comment>If the creation of an account was successful, we expect an account-id to be returned so we can log in. If this happens, the server sent an invalid response.</comment>
</data>
<data name="ConnectionError_InvalidAccountId" xml:space="preserve">
<value>Identifiant de compte invalide.</value>
<comment>The account id used was not found on the server.</comment>
</data>
<data name="ConnectionError_LoginFailed" xml:space="preserve">
<value>Échec de connexion ({0}).</value>
<comment>Logging in failed with an error code, but there is no (translated) error message for it.</comment>
</data>
<data name="ConnectionError_LoginReturnedNoToken" xml:space="preserve">
<value>Informations de connexion non disponibles.</value>
<comment>The server sent no valid client token, even though the login was successful.</comment>
</data>
<data name="ConnectionError_CouldNotConnectToServer" xml:space="preserve">
<value>Échec de connexion au serveur : {0}</value>
</data>
<!-- Config Window: Deep Dungeons -->
<data name="ConfigTab_DeepDungeons" xml:space="preserve">
<value>Donjons sans fonds</value>
</data>
<data name="Config_Traps_Show" xml:space="preserve">
<value>Afficher les pièges</value>
</data>
<data name="Config_Traps_Color" xml:space="preserve">
<value>Couleur pièges</value>
</data>
<data name="Config_Traps_HideImpossible" xml:space="preserve">
<value>Masquer les pièges de l'étage actuel</value>
<comment>When a trap has no chance of appearing (after a pomander of safety/sight is used), hide it.</comment>
</data>
<data name="Config_Traps_HideImpossible_ToolTip" xml:space="preserve">
<value>Lors de l'utilisation d'une poterie magique de localisation, seul les locations confirmées sont affichées, les locations potentielles sont masquées.
Lors de l'utilisation d'une poterie magique de désamorçage, tous les pièges sont masqués.</value>
</data>
<data name="Config_HoardCoffers_Show" xml:space="preserve">
<value>Afficher les trésors cachés</value>
</data>
<data name="Config_HoardCoffers_Color" xml:space="preserve">
<value>Couleur trésors cachés</value>
</data>
<data name="Config_HoardCoffers_HideImpossible" xml:space="preserve">
<value>Masquer les trésors cachés ne se trouvent pas sur l'étage actuel</value>
<comment>When a hoard coffer has no chance of appearing (after a pomander of safety/sight is used), hide it.</comment>
</data>
<data name="Config_HoardCoffers_HideImpossible_ToolTip" xml:space="preserve">
<value>Lors de l'utilisation d'une poterie magique d'intuition, seul les trésors cachés confirmés sont visibles, les locations potentielles sont masquées.</value>
</data>
<data name="Config_SilverCoffer_Show" xml:space="preserve">
<value>Afficher les coffres en argent de l'étage actuel</value>
</data>
<data name="Config_SilverCoffers_ToolTip" xml:space="preserve">
<value>Afficher tous les coffres en argent actuellement visible sur l'étage actuel.
Il n'y a pas de synchronisation avec les autres joueurs ni de sauvegarde entre les étages / tentatives.</value>
</data>
<data name="Config_SilverCoffer_Color" xml:space="preserve">
<value>Couleur coffres argent</value>
</data>
<data name="Config_SilverCoffer_Filled" xml:space="preserve">
<value>Remplir</value>
<comment>Whether silver coffers should only be drawn with the circle outline or as filled circle.</comment>
</data>
<!-- Config Window: Community -->
<data name="ConfigTab_Community" xml:space="preserve">
<value>Communauté</value>
</data>
<data name="Config_TestConnection" xml:space="preserve">
<value>Test de connexion</value>
</data>
<data name="Config_TestConnection_Connecting" xml:space="preserve">
<value>Test en cours...</value>
<comment>When clicking on the 'Test Connection' button, this is shown until a success/error message is available.</comment>
</data>
<!-- Config Window: Import -->
<data name="ConfigTab_Import" xml:space="preserve">
<value>Importer</value>
</data>
<data name="Config_ImportExplanation1" xml:space="preserve">
<value>Utiliser un fichier d'importation peut être utilisé si vous n'arriver pas à vous connecter au serveur ou bien que vous ne souhaitez pas partager vos découvertes.</value>
</data>
<data name="Config_ImportExplanation2" xml:space="preserve">
<value>Les exports sont (actuellement) générées manuellement. Ils incluent actuellement les trésors cachés et pièges rencontrés par 5 personnes ou plus. Cela peut amener des résultats sporadiques pour les étages supérieurs, cependant les étages communs (tel que PdM 51-60, PdC 21-30) sont proches d'être complets.</value>
</data>
<data name="Config_ImportExplanation3" xml:space="preserve">
<value>Si vous n'êtes pas connecté, importer un fichier n'aura aucun impact.</value>
</data>
<data name="Config_ImportDownloadLocation" xml:space="preserve">
<value>Les exports sont disponibles depuis {0} (en tant que fichiers *.pal).</value>
</data>
<data name="Config_Import_VisitGitHub" xml:space="preserve">
<value>Visiter GitHub</value>
</data>
<data name="Config_SelectImportFile" xml:space="preserve">
<value>Fichier à importer :</value>
</data>
<data name="Config_SelectImportFile_Hint" xml:space="preserve">
<value>Chemin vers le fichier *.pal</value>
<comment>When importing a file, this is the hint that shows up in the 'path' input box while no file has been selected.</comment>
</data>
<data name="Config_StartImport" xml:space="preserve">
<value>Démarrer l'importation</value>
</data>
<data name="Config_UndoImportExplanation1" xml:space="preserve">
<value>Votre dernière importation était le {0}, qui a ajoutée la base de données de pièges et trésors cachés depuis {1} créée le {2:d}.</value>
</data>
<data name="Config_UndoImportExplanation2" xml:space="preserve">
<value>Si vous pensez que c'est une erreur, vous pouvez supprimer tous les emplacements trouvés dans l'import (aucune des locations que vous avez trouvées ne sont modifiées).</value>
</data>
<data name="Config_UndoImport" xml:space="preserve">
<value>Annuler l'importation</value>
</data>
<!-- Config Window: Export -->
<data name="ConfigTab_Export" xml:space="preserve">
<value>Exporter</value>
</data>
<data name="Config_ExportSource" xml:space="preserve">
<value>Exporter tous les marqueurs depuis {0} :</value>
</data>
<data name="Config_Export_SaveAs" xml:space="preserve">
<value>Enregistrer sous :</value>
</data>
<data name="Config_StartExport" xml:space="preserve">
<value>Démarrer l'exportation</value>
</data>
<!-- Config Window: Renderer -->
<data name="ConfigTab_Renderer" xml:space="preserve">
<value>Moteur de rendu</value>
<comment>Configuration tab to select Splatoon or Simple as rendering backend</comment>
</data>
<data name="Config_SelectRenderBackend" xml:space="preserve">
<value>Choisissez le moteur de rendu pour les marqueurs :</value>
</data>
<data name="Config_Renderer_Splatoon" xml:space="preserve">
<value>Splatoon</value>
<comment>Splatoon plugin. Do not localize.</comment>
</data>
<data name="Config_Renderer_Splatoon_Hint" xml:space="preserve">
<value>Par défaut, requiert d'avoir installé Splatoon</value>
</data>
<data name="Config_Renderer_Simple" xml:space="preserve">
<value>Simple</value>
</data>
<data name="Config_Renderer_Simple_Hint" xml:space="preserve">
<value>Expérimental</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>
</data>
<!-- Config Window: Debug -->
<data name="ConfigTab_Debug" xml:space="preserve">
<value>Débogage</value>
</data>
<data name="Config_Debug_NotInADeepDungeon" xml:space="preserve">
<value>Vous nêtes PAS dans un donjon sans fond.</value>
</data>
<!-- Statistics Window -->
<data name="Statistics" xml:space="preserve">
<value>Statistiques</value>
</data>
<data name="Statistics_TerritoryId" xml:space="preserve">
<value>Id</value>
</data>
<data name="Statistics_InstanceName" xml:space="preserve">
<value>Nom de l'instance</value>
</data>
<data name="Statistics_Traps" xml:space="preserve">
<value>Pièges</value>
</data>
<data name="Statistics_HoardCoffers" xml:space="preserve">
<value>Trésor</value>
</data>
<!-- Agreement Window -->
<data name="Explanation_1" xml:space="preserve">
<value>Pal Palace affiche la location des potentiels pièges et trésors cachés.</value>
</data>
<data name="Explanation_2" xml:space="preserve">
<value>Pour ce faire, l'utilisation d'une poterie magique révélant l'emplacement d'un piège ou trésor caché sauvera la location révélée.</value>
</data>
<data name="Explanation_3" xml:space="preserve">
<value>Idéalement, nous voulons découvrir l'emplacement de chaque piège et trésor potentiel dans le jeu, mais le faire seul peut s'avérer très fastidieux. Les étages 51-60 plus de 300 locations de piège et plus de 290 locations de coffres et nous ne savons pas si la carte est complète. Les étages plus lointains on naturellement moins de tentatives, ce qui rends la cartographie plus complexe.</value>
</data>
<data name="Explanation_4" xml:space="preserve">
<value>Vous pouvez décider si vous souhaitez partager la location des pièges coffres que vous trouvez avec la communauté, ce qui vous permets également de voir les locations trouvées par les autres joueurs. Cette option peut être modifiée à tout moment. Aucune donnée concernant votre personnage FFXIV ou compte n'est envoyée vers notre serveur.</value>
</data>
<data name="Config_UploadMyDiscoveries_ShowOtherTraps" xml:space="preserve">
<value>Envoyer mes découvertes, afficher les pièges et coffres que les autres joueurs ont découvert</value>
</data>
<data name="Config_NeverUploadDiscoveries_ShowMyTraps" xml:space="preserve">
<value>Ne jamais envoyer mes découvertes, afficher uniquement les pièges et trésors que j'ai découverts moi-même</value>
</data>
<data name="Agreement_Warning1" xml:space="preserve">
<value>Bien que ce ne soit pas de l'automatisation, vous enfreignez certainement les conditions d'utilisation.</value>
</data>
<data name="Agreement_Warning2" xml:space="preserve">
<value>Les autres joueurs de votre équipe peuvent toujours voir vos actions.</value>
</data>
<data name="Agreement_Warning3" xml:space="preserve">
<value>De ce fait, veuillez éviter de mentionner ce plugin en jeu et de ne pas partager de vidéos / captures d'écrans.</value>
</data>
<data name="Agreement_UsingThisOnMyOwnRisk" xml:space="preserve">
<value>Je comprends que j'utilise ce plugin à mes propres risques.</value>
</data>
<data name="Agreement_ViewPluginAndServerSourceCode" xml:space="preserve">
<value>Voir le code source du plugin et du serveur</value>
</data>
<data name="Agreement_PickOneOption" xml:space="preserve">
<value>Veuillez choisir une des options ci-dessous.</value>
<comment>Shown if neither of the two radio buttons in the setup setup window are selected.</comment>
</data>
<!-- Import (chat messages) -->
<data name="ImportCompleteStatistics" xml:space="preserve">
<value>Importation de {0} nouvelles locations de pièges et {1} locations de trésors cachés.</value>
<comment>After the import of a *.pal file, the number of traps/hoard coffers is shown as a summary.</comment>
</data>
<data name="Error_ImportFailed" xml:space="preserve">
<value>Échec de l'import : {0}</value>
</data>
<data name="Error_ImportFailed_IncompatibleVersion" xml:space="preserve">
<value>Échec de l'import : version incompatible.</value>
</data>
<data name="Error_ImportFailed_InvalidFile" xml:space="preserve">
<value>Échec de l'import : fichier invalide.</value>
</data>
<!-- Other -->
</root>

View File

@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!-- Common -->
<data name="Palace_Pal" xml:space="preserve">
<value>Palace Pal</value>
<comment>Plugin Name</comment>
</data>
<data name="PalaceOfTheDead" xml:space="preserve">
<value>死者の宮殿</value>
</data>
<data name="HeavenOnHigh" xml:space="preserve">
<value>アメノミハシラ</value>
</data>
<data name="EurekaOrthos" xml:space="preserve">
<value>オルト・エウレカ</value>
</data>
<data name="Save" xml:space="preserve">
<value>保存</value>
</data>
<data name="SaveAndClose" xml:space="preserve">
<value>保存して閉じる</value>
</data>
<!-- Generic Errors -->
<data name="Error_FirstTimeSetupRequired" xml:space="preserve">
<value>最初にセットアップを完了してください。</value>
<comment>Before using any /pal command, the initial setup/agreeement needs to be completed.</comment>
</data>
<data name="Error_WrongRepository" xml:space="preserve">
<value>引き続き使用する場合は公式リポジトリ {0} からこのプラグインをインストールしてください。</value>
</data>
<!-- /pal commands -->
<data name="Command_pal_HelpText" xml:space="preserve">
<value>設定を開く</value>
<comment>Help text for the /pal command, shown in the Plugin Installer</comment>
</data>
<data name="Command_pal_UnknownSubcommand" xml:space="preserve">
<value>そのコマンドはありません。: '{0}' for '{1}'。</value>
<comment>Error shown when using '/pal xxx' with an unknown argument 'xxx'.</comment>
</data>
<data name="Command_pal_stats_CurrentFloor" xml:space="preserve">
<value>設定からDebugタブを開くことで、現在のフロアの統計情報を表示できます。</value>
</data>
<data name="Command_pal_stats_UnableToFetchStatistics" xml:space="preserve">
<value>統計情報を取得できません。</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</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>
</data>
<data name="ConnectionError_NotOnline" xml:space="preserve">
<value>接続に失敗しました。</value>
<comment>Shown if you attempt to connect to the server while you have selected 'never upload discoveries, only show traps and coffers i found myself'</comment>
</data>
<data name="ConnectionError_OldVersion" xml:space="preserve">
<value>Palace Palのバージョンが古くなっています。プラグインインストーラを使用してプラグインを更新してください。</value>
<comment>Shown if the version is too old to create an account or log in.</comment>
</data>
<data name="ConnectionError_CreateAccountFailed" xml:space="preserve">
<value>アカウントを作成できませんでした。({0})</value>
<comment>Creating an account failed with an error code, but there is no (translated) error message for it.</comment>
</data>
<data name="ConnectionError_CreateAccountReturnedNoId" xml:space="preserve">
<value>アカウントが作成されようとしましたが、アカウントIDがありません。</value>
<comment>If the creation of an account was successful, we expect an account-id to be returned so we can log in. If this happens, the server sent an invalid response.</comment>
</data>
<data name="ConnectionError_InvalidAccountId" xml:space="preserve">
<value>無効なアカウントIDです。</value>
<comment>The account id used was not found on the server.</comment>
</data>
<data name="ConnectionError_LoginFailed" xml:space="preserve">
<value>ログインできませんでした 。({0})</value>
<comment>Logging in failed with an error code, but there is no (translated) error message for it.</comment>
</data>
<data name="ConnectionError_LoginReturnedNoToken" xml:space="preserve">
<value>ログイン情報がありません。</value>
<comment>The server sent no valid client token, even though the login was successful.</comment>
</data>
<data name="ConnectionError_CouldNotConnectToServer" xml:space="preserve">
<value>サーバーに接続できません: {0}</value>
</data>
<!-- Config Window: Deep Dungeons -->
<data name="ConfigTab_DeepDungeons" xml:space="preserve">
<value>ディープダンジョン</value>
</data>
<data name="Config_Traps_Show" xml:space="preserve">
<value>トラップを表示</value>
</data>
<data name="Config_Traps_Color" xml:space="preserve">
<value>トラップの色</value>
</data>
<data name="Config_Traps_HideImpossible" xml:space="preserve">
<value>現在のフロアに存在しないトラップを非表示</value>
<comment>When a trap has no chance of appearing (after a pomander of safety/sight is used), hide it.</comment>
</data>
<data name="Config_Traps_HideImpossible_ToolTip" xml:space="preserve">
<value>サイトロを使用した場合、存在するトラップの位置のみが表示されます。
呪印解除を使用した場合、全てのトラップが非表示になります。</value>
</data>
<data name="Config_HoardCoffers_Show" xml:space="preserve">
<value>埋もれた財宝を表示</value>
</data>
<data name="Config_HoardCoffers_Color" xml:space="preserve">
<value>埋もれた財宝の色</value>
</data>
<data name="Config_HoardCoffers_HideImpossible" xml:space="preserve">
<value>現在のフロアに存在しない埋もれた財宝を非表示</value>
<comment>When a hoard coffer has no chance of appearing (after a pomander of safety/sight is used), hide it.</comment>
</data>
<data name="Config_HoardCoffers_HideImpossible_ToolTip" xml:space="preserve">
<value>財宝感知を使用した場合、現在のフロアに埋もれた財宝が存在する場合は表示されます。
存在しない場合は非表示になります。</value>
</data>
<data name="Config_SilverCoffer_Show" xml:space="preserve">
<value>銀の宝箱を表示</value>
</data>
<data name="Config_SilverCoffers_ToolTip" xml:space="preserve">
<value>現在のフロアにある全ての銀の宝箱を表示します。
これは他のプレイヤーと同期されず、データは保存されません。</value>
</data>
<data name="Config_SilverCoffer_Color" xml:space="preserve">
<value>銀の宝箱の色</value>
</data>
<data name="Config_SilverCoffer_Filled" xml:space="preserve">
<value>塗りつぶす</value>
<comment>Whether silver coffers should only be drawn with the circle outline or as filled circle.</comment>
</data>
<data name="Config_GoldCoffer_Show" xml:space="preserve">
<value>金の宝箱を表示</value>
</data>
<data name="Config_GoldCoffers_ToolTip" xml:space="preserve">
<value>現在のフロアにある全ての金の宝箱を表示します。
これは他のプレイヤーと同期されず、データは保存されません。</value>
</data>
<data name="Config_GoldCoffer_Color" xml:space="preserve">
<value>金の宝箱の色</value>
</data>
<data name="Config_GoldCoffer_Filled" xml:space="preserve">
<value>塗りつぶす</value>
<comment>Whether gold coffers should only be drawn with the circle outline or as filled circle.</comment>
</data>
<!-- Config Window: Community -->
<data name="ConfigTab_Community" xml:space="preserve">
<value>コミュニティ</value>
</data>
<data name="Config_TestConnection" xml:space="preserve">
<value>接続をテストする</value>
</data>
<data name="Config_TestConnection_Connecting" xml:space="preserve">
<value>接続テスト中...</value>
<comment>When clicking on the 'Test Connection' button, this is shown until a success/error message is available.</comment>
</data>
<!-- Config Window: Import -->
<data name="ConfigTab_Import" xml:space="preserve">
<value>インポート</value>
</data>
<data name="Config_ImportExplanation1" xml:space="preserve">
<value>エクスポートを使用すると、サーバーに接続できない場合や、情報を共有したくない場合に便利です。</value>
</data>
<data name="Config_ImportExplanation2" xml:space="preserve">
<value>エクスポートは(現在)手動で生成され、5人以上が遭遇したトラップ/宝箱のみが含まれています。
深層の情報は不安要素が多くなりますが、多くのプレイヤーが挑戦するフロア(死者の宮殿51F-60F、アメミハシラ21F-30F)は完成に近付いています。</value>
</data>
<data name="Config_ImportExplanation3" xml:space="preserve">
<value>オフラインモードで利用していない場合は、ファイルをインポートしても顕著な効果を得られません。</value>
</data>
<data name="Config_ImportDownloadLocation" xml:space="preserve">
<value>エクスポートは {0} (*.pal ファイルとして) から入手できます。</value>
</data>
<data name="Config_Import_VisitGitHub" xml:space="preserve">
<value>GitHubを開く</value>
</data>
<data name="Config_SelectImportFile" xml:space="preserve">
<value>ファイルを選択</value>
</data>
<data name="Config_StartImport" xml:space="preserve">
<value>インポート</value>
</data>
<data name="Config_UndoImportExplanation1" xml:space="preserve">
<value>あなたが最後にインポートしたファイルは{0}です。
{1}が{2:d}に作成したデータベースです。</value>
</data>
<data name="Config_UndoImportExplanation2" xml:space="preserve">
<value>間違いだと思われる場合は、インポートで見つかったすべての場所を削除することができます(自分で見た場所は変更されません)。</value>
</data>
<data name="Config_UndoImport" xml:space="preserve">
<value>インポートを取り消す</value>
</data>
<!-- Config Window: Export -->
<data name="ConfigTab_Export" xml:space="preserve">
<value>エクスポート</value>
</data>
<data name="Config_ExportSource" xml:space="preserve">
<value>{0} から全てのマーカーをエクスポート</value>
</data>
<data name="Config_Export_SaveAs" xml:space="preserve">
<value>名前をつけて保存</value>
</data>
<data name="Config_StartExport" xml:space="preserve">
<value>エクスポートの開始</value>
</data>
<!-- Config Window: Renderer -->
<data name="ConfigTab_Renderer" xml:space="preserve">
<value>レンダリング</value>
<comment>Configuration tab to select Splatoon or Simple as rendering backend</comment>
</data>
<data name="Config_SelectRenderBackend" xml:space="preserve">
<value>マーカーに使用するバックエンドを選択:</value>
</data>
<data name="Config_Renderer_Splatoon" xml:space="preserve">
<value>Splatoon</value>
<comment>Splatoon plugin. Do not localize.</comment>
</data>
<data name="Config_Renderer_Splatoon_Hint" xml:space="preserve">
<value>デフォルトではSpatoonをインストールする必要があります</value>
</data>
<data name="Config_Renderer_Simple" xml:space="preserve">
<value>シンプル表示</value>
</data>
<data name="Config_Renderer_Simple_Hint" xml:space="preserve">
<value>試験的機能</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>
</data>
<!-- Config Window: Debug -->
<data name="ConfigTab_Debug" xml:space="preserve">
<value>Debug</value>
</data>
<data name="Config_Debug_NotInADeepDungeon" xml:space="preserve">
<value>あなたはディープダンジョンにいません。</value>
</data>
<!-- Statistics Window -->
<data name="Statistics" xml:space="preserve">
<value>統計</value>
</data>
<data name="Statistics_TerritoryId" xml:space="preserve">
<value>エリアID</value>
</data>
<data name="Statistics_InstanceName" xml:space="preserve">
<value>インスタンス名</value>
</data>
<data name="Statistics_Traps" xml:space="preserve">
<value>トラップ</value>
</data>
<data name="Statistics_HoardCoffers" xml:space="preserve">
<value>埋もれた財宝</value>
</data>
<!-- Agreement Window -->
<data name="Explanation_1" xml:space="preserve">
<value>Pal Palaceは、潜在的なトラップ/宝箱がある場所を表示します。</value>
</data>
<data name="Explanation_2" xml:space="preserve">
<value>これを行うには、土器を使用してトラップや宝箱の場所を明らかにすると、あなたが見ているものの位置が保存されます。</value>
</data>
<data name="Explanation_3" xml:space="preserve">
<value>理想的には、我々はゲーム内のすべての潜在的なトラップと宝箱の位置を発見したいです。しかし、これを1人で行うことは非常に退屈です。フロア51-60には300箇所以上のトラップがあり、290箇所以上の宝箱があります。 フロアが高ければ高いほど、試行回数が少なくなり、ソロではマッピングがより難しくなります。</value>
</data>
<data name="Explanation_4" xml:space="preserve">
<value>トラップと宝箱をコミュニティと共有するかどうかを選択できます。
他のプレイヤーが見つけたトラップや宝箱も表示できます。
これはいつでも変更することができます。
あなたのFFXIVキャラクターに関するデータやアカウント情報は送信されることはありません。</value>
</data>
<data name="Config_UploadMyDiscoveries_ShowOtherTraps" xml:space="preserve">
<value>自分で見つけたトラップ/宝箱の情報をアップロードし、他のプレイヤーが発見したトラップ/宝箱を表示します</value>
</data>
<data name="Config_NeverUploadDiscoveries_ShowMyTraps" xml:space="preserve">
<value>データをアップロードせず、自分で発見したトラップと埋もれた財宝だけを表示する</value>
</data>
<data name="Agreement_Warning1" xml:space="preserve">
<value>これは自動化機能ではありませんが、ToSを壊す可能性は非常に高いです。</value>
</data>
<data name="Agreement_Warning2" xml:space="preserve">
<value>あなたのパーティーの他のプレイヤーは、あなたが立っている/歩く場所をいつでも見ることができます。</value>
</data>
<data name="Agreement_Warning3" xml:space="preserve">
<value>そのため、ゲーム内で言及することは避けて、ビデオ/スクリーンショットを共有しないでください。</value>
</data>
<data name="Agreement_UsingThisOnMyOwnRisk" xml:space="preserve">
<value>私は自分の責任でこのプラグインを使用していることを理解しています.</value>
</data>
<data name="Agreement_ViewPluginAndServerSourceCode" xml:space="preserve">
<value>プラグインとサーバーのソースコードを表示</value>
</data>
<data name="Agreement_PickOneOption" xml:space="preserve">
<value>以下のいずれかのオプションを選択してください。</value>
<comment>Shown if neither of the two radio buttons in the setup setup window are selected.</comment>
</data>
<!-- Import (chat messages) -->
<data name="ImportCompleteStatistics" xml:space="preserve">
<value>{0} 個の新しいトラップの場所と {1} 個の新しい宝箱の場所をインポートしました。</value>
<comment>After the import of a *.pal file, the number of traps/hoard coffers is shown as a summary.</comment>
</data>
<data name="Error_ImportFailed" xml:space="preserve">
<value>インポートに失敗しました。
{0}</value>
</data>
<data name="Error_ImportFailed_IncompatibleVersion" xml:space="preserve">
<value>インポートに失敗しました: 互換性のないバージョンです。</value>
</data>
<data name="Error_ImportFailed_InvalidFile" xml:space="preserve">
<value>インポートに失敗しました: 無効なファイルです。</value>
</data>
<!-- Other -->
</root>

View File

@ -43,8 +43,12 @@
<!-- Generic Errors --> <!-- Generic Errors -->
<data name="Error_FirstTimeSetupRequired" xml:space="preserve"> <data name="Error_FirstTimeSetupRequired" xml:space="preserve">
<value>Please finish the first-time setup first.</value> <value>Please finish the initial setup first.</value>
<comment>Before using any /pal command, the first-time setup/agreeement needs to be completed.</comment> <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>
<comment>Shown when the plugin fails to load, with the placeholder filled with the exception message.</comment>
</data> </data>
<data name="Error_WrongRepository" xml:space="preserve"> <data name="Error_WrongRepository" xml:space="preserve">
<value>Please install this plugin from the official repository at {0} to continue using it.</value> <value>Please install this plugin from the official repository at {0} to continue using it.</value>
@ -52,7 +56,7 @@
<!-- /pal commands --> <!-- /pal commands -->
<data name="Command_pal_HelpText" xml:space="preserve"> <data name="Command_pal_HelpText" xml:space="preserve">
<value>Open the configuration/debug window</value> <value>Open the configuration window</value>
<comment>Help text for the /pal command, shown in the Plugin Installer</comment> <comment>Help text for the /pal command, shown in the Plugin Installer</comment>
</data> </data>
<data name="Command_pal_UnknownSubcommand" xml:space="preserve"> <data name="Command_pal_UnknownSubcommand" xml:space="preserve">
@ -66,9 +70,9 @@
<value>Unable to fetch statistics.</value> <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> <comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data> </data>
<data name="Command_pal_updatesaves" xml:space="preserve"> <data name="Error_CommandFailed" xml:space="preserve">
<value>Updated all locally cached marker files to latest version.</value> <value>Command could not be executed: {0}</value>
<comment>Shown after /pal update-saves was successful.</comment> <comment>Shown when '/pal ...' fails, with the placeholder filled with the exception message.</comment>
</data> </data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. --> <!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
@ -107,7 +111,7 @@
<value>Could not connect to server: {0}</value> <value>Could not connect to server: {0}</value>
</data> </data>
<!-- Config Window --> <!-- Config Window: Deep Dungeons -->
<data name="ConfigTab_DeepDungeons" xml:space="preserve"> <data name="ConfigTab_DeepDungeons" xml:space="preserve">
<value>Deep Dungeons</value> <value>Deep Dungeons</value>
</data> </data>
@ -142,7 +146,7 @@ When using a Pomander of Safety, all traps are hidden.</value>
<value>Show silver coffers on current floor</value> <value>Show silver coffers on current floor</value>
</data> </data>
<data name="Config_SilverCoffers_ToolTip" xml:space="preserve"> <data name="Config_SilverCoffers_ToolTip" xml:space="preserve">
<value>Shows all the silver coffers visible to you on the current floor. <value>Shows nearby silver coffers (gear upgrades and magicites) on the current floor.
This is not synchronized with other players and not saved between floors/runs.</value> This is not synchronized with other players and not saved between floors/runs.</value>
</data> </data>
<data name="Config_SilverCoffer_Color" xml:space="preserve"> <data name="Config_SilverCoffer_Color" xml:space="preserve">
@ -152,14 +156,34 @@ This is not synchronized with other players and not saved between floors/runs.</
<value>Draw filled</value> <value>Draw filled</value>
<comment>Whether silver coffers should only be drawn with the circle outline or as filled circle.</comment> <comment>Whether silver coffers should only be drawn with the circle outline or as filled circle.</comment>
</data> </data>
<data name="Config_GoldCoffer_Show" xml:space="preserve">
<value>Show gold coffers on current floor</value>
</data>
<data name="Config_GoldCoffers_ToolTip" xml:space="preserve">
<value>Shows nearby gold coffers (containing pomanders) on the current floor.
This is not synchronized with other players and not saved between floors/runs.</value>
</data>
<data name="Config_GoldCoffer_Color" xml:space="preserve">
<value>Gold Coffer color</value>
</data>
<data name="Config_GoldCoffer_Filled" xml:space="preserve">
<value>Draw filled</value>
<comment>Whether gold coffers should only be drawn with the circle outline or as filled circle.</comment>
</data>
<!-- Config Window: Community -->
<data name="ConfigTab_Community" xml:space="preserve"> <data name="ConfigTab_Community" xml:space="preserve">
<value>Community</value> <value>Community</value>
</data> </data>
<data name="Config_TestConnection" xml:space="preserve"> <data name="Config_TestConnection" xml:space="preserve">
<value>Test Connection</value> <value>Test Connection</value>
</data> </data>
<data name="Config_TestConnection_Connecting" xml:space="preserve">
<value>Testing...</value>
<comment>When clicking on the 'Test Connection' button, this is shown until a success/error message is available.</comment>
</data>
<!-- Config Window: Import -->
<data name="ConfigTab_Import" xml:space="preserve"> <data name="ConfigTab_Import" xml:space="preserve">
<value>Import</value> <value>Import</value>
</data> </data>
@ -181,6 +205,10 @@ This is not synchronized with other players and not saved between floors/runs.</
<data name="Config_SelectImportFile" xml:space="preserve"> <data name="Config_SelectImportFile" xml:space="preserve">
<value>File to Import:</value> <value>File to Import:</value>
</data> </data>
<data name="Config_SelectImportFile_Hint" xml:space="preserve">
<value>Path to *.pal file</value>
<comment>When importing a file, this is the hint that shows up in the 'path' input box while no file has been selected.</comment>
</data>
<data name="Config_StartImport" xml:space="preserve"> <data name="Config_StartImport" xml:space="preserve">
<value>Start Import</value> <value>Start Import</value>
</data> </data>
@ -194,6 +222,7 @@ This is not synchronized with other players and not saved between floors/runs.</
<value>Undo Import</value> <value>Undo Import</value>
</data> </data>
<!-- Config Window: Export -->
<data name="ConfigTab_Export" xml:space="preserve"> <data name="ConfigTab_Export" xml:space="preserve">
<value>Export</value> <value>Export</value>
</data> </data>
@ -207,6 +236,7 @@ This is not synchronized with other players and not saved between floors/runs.</
<value>Start Export</value> <value>Start Export</value>
</data> </data>
<!-- Config Window: Renderer -->
<data name="ConfigTab_Renderer" xml:space="preserve"> <data name="ConfigTab_Renderer" xml:space="preserve">
<value>Renderer</value> <value>Renderer</value>
<comment>Configuration tab to select Splatoon or Simple as rendering backend</comment> <comment>Configuration tab to select Splatoon or Simple as rendering backend</comment>
@ -227,14 +257,12 @@ This is not synchronized with other players and not saved between floors/runs.</
<data name="Config_Renderer_Simple_Hint" xml:space="preserve"> <data name="Config_Renderer_Simple_Hint" xml:space="preserve">
<value>experimental</value> <value>experimental</value>
</data> </data>
<data name="Config_Splatoon_Test" xml:space="preserve">
<value>Splatoon Test:</value>
</data>
<data name="Config_Splatoon_DrawCircles" xml:space="preserve"> <data name="Config_Splatoon_DrawCircles" xml:space="preserve">
<value>Draw trap &amp; coffer circles around self</value> <value>Draw trap &amp; coffer circles around self</value>
<comment>To test the Splatoon integration, you can draw markers around yourself.</comment> <comment>To test the Splatoon integration, you can draw markers around yourself.</comment>
</data> </data>
<!-- Config Window: Debug -->
<data name="ConfigTab_Debug" xml:space="preserve"> <data name="ConfigTab_Debug" xml:space="preserve">
<value>Debug</value> <value>Debug</value>
</data> </data>
@ -295,10 +323,10 @@ This is not synchronized with other players and not saved between floors/runs.</
</data> </data>
<data name="Agreement_PickOneOption" xml:space="preserve"> <data name="Agreement_PickOneOption" xml:space="preserve">
<value>Please chose one of the options above.</value> <value>Please chose one of the options above.</value>
<comment>Shown if neither of the two radio buttons in the first-time setup window are selected.</comment> <comment>Shown if neither of the two radio buttons in the setup setup window are selected.</comment>
</data> </data>
<!-- Import --> <!-- Import (chat messages) -->
<data name="ImportCompleteStatistics" xml:space="preserve"> <data name="ImportCompleteStatistics" xml:space="preserve">
<value>Imported {0} new trap locations and {1} new hoard coffer locations.</value> <value>Imported {0} new trap locations and {1} new hoard coffer locations.</value>
<comment>After the import of a *.pal file, the number of traps/hoard coffers is shown as a summary.</comment> <comment>After the import of a *.pal file, the number of traps/hoard coffers is shown as a summary.</comment>
@ -312,6 +340,5 @@ This is not synchronized with other players and not saved between floors/runs.</
<data name="Error_ImportFailed_InvalidFile" xml:space="preserve"> <data name="Error_ImportFailed_InvalidFile" xml:space="preserve">
<value>Import failed: Invalid file.</value> <value>Import failed: Invalid file.</value>
</data> </data>
<!-- Other --> <!-- Other -->
</root> </root>

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

@ -0,0 +1,21 @@
# 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

@ -1,8 +1,8 @@
namespace Pal.Client.Rendering namespace Pal.Client.Rendering;
internal enum ELayer
{ {
internal enum ELayer TrapHoard,
{ RegularCoffers,
TrapHoard, Test,
RegularCoffers,
}
} }

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,9 +1,8 @@
namespace Pal.Client.Rendering namespace Pal.Client.Rendering;
{
public interface IRenderElement
{
bool IsValid { get; }
uint Color { get; set; } public interface IRenderElement
} {
bool IsValid { get; }
bool Enabled { get; set; }
} }

View File

@ -1,19 +1,19 @@
using ImGuiNET; using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics; using System.Numerics;
using System.Text; using Pal.Client.Configuration;
using System.Threading.Tasks; using Pal.Client.Floors;
namespace Pal.Client.Rendering namespace Pal.Client.Rendering;
internal interface IRenderer
{ {
internal interface IRenderer ERenderer GetConfigValue();
{
void SetLayer(ELayer layer, IReadOnlyList<IRenderElement> elements);
void ResetLayer(ELayer layer); void SetLayer(ELayer layer, IReadOnlyList<IRenderElement> elements);
IRenderElement CreateElement(Marker.EType type, Vector3 pos, uint color, bool fill = false); void ResetLayer(ELayer layer);
}
IRenderElement CreateElement(MemoryLocation.EType type, Vector3 pos, bool enabled, uint color, bool fill = false);
void DrawDebugItems(uint trapColor, uint hoardColor);
} }

View File

@ -1,20 +1,23 @@
using System.Collections.Generic; using System.Collections.Generic;
using Pal.Client.Floors;
namespace Pal.Client.Rendering namespace Pal.Client.Rendering;
internal sealed class MarkerConfig
{ {
internal class MarkerConfig private static readonly MarkerConfig EmptyConfig = new();
private static readonly Dictionary<MemoryLocation.EType, MarkerConfig> MarkerConfigs = new()
{ {
{ 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 } },
{ MemoryLocation.EType.GoldCoffer, new MarkerConfig { Radius = 1f } },
};
private readonly static Dictionary<Marker.EType, MarkerConfig> _markerConfig = new Dictionary<Marker.EType, MarkerConfig> public float OffsetY { get; private init; }
{ public float Radius { get; private init; } = 0.25f;
{ 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 } },
};
public float OffsetY { get; set; } = 0; public static MarkerConfig ForType(MemoryLocation.EType type) =>
public float Radius { get; set; } = 0.25f; MarkerConfigs.GetValueOrDefault(type, EmptyConfig);
public static MarkerConfig ForType(Marker.EType type) => _markerConfig[type] ?? new MarkerConfig();
}
} }

View File

@ -0,0 +1,78 @@
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);
}
public bool RequireRedraw { get; set; }
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());
RequireRedraw = true;
}
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, bool enabled, uint color,
bool fill = false)
=> _implementation.CreateElement(type, pos, enabled, 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();
}
}

Some files were not shown because too many files have changed in this diff Show More