Compare commits

...

186 Commits

Author SHA1 Message Date
Liza d239a6e4b0
API 10 2024-07-06 13:26:39 +02:00
Liza 9d5e4a797b
Update Dockerfile 2024-07-06 13:20:17 +02:00
Liza 3e2f917c14
API 10 2024-07-06 13:14:59 +02:00
Liza 2fdf9b1e4d
Remove GitInfo dependency 2024-01-16 11:41:13 +01:00
Liza 18ffd66086
Update icon URL 2024-01-16 07:03:56 +01:00
Liza 64e576a004
Render: Ensure RenderElement is null if we don't recreate it 2023-11-30 14:01:59 +01:00
Liza 0d1882d97f
Render: Performance fix 2023-11-26 22:14:04 +01:00
Liza 964119cfd2
Render: Use enabled instead of color for showing/hiding elements 2023-11-26 20:13:17 +01:00
Liza 309edfcd17
Bump version/recompile 2023-11-14 20:21:40 +01:00
Liza 8fbd3fbc0d
Update docker build 2023-11-11 01:56:09 +01:00
Liza ce9be45e8f
Update docker build 2023-11-11 01:54:43 +01:00
Liza 8def5e28a4
Update dependencies 2023-11-11 01:41:15 +01:00
Liza ba06576c2d
Update dependencies 2023-11-11 01:39:17 +01:00
Liza f259f03534
Update server submodule structure 2023-11-11 01:39:10 +01:00
Liza cf3a24afe8
Update header icon logic for Dalamud changes 2023-11-09 11:32:32 +01:00
Liza 2a180345eb
Update submodule URL 2023-10-14 00:02:21 +02:00
Liza 345c9c59a3
Add LLib, remove outdated info, add config window constraints 2023-10-11 03:56:18 +02:00
Liza 5c4e9f30e0
API 9 2023-10-03 22:05:19 +02:00
Liza d337413b82 Update docker-build.sh 2023-10-03 17:54:53 +00:00
Liza 5a5d7fecfd
Change nuget lib path 2023-10-03 11:10:43 +02:00
Liza 1ee86d378f
API 9 2023-10-03 11:08:38 +02:00
Liza cd52192d15
Setup server/test submodules 2023-09-17 22:28:37 +02:00
Liza 6260f35b61 Update repo location 2023-07-31 02:58:24 +02:00
Liza 79ad7fbc39 Update README 2023-07-30 23:53:47 +02:00
Liza 1bdcc7179c Update image location 2023-07-30 23:26:15 +02:00
Liza 5ba18477b4 Update repo url 2023-07-30 23:22:33 +02:00
Liza 5f506bda8a Update server url 2023-07-30 22:32:39 +02:00
Liza bbec57c3ad Update actions 2023-07-30 21:49:14 +02:00
Liza 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
Liza 8042aee951 Use file-scoped namespaces 2023-03-30 22:02:51 +02:00
Liza 0b07e8b8f6 Code cleanup 2023-03-27 20:00:30 +02:00
Liza 7e73430179 Add palace service tests, remove standalone client 2023-03-27 18:02:18 +02:00
Liza cec3597629 Update dependencies 2023-03-26 15:57:50 +02:00
Liza ae29eaa52f Update ECommons 2023-03-26 15:49:52 +02:00
Liza b81ced33e8 Code style tweaks 2023-03-26 15:47:43 +02:00
Liza 87806397d1 Configure GHA caching 2023-03-26 14:26:12 +02:00
Liza d645aa9ea7 Configure GHA dotnet test timeout 2023-03-26 13:55:30 +02:00
Liza 4288440e6c Add export tests 2023-03-26 03:11:01 +02:00
Liza 36b328c29e Add simple account test 2023-03-25 19:14:32 +01:00
Liza 232ab164be Update DE localization 2023-03-25 02:47:21 +01:00
Liza a4890c0159 Fix duplicate seen location errors 2023-03-25 02:39:54 +01:00
Liza 7bdf97411c Hide occasional FileLoadException (temporary error) 2023-03-11 01:50:26 +01:00
Liza 3e9f14419c Update gRPC packages 2023-03-10 03:08:44 +01:00
Liza a21e9335aa GHA: Add workflow_dispatch to docker build 2023-03-10 02:44:04 +01:00
Liza 1b415d7a7f Improve repo exception handling 2023-03-08 01:27:10 +01:00
Liza c5acb2ca54 Eureka Orthos: Add owlet trap to pomander of sight 2023-03-07 23:28:58 +01:00
Liza 7a514fad2a Add debug feature for object table 2023-03-07 21:55:50 +01:00
Liza 125b687a9c Eureka Orthos: Territory types 2023-03-07 12:58:10 +01:00
Liza 07ed62cd9a Update JP & DE localization 2023-03-02 02:00:05 +01:00
Liza 7179d41e59 Fix local seen 2023-02-26 17:54:15 +01:00
Liza 8279bfe9bf Fix config changes to trap/coffer locations not being applied when saving 2023-02-26 17:43:20 +01:00
Liza e45b72a655 Add gold coffers (#10) 2023-02-26 17:31:37 +01:00
Liza cbdcf58063 Move logging into nuget package 2023-02-25 15:32:26 +01:00
Liza d25e21619d Merge branch 'signtool-fix' 2023-02-25 01:35:26 +01:00
Liza 2bfbeacdd0 fix CI build 2023-02-25 01:32:39 +01:00
Liza 88ec7a00da Improve error handling if a command fails with an exception 2023-02-25 01:15:19 +01:00
Liza 0c922c1695 Use PalConfigCommand for the config UI button (partial revert of previous commit) 2023-02-25 01:07:10 +01:00
Liza e1dc8cafd9 Fix /pal test-connection not working if using Simple renderer 2023-02-25 01:06:33 +01:00
Liza e79e8de6dc Use ISubCommand for subcommands 2023-02-25 00:55:48 +01:00
Liza f140fb870c Signing test 2023-02-24 16:21:38 +01:00
Liza 3663bbb241 Improve error handling 2023-02-24 11:18:03 +01:00
Liza 17f7dcdf12 Db: Make backups work for open databases 2023-02-23 16:13:46 +01:00
Liza f02aeffb26 Fix missing config file breaking plugin load 2023-02-23 09:39:09 +01:00
Liza b658956c4b Fix cleanup not properly closing db 2023-02-23 01:12:05 +01:00
Liza 98bc4887d6 New config and database structures (#8) 2023-02-23 00:51:17 +01:00
Liza efeb30331c Db: Use precompiled model 2023-02-23 00:38:52 +01:00
Liza 8a27eca8b3 DI: Just use file.copy for backups 2023-02-23 00:09:49 +01:00
Liza 0d8f655936 Reset remote url 2023-02-23 00:02:31 +01:00
Liza 26b3a54ebd DI: Update namespaces 2023-02-22 23:58:05 +01:00
Liza 8d17c02186 DI: Load entire DI container in the background 2023-02-22 22:20:50 +01:00
Liza dbe6abd1db Net: Change retry logic 2023-02-22 21:54:33 +01:00
Liza 7bccec0bae Db: lmport 2023-02-22 20:30:26 +01:00
Liza d5dc55a0c4 Db: Fix various things around local persistence/net interactions 2023-02-22 17:21:48 +01:00
Liza 802e0c4cde Db: Backups 2023-02-21 17:32:13 +01:00
Liza e0d4a5d676 Namespace cleanup 2023-02-21 16:38:27 +01:00
Liza 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
Liza ff02b1f03c Downgrade GitInfo to avoid extra dependency dll in dist 2023-02-21 16:14:56 +01:00
Liza d1fdd1ce12 Determine client build version automatically from git tag 2023-02-21 16:04:51 +01:00
Liza 7b5bb3ee3a Use textwrap for radio buttons 2023-02-21 14:58:30 +01:00
Liza 7c968fa9d4 Update README 2023-02-21 14:07:52 +01:00
Liza 94f3fa2ede Db: Move Markers into database 2023-02-18 21:12:36 +01:00
Liza f63e70b0c4 DI: Build root scope async while still registering events/commands during plugin init 2023-02-18 04:34:49 +01:00
Liza adddbc452c DI: Support debug items in SimpleRenderer, remove IDrawDebugItems 2023-02-17 19:31:43 +01:00
Liza 5419f51942 DI: Only initialize one renderer at once 2023-02-17 19:12:44 +01:00
Liza 8986b368c7 Log: Use Serilog directly 2023-02-17 18:36:55 +01:00
Liza e624c5b628 Db: Migrate markers to db 2023-02-17 18:36:22 +01:00
Liza 57a5be7938 Config/Db: Move V1 json layouts to legacy folders 2023-02-17 16:30:20 +01:00
Liza 870f29f0c6 DI: Add Chat instead of using ChatExtensions 2023-02-17 15:51:45 +01:00
Liza 8b6dd52b54 Logging: Use underlying serilog logger directly 2023-02-17 13:57:12 +01:00
Liza 3954c839fb Merge branch 'master' into new-config-and-data 2023-02-17 01:14:12 +01:00
Liza f1171d6ccd Update french localization 2023-02-17 01:08:46 +01:00
Liza 0bb7301ca1 Logging: Update PluginLog to use Microsoft.Extensions.Logging instead 2023-02-17 00:54:23 +01:00
Liza a5456a54a0 DI: Cleanup 2023-02-16 22:09:29 +01:00
Liza 29342264c0 Tweaks for pdb files 2023-02-16 20:40:32 +01:00
Liza c7d5aa1eaa Db: Migrate ImportHistory to sqlite 2023-02-16 19:51:54 +01:00
Liza 7e2ccd3b42 Config: Remove Pal.Client.Net dependency for HasRole check 2023-02-16 13:27:28 +01:00
Liza 5c82382161 DI: Remove Service class 2023-02-16 13:17:55 +01:00
Liza e27f5a3201 Export: Version bump 2023-02-16 10:51:25 +01:00
Liza 3d560fad7f Make chat messages/errors consistent 2023-02-16 10:46:49 +01:00
Liza e3459a0182 DI: Remove WindowSystemExtensions 2023-02-16 10:29:17 +01:00
Liza 7d04cd7575 DI: Split QueueHandler into multiple classes 2023-02-16 10:25:33 +01:00
Liza 29aefee135 DI: Fix migration, cleanup 2023-02-15 23:51:35 +01:00
Liza c52341eb0d DI: Initial Draft 2023-02-15 23:17:52 +01:00
Liza faa35feade Config: Few JsonRequired attributes 2023-02-15 19:02:57 +01:00
Liza e7c2cd426b Config: Clean up 2023-02-15 13:34:44 +01:00
Liza d1cb7e08f2 Config: Make DPAPI support optional 2023-02-15 13:27:41 +01:00
Liza 550fa92a53 Config: Improve account model 2023-02-15 13:00:00 +01:00
Liza 16a17e0dcf Config: Add EF Core 2023-02-15 10:46:04 +01:00
Liza 4f8deea8e0 Config: account tweaks, UTF-8 fix, update server url 2023-02-15 10:27:00 +01:00
Liza 6412afbfbb Merge branch 'master' into new-config-and-data 2023-02-15 02:40:58 +01:00
Liza d3b8001a61 Fix build workflow ignore path 2023-02-15 02:40:15 +01:00
Liza b0de113ad2 New configuration format 2023-02-15 02:38:04 +01:00
Liza 4be0bdf637 Update solution items 2023-02-13 22:44:17 +01:00
Liza c586ced5bd Clicking on 'Test Connection' again cancels previous connection attempt 2023-02-13 22:43:30 +01:00
Liza 019a204862 Add build.yml as solution item 2023-02-13 17:38:17 +01:00
Liza b81575de84 Update version 2023-02-13 00:22:04 +01:00
Liza 2a32cf5c8d Update workflows 2023-02-12 22:45:57 +01:00
Liza 8857b93a19 Update workflows 2023-02-12 22:40:43 +01:00
Liza f9cbf0494a Fix HoH 31-90 display names 2023-02-12 22:33:30 +01:00
Liza a39eaa11f1 Add build workflow 2023-02-12 22:31:34 +01:00
Liza 9c0699bf2c Update JP localization 2023-02-12 21:00:05 +01:00
Liza 3c3ebba645 Simplify English text a bit 2023-02-12 20:58:25 +01:00
Liza ea9feb9599 Update Version 2023-02-12 14:09:53 +01:00
Liza 58e0e8d44c Add FR localization 2023-02-12 14:08:58 +01:00
Liza 16bc4fc34f Fix agreement window title 2023-02-12 00:41:48 +01:00
Liza 2bfcfee753 Code cleanup bits 2023-02-11 21:10:45 +01:00
Liza 12de110b04 Update config tab names to include stable ids, add more localizable text 2023-02-11 14:40:22 +01:00
Liza 0bec8cf759 Update window titles on language change 2023-02-11 14:31:43 +01:00
Liza c7b8e69732 Update StatisticsWindow to use locale-independent ids 2023-02-11 14:17:26 +01:00
Liza 3cd75ff9a5 Update AgreementWindow to be resizeable/have more word wrapping 2023-02-11 14:08:16 +01:00
Liza f7d707073b Add JP localization 2023-02-11 13:47:54 +01:00
Liza a180309815 Color fix 2023-02-10 21:25:28 +01:00
Liza 65bfb041b4 Update version 2023-02-10 21:08:30 +01:00
Liza b150e5727b Switch server image paths 2023-02-10 21:07:39 +01:00
Liza 049d5ded9b Update README 2023-02-10 21:06:34 +01:00
Liza cce435cc7b Fix workflow 2023-02-10 20:55:58 +01:00
Liza b23a79629d Add crowdin.yml 2023-02-10 20:52:08 +01:00
Liza f7840ab29b Move language strings into resx file 2023-02-10 20:48:43 +01:00
Liza 01d6b7f2dd Remove `experimental` tooltip 2023-02-10 19:00:04 +01:00
Liza db78d35fba Move server installation README 2023-02-10 00:02:05 +01:00
Liza 84a6f1e0a1 Update ip forwarding logic 2023-02-09 23:13:19 +01:00
Liza f8b77b9aa9 Dockerfile for server build 2023-02-09 22:07:09 +01:00
Liza 25709f5530 Promote VFX markers to proper traps 2023-02-08 20:41:45 +01:00
Liza 7122a07a28 Yeet old marker type 2023-02-08 19:54:10 +01:00
Liza b13d26a731 Fix IsValid logic not working if switching renders in PotD/HoH 2023-02-08 16:24:22 +01:00
Liza db89966be2 Pluggable Renderer selection 2023-02-08 16:07:26 +01:00
Liza 9263b9edfc Add ScaleFactor to math, since some trap locations are too close to others 2023-02-07 21:28:27 +01:00
Liza 136774eb71 Move duplication logic for positions into commons 2023-02-07 21:25:09 +01:00
Liza 53bd00285b Update README 2023-02-07 20:51:22 +01:00
Liza 209bfa62b8 Move IconUrl to json 2023-02-06 22:09:39 +01:00
Liza 51d474099f Update RemoteSeenOn logic 2023-02-06 22:01:18 +01:00
Liza c203f5bc95 Fix default version 2023-02-06 21:46:23 +01:00
Liza ea553a6d8a Imports 2023-02-05 04:25:08 +01:00
Liza b959355121 Debug markers, client implementation 2023-02-05 04:21:24 +01:00
Liza 74d18c95c6 Debug markers, server implementation 2023-02-05 03:38:37 +01:00
Liza 8e4af4ef81 Bump version 2023-02-05 02:16:55 +01:00
Liza 7d45d50f9a Local state cleanup
Follow-up for 8a2f071582
2023-02-05 02:15:58 +01:00
Liza 527d8a3bcb Revert " Mark traps that explode without pomander of sight as seen"
This reverts commit 8a2f071582.
2023-02-05 01:18:21 +01:00
Liza 6013b84568 Update statistics window 2023-02-05 00:12:24 +01:00
Liza 8a2f071582 Mark traps that explode without pomander of sight as seen 2023-02-03 03:50:55 +01:00
Liza 1e83e95f37 Bump ECommons 2023-02-02 20:59:06 +01:00
Liza 532889f211 /pal near as debug command 2023-02-02 17:16:03 +01:00
Liza 1dd670ff03 Update releases link 2023-02-02 16:45:54 +01:00
Liza 8ad3d598c6 Bump LangVersion 2023-01-15 22:53:02 +01:00
Liza 14bced042c ⬆️ API Level 8, dependency updates 2023-01-11 18:41:57 +01:00
Liza f64b85f9b8 Add undo import 2022-12-24 10:27:59 +01:00
Liza fc4a627c92 Clean up proto file 2022-12-22 02:16:51 +01:00
Liza b47b55f7d4 ⬆️ Dependency updates 2022-12-22 02:04:31 +01:00
Liza 372d1bad2f 🔥 Remove LoginReply expiresAt 2022-12-22 02:03:54 +01:00
Liza 898a7c8d9c ⚗️ Account id converter 2022-12-22 01:52:52 +01:00
Liza d917991c13 Update ECommons 2022-12-22 01:04:00 +01:00
Liza 4c22334761 ⚗️ Import/Export: Import, event rework 2022-12-22 01:01:09 +01:00
Liza bb721bc37f ⚗️ Import/Export: Export client 2022-12-21 20:23:48 +01:00
Liza 75b23cdaed Parse JWT for expiry & roles 2022-12-11 15:23:23 +01:00
Liza dfc9c3538c ⚗️ Import/Export: Draft export endpoint 2022-12-10 22:07:32 +01:00
Liza 976bfcbe2c ♻️ ConfigWindow: move tab logic into separate methods 2022-12-10 20:53:07 +01:00
Liza aa4f28b4fd Add CONTRIBUTING.md 2022-12-10 20:38:18 +01:00
Liza 0bd3794783 Update repo logic 2022-12-07 17:29:50 +01:00
Liza 83944234ca 📄 Add LICENSE 2022-12-01 18:15:19 +01:00
Liza 3c6822aa64 add dotnet-format as pre-commit hook 2022-12-01 17:56:26 +01:00
Liza e6c56e5bce Third party repo stuff 2022-11-30 22:15:26 +01:00
Liza bdc8187492 Log third party repo usage 2022-11-30 21:17:47 +01:00
Liza b69a53ef3d Make /pal config an alias for /pal 2022-11-30 16:39:50 +01:00
Liza 0d8a39a2b2 Bump client version 2022-11-30 14:22:49 +01:00
Liza 2ac674da37 Bump dependencies, use net7 for server 2022-11-30 13:53:03 +01:00
Liza e4af59d2c4 Update introductory text 2022-11-30 13:24:29 +01:00
128 changed files with 10760 additions and 1942 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
*/bin
*/obj
.git
.gitignore
Dockerfile
.dockerignore
docker-build.sh
.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

8
.githooks/pre-commit Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
#
# Use with:
#
# $> git config core.hooksPath .githooks
if ! git diff --cached --quiet -- "*.cs"; then
dotnet format --verbosity quiet --exclude vendor/
fi

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

6
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,6 @@
# Contribution Policy
Palace Pal is open-source, not open-contribution.
As such, feature requests should be opened as issues; pull requests from
outside contributors are currently not be accepted.

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>

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
ARG TARGETARCH
WORKDIR /build
COPY Pal.Common/Pal.Common.csproj Pal.Common/
COPY Server/Server/Pal.Server.csproj Server/Server/
RUN dotnet restore Server/Server/Pal.Server.csproj -a $TARGETARCH
COPY . ./
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
EXPOSE 5415
ENV DOTNET_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=
ENV DataDirectory=/data
ENV UseForwardedIp=true
ENV Kestrel__Endpoints__Http2__Url=http://+:5415
RUN adduser --uid 2000 --disabled-password --group --no-create-home --quiet --system pal
WORKDIR /app
COPY --from=build-env /dist .
USER pal
ENTRYPOINT ["dotnet", "Pal.Server.dll"]

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

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,76 +0,0 @@
using Dalamud.Configuration;
using System;
using System.Collections.Generic;
using System.Numerics;
namespace Pal.Client
{
public class Configuration : IPluginConfiguration
{
public int Version { get; set; } = 2;
#region Saved configuration values
public bool FirstUse { get; set; } = true;
public EMode Mode { get; set; } = EMode.Offline;
[Obsolete]
public string? DebugAccountId { private get; set; }
[Obsolete]
public string? AccountId { private get; set; }
public Dictionary<string, Guid> AccountIds { 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;
#endregion
public delegate void OnSaved();
public event OnSaved? Saved;
#pragma warning disable CS0612 // Type or member is obsolete
public void Migrate()
{
if (Version == 1)
{
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();
}
}
#pragma warning restore CS0612 // Type or member is obsolete
public void Save()
{
Service.PluginInterface.SavePluginConfig(this);
Saved?.Invoke();
}
public enum EMode
{
/// <summary>
/// Fetches trap locations from remote server.
/// </summary>
Online = 1,
/// <summary>
/// Only shows traps found by yourself uisng a pomander of sight.
/// </summary>
Offline = 2,
}
}
}

View File

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

@ -15,6 +15,7 @@
OutputPath="$(OutputPath)" OutputPath="$(OutputPath)"
AssemblyName="$(AssemblyName)" AssemblyName="$(AssemblyName)"
MakeZip="true" MakeZip="true"
VersionComponents="2"/> VersionComponents="2"
Exclude="ECommons.pdb;ECommons.xml"/>
</Target> </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,5 +0,0 @@
#!/bin/sh
curl -O https://raw.githubusercontent.com/karashiiro/DalamudPluginProjectTemplate/master/.github/workflows/dotnet.yml
mkdir .github
mkdir .github/workflows
mv dotnet.yml .github/workflows

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

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

@ -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,103 +0,0 @@
using Pal.Common;
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 JsonSerializerOptions { IncludeFields = true };
private static readonly int _currentVersion = 3;
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));
}
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),
};
version = save.Version;
}
localState.ApplyFilters();
if (version < _currentVersion)
localState.Save();
return localState;
}
public void Save()
{
string path = GetSaveLocation(TerritoryType);
ApplyFilters();
File.WriteAllText(path, JsonSerializer.Serialize(new SaveFile
{
Version = _currentVersion,
Markers = new HashSet<Marker>(Markers)
}, _jsonSerializerOptions));
}
private static string GetSaveLocation(uint territoryType) => Path.Join(Service.PluginInterface.GetPluginConfigDirectory(), $"{territoryType}.json");
public static void UpdateAll()
{
foreach (ETerritoryType territory in typeof(ETerritoryType).GetEnumValues())
{
LocalState? localState = Load((ushort)territory);
if (localState != null)
localState.Save();
}
}
public class SaveFile
{
public int Version { get; set; }
public HashSet<Marker> Markers { get; set; } = new();
}
}
}

View File

@ -1,91 +0,0 @@
using ECommons.SplatoonAPI;
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; } = false;
/// <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<Guid> RemoteSeenOn { get; set; } = new List<Guid>();
/// <summary>
/// Whether this marker was requested to be seen, to avoid duplicate requests.
/// </summary>
[JsonIgnore]
public bool RemoteSeenRequested { get; set; } = false;
[JsonIgnore]
public Element? SplatoonElement { 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, (int)Position.X, (int)Position.Y, (int)Position.Z);
}
public override bool Equals(object? obj)
{
return obj is Marker otherMarker && Type == otherMarker.Type && (int)Position.X == (int)otherMarker.Position.X && (int)Position.Y == (int)otherMarker.Position.Y && (int)Position.Z == (int)otherMarker.Position.Z;
}
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,
#endregion
# region Markers that only show up if they're currently visible
SilverCoffer = 100,
#endregion
}
}
}

View File

@ -1,77 +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) => 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

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Pal.Client.Net;
internal sealed 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)
{
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 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

@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
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.Properties;
using Version = System.Version;
namespace Pal.Client.Net;
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)
{
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)
{
_logger.LogDebug("Not Online, not attempting to establish a connection");
return (false, Localization.ConnectionError_NotOnline, false);
}
if (_channel == null ||
!(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle))
{
Dispose();
_logger.LogInformation("Creating new gRPC channel");
_channel = GrpcChannel.ForAddress(RemoteUrl, new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
ConnectTimeout = TimeSpan.FromSeconds(5),
SslOptions = GetSslClientAuthenticationOptions(),
},
LoggerFactory = loggerFactory,
});
_logger.LogInformation("Connecting to upstream service at {Url}", RemoteUrl);
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);
IAccountConfiguration? configuredAccount = _configuration.FindAccount(RemoteUrl);
if (configuredAccount == null)
{
_logger.LogInformation("No account information saved for {Url}, creating new account", RemoteUrl);
var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest
{
Version = new()
{
Major = PluginVersion.Major,
Minor = PluginVersion.Minor,
},
},
headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10),
cancellationToken: cancellationToken);
if (createAccountReply.Success)
{
if (!Guid.TryParse(createAccountReply.AccountId, out Guid accountId))
throw new InvalidOperationException("invalid account id returned");
configuredAccount = _configuration.CreateAccount(RemoteUrl, accountId);
_logger.LogInformation("Account created with id {AccountId}", accountId.ToPartialId());
_configurationManager.Save(_configuration);
}
else
{
_logger.LogError("Account creation failed with error {Error}", createAccountReply.Error);
if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade)
{
_chat.Error(Localization.ConnectionError_OldVersion);
_warnedAboutUpgrade = true;
}
return (false,
string.Format(Localization.ConnectionError_CreateAccountFailed, createAccountReply.Error),
false);
}
}
cancellationToken.ThrowIfCancellationRequested();
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (configuredAccount == null)
{
_logger.LogWarning("No account to login with");
return (false, Localization.ConnectionError_CreateAccountReturnedNoId, false);
}
if (!_loginInfo.IsValid)
{
_logger.LogInformation("Logging in with account id {AccountId}",
configuredAccount.AccountId.ToPartialId());
LoginReply loginReply = await accountClient.LoginAsync(
new LoginRequest
{
AccountId = configuredAccount.AccountId.ToString(),
Version = new()
{
Major = PluginVersion.Major,
Minor = PluginVersion.Minor,
},
},
headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10),
cancellationToken: cancellationToken);
if (loginReply.Success)
{
_logger.LogInformation("Login successful with account id: {AccountId}",
configuredAccount.AccountId.ToPartialId());
_loginInfo = new LoginInfo(loginReply.AuthToken);
bool save = configuredAccount.EncryptIfNeeded();
List<string> newRoles = _loginInfo.Claims?.Roles.ToList() ?? new();
if (!newRoles.SequenceEqual(configuredAccount.CachedRoles))
{
configuredAccount.CachedRoles = newRoles;
save = true;
}
if (save)
_configurationManager.Save(_configuration);
}
else
{
_logger.LogError("Login failed with error {Error}", loginReply.Error);
_loginInfo = new LoginInfo(null);
if (loginReply.Error == LoginError.InvalidAccountId)
{
_configuration.RemoveAccount(RemoteUrl);
_configurationManager.Save(_configuration);
_logger.LogInformation("Attempting connection retry without account id");
return (false, Localization.ConnectionError_InvalidAccountId, true);
}
if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade)
{
_chat.Error(Localization.ConnectionError_OldVersion);
_warnedAboutUpgrade = true;
}
return (false, string.Format(Localization.ConnectionError_LoginFailed, loginReply.Error),
false);
}
}
if (!_loginInfo.IsValid)
{
_logger.LogError("Login state is loggedIn={LoggedIn}, expired={Expired}", _loginInfo.IsLoggedIn,
_loginInfo.IsExpired);
return (false, Localization.ConnectionError_LoginReturnedNoToken, false);
}
cancellationToken.ThrowIfCancellationRequested();
return (true, string.Empty, false);
}
finally
{
_logger.LogTrace("Releasing connectLock");
_connectLock.Release();
}
}
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

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

View File

@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
using Pal.Client.Database;
using Pal.Client.Floors;
using Palace;
namespace Pal.Client.Net;
internal partial class RemoteApi
{
public async Task<(bool, List<PersistentLocation>)> DownloadRemoteMarkers(ushort territoryId,
CancellationToken cancellationToken = default)
{
if (!await Connect(cancellationToken))
return (false, new());
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
var downloadReply = await palaceClient.DownloadFloorsAsync(
new DownloadFloorsRequest { TerritoryType = territoryId }, headers: AuthorizedHeaders(),
cancellationToken: cancellationToken);
return (downloadReply.Success, downloadReply.Objects.Select(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
{
TerritoryType = territoryType,
};
uploadRequest.Objects.AddRange(locations.Select(m => new PalaceObject
{
Type = m.Type.ToObjectType(),
X = m.Position.X,
Y = m.Position.Y,
Z = m.Position.Z
}));
var uploadReply = await palaceClient.UploadFloorsAsync(uploadRequest, headers: AuthorizedHeaders(),
cancellationToken: cancellationToken);
return (uploadReply.Success, uploadReply.Objects.Select(CreateLocationFromNetworkObject).ToList());
}
public async Task<bool> MarkAsSeen(ushort territoryType, IReadOnlyList<PersistentLocation> locations,
CancellationToken cancellationToken = default)
{
if (locations.Count == 0)
return true;
if (!await Connect(cancellationToken))
return false;
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
{
Type = obj.Type.ToMemoryType(),
Position = new Vector3(obj.X, obj.Y, obj.Z),
NetworkId = Guid.Parse(obj.NetworkId),
Source = ClientLocation.ESource.Download,
};
}
public async Task<(bool, List<FloorStatistics>)> FetchStatistics(CancellationToken cancellationToken = default)
{
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

@ -0,0 +1,57 @@
using System;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Dalamud.Logging;
using Grpc.Core;
using Microsoft.Extensions.Logging;
namespace Pal.Client.Net;
internal partial class RemoteApi
{
private Metadata UnauthorizedHeaders() => new()
{
{ "User-Agent", _userAgent },
};
private Metadata AuthorizedHeaders() => new()
{
{ "Authorization", $"Bearer {_loginInfo.AuthToken}" },
{ "User-Agent", _userAgent },
};
private SslClientAuthenticationOptions? GetSslClientAuthenticationOptions()
{
#if !DEBUG
var secrets = typeof(RemoteApi).Assembly.GetType("Pal.Client.Secrets");
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];
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
{
ClientCertificates = new X509CertificateCollection()
{
certificate,
},
};
#else
_logger.LogDebug("Not using client certificate");
return null;
#endif
}
}

View File

@ -1,286 +1,51 @@
using Account; using System;
using Dalamud.Game.Gui;
using Dalamud.Logging; using Dalamud.Logging;
using Grpc.Core;
using Grpc.Net.Client; using Grpc.Net.Client;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Palace; using Pal.Client.Configuration;
using System; using Pal.Client.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Security;
using System.Numerics;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
namespace Pal.Client.Net namespace Pal.Client.Net;
{
internal class RemoteApi : IDisposable internal sealed partial class RemoteApi : IDisposable
{ {
#if DEBUG #if DEBUG
private const string remoteUrl = "http://localhost:5145"; public const string RemoteUrl = "http://localhost:5415";
#else #else
private const string remoteUrl = "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 LoginReply? _lastLoginReply; private LoginInfo _loginInfo = new(null);
private bool _warnedAboutUpgrade = false; private bool _warnedAboutUpgrade;
public Guid? AccountId public RemoteApi(
ILoggerFactory loggerFactory,
ILogger<RemoteApi> logger,
Chat chat,
ConfigurationManager configurationManager,
IPalacePalConfiguration configuration)
{ {
get => Service.Configuration.AccountIds.TryGetValue(remoteUrl, out Guid accountId) ? accountId : null; _loggerFactory = loggerFactory;
set _logger = logger;
{ _chat = chat;
if (value != null) _configurationManager = configurationManager;
Service.Configuration.AccountIds[remoteUrl] = value.Value; _configuration = configuration;
else
Service.Configuration.AccountIds.Remove(remoteUrl);
}
}
private string PartialAccountId =>
AccountId?.ToString()?.PadRight(14).Substring(0, 13) ?? "[no account id]";
private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken, ILoggerFactory? loggerFactory = null, bool retry = true)
{
if (Service.Configuration.Mode != Configuration.EMode.Online)
{
PluginLog.Debug("TryConnect: Not Online, not attempting to establish a connection");
return (false, "You are not online.");
}
if (_channel == null || !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle))
{
Dispose();
PluginLog.Information("TryConnect: Creating new gRPC channel");
_channel = GrpcChannel.ForAddress(remoteUrl, new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
ConnectTimeout = TimeSpan.FromSeconds(5),
SslOptions = GetSslClientAuthenticationOptions(),
},
LoggerFactory = loggerFactory,
});
PluginLog.Information($"TryConnect: Connecting to upstream service at {remoteUrl}");
await _channel.ConnectAsync(cancellationToken);
}
var accountClient = new AccountService.AccountServiceClient(_channel);
if (AccountId == null)
{
PluginLog.Information($"TryConnect: No account information saved for {remoteUrl}, creating new account");
var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest(), headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
if (createAccountReply.Success)
{
AccountId = Guid.Parse(createAccountReply.AccountId);
PluginLog.Information($"TryConnect: Account created with id {PartialAccountId}");
Service.Configuration.Save();
}
else
{
PluginLog.Error($"TryConnect: Account creation failed with error {createAccountReply.Error}");
if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade)
{
Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.");
_warnedAboutUpgrade = true;
}
return (false, $"Could not create account ({createAccountReply.Error}).");
}
}
if (AccountId == null)
{
PluginLog.Warning("TryConnect: No account id to login with");
return (false, "No account-id after account was attempted to be created.");
}
if (_lastLoginReply == null || string.IsNullOrEmpty(_lastLoginReply.AuthToken) || _lastLoginReply.ExpiresAt.ToDateTime().ToLocalTime() < DateTime.Now)
{
PluginLog.Information($"TryConnect: Logging in with account id {PartialAccountId}");
_lastLoginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
if (_lastLoginReply.Success)
{
PluginLog.Information($"TryConnect: Login successful with account id: {PartialAccountId}");
}
else
{
PluginLog.Error($"TryConnect: Login failed with error {_lastLoginReply.Error}");
if (_lastLoginReply.Error == LoginError.InvalidAccountId)
{
AccountId = null;
Service.Configuration.Save();
if (retry)
{
PluginLog.Information("TryConnect: Attempting connection retry without account id");
return await TryConnect(cancellationToken, retry: false);
}
else
return (false, "Invalid account id.");
}
if (_lastLoginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade)
{
Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.");
_warnedAboutUpgrade = true;
}
return (false, $"Could not log in ({_lastLoginReply.Error}).");
}
}
if (_lastLoginReply == null)
{
PluginLog.Error("TryConnect: No account available");
return (false, "No login information available.");
}
bool success = !string.IsNullOrEmpty(_lastLoginReply?.AuthToken);
if (!success)
return (success, "Login reply did not include auth token.");
return (success, string.Empty);
}
private async Task<bool> Connect(CancellationToken cancellationToken)
{
var result = await TryConnect(cancellationToken);
return result.Success;
}
public async Task<string> VerifyConnection(CancellationToken cancellationToken = default)
{
_warnedAboutUpgrade = false;
var connectionResult = await TryConnect(cancellationToken, loggerFactory: _grpcToPluginLogLoggerFactory);
if (!connectionResult.Success)
return $"Could not connect to server: {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);
return "Connection successful.";
}
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(o => CreateMarkerFromNetworkObject(o)).ToList());
}
public async Task<(bool, List<Marker>)> UploadMarker(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
{
if (markers.Count == 0)
return (true, new());
if (!await Connect(cancellationToken))
return (false, new());
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
var uploadRequest = new UploadFloorsRequest
{
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)
{
if (markers.Count == 0)
return true;
if (!await Connect(cancellationToken))
return false;
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
var seenRequest = new MarkObjectsSeenRequest { TerritoryType = territoryType };
foreach (var marker in markers)
seenRequest.NetworkIds.Add(marker.NetworkId.ToString());
var seenReply = await palaceClient.MarkObjectsSeenAsync(seenRequest, headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
return seenReply.Success;
}
private Marker CreateMarkerFromNetworkObject(PalaceObject obj) =>
new Marker((Marker.EType)obj.Type, new Vector3(obj.X, obj.Y, obj.Z), Guid.Parse(obj.NetworkId));
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());
}
private Metadata UnauthorizedHeaders() => new Metadata
{
{ "User-Agent", UserAgent },
};
private Metadata AuthorizedHeaders() => new Metadata
{
{ "Authorization", $"Bearer {_lastLoginReply?.AuthToken}" },
{ "User-Agent", UserAgent },
};
private SslClientAuthenticationOptions? GetSslClientAuthenticationOptions()
{
#if !DEBUG
var secrets = typeof(RemoteApi).Assembly.GetType("Pal.Client.Secrets");
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;
#endif
} }
public void Dispose() public void Dispose()
{ {
PluginLog.Debug("Disposing gRPC channel"); _logger.LogDebug("Disposing gRPC channel");
_channel?.Dispose(); _channel?.Dispose();
_channel = null; _channel = null;
} }
} }
}

View File

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

View File

@ -2,7 +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",
"Tags": [ "potd", "palace", "hoh", "splatoon" ] "IconUrl": "https://plugins.carvel.li/icons/PalacePal.png",
"Tags": [
"potd",
"palace",
"hoh",
"splatoon"
]
} }

View File

@ -1,761 +1,235 @@
using Dalamud.Game; using System;
using Dalamud.Game.ClientState.Conditions; using System.Collections.Generic;
using Dalamud.Game.ClientState.Objects.Types; using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Extensions.MicrosoftLogging;
using Dalamud.Game.Command; using Dalamud.Game.Command;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface.Windowing; using Dalamud.Interface.Windowing;
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using ECommons; using ECommons;
using ECommons.Schedulers; using ECommons.DalamudServices;
using ECommons.SplatoonAPI; using Microsoft.Extensions.DependencyInjection;
using Grpc.Core; using Microsoft.Extensions.Logging;
using ImGuiNET; using Pal.Client.Commands;
using Lumina.Excel.GeneratedSheets; using Pal.Client.Configuration;
using Pal.Client.Windows; using Pal.Client.DependencyInjection;
using Pal.Common; using Pal.Client.Properties;
using System; using Pal.Client.Rendering;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
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)
{ {
private const long ON_TERRITORY_CHANGE = -2; _pluginInterface = pluginInterface;
private const uint COLOR_INVISIBLE = 0; _commandManager = commandManager;
private const string SPLATOON_TRAP_HOARD = "PalacePal.TrapHoard"; _clientState = clientState;
private const string SPLATOON_REGULAR_COFFERS = "PalacePal.RegularCoffers"; _chatGui = chatGui;
_framework = framework;
_logger = new DalamudLoggerProvider(pluginLog).CreateLogger<Plugin>();
private readonly ConcurrentQueue<Sync> _pendingSyncResponses = new(); // set up the current UI language before creating anything
private readonly static Dictionary<Marker.EType, MarkerConfig> _markerConfig = new Dictionary<Marker.EType, MarkerConfig> Localization.Culture = new CultureInfo(_pluginInterface.UiLanguage);
_commandManager.AddHandler("/pal", new CommandInfo(OnCommand)
{ {
{ Marker.EType.Trap, new MarkerConfig { Radius = 1.7f } }, HelpMessage = Localization.Command_pal_HelpText
{ Marker.EType.Hoard, new MarkerConfig { Radius = 1.7f, OffsetY = -0.03f } },
{ Marker.EType.SilverCoffer, new MarkerConfig { Radius = 1f } },
};
private bool _configUpdated = false;
private LocalizedChatMessages _localizedChatMessages = new();
internal ConcurrentDictionary<ushort, LocalState> FloorMarkers { get; } = new();
internal ConcurrentBag<Marker> EphemeralMarkers { get; set; } = new();
internal ushort LastTerritory { get; private 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; }
public string Name => "Palace Pal";
public Plugin(DalamudPluginInterface pluginInterface)
{
ECommonsMain.Init(pluginInterface, this, Module.SplatoonAPI);
pluginInterface.Create<Service>();
Service.Plugin = this;
Service.Configuration = (Configuration?)pluginInterface.GetPluginConfig() ?? pluginInterface.Create<Configuration>()!;
Service.Configuration.Migrate();
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 += Service.WindowSystem.Draw;
pluginInterface.UiBuilder.OpenConfigUi += OnOpenConfigUi;
Service.Framework.Update += OnFrameworkUpdate;
Service.Configuration.Saved += OnConfigSaved;
Service.Chat.ChatMessage += OnChatMessage;
Service.CommandManager.AddHandler("/pal", new CommandInfo(OnCommand)
{
HelpMessage = "Open the configuration/debug window"
}); });
ReloadLanguageStrings(); // Using TickScheduler requires ECommons to at least be partially initialized
// ECommonsMain.Dispose leaves this untouched.
Svc.Init(pluginInterface);
Task.Run(async () => await CreateDependencyContext());
} }
public void OnOpenConfigUi() private async Task CreateDependencyContext()
{ {
Window? configWindow; try
if (Service.Configuration.FirstUse) {
configWindow = Service.WindowSystem.GetWindow<AgreementWindow>(); _dependencyInjectionContext = _pluginInterface.Create<DependencyInjectionContext>(this)
else ?? throw new Exception("Could not create DI root context class");
configWindow = Service.WindowSystem.GetWindow<ConfigWindow>(); var serviceProvider = _dependencyInjectionContext.BuildServiceContainer();
_initCts.Token.ThrowIfCancellationRequested();
if (configWindow != null) _logger = serviceProvider.GetRequiredService<ILogger<Plugin>>();
configWindow.IsOpen = true; _windowSystem = serviceProvider.GetRequiredService<WindowSystem>();
_rootScope = serviceProvider.CreateScope();
var loader = _rootScope.ServiceProvider.GetRequiredService<DependencyContextInitializer>();
await loader.InitializeAsync(_initCts.Token);
await _framework.RunOnFrameworkThread(() =>
{
_pluginInterface.UiBuilder.Draw += Draw;
_pluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi;
_pluginInterface.LanguageChanged += LanguageChanged;
_clientState.Login += Login;
});
_rootScopeCompletionSource.SetResult(_rootScope);
_loadState = ELoadState.Loaded;
}
catch (Exception e) when (e is ObjectDisposedException
or OperationCanceledException
or RepoVerification.RepoVerificationFailedException
|| (e is FileLoadException && _pluginInterface.IsDev))
{
_rootScopeCompletionSource.SetException(e);
_loadState = ELoadState.Error;
}
catch (Exception e)
{
_rootScopeCompletionSource.SetException(e);
_logger.LogError(e, "Async load failed");
ShowErrorOnLogin(() =>
new Chat(_chatGui).Error(string.Format(Localization.Error_LoadFailed,
$"{e.GetType()} - {e.Message}")));
_loadState = ELoadState.Error;
}
}
private void ShowErrorOnLogin(Action? loginAction)
{
if (_clientState.IsLoggedIn)
{
loginAction?.Invoke();
_loginAction = null;
}
else
_loginAction = loginAction;
}
private void Login()
{
_loginAction?.Invoke();
_loginAction = null;
} }
private void OnCommand(string command, string arguments) private void OnCommand(string command, string arguments)
{ {
if (Service.Configuration.FirstUse) arguments = arguments.Trim();
Task.Run(async () =>
{ {
Service.Chat.PrintError("[Palace Pal] Please finish the first-time setup first."); IServiceScope rootScope;
return; Chat chat;
}
try try
{ {
arguments = arguments.Trim(); rootScope = await _rootScopeCompletionSource.Task;
switch (arguments) chat = rootScope.ServiceProvider.GetRequiredService<Chat>();
{
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("Updated all locally cached marker files to latest version.");
break;
#endif
case "":
Service.WindowSystem.GetWindow<ConfigWindow>()?.Toggle();
break;
default:
Service.Chat.PrintError($"[Palace Pal] Unknown sub-command '{arguments}' for '{command}'.");
break;
}
} }
catch (Exception e) catch (Exception e)
{ {
Service.Chat.PrintError($"[Palace Pal] {e}"); _logger.LogError(e, "Could not wait for command root scope");
return;
} }
}
#region IDisposable Support
protected virtual void Dispose(bool disposing)
{
if (!disposing) return;
Service.CommandManager.RemoveHandler("/pal");
Service.PluginInterface.UiBuilder.Draw -= Service.WindowSystem.Draw;
Service.PluginInterface.UiBuilder.OpenConfigUi -= OnOpenConfigUi;
Service.Framework.Update -= OnFrameworkUpdate;
Service.Configuration.Saved -= OnConfigSaved;
Service.Chat.ChatMessage -= OnChatMessage;
Service.WindowSystem.RemoveAllWindows();
Service.RemoteApi.Dispose();
try try
{ {
Splatoon.RemoveDynamicElements(SPLATOON_TRAP_HOARD); IPalacePalConfiguration configuration =
Splatoon.RemoveDynamicElements(SPLATOON_REGULAR_COFFERS); rootScope.ServiceProvider.GetRequiredService<IPalacePalConfiguration>();
} if (configuration.FirstUse && arguments != "" && arguments != "config")
catch
{ {
// destroyed on territory change either way chat.Error(Localization.Error_FirstTimeSetupRequired);
return;
} }
ECommonsMain.Dispose();
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() public void Dispose()
{ {
Dispose(true); _commandManager.RemoveHandler("/pal");
GC.SuppressFinalize(this);
}
#endregion
private void OnConfigSaved() if (_loadState == ELoadState.Loaded)
{ {
_configUpdated = true; _pluginInterface.UiBuilder.Draw -= Draw;
_pluginInterface.UiBuilder.OpenConfigUi -= OpenConfigUi;
_pluginInterface.LanguageChanged -= LanguageChanged;
_clientState.Login -= Login;
} }
private void OnChatMessage(XivChatType type, uint senderId, ref SeString sender, ref SeString seMessage, ref bool isHandled) _initCts.Cancel();
{ _rootScope?.Dispose();
if (Service.Configuration.FirstUse) _dependencyInjectionContext?.Dispose();
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 OnFrameworkUpdate(Framework framework) private enum ELoadState
{ {
if (Service.Configuration.FirstUse) Initializing,
return; Loaded,
Error
try
{
bool recreateLayout = false;
if (_configUpdated)
{
if (Service.Configuration.Mode == Configuration.EMode.Offline)
{
LocalState.UpdateAll();
FloorMarkers.Clear();
EphemeralMarkers.Clear();
LastTerritory = 0;
}
_configUpdated = false;
recreateLayout = true;
}
bool saveMarkers = false;
if (LastTerritory != Service.ClientState.TerritoryType)
{
LastTerritory = Service.ClientState.TerritoryType;
TerritorySyncState = SyncState.NotAttempted;
if (IsInDeepDungeon())
FloorMarkers[LastTerritory] = LocalState.Load(LastTerritory) ?? new LocalState(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));
}
if (_pendingSyncResponses.Count > 0)
{
HandleSyncResponses();
recreateLayout = true;
saveMarkers = true;
}
if (!FloorMarkers.TryGetValue(LastTerritory, out var currentFloor))
FloorMarkers[LastTerritory] = currentFloor = new LocalState(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}";
}
}
private void HandlePersistentMarkers(LocalState currentFloor, IList<Marker> visibleMarkers, bool saveMarkers, bool recreateLayout)
{
var config = Service.Configuration;
var currentFloorMarkers = currentFloor.Markers;
bool updateSeenMarkers = false;
var accountId = Service.RemoteApi.AccountId;
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 (accountId != null && knownMarker.NetworkId != null && !knownMarker.RemoteSeenRequested && !knownMarker.RemoteSeenOn.Contains(accountId.Value))
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.SplatoonElement == null || !marker.SplatoonElement.IsValid())
{
recreateLayout = true;
break;
}
if (marker.SplatoonElement.color != desiredColor)
marker.SplatoonElement.color = desiredColor;
}
}
catch (Exception e)
{
DebugMessage = $"{DateTime.Now}\n{e}";
recreateLayout = true;
}
}
if (updateSeenMarkers && accountId != null)
{
var markersToUpdate = currentFloorMarkers.Where(x => x.Seen && x.NetworkId != null && !x.RemoteSeenRequested && !x.RemoteSeenOn.Contains(accountId.Value)).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)
{
Splatoon.RemoveDynamicElements(SPLATOON_TRAP_HOARD);
List<Element> elements = new List<Element>();
foreach (var marker in currentFloorMarkers)
{
if (marker.Seen || config.Mode == Configuration.EMode.Online)
{
if (marker.Type == Marker.EType.Trap && config.ShowTraps)
{
var element = CreateSplatoonElement(marker.Type, marker.Position, DetermineColor(marker, visibleMarkers));
marker.SplatoonElement = element;
elements.Add(element);
}
else if (marker.Type == Marker.EType.Hoard && config.ShowHoard)
{
var element = CreateSplatoonElement(marker.Type, marker.Position, DetermineColor(marker, visibleMarkers));
marker.SplatoonElement = element;
elements.Add(element);
}
}
}
if (elements.Count == 0)
return;
// we need to delay this, as the current framework update could be before splatoon's, in which case it would immediately delete the layout
new TickScheduler(delegate
{
try
{
Splatoon.AddDynamicElements(SPLATOON_TRAP_HOARD, elements.ToArray(), new long[] { Environment.TickCount64 + 60 * 60 * 1000, ON_TERRITORY_CHANGE });
}
catch (Exception e)
{
DebugMessage = $"{DateTime.Now}\n{e}";
}
});
}
}
private uint DetermineColor(Marker marker, IList<Marker> visibleMarkers)
{
if (marker.Type == Marker.EType.Trap)
{
if (PomanderOfSight == PomanderState.Inactive || !Service.Configuration.OnlyVisibleTrapsAfterPomander || visibleMarkers.Any(x => x == marker))
return ImGui.ColorConvertFloat4ToU32(Service.Configuration.TrapColor);
else
return COLOR_INVISIBLE;
}
else
{
if (PomanderOfIntuition == PomanderState.Inactive || !Service.Configuration.OnlyVisibleHoardAfterPomander || visibleMarkers.Any(x => x == marker))
return ImGui.ColorConvertFloat4ToU32(Service.Configuration.HoardColor);
else
return COLOR_INVISIBLE;
}
}
private void HandleEphemeralMarkers(IList<Marker> visibleMarkers, bool recreateLayout)
{
recreateLayout |= EphemeralMarkers.Any(existingMarker => !visibleMarkers.Any(x => x == existingMarker));
recreateLayout |= visibleMarkers.Any(visibleMarker => !EphemeralMarkers.Any(x => x == visibleMarker));
if (recreateLayout)
{
Splatoon.RemoveDynamicElements(SPLATOON_REGULAR_COFFERS);
EphemeralMarkers.Clear();
var config = Service.Configuration;
List<Element> elements = new List<Element>();
foreach (var marker in visibleMarkers)
{
EphemeralMarkers.Add(marker);
if (marker.Type == Marker.EType.SilverCoffer && config.ShowSilverCoffers)
{
var element = CreateSplatoonElement(marker.Type, marker.Position, config.SilverCofferColor, config.FillSilverCoffers);
marker.SplatoonElement = element;
elements.Add(element);
}
}
if (elements.Count == 0)
return;
new TickScheduler(delegate
{
try
{
Splatoon.AddDynamicElements(SPLATOON_REGULAR_COFFERS, elements.ToArray(), new long[] { Environment.TickCount64 + 60 * 60 * 1000, ON_TERRITORY_CHANGE });
}
catch (Exception e)
{
DebugMessage = $"{DateTime.Now}\n{e}";
}
});
}
}
private async Task DownloadMarkersForTerritory(ushort territoryId)
{
try
{
var (success, downloadedMarkers) = await Service.RemoteApi.DownloadRemoteMarkers(territoryId);
_pendingSyncResponses.Enqueue(new Sync
{
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);
_pendingSyncResponses.Enqueue(new Sync
{
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);
_pendingSyncResponses.Enqueue(new Sync
{
Type = SyncType.MarkSeen,
TerritoryType = territoryId,
Success = success,
Markers = markersToUpdate,
});
}
catch (Exception e)
{
DebugMessage = $"{DateTime.Now}\n{e}";
}
}
private async Task FetchFloorStatistics()
{
if (Service.Configuration.Mode != Configuration.EMode.Online)
{
Service.Chat.Print($"[Palace Pal] You can view statistics for the floor you're currently on by opening the 'Debug' tab in the configuration window.");
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.PrintError("[Palace Pal] Unable to fetch statistics.");
}
}
catch (RpcException e) when (e.StatusCode == StatusCode.PermissionDenied)
{
Service.Chat.Print($"[Palace Pal] You can view statistics for the floor you're currently on by opening the 'Debug' tab in the configuration window.");
}
catch (Exception e)
{
Service.Chat.PrintError($"[Palace Pal] {e}");
}
}
private void HandleSyncResponses()
{
while (_pendingSyncResponses.TryDequeue(out Sync? sync) && sync != null)
{
try
{
var territoryId = sync.TerritoryType;
var remoteMarkers = sync.Markers;
if (Service.Configuration.Mode == Configuration.EMode.Online && sync.Success && FloorMarkers.TryGetValue(territoryId, out var currentFloor) && remoteMarkers.Count > 0)
{
switch (sync.Type)
{
case SyncType.Download:
case SyncType.Upload:
foreach (var remoteMarker in remoteMarkers)
{
// Both uploads and downloads return the network id to be set, but only the downloaded marker is new as in to-be-saved.
Marker? localMarker = currentFloor.Markers.SingleOrDefault(x => x == remoteMarker);
if (localMarker != null)
{
localMarker.NetworkId = remoteMarker.NetworkId;
continue;
}
if (sync.Type == SyncType.Download)
currentFloor.Markers.Add(remoteMarker);
}
break;
case SyncType.MarkSeen:
var accountId = Service.RemoteApi.AccountId;
if (accountId == null)
break;
foreach (var remoteMarker in remoteMarkers)
{
Marker? localMarker = currentFloor.Markers.SingleOrDefault(x => x == remoteMarker);
if (localMarker != null)
localMarker.RemoteSeenOn.Add(accountId.Value);
}
break;
}
}
// don't modify state for outdated floors
if (LastTerritory != territoryId)
continue;
if (sync.Type == SyncType.Download)
{
if (sync.Success)
TerritorySyncState = SyncState.Complete;
else
TerritorySyncState = SyncState.Failed;
}
}
catch (Exception e)
{
DebugMessage = $"{DateTime.Now}\n{e}";
if (sync.Type == SyncType.Download)
TerritorySyncState = SyncState.Failed;
}
}
}
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;
}
}
return result;
}
internal bool IsInDeepDungeon() =>
Service.ClientState.IsLoggedIn
&& Service.Condition[ConditionFlag.InDeepDungeon]
&& typeof(ETerritoryType).IsEnumDefined(Service.ClientState.TerritoryType);
internal static Element CreateSplatoonElement(Marker.EType type, Vector3 pos, Vector4 color, bool fill = false)
=> CreateSplatoonElement(type, pos, ImGui.ColorConvertFloat4ToU32(color), fill);
internal static Element CreateSplatoonElement(Marker.EType type, Vector3 pos, uint color, bool fill = false)
{
return new Element(ElementType.CircleAtFixedCoordinates)
{
refX = pos.X,
refY = pos.Z, // z and y are swapped
refZ = pos.Y,
offX = 0,
offY = 0,
offZ = _markerConfig[type].OffsetY,
Filled = fill,
radius = _markerConfig[type].Radius,
FillStep = 1,
color = color,
thicc = 2,
};
}
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+)") + "$"),
};
}
private string GetLocalizedString(uint id)
{
return Service.DataManager.GetExcelSheet<LogMessage>()?.GetRow(id)?.Text?.ToString() ?? "Unknown";
}
internal class Sync
{
public SyncType Type { get; set; }
public ushort TerritoryType { get; set; }
public bool Success { get; set; }
public List<Marker> Markers { get; set; } = new();
}
public enum SyncState
{
NotAttempted,
NotNeeded,
Started,
Complete,
Failed,
}
public enum SyncType
{
Upload,
Download,
MarkSeen,
}
public enum PomanderState
{
Inactive,
Active,
FoundOnCurrentFloor,
PomanderOfSafetyUsed,
}
private class MarkerConfig
{
public float OffsetY { get; set; } = 0;
public float Radius { get; set; } = 0.25f;
}
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+)$");
}
} }
} }

View File

@ -0,0 +1,866 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Pal.Client.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Localization {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Localization() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Pal.Client.Properties.Localization", typeof(Localization).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Please chose one of the options above..
/// </summary>
internal static string Agreement_PickOneOption {
get {
return ResourceManager.GetString("Agreement_PickOneOption", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to I understand I&apos;m using this plugin on my own risk..
/// </summary>
internal static string Agreement_UsingThisOnMyOwnRisk {
get {
return ResourceManager.GetString("Agreement_UsingThisOnMyOwnRisk", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to View plugin &amp; server source code.
/// </summary>
internal static string Agreement_ViewPluginAndServerSourceCode {
get {
return ResourceManager.GetString("Agreement_ViewPluginAndServerSourceCode", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to While this is not an automation feature, you&apos;re still very likely to break the ToS..
/// </summary>
internal static string Agreement_Warning1 {
get {
return ResourceManager.GetString("Agreement_Warning1", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Other players in your party can always see where you&apos;re standing/walking..
/// </summary>
internal static string Agreement_Warning2 {
get {
return ResourceManager.GetString("Agreement_Warning2", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to As such, please avoid mentioning it in-game and do not share videos/screenshots..
/// </summary>
internal static string Agreement_Warning3 {
get {
return ResourceManager.GetString("Agreement_Warning3", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open the configuration window.
/// </summary>
internal static string Command_pal_HelpText {
get {
return ResourceManager.GetString("Command_pal_HelpText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You can view statistics for the floor you&apos;re currently on by opening the &apos;Debug&apos; tab in the configuration window..
/// </summary>
internal static string Command_pal_stats_CurrentFloor {
get {
return ResourceManager.GetString("Command_pal_stats_CurrentFloor", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unable to fetch statistics..
/// </summary>
internal static string Command_pal_stats_UnableToFetchStatistics {
get {
return ResourceManager.GetString("Command_pal_stats_UnableToFetchStatistics", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unknown sub-command &apos;{0}&apos; for &apos;{1}&apos;..
/// </summary>
internal static string Command_pal_UnknownSubcommand {
get {
return ResourceManager.GetString("Command_pal_UnknownSubcommand", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You are NOT in a deep dungeon..
/// </summary>
internal static string Config_Debug_NotInADeepDungeon {
get {
return ResourceManager.GetString("Config_Debug_NotInADeepDungeon", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save as:.
/// </summary>
internal static string Config_Export_SaveAs {
get {
return ResourceManager.GetString("Config_Export_SaveAs", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Export all markers from {0}:.
/// </summary>
internal static string Config_ExportSource {
get {
return ResourceManager.GetString("Config_ExportSource", resourceCulture);
}
}
/// <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>
/// Looks up a localized string similar to Hoard Coffer color.
/// </summary>
internal static string Config_HoardCoffers_Color {
get {
return ResourceManager.GetString("Config_HoardCoffers_Color", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hide hoard coffers not on current floor.
/// </summary>
internal static string Config_HoardCoffers_HideImpossible {
get {
return ResourceManager.GetString("Config_HoardCoffers_HideImpossible", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to When using a Pomander of intuition, only the actual hoard coffer location is visible, all other (potential) hoard coffers are hidden..
/// </summary>
internal static string Config_HoardCoffers_HideImpossible_ToolTip {
get {
return ResourceManager.GetString("Config_HoardCoffers_HideImpossible_ToolTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show hoard coffers.
/// </summary>
internal static string Config_HoardCoffers_Show {
get {
return ResourceManager.GetString("Config_HoardCoffers_Show", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Visit GitHub.
/// </summary>
internal static string Config_Import_VisitGitHub {
get {
return ResourceManager.GetString("Config_Import_VisitGitHub", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Exports are available from {0} (as *.pal files)..
/// </summary>
internal static string Config_ImportDownloadLocation {
get {
return ResourceManager.GetString("Config_ImportDownloadLocation", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Using an export is useful if you&apos;re unable to connect to the server, or don&apos;t wish to share your findings..
/// </summary>
internal static string Config_ImportExplanation1 {
get {
return ResourceManager.GetString("Config_ImportExplanation1", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Exports are (currently) generated manually, and they only include traps and hoard coffers encountered by 5 or more people. This may lead to higher floors having very sporadic coverage, but commonly run floors (e.g. PotD 51-60, HoH 21-30) are closer to complete..
/// </summary>
internal static string Config_ImportExplanation2 {
get {
return ResourceManager.GetString("Config_ImportExplanation2", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to If you aren&apos;t offline, importing a file won&apos;t have any noticeable effect..
/// </summary>
internal static string Config_ImportExplanation3 {
get {
return ResourceManager.GetString("Config_ImportExplanation3", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Never upload discoveries, show only traps and coffers I found myself.
/// </summary>
internal static string Config_NeverUploadDiscoveries_ShowMyTraps {
get {
return ResourceManager.GetString("Config_NeverUploadDiscoveries_ShowMyTraps", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Simple.
/// </summary>
internal static string Config_Renderer_Simple {
get {
return ResourceManager.GetString("Config_Renderer_Simple", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to experimental.
/// </summary>
internal static string Config_Renderer_Simple_Hint {
get {
return ResourceManager.GetString("Config_Renderer_Simple_Hint", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Splatoon.
/// </summary>
internal static string Config_Renderer_Splatoon {
get {
return ResourceManager.GetString("Config_Renderer_Splatoon", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to default, required Splatoon to be installed.
/// </summary>
internal static string Config_Renderer_Splatoon_Hint {
get {
return ResourceManager.GetString("Config_Renderer_Splatoon_Hint", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to File to Import:.
/// </summary>
internal static string Config_SelectImportFile {
get {
return ResourceManager.GetString("Config_SelectImportFile", resourceCulture);
}
}
/// <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>
/// Looks up a localized string similar to Select which render backend to use for markers:.
/// </summary>
internal static string Config_SelectRenderBackend {
get {
return ResourceManager.GetString("Config_SelectRenderBackend", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Silver Coffer color.
/// </summary>
internal static string Config_SilverCoffer_Color {
get {
return ResourceManager.GetString("Config_SilverCoffer_Color", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Draw filled.
/// </summary>
internal static string Config_SilverCoffer_Filled {
get {
return ResourceManager.GetString("Config_SilverCoffer_Filled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show silver coffers on current floor.
/// </summary>
internal static string Config_SilverCoffer_Show {
get {
return ResourceManager.GetString("Config_SilverCoffer_Show", resourceCulture);
}
}
/// <summary>
/// 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..
/// </summary>
internal static string Config_SilverCoffers_ToolTip {
get {
return ResourceManager.GetString("Config_SilverCoffers_ToolTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Draw trap &amp; coffer circles around self.
/// </summary>
internal static string Config_Splatoon_DrawCircles {
get {
return ResourceManager.GetString("Config_Splatoon_DrawCircles", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Start Export.
/// </summary>
internal static string Config_StartExport {
get {
return ResourceManager.GetString("Config_StartExport", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Start Import.
/// </summary>
internal static string Config_StartImport {
get {
return ResourceManager.GetString("Config_StartImport", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Test Connection.
/// </summary>
internal static string Config_TestConnection {
get {
return ResourceManager.GetString("Config_TestConnection", resourceCulture);
}
}
/// <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>
/// Looks up a localized string similar to Trap color.
/// </summary>
internal static string Config_Traps_Color {
get {
return ResourceManager.GetString("Config_Traps_Color", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hide traps not on current floor.
/// </summary>
internal static string Config_Traps_HideImpossible {
get {
return ResourceManager.GetString("Config_Traps_HideImpossible", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to When using a Pomander of Sight, only the actual trap locations are visible, all other traps are hidden.
///When using a Pomander of Safety, all traps are hidden..
/// </summary>
internal static string Config_Traps_HideImpossible_ToolTip {
get {
return ResourceManager.GetString("Config_Traps_HideImpossible_ToolTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show traps.
/// </summary>
internal static string Config_Traps_Show {
get {
return ResourceManager.GetString("Config_Traps_Show", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Undo Import.
/// </summary>
internal static string Config_UndoImport {
get {
return ResourceManager.GetString("Config_UndoImport", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Your last import was on {0}, which added the trap/hoard coffer database from {1} created on {2:d}..
/// </summary>
internal static string Config_UndoImportExplanation1 {
get {
return ResourceManager.GetString("Config_UndoImportExplanation1", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to If you think that was a mistake, you can remove all locations only found in the import (any location you&apos;ve seen yourself is not changed)..
/// </summary>
internal static string Config_UndoImportExplanation2 {
get {
return ResourceManager.GetString("Config_UndoImportExplanation2", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Upload my discoveries, show traps &amp; coffers other players have discovered.
/// </summary>
internal static string Config_UploadMyDiscoveries_ShowOtherTraps {
get {
return ResourceManager.GetString("Config_UploadMyDiscoveries_ShowOtherTraps", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Community.
/// </summary>
internal static string ConfigTab_Community {
get {
return ResourceManager.GetString("ConfigTab_Community", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Debug.
/// </summary>
internal static string ConfigTab_Debug {
get {
return ResourceManager.GetString("ConfigTab_Debug", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Deep Dungeons.
/// </summary>
internal static string ConfigTab_DeepDungeons {
get {
return ResourceManager.GetString("ConfigTab_DeepDungeons", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Export.
/// </summary>
internal static string ConfigTab_Export {
get {
return ResourceManager.GetString("ConfigTab_Export", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import.
/// </summary>
internal static string ConfigTab_Import {
get {
return ResourceManager.GetString("ConfigTab_Import", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Renderer.
/// </summary>
internal static string ConfigTab_Renderer {
get {
return ResourceManager.GetString("ConfigTab_Renderer", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Could not connect to server: {0}.
/// </summary>
internal static string ConnectionError_CouldNotConnectToServer {
get {
return ResourceManager.GetString("ConnectionError_CouldNotConnectToServer", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Could not create account ({0})..
/// </summary>
internal static string ConnectionError_CreateAccountFailed {
get {
return ResourceManager.GetString("ConnectionError_CreateAccountFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No account-id after account was attempted to be created..
/// </summary>
internal static string ConnectionError_CreateAccountReturnedNoId {
get {
return ResourceManager.GetString("ConnectionError_CreateAccountReturnedNoId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Invalid account id..
/// </summary>
internal static string ConnectionError_InvalidAccountId {
get {
return ResourceManager.GetString("ConnectionError_InvalidAccountId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Could not log in ({0})..
/// </summary>
internal static string ConnectionError_LoginFailed {
get {
return ResourceManager.GetString("ConnectionError_LoginFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No login information available..
/// </summary>
internal static string ConnectionError_LoginReturnedNoToken {
get {
return ResourceManager.GetString("ConnectionError_LoginReturnedNoToken", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You are not online..
/// </summary>
internal static string ConnectionError_NotOnline {
get {
return ResourceManager.GetString("ConnectionError_NotOnline", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer..
/// </summary>
internal static string ConnectionError_OldVersion {
get {
return ResourceManager.GetString("ConnectionError_OldVersion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Connection successful..
/// </summary>
internal static string ConnectionSuccessful {
get {
return ResourceManager.GetString("ConnectionSuccessful", resourceCulture);
}
}
/// <summary>
/// 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>
internal static string Error_FirstTimeSetupRequired {
get {
return ResourceManager.GetString("Error_FirstTimeSetupRequired", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import failed: {0}.
/// </summary>
internal static string Error_ImportFailed {
get {
return ResourceManager.GetString("Error_ImportFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import failed: Incompatible version..
/// </summary>
internal static string Error_ImportFailed_IncompatibleVersion {
get {
return ResourceManager.GetString("Error_ImportFailed_IncompatibleVersion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import failed: Invalid file..
/// </summary>
internal static string Error_ImportFailed_InvalidFile {
get {
return ResourceManager.GetString("Error_ImportFailed_InvalidFile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Plugin could not be loaded: {0}.
/// </summary>
internal static string Error_LoadFailed {
get {
return ResourceManager.GetString("Error_LoadFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please install this plugin from the official repository at {0} to continue using it..
/// </summary>
internal static string Error_WrongRepository {
get {
return ResourceManager.GetString("Error_WrongRepository", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Eureka Orthos.
/// </summary>
internal static string EurekaOrthos {
get {
return ResourceManager.GetString("EurekaOrthos", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pal Palace will show where potential trap &amp; hoard coffer locations are..
/// </summary>
internal static string Explanation_1 {
get {
return ResourceManager.GetString("Explanation_1", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to To do this, using a pomander to reveal trap or treasure chest locations will save the position of what you see..
/// </summary>
internal static string Explanation_2 {
get {
return ResourceManager.GetString("Explanation_2", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Ideally, we want to discover every potential trap and chest location in the game, but doing this alone is very tedious. Floor 51-60 has over 300 trap locations and over 290 coffer locations - and we don&apos;t know if that map is complete. Higher floors naturally see fewer runs, making solo attempts to map the place much harder..
/// </summary>
internal static string Explanation_3 {
get {
return ResourceManager.GetString("Explanation_3", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You can decide whether you want to share traps and chests you find with the community, which likewise also will let you see chests and coffers found by other players. This can be changed at any time. No data regarding your FFXIV character or account is ever sent to our server..
/// </summary>
internal static string Explanation_4 {
get {
return ResourceManager.GetString("Explanation_4", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Heaven on High.
/// </summary>
internal static string HeavenOnHigh {
get {
return ResourceManager.GetString("HeavenOnHigh", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Imported {0} new trap locations and {1} new hoard coffer locations..
/// </summary>
internal static string ImportCompleteStatistics {
get {
return ResourceManager.GetString("ImportCompleteStatistics", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Palace Pal.
/// </summary>
internal static string Palace_Pal {
get {
return ResourceManager.GetString("Palace_Pal", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Palace of the Dead.
/// </summary>
internal static string PalaceOfTheDead {
get {
return ResourceManager.GetString("PalaceOfTheDead", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save.
/// </summary>
internal static string Save {
get {
return ResourceManager.GetString("Save", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save &amp; Close.
/// </summary>
internal static string SaveAndClose {
get {
return ResourceManager.GetString("SaveAndClose", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Statistics.
/// </summary>
internal static string Statistics {
get {
return ResourceManager.GetString("Statistics", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hoard.
/// </summary>
internal static string Statistics_HoardCoffers {
get {
return ResourceManager.GetString("Statistics_HoardCoffers", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Instance Name.
/// </summary>
internal static string Statistics_InstanceName {
get {
return ResourceManager.GetString("Statistics_InstanceName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Id.
/// </summary>
internal static string Statistics_TerritoryId {
get {
return ResourceManager.GetString("Statistics_TerritoryId", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Traps.
/// </summary>
internal static string Statistics_Traps {
get {
return ResourceManager.GetString("Statistics_Traps", resourceCulture);
}
}
}
}

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

@ -0,0 +1,344 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<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>Palace of the Dead</value>
</data>
<data name="HeavenOnHigh" xml:space="preserve">
<value>Heaven on High</value>
</data>
<data name="EurekaOrthos" xml:space="preserve">
<value>Eureka Orthos</value>
</data>
<data name="Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="SaveAndClose" xml:space="preserve">
<value>Save &amp; Close</value>
</data>
<!-- Generic Errors -->
<data name="Error_FirstTimeSetupRequired" xml:space="preserve">
<value>Please finish the initial setup first.</value>
<comment>Before using any /pal command, the initial setup/agreeement needs to be completed.</comment>
</data>
<data name="Error_LoadFailed" xml:space="preserve">
<value>Plugin could not be loaded: {0}</value>
<comment>Shown when the plugin fails to load, with the placeholder filled with the exception message.</comment>
</data>
<data name="Error_WrongRepository" xml:space="preserve">
<value>Please install this plugin from the official repository at {0} to continue using it.</value>
</data>
<!-- /pal commands -->
<data name="Command_pal_HelpText" xml:space="preserve">
<value>Open the configuration window</value>
<comment>Help text for the /pal command, shown in the Plugin Installer</comment>
</data>
<data name="Command_pal_UnknownSubcommand" xml:space="preserve">
<value>Unknown sub-command '{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>You can view statistics for the floor you're currently on by opening the 'Debug' tab in the configuration window.</value>
</data>
<data name="Command_pal_stats_UnableToFetchStatistics" xml:space="preserve">
<value>Unable to fetch statistics.</value>
<comment>Shown when /pal stats produces a server-side error, and the statistics window can't be loaded.</comment>
</data>
<data name="Error_CommandFailed" xml:space="preserve">
<value>Command could not be executed: {0}</value>
<comment>Shown when '/pal ...' fails, with the placeholder filled with the exception message.</comment>
</data>
<!-- Messages while connecting to the server. These are typically only visible when clicking 'Test connection'. -->
<data name="ConnectionSuccessful" xml:space="preserve">
<value>Connection successful.</value>
</data>
<data name="ConnectionError_NotOnline" xml:space="preserve">
<value>You are not 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>Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.</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>Could not create account ({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>No account-id after account was attempted to be created.</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>Invalid account id.</value>
<comment>The account id used was not found on the server.</comment>
</data>
<data name="ConnectionError_LoginFailed" xml:space="preserve">
<value>Could not log in ({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>No login information available.</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>Could not connect to server: {0}</value>
</data>
<!-- Config Window: Deep Dungeons -->
<data name="ConfigTab_DeepDungeons" xml:space="preserve">
<value>Deep Dungeons</value>
</data>
<data name="Config_Traps_Show" xml:space="preserve">
<value>Show traps</value>
</data>
<data name="Config_Traps_Color" xml:space="preserve">
<value>Trap color</value>
</data>
<data name="Config_Traps_HideImpossible" xml:space="preserve">
<value>Hide traps not on current floor</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>When using a Pomander of Sight, only the actual trap locations are visible, all other traps are hidden.
When using a Pomander of Safety, all traps are hidden.</value>
</data>
<data name="Config_HoardCoffers_Show" xml:space="preserve">
<value>Show hoard coffers</value>
</data>
<data name="Config_HoardCoffers_Color" xml:space="preserve">
<value>Hoard Coffer color</value>
</data>
<data name="Config_HoardCoffers_HideImpossible" xml:space="preserve">
<value>Hide hoard coffers not on current floor</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>When using a Pomander of intuition, only the actual hoard coffer location is visible, all other (potential) hoard coffers are hidden.</value>
</data>
<data name="Config_SilverCoffer_Show" xml:space="preserve">
<value>Show silver coffers on current floor</value>
</data>
<data name="Config_SilverCoffers_ToolTip" xml:space="preserve">
<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>
</data>
<data name="Config_SilverCoffer_Color" xml:space="preserve">
<value>Silver Coffer color</value>
</data>
<data name="Config_SilverCoffer_Filled" xml:space="preserve">
<value>Draw filled</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>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">
<value>Community</value>
</data>
<data name="Config_TestConnection" xml:space="preserve">
<value>Test Connection</value>
</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">
<value>Import</value>
</data>
<data name="Config_ImportExplanation1" xml:space="preserve">
<value>Using an export is useful if you're unable to connect to the server, or don't wish to share your findings.</value>
</data>
<data name="Config_ImportExplanation2" xml:space="preserve">
<value>Exports are (currently) generated manually, and they only include traps and hoard coffers encountered by 5 or more people. This may lead to higher floors having very sporadic coverage, but commonly run floors (e.g. PotD 51-60, HoH 21-30) are closer to complete.</value>
</data>
<data name="Config_ImportExplanation3" xml:space="preserve">
<value>If you aren't offline, importing a file won't have any noticeable effect.</value>
</data>
<data name="Config_ImportDownloadLocation" xml:space="preserve">
<value>Exports are available from {0} (as *.pal files).</value>
</data>
<data name="Config_Import_VisitGitHub" xml:space="preserve">
<value>Visit GitHub</value>
</data>
<data name="Config_SelectImportFile" xml:space="preserve">
<value>File to Import:</value>
</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">
<value>Start Import</value>
</data>
<data name="Config_UndoImportExplanation1" xml:space="preserve">
<value>Your last import was on {0}, which added the trap/hoard coffer database from {1} created on {2:d}.</value>
</data>
<data name="Config_UndoImportExplanation2" xml:space="preserve">
<value>If you think that was a mistake, you can remove all locations only found in the import (any location you've seen yourself is not changed).</value>
</data>
<data name="Config_UndoImport" xml:space="preserve">
<value>Undo Import</value>
</data>
<!-- Config Window: Export -->
<data name="ConfigTab_Export" xml:space="preserve">
<value>Export</value>
</data>
<data name="Config_ExportSource" xml:space="preserve">
<value>Export all markers from {0}:</value>
</data>
<data name="Config_Export_SaveAs" xml:space="preserve">
<value>Save as:</value>
</data>
<data name="Config_StartExport" xml:space="preserve">
<value>Start Export</value>
</data>
<!-- Config Window: Renderer -->
<data name="ConfigTab_Renderer" xml:space="preserve">
<value>Renderer</value>
<comment>Configuration tab to select Splatoon or Simple as rendering backend</comment>
</data>
<data name="Config_SelectRenderBackend" xml:space="preserve">
<value>Select which render backend to use for markers:</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>default, required Splatoon to be installed</value>
</data>
<data name="Config_Renderer_Simple" xml:space="preserve">
<value>Simple</value>
</data>
<data name="Config_Renderer_Simple_Hint" xml:space="preserve">
<value>experimental</value>
</data>
<data name="Config_Splatoon_DrawCircles" xml:space="preserve">
<value>Draw trap &amp; coffer circles around self</value>
<comment>To test the Splatoon integration, you can draw markers around yourself.</comment>
</data>
<!-- Config Window: Debug -->
<data name="ConfigTab_Debug" xml:space="preserve">
<value>Debug</value>
</data>
<data name="Config_Debug_NotInADeepDungeon" xml:space="preserve">
<value>You are NOT in a deep dungeon.</value>
</data>
<!-- Statistics Window -->
<data name="Statistics" xml:space="preserve">
<value>Statistics</value>
</data>
<data name="Statistics_TerritoryId" xml:space="preserve">
<value>Id</value>
</data>
<data name="Statistics_InstanceName" xml:space="preserve">
<value>Instance Name</value>
</data>
<data name="Statistics_Traps" xml:space="preserve">
<value>Traps</value>
</data>
<data name="Statistics_HoardCoffers" xml:space="preserve">
<value>Hoard</value>
</data>
<!-- Agreement Window -->
<data name="Explanation_1" xml:space="preserve">
<value>Pal Palace will show where potential trap &amp; hoard coffer locations are.</value>
</data>
<data name="Explanation_2" xml:space="preserve">
<value>To do this, using a pomander to reveal trap or treasure chest locations will save the position of what you see.</value>
</data>
<data name="Explanation_3" xml:space="preserve">
<value>Ideally, we want to discover every potential trap and chest location in the game, but doing this alone is very tedious. Floor 51-60 has over 300 trap locations and over 290 coffer locations - and we don't know if that map is complete. Higher floors naturally see fewer runs, making solo attempts to map the place much harder.</value>
</data>
<data name="Explanation_4" xml:space="preserve">
<value>You can decide whether you want to share traps and chests you find with the community, which likewise also will let you see chests and coffers found by other players. This can be changed at any time. No data regarding your FFXIV character or account is ever sent to our server.</value>
</data>
<data name="Config_UploadMyDiscoveries_ShowOtherTraps" xml:space="preserve">
<value>Upload my discoveries, show traps &amp; coffers other players have discovered</value>
</data>
<data name="Config_NeverUploadDiscoveries_ShowMyTraps" xml:space="preserve">
<value>Never upload discoveries, show only traps and coffers I found myself</value>
</data>
<data name="Agreement_Warning1" xml:space="preserve">
<value>While this is not an automation feature, you're still very likely to break the ToS.</value>
</data>
<data name="Agreement_Warning2" xml:space="preserve">
<value>Other players in your party can always see where you're standing/walking.</value>
</data>
<data name="Agreement_Warning3" xml:space="preserve">
<value>As such, please avoid mentioning it in-game and do not share videos/screenshots.</value>
</data>
<data name="Agreement_UsingThisOnMyOwnRisk" xml:space="preserve">
<value>I understand I'm using this plugin on my own risk.</value>
</data>
<data name="Agreement_ViewPluginAndServerSourceCode" xml:space="preserve">
<value>View plugin &amp; server source code</value>
</data>
<data name="Agreement_PickOneOption" xml:space="preserve">
<value>Please chose one of the options above.</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>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>
</data>
<data name="Error_ImportFailed" xml:space="preserve">
<value>Import failed: {0}</value>
</data>
<data name="Error_ImportFailed_IncompatibleVersion" xml:space="preserve">
<value>Import failed: Incompatible version.</value>
</data>
<data name="Error_ImportFailed_InvalidFile" xml:space="preserve">
<value>Import failed: Invalid file.</value>
</data>
<!-- Other -->
</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

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

View File

@ -0,0 +1,8 @@
namespace Pal.Client.Rendering;
public interface IRenderElement
{
bool IsValid { get; }
bool Enabled { get; set; }
}

View File

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

View File

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

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

View File

@ -0,0 +1,6 @@
namespace Pal.Client.Rendering;
internal static class RenderData
{
public static readonly long TestLayerTimeout = 10_000;
}

View File

@ -0,0 +1,206 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Dalamud.Interface.Utility;
using Dalamud.Plugin.Services;
using ImGuiNET;
using Pal.Client.Configuration;
using Pal.Client.Floors;
namespace Pal.Client.Rendering;
/// <summary>
/// Simple renderer that only draws basic stuff.
///
/// This is based on what SliceIsRight uses, and what PalacePal used before it was
/// remade into PalacePal (which is the third or fourth iteration on the same idea
/// I made, just with a clear vision).
/// </summary>
internal sealed class SimpleRenderer : IRenderer, IDisposable
{
private const int SegmentCount = 20;
private readonly IClientState _clientState;
private readonly IGameGui _gameGui;
private readonly IPalacePalConfiguration _configuration;
private readonly TerritoryState _territoryState;
private readonly ConcurrentDictionary<ELayer, SimpleLayer> _layers = new();
public SimpleRenderer(IClientState clientState, IGameGui gameGui, IPalacePalConfiguration configuration,
TerritoryState territoryState)
{
_clientState = clientState;
_gameGui = gameGui;
_configuration = configuration;
_territoryState = territoryState;
}
public void SetLayer(ELayer layer, IReadOnlyList<IRenderElement> elements)
{
_layers[layer] = new SimpleLayer
{
TerritoryType = _clientState.TerritoryType,
Elements = elements.Cast<SimpleElement>().ToList()
};
}
public void ResetLayer(ELayer layer)
{
if (_layers.Remove(layer, out var l))
l.Dispose();
}
public IRenderElement CreateElement(MemoryLocation.EType type, Vector3 pos, bool enabled, uint color,
bool fill = false)
{
var config = MarkerConfig.ForType(type);
return new SimpleElement
{
Type = type,
Position = pos + new Vector3(0, config.OffsetY, 0),
Enabled = enabled,
Color = color,
Radius = config.Radius,
Fill = fill,
};
}
public void DrawDebugItems(uint trapColor, uint hoardColor)
{
_layers[ELayer.Test] = new SimpleLayer
{
TerritoryType = _clientState.TerritoryType,
Elements = new List<SimpleElement>
{
(SimpleElement)CreateElement(
MemoryLocation.EType.Trap,
_clientState.LocalPlayer?.Position ?? default,
true,
trapColor),
(SimpleElement)CreateElement(
MemoryLocation.EType.Hoard,
_clientState.LocalPlayer?.Position ?? default,
true,
hoardColor)
},
ExpiresAt = Environment.TickCount64 + RenderData.TestLayerTimeout
};
}
public void DrawLayers()
{
if (_layers.Count == 0)
return;
ImGuiHelpers.ForceNextWindowMainViewport();
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero);
ImGuiHelpers.SetNextWindowPosRelativeMainViewport(Vector2.Zero, ImGuiCond.None, Vector2.Zero);
ImGui.SetNextWindowSize(ImGuiHelpers.MainViewport.Size);
if (ImGui.Begin("###PalacePalSimpleRender",
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground |
ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoSavedSettings |
ImGuiWindowFlags.AlwaysUseWindowPadding))
{
foreach (var layer in _layers.Values.Where(l => l.IsValid(_clientState)))
{
foreach (var e in layer.Elements)
Draw(e);
}
foreach (var key in _layers.Where(l => !l.Value.IsValid(_clientState))
.Select(l => l.Key)
.ToList())
ResetLayer(key);
ImGui.End();
}
ImGui.PopStyleVar();
}
private void Draw(SimpleElement e)
{
if (!e.Enabled)
return;
switch (e.Type)
{
case MemoryLocation.EType.Hoard:
// ignore distance if this is a found hoard coffer
if (_territoryState.PomanderOfIntuition == PomanderState.Active &&
_configuration.DeepDungeons.HoardCoffers.OnlyVisibleAfterPomander)
break;
goto case MemoryLocation.EType.Trap;
case MemoryLocation.EType.Trap:
var playerPos = _clientState.LocalPlayer?.Position;
if (playerPos == null)
return;
if ((playerPos.Value - e.Position).Length() > 65)
return;
break;
}
bool onScreen = false;
for (int index = 0; index < 2 * SegmentCount; ++index)
{
onScreen |= _gameGui.WorldToScreen(new Vector3(
e.Position.X + e.Radius * (float)Math.Sin(Math.PI / SegmentCount * index),
e.Position.Y,
e.Position.Z + e.Radius * (float)Math.Cos(Math.PI / SegmentCount * index)),
out Vector2 vector2);
ImGui.GetWindowDrawList().PathLineTo(vector2);
}
if (onScreen)
{
if (e.Fill)
ImGui.GetWindowDrawList().PathFillConvex(e.Color);
else
ImGui.GetWindowDrawList().PathStroke(e.Color, ImDrawFlags.Closed, 2);
}
else
ImGui.GetWindowDrawList().PathClear();
}
public ERenderer GetConfigValue()
=> ERenderer.Simple;
public void Dispose()
{
foreach (var l in _layers.Values)
l.Dispose();
}
public sealed class SimpleLayer : IDisposable
{
public required ushort TerritoryType { get; init; }
public required IReadOnlyList<SimpleElement> Elements { get; init; }
public long ExpiresAt { get; init; } = long.MaxValue;
public bool IsValid(IClientState clientState) =>
TerritoryType == clientState.TerritoryType && ExpiresAt >= Environment.TickCount64;
public void Dispose()
{
foreach (var e in Elements)
e.IsValid = false;
}
}
public sealed class SimpleElement : IRenderElement
{
public bool IsValid { get; set; } = true;
public required MemoryLocation.EType Type { get; init; }
public required Vector3 Position { get; init; }
public required bool Enabled { get; set; }
public required uint Color { get; set; }
public required float Radius { get; init; }
public required bool Fill { get; init; }
}
}

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