From 4e6e9bc9d9aff787b8f59cf30497e24ef14779d0 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 27 Aug 2026 18:57:21 +0200 Subject: [PATCH] feat(mosstank): add VTank-style automation PoC --- docs/ISSUES.md | 73 + docs/architecture/acdream-architecture.md | 66 + docs/launch-options.md | 1 + docs/plans/2026-04-24-ui-framework.md | 34 +- .../2026-08-26-mosstank-parity-campaign.md | 483 ++ ...-26-mosstank-vtank-utilitybelt-research.md | 447 ++ .../2026-08-26-mosstank-autocombat-design.md | 95 + src/AcDream.App/AcDream.App.csproj | 40 +- .../InteractionRetainedUiComposition.cs | 15 +- .../Composition/SessionPlayerComposition.cs | 6 +- .../Input/DispatcherMovementInputSource.cs | 2 +- src/AcDream.App/Net/LiveSessionAppSource.cs | 11 +- .../Net/LiveSessionRuntimeFactory.cs | 1 + .../GraphicalWindowBackendSelection.cs | 7 + .../Platform/Win32GlfwActiveWindowGuard.cs | 282 ++ .../Plugins/AppAutomationSurface.cs | 3293 ++++++++++++- src/AcDream.App/Plugins/AppPluginHost.cs | 12 +- src/AcDream.App/Plugins/BufferedUiRegistry.cs | 243 +- src/AcDream.App/Plugins/FilePluginStorage.cs | 86 + .../Plugins/LocalPluginPeerRegistry.cs | 225 + src/AcDream.App/Program.cs | 14 +- src/AcDream.App/Rendering/GameWindow.cs | 48 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 21 + src/AcDream.App/RuntimeOptions.cs | 19 +- .../UI/ItemInteractionController.cs | 299 +- .../ProjectileDebugOverlayController.cs | 137 + src/AcDream.App/UI/MarkupDocument.cs | 479 +- src/AcDream.App/UI/PluginSidePanel.cs | 355 ++ src/AcDream.App/UI/RetailUiRuntime.cs | 106 +- src/AcDream.App/UI/UiElement.cs | 24 +- src/AcDream.App/UI/UiField.cs | 10 + src/AcDream.App/UI/UiMarkupList.cs | 96 + src/AcDream.App/UI/UiMarkupTabButton.cs | 47 + src/AcDream.App/UI/UiMarkupToggle.cs | 75 + src/AcDream.App/UI/UiMenu.cs | 4 +- src/AcDream.App/UI/UiScrollbar.cs | 17 +- .../World/LiveEntityDeletionController.cs | 15 + src/AcDream.Content/MagicCatalog.cs | 20 +- src/AcDream.Core.Net/GameEventWiring.cs | 13 +- .../Messages/InventoryActions.cs | 45 + src/AcDream.Core.Net/WorldSession.cs | 13 + .../packages.win-x64.lock.json | 109 +- src/AcDream.Core/Items/ClientObject.cs | 15 + src/AcDream.Core/Items/ClientObjectTable.cs | 42 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 6 +- src/AcDream.Core/Physics/ResolveResult.cs | 14 +- .../Plugins/PluginCommandRegistry.cs | 142 + .../Plugins/PluginLootClassifierRegistry.cs | 151 + src/AcDream.Core/Plugins/PluginSession.cs | 5 +- src/AcDream.Core/Plugins/ScopedPluginHost.cs | 297 +- .../Hosting/HeadlessLocalPlayerFrameHost.cs | 2 +- .../Hosting/HeadlessSessionHost.cs | 12 +- .../Plugins/HeadlessPluginHost.cs | 5 +- .../Plugins/HeadlessPluginSession.cs | 6 +- src/AcDream.Plugin.Abstractions/Automation.cs | 304 +- .../CombatAutomation.cs | 152 + .../EnchantmentAutomation.cs | 35 + .../EquipmentAutomation.cs | 64 + .../FellowshipAutomation.cs | 72 + .../IPluginHost.cs | 13 + .../IPluginStorage.cs | 22 + .../IUiRegistry.cs | 166 + .../ItemAutomation.cs | 245 + .../LoginAutomation.cs | 25 + .../LootAutomation.cs | 69 + .../LootClassifierPlugins.cs | 93 + .../MagicAutomation.cs | 11 + .../NavigationAutomation.cs | 115 + .../NetworkAutomation.cs | 28 + .../PluginCommands.cs | 48 + .../ProjectileAutomation.cs | 91 + .../RecoveryAutomation.cs | 21 + .../SelectionAutomation.cs | 18 + .../WorldObjectAutomation.cs | 117 + .../WorldTimeAutomation.cs | 20 + .../AcDream.Plugins.MossTank.csproj | 5 +- .../AttackSpellCatalog.cs | 408 ++ .../AutoAttackPower.cs | 173 + src/AcDream.Plugins.MossTank/BuffPlan.cs | 150 +- .../CombatController.cs | 2059 +++++++++ .../CombatFailureTracker.cs | 173 + .../CombatItemDebuffPlanner.cs | 224 + .../CombatSettings.cs | 151 + src/AcDream.Plugins.MossTank/Crafting.cs | 649 +++ .../DebuffScheduler.cs | 400 ++ .../DispelController.cs | 420 ++ .../Expressions/CoreExpressionFunctions.cs | 653 +++ .../Expressions/ExperienceMeter.cs | 84 + .../Expressions/ExpressionEngine.cs | 789 ++++ .../Expressions/ExpressionRuntime.cs | 204 + .../Expressions/ExpressionValue.cs | 240 + .../Expressions/HostExpressionFunctions.cs | 1276 +++++ .../Expressions/MossTankExpressionRuntime.cs | 429 ++ .../Expressions/QuestTracker.cs | 152 + .../Expressions/SalvageStagingManager.cs | 59 + .../Expressions/StatusHudManager.cs | 68 + .../FellowshipManager.cs | 523 +++ .../GrenadeCatalog.cs | 79 + .../InventoryMaintenance.cs | 299 ++ .../ItemManaRecharge.cs | 140 + src/AcDream.Plugins.MossTank/Looting.cs | 1766 +++++++ src/AcDream.Plugins.MossTank/Meta.cs | 656 +++ .../MetaViewManager.cs | 97 + .../MonsterExpression.cs | 608 +++ src/AcDream.Plugins.MossTank/MonsterRules.cs | 150 + .../MossTankCommands.cs | 1046 +++++ .../MossTankLootProfileStore.cs | 477 ++ .../MossTankMetaProfileStore.cs | 286 ++ src/AcDream.Plugins.MossTank/MossTankPanel.cs | 4118 ++++++++++++++++- .../MossTankPlugin.cs | 21 +- .../MossTankProfileRecovery.cs | 46 + .../MossTankProfileStore.cs | 922 ++++ .../MossTankRouteProfileStore.cs | 437 ++ src/AcDream.Plugins.MossTank/Navigation.cs | 1110 +++++ src/AcDream.Plugins.MossTank/PetAutomation.cs | 315 ++ .../PetDeviceCatalog.cs | 51 + .../ProfileGiveController.cs | 247 + .../SpellComponentPolicy.cs | 73 + src/AcDream.Plugins.MossTank/VitalPlan.cs | 219 +- src/AcDream.Plugins.MossTank/VitalRecharge.cs | 1103 +++++ .../VtankAmmunitionDatabase.cs | 164 + .../VtankAmmunitionOptions.tsv | 121 + .../VtankCraftDatabase.cs | 81 + .../VtankCraftRecipes.tsv | 758 +++ .../VtankDamageDatabase.cs | 289 ++ .../VtankLootProfileSerializer.cs | 446 ++ .../VtankLootRequirementEvaluator.cs | 576 +++ .../VtankMetaProfileSerializer.cs | 742 +++ .../VtankNavRouteSerializer.cs | 303 ++ .../VtankOptionCatalog.cs | 221 + .../mosstank-settings.xml | 53 - src/AcDream.Plugins.MossTank/mosstank.xml | 593 ++- .../packages.win-x64.lock.json | 6 +- src/AcDream.Runtime/Chat/ChatCommandRouter.cs | 10 + src/AcDream.Runtime/Chat/ICommandBus.cs | 10 + .../Chat/LiveChatCommandRoute.cs | 11 +- src/AcDream.Runtime/GameRuntimeActionViews.cs | 8 +- .../Gameplay/PlayerMovementController.cs | 50 +- .../Gameplay/RuntimeActionState.cs | 46 +- .../Gameplay/RuntimeCombatAttackState.cs | 18 +- .../Gameplay/RuntimeCombatModeState.cs | 38 + .../Gameplay/RuntimeFriendlyTargetQuery.cs | 32 + .../Gameplay/RuntimeHostileTargetQuery.cs | 146 + .../RuntimeInteractionTransactionState.cs | 41 +- .../RuntimeLocalPlayerMovementState.cs | 2 +- .../Gameplay/RuntimeSpellCastState.cs | 51 +- .../Session/LiveSessionController.cs | 57 + .../packages.win-x64.lock.json | 51 +- .../DispatcherMovementInputSourceTests.cs | 15 + .../Win32GlfwActiveWindowGuardTests.cs | 46 + .../Plugins/AppAutomationSurfaceTests.cs | 277 ++ .../Plugins/BufferedUiRegistryTests.cs | 90 + ...ExternalRenderPackPackageLifecycleTests.cs | 13 +- .../Plugins/FilePluginStorageTests.cs | 35 + .../Plugins/GraphicalPluginSessionTests.cs | 11 +- .../Plugins/LocalPluginPeerRegistryTests.cs | 73 + .../Rendering/LinuxPlatformBoundaryTests.cs | 12 +- .../AcDream.App.Tests/RuntimeOptionsTests.cs | 16 + .../UI/ItemInteractionControllerTests.cs | 192 +- .../ProjectileDebugOverlayControllerTests.cs | 51 + .../UI/MarkupDocumentTests.cs | 166 + .../UI/PluginSidePanelTests.cs | 132 + .../Messages/InventoryActionsTests.cs | 25 + .../WorldSessionInventoryActionTests.cs | 18 + .../Items/ClientObjectTableUpdateTests.cs | 21 + .../Plugins/PluginCommandRegistryTests.cs | 59 + .../Plugins/PluginLoaderTests.cs | 7 +- .../PluginLootClassifierRegistryTests.cs | 80 + .../Plugins/PluginSessionTests.cs | 89 +- .../HeadlessPluginSessionTests.cs | 7 +- .../HeadlessSessionHostTests.cs | 15 + .../AcDream.Plugins.MossTank.Tests.csproj | 5 + .../AttackSpellCatalogTests.cs | 347 ++ .../AutoAttackPowerTests.cs | 118 + .../CombatControllerTests.cs | 1228 +++++ .../CombatFailureTrackerTests.cs | 106 + .../CombatItemDebuffPlannerTests.cs | 182 + .../CraftingTests.cs | 393 ++ .../DebuffSchedulerTests.cs | 219 + .../DispelControllerTests.cs | 375 ++ .../ExpressionEngineTests.cs | 194 + .../FellowshipManagerTests.cs | 201 + .../GrenadeCatalogTests.cs | 25 + .../HostExpressionFunctionsTests.cs | 669 +++ .../InventoryMaintenanceTests.cs | 225 + .../ItemManaRechargeTests.cs | 73 + .../LootingTests.cs | 1000 ++++ .../MetaEngineTests.cs | 286 ++ .../MetaViewManagerTests.cs | 202 + .../MonsterExpressionTests.cs | 164 + .../MossTankMarkupContractTests.cs | 262 ++ .../MossTankPanelTests.cs | 1255 ++++- .../NavigationTests.cs | 623 +++ .../PetAutomationTests.cs | 283 ++ .../ProfileGiveControllerTests.cs | 230 + .../VitalRechargeTests.cs | 397 ++ .../VtankAmmunitionDatabaseTests.cs | 134 + .../VtankDamageDatabaseTests.cs | 58 + .../VtankLootProfileSerializerTests.cs | 118 + .../VtankMetaProfileSerializerTests.cs | 190 + .../VtankNavRouteSerializerTests.cs | 199 + .../Gameplay/PlayerMouseLookMovementTests.cs | 57 + .../Gameplay/RuntimeActionStateTests.cs | 27 + .../Gameplay/RuntimeCombatAttackStateTests.cs | 21 + .../Gameplay/RuntimeCombatModeStateTests.cs | 18 + .../RuntimeHostileTargetQueryTests.cs | 38 + ...RuntimeInteractionTransactionStateTests.cs | 64 + .../RuntimeLocalPlayerMovementStateTests.cs | 34 + .../Gameplay/RuntimeSpellCastStateTests.cs | 34 + .../Session/LiveSessionControllerTests.cs | 39 + .../Panels/Chat/ChatCommandRouterTests.cs | 32 + tools/cdb/i451-dual-client-av.cdb | 10 + 212 files changed, 49462 insertions(+), 416 deletions(-) create mode 100644 docs/plans/2026-08-26-mosstank-parity-campaign.md create mode 100644 docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md create mode 100644 docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md create mode 100644 src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs create mode 100644 src/AcDream.App/Plugins/FilePluginStorage.cs create mode 100644 src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs create mode 100644 src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs create mode 100644 src/AcDream.App/UI/PluginSidePanel.cs create mode 100644 src/AcDream.App/UI/UiMarkupList.cs create mode 100644 src/AcDream.App/UI/UiMarkupTabButton.cs create mode 100644 src/AcDream.App/UI/UiMarkupToggle.cs create mode 100644 src/AcDream.Core/Plugins/PluginCommandRegistry.cs create mode 100644 src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs create mode 100644 src/AcDream.Plugin.Abstractions/CombatAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/IPluginStorage.cs create mode 100644 src/AcDream.Plugin.Abstractions/ItemAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/LoginAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/LootAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs create mode 100644 src/AcDream.Plugin.Abstractions/MagicAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/NavigationAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/NetworkAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/PluginCommands.cs create mode 100644 src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/SelectionAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs create mode 100644 src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs create mode 100644 src/AcDream.Plugins.MossTank/AutoAttackPower.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatController.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatFailureTracker.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatSettings.cs create mode 100644 src/AcDream.Plugins.MossTank/Crafting.cs create mode 100644 src/AcDream.Plugins.MossTank/DebuffScheduler.cs create mode 100644 src/AcDream.Plugins.MossTank/DispelController.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs create mode 100644 src/AcDream.Plugins.MossTank/FellowshipManager.cs create mode 100644 src/AcDream.Plugins.MossTank/GrenadeCatalog.cs create mode 100644 src/AcDream.Plugins.MossTank/InventoryMaintenance.cs create mode 100644 src/AcDream.Plugins.MossTank/ItemManaRecharge.cs create mode 100644 src/AcDream.Plugins.MossTank/Looting.cs create mode 100644 src/AcDream.Plugins.MossTank/Meta.cs create mode 100644 src/AcDream.Plugins.MossTank/MetaViewManager.cs create mode 100644 src/AcDream.Plugins.MossTank/MonsterExpression.cs create mode 100644 src/AcDream.Plugins.MossTank/MonsterRules.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankCommands.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/Navigation.cs create mode 100644 src/AcDream.Plugins.MossTank/PetAutomation.cs create mode 100644 src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs create mode 100644 src/AcDream.Plugins.MossTank/ProfileGiveController.cs create mode 100644 src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs create mode 100644 src/AcDream.Plugins.MossTank/VitalRecharge.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv create mode 100644 src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv create mode 100644 src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs delete mode 100644 src/AcDream.Plugins.MossTank/mosstank-settings.xml create mode 100644 tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs create mode 100644 tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs create mode 100644 tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs create mode 100644 tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/PluginCommandRegistryTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/PluginLootClassifierRegistryTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/AttackSpellCatalogTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/AutoAttackPowerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CombatFailureTrackerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CombatItemDebuffPlannerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CraftingTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/DebuffSchedulerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/DispelControllerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/ExpressionEngineTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/FellowshipManagerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/GrenadeCatalogTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/HostExpressionFunctionsTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/InventoryMaintenanceTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/ItemManaRechargeTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/LootingTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MetaEngineTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MetaViewManagerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MonsterExpressionTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/PetAutomationTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/ProfileGiveControllerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VitalRechargeTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankAmmunitionDatabaseTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankDamageDatabaseTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankLootProfileSerializerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankNavRouteSerializerTests.cs create mode 100644 tools/cdb/i451-dual-client-av.cdb diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 199cd8da..2b031850 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,79 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #451 — GLFW can dereference another acdream process's private window pointer after cross-process activation + +**Status:** IN-PROGRESS — exact root fixed; 100-switch/30-minute dual-client +stress passed 2026-08-27. Graceful exit of both sessions remains before closure. +**Component:** graphical host / GLFW Win32 event pump / multi-process stability. +**Severity:** HIGH for multi-account play; one of the two sessions is lost without +an orderly disconnect. + +Running two copies of the exact isolated `app-release23` graphical artifact +against local ACE reproduced the same access violation three times. The +faulting process varied: secondary PID 13688 at 15:01:39, primary PID 15300 at +15:03:35, and fresh primary PID 31412 at 15:08:58. The last occurrence fired +while the fresh primary was still on character selection, before EnterWorld; +the already-in-world secondary survived. Windows Application Error reports +all three as `coreclr.dll` exception `0xC0000005`, fault offset `0x356d4f`. +The managed terminal stack is only: + +```text +Silk.NET.Windowing.WindowExtensions...Run +Silk.NET.Windowing.Internals.ViewImplementationBase.Run +Silk.NET.Windowing.Glfw.GlfwWindow.Run +AcDream.App.Rendering.GameWindow.Run +``` + +This is not #422's rare `0xC0000374` heap corruption during graceful process +exit: #451 happens while both graphical clients are active and reproduces +quickly. It is also not a MossTank/plugin-API, CoreCLR, Vulkan, PAK, or world- +cache failure. + +**Exact root (first-chance cdb proof):** the access violation is in packaged +GLFW's Win32 event pump at `glfw3+0x10681`, not in CoreCLR. During its modifier- +key repair pass `_glfwPollEventsWin32` calls `GetActiveWindow`, then +`GetPropW(hwnd, L"GLFW")`, and dereferences the returned value as this +process's `_GLFWwindow*`. Windows UI automation temporarily joins input queues, +so the primary process can receive the secondary process's HWND. Because every +GLFW process uses the same `GLFW` property name, `GetPropW` succeeds but returns +the secondary process's private pointer. The crashed primary had +`rbx=00000202a7180ab0`; a debugger breakpoint in the surviving secondary +reported its own valid `ACTIVE_GLFW_WINDOW=00000202a7180ab0` — exact pointer +identity across the process boundary. + +**Fix:** `Win32GlfwActiveWindowGuard` patches only `glfw3.dll`'s import-address- +table slot for `USER32!GetActiveWindow`, after the GLFW library is loaded and +before `glfwInit`/window creation. The replacement returns the real HWND only +when `GetWindowThreadProcessId` says it belongs to the current process; +otherwise it returns zero, GLFW's existing safe "nothing to repair" branch. +There is no system-wide hook and no other module is changed. Four focused +tests cover local, foreign, null and unowned HWNDs. + +The isolated `app-release24` live gate launched two graphical clients, both +logged `GLFW foreign-active-window guard installed (#451)`, entered the world, +and remained responsive through 100 rapid forced cross-process activation +switches — the exact prior trigger — plus a 30-minute combined in-world soak. +The local peer API then passed in both directions: the secondary evaluated the +primary heartbeat and returned `+Acdream`. A two-member fellowship gate also +passed with both canonical rosters populated and the secondary returning `2` +from `getfellowshipcount[]`; both processes remained alive and responsive. + +Evidence: + +- `artifacts/live-gates/mosstank-final23-secondary/` +- `artifacts/live-gates/mosstank-final23-secondary2/` +- `artifacts/live-gates/mosstank-final23-primary5/` +- `artifacts/live-gates/i451-cdb-primary-attach/cdb.log` +- `artifacts/live-gates/i451-cdb-secondary/cdb.log` +- `artifacts/live-gates/i451-guard-primary2/` +- `artifacts/live-gates/i451-guard-secondary/` +- Windows Application Error events at 2026-08-27 15:01:39, 15:03:35 and + 15:08:58 (same module, exception and offset). + +**Next:** close only after both `app-release24` sessions exit gracefully; the +activation and sustained in-world portions of the regression gate have passed. + ## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0` **Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 9487b18b..f7bb3adb 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -137,6 +137,68 @@ loads none). The headless adapter projects entity snapshots on demand from the canonical Runtime view, subscribes to Runtime's ordered events, and borrows the exact Runtime selection owner; it does not mirror gameplay state. +Graphical plugin panels are first-class retained windows. A plugin calls +`IUiRegistry.AddPanel` with a BCL-only `PluginPanelDescriptor`; Core's scoped +host authenticates the owner from the loaded manifest, and App derives the +stable identity `plugin:{pluginId}:{windowId}`. The host, not the plugin, owns +window geometry, z-order, persisted visibility, minimize/restore chrome, and +the shared right-edge plugin shelf. Minimizing only hides the presentation: +the plugin session, event subscriptions, automation policy, and binding object +remain live. Legacy `AddMarkupPanel` registrations are enriched into the same +first-class path, so API-v1 plugins keep working without a second lifecycle. +The markup vocabulary includes nested groups plus retained tab, toggle, +slider, editable-field, and retail-menu controls. Fields bind live +`Action` change/submit callbacks and menus bind an +`IEnumerable` plus selection callback, so plugin-owned profile/rule +editors stay behind the BCL contract instead of importing App widgets. These +are presentation bindings only and never become parallel gameplay owners. + +Durable plugin data uses the BCL-only `IPluginHost.Storage` contract. Core's +manifest-authenticated scoped host prefixes every logical key with the loaded +plugin id; graphical composition writes atomically beneath the per-user config +root (`plugins/{pluginId}/...`). Plugins receive neither another plugin's +namespace nor a machine-specific path. Hosts without durable storage expose +`NoOpPluginStorage` and report the capability unavailable. +The additive `List(prefix)` operation enumerates only keys inside that same +authenticated namespace, allowing plugins to discover explicit import/export +files without receiving a filesystem path or crossing plugin ownership. + +`IPluginHost.Automation` is the additive gameplay-automation projection. Its +character, spell, magic, chat, combat, equipment, item, loot, fellowship, +enchantment-observation, and navigation +groups contain BCL-only immutable snapshots plus attempt-style commands; the +graphical implementation borrows the exact `GameRuntime` +character/action/entity/object/vendor/fellowship owners. Item projections also +carry Virindi's stable ObjectClass plus ordered ObjDesc subpalette samples; +the graphical host resolves each representative RGB directly from portal DAT +using VTank's sample-index formula. MossTank owns all +macro policy (buff planning, target rules, selection scoring, corpse policy, +loot-rule ordering and action timing). In particular, `ICombatAutomation` +does not create a plugin combat model: each hostile capture is a detached +point-in-time projection of `RuntimeHostileTargetQuery`, and physical commands +enter the canonical `RuntimeCombatModeState` / `RuntimeCombatAttackState` +press-charge-release state machine. Item and loot commands similarly enter +App's one `ItemInteractionController`: appraisal, use/apply, pickup, +move/split/merge/drop/give, retail 0x027D salvage, and current-vendor sale +reuse the same readiness checks, reservations, wire sends, and authoritative +completion/object-table signals as retained retail UI. Plugins never hold an +optimistic inventory or vendor shadow. Navigation similarly projects live and +server-accepted position, portal/object state, and semantic movement levels; +the App host applies those levels through Runtime's one command interpreter. +Route sequencing, steering cones, follow breadcrumbs, checkpoint policy, +door/lockpick decisions and portal retry behavior remain plugin-owned. The +shared enchantment-observation group is deliberately a confirmed-cast timer +ledger rather than another authoritative spellbook: the host records successful +local duration casts and cooperating plugins can report their own confirmed +casts, matching VTank's `LogSpellCast` contract. It resets at session detach; +dispel/debuff policy remains plugin-owned. The +inert default remains +`NoOpAutomationSurface`, preserving one plugin code path on hosts without a +live gameplay session. +`ICharacterInfo.Name` projects the canonical local `ClientObject.Name` (empty +when unavailable) solely for per-character plugin profile scoping; it does not +introduce a second identity owner. + Core `SelectionState` is the sole selected-object owner for world, radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins; `IPluginHost.Selection` exposes that same state and retail-style old/new callback. @@ -420,6 +482,10 @@ src/ IGameState.cs -> done IEvents.cs -> done ISelectionService.cs -> done + IPluginStorage.cs -> manifest-scoped durable text profiles + Automation.cs -> character/spell/magic/chat automation groups + CombatAutomation.cs -> hostile snapshots + retail combat attempts + EnchantmentAutomation.cs -> shared confirmed duration-cast timer ledger AcDream.App/ Layer 1 + Layer 4 wiring Platform/ diff --git a/docs/launch-options.md b/docs/launch-options.md index 109ebc76..0d6178ec 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -98,6 +98,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release | `ACDREAM_NEAR_RADIUS` | `=` | Overrides preset's `NearRadius` (inner streaming ring radius) | Changes the streaming/render window size — CLAUDE.md: leave unset for measurement/gate runs | preset's `NearRadius` (Low=2, Medium=3, High=4, Ultra=5) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:46`) | | `ACDREAM_NO_AUDIO` | `=1` | Suppresses OpenAL device init entirely (headless / driver-broken machines). | Fail-open design: even without this flag, a missing/broken OpenAL driver already makes `IsAvailable=false` and all `Play*` calls no-ops — this flag is the explicit, deliberate version of the same fallback. When set, `LiveSessionWorldRuntime.WorldAudio` is `null` and logout-audio reset/resume steps no-op. | `false` | `RuntimeOptions.NoAudio` → `GameWindow.cs:1430` → `ContentEffectsAudioCompositionPhase` → `OpenAlAudioEngine.cs` (fail-open doc), `LiveSessionRuntimeFactory.cs:71` (`WorldAudio` gate) | | `ACDREAM_PAK_PATH` | `=` | Overrides the path to the prepared machine-local asset bake (`acdream.pak`) that production world-mesh streaming reads through `IPreparedAssetSource`. | none beyond the obvious | unset → `/acdream.pak` | `RuntimeOptions.PreparedAssetPath` → `ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` | +| `ACDREAM_PLUGIN_TAGS` | comma-separated tags (maximum 128 tags, 128 characters each) | Advertises machine-local role/group tags through the plugin peer-discovery API, for UtilityBelt-compatible expressions such as client selection by tag. Values are trimmed and deduplicated case-insensitively. | Writes the tags into the bounded local peer heartbeat document while a character is in world; no network traffic leaves the machine. | unset → no tags | `RuntimeOptions.PluginTags` → `AppAutomationSurface` / `LocalPluginPeerRegistry` | | `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=` (`>0`, else default; overflow-checked) | Byte ceiling for the retail alpha (translucency) draw queue's scratch buffer | Shrinking below production working set changes translucency-queue eviction/reflow behavior — not comparable to a default-budget perf run | 16 MiB | `ResidencyBudgetOptions.Parse` (`ResidencyBudgetOptions.cs:88-89`), flows through `RuntimeOptions.ResidencyBudgets`→`AlphaScratchBudgetProfile.Create`→`RetailAlphaQueue` ctor (`GameWindow.cs:721-725`) | | `ACDREAM_RESIDENCY_ANIMATION_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the retained animation-data cache | Changes cache eviction cadence for animation data — a perf/memory measurement under a non-default value is not representative | 512 | `ResidencyBudgetOptions.Parse` (`:82-84`) | | `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained animation-data cache | Same cache-behavior-change caveat as the entries variant | 64 MiB | `ResidencyBudgetOptions.Parse` (`:79-81`) | diff --git a/docs/plans/2026-04-24-ui-framework.md b/docs/plans/2026-04-24-ui-framework.md index a216406e..0a82d8a8 100644 --- a/docs/plans/2026-04-24-ui-framework.md +++ b/docs/plans/2026-04-24-ui-framework.md @@ -184,17 +184,28 @@ panel through `IPanelRenderer`. ## Plugin UI API -The shipped plugin-facing gameplay UI contract is -`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel`: a plugin provides -KSML-style markup and a binding object; the host builds it into the retained -`UiRoot` tree. `IPanel`/`IPanelRenderer` remains a first-party developer-panel -contract and is intentionally not referenced by `Plugin.Abstractions`. +The shipped plugin-facing gameplay UI contract is the additive BCL-only +`AcDream.Plugin.Abstractions.IUiRegistry.AddPanel`: a plugin provides a stable +window id/title/icon descriptor, KSML-style markup, and a binding object; the +host builds it into the retained `UiRoot` tree. The API-v1 +`AddMarkupPanel` member remains source/binary compatible and is enriched into +the same first-class window route by the scoped host. `IPanel`/ +`IPanelRenderer` remains a historical first-party developer-panel contract and +is intentionally not referenced by `Plugin.Abstractions`. -This makes plugin gameplay panels independent of ImGui while allowing them to -share the retained input, window, and DAT-sprite runtime. Registrations made -before the GL host exists are buffered. In builds where retail UI is disabled, -they remain registered but have no gameplay surface; the long-term release -configuration enables retained gameplay UI. +This makes plugin gameplay panels presentation-assembly independent while +allowing them to share the retained input, window, and DAT-sprite runtime. +Registrations made before the graphical host exists are buffered. The host +assigns `plugin:{pluginId}:{windowId}`, registers every panel with the common +window manager, persists its geometry/visibility, and exposes it through the +shared plugin sidepanel. Hiding/minimizing a panel does not dispose or pause the +plugin. No-window hosts retain the plugin session but expose the no-op UI +capability. + +The retained markup vocabulary includes panels, nested groups, labels, +buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to +BCL-visible properties/actions on the plugin binding object; visible controls +must correspond to real behavior, never placeholders that report success. The following was the original pre-D.2b proposal and remains historical context, not the shipped plugin contract: @@ -255,7 +266,8 @@ walk around / take damage / regen. ### Sprint 3 — Plugin API hardening (superseded shape) - Document the `IPanel` contract. -- The shipped route is `IUiRegistry.AddMarkupPanel`, not plugin-owned +- The shipped route is `IUiRegistry.AddPanel` (with `AddMarkupPanel` as the + compatible legacy entry), not plugin-owned `IPanel` implementations. - Confirm plugins can subscribe to game events and expose retained markup bindings without referencing App or ImGui assemblies. diff --git a/docs/plans/2026-08-26-mosstank-parity-campaign.md b/docs/plans/2026-08-26-mosstank-parity-campaign.md new file mode 100644 index 00000000..2ac3576e --- /dev/null +++ b/docs/plans/2026-08-26-mosstank-parity-campaign.md @@ -0,0 +1,483 @@ +# MossTank — VTank parity campaign + +Date: 2026-08-26 +Status: ACTIVE — MT1 USER-PASSED; MTUI–MT9 functional/API scope complete; connected shelf/shell, accessibility, reconnect, bidirectional peer-expression and two-member fellowship gates passed; #451 root fixed and 30-minute dual-client activation soak passed; hostile/collision gates remain + +Research baseline: +`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md` + +## Product definition + +MossTank will provide the complete automation capability associated with +Virindi Tank, implemented as a first-class acdream plugin over a stable, +BCL-only plugin API. UtilityBelt's typed expression dialect is the scripting +baseline. Native file formats may differ; behavior and extensibility may not. +The finished surface is a visually verbatim VTank reproduction: every VTank +tab and function is present and every enabled control invokes real behavior. + +## Non-negotiable boundaries + +- modern code, behavior matched to documented VTank/retail behavior; +- one Runtime owner for every state/action; plugin API is a borrowed projection; +- policy engines remain in MossTank, not App or Runtime; +- plugin UI only through `IUiRegistry`; +- every API addition works in graphical and no-window hosts, with explicit + unavailable behavior until the host can genuinely supply it; +- no fake success and no silent expression-function omission. + +## Slice ledger + +### MT0 — research and campaign design + +- [x] Reconcile existing VTank audit with current Runtime ownership. +- [x] Audit current UtilityBelt grammar and all 260 expression declarations. +- [x] Define complete capability ledger and staged architecture. + +### MT1 — autocombat foundation (current stop gate) + +- [x] Add target/combat views and attempt commands to the plugin API. +- [x] Project canonical hostile, selection, mode, power and spell state. +- [x] Implement target lock and range/angle/hybrid selection. +- [x] Implement melee/missile charge-release and direct offensive magic. +- [x] Deliver the polished combat dashboard and settings. +- [x] Focused, App/Runtime and complete solution gates. +- [x] Connected user gate: user confirmed autocombat works in the plugin. + +MT1 intentionally does not pretend later features exist. It is “autocombat +ported,” not “all combat policy ported.” + +### MTUI — generic plugin-window and VTank shell foundation + +- [x] Add manifest-authenticated, stable plugin panel descriptors without + breaking API-v1 hosts/plugins. +- [x] Register plugin panels with the common retained window manager so + geometry and visibility persist. +- [x] Add the shared right-edge plugin shelf and window minimize/restore; + hidden panels leave the plugin session and automation running. +- [x] Add reusable nested groups, tabs, lamp toggles and sliders to retained + plugin markup. +- [x] Replace MossTank's dashboard/settings pair with one VTank-shaped shell + using the exact Options, Profiles, Vitals, Monsters, Items, Consumables, + Buffs, Route, Meta tab order. +- [x] Bind all currently enabled controls to real MT1/buff behavior and leave + unimplemented tabs visibly disabled. +- [x] Enable the Items/Consumables pages against durable, manifest-scoped + exact-name profiles; selection and Add/Add-no-buffs/Add-All-Peas controls + all mutate the policy consumed by combat. +- [x] Connected visual gate: shelf placement, minimize/restore persistence, + and first VTank-shell comparison in the live client. + +### MT2 — complete monster/weapon/debuff combat policy + +- [x] ordered `DEFAULT` + first-match monster rules; +- [x] priorities -1..4 and complete action-flag matrix; +- [x] damage/weapon/offhand selection, swap state machine and auto power, + including the official GameInfoDB exact-name overrides, ordered creature- + species preferences, and VTank's final elemental fallback; +- [x] debuff groups, skill/level choice, receipt-gated reapply and explicit + wand switching policy; +- [x] ring/arc/bolt density/range logic, streaks, Void, harm/martyr, grenades, + lenses, cast-on-strike and pets; + (carried phials are complete; crafting a missing phial belongs to MT4's + generalized craft transaction); +- [x] blacklist and both ghost-monster detectors, including canonical App + entity teardown for a detected client ghost. + +MT2 checkpoint 2026-08-27: the BCL API now projects complete learned-combat +spell metadata, server cast and physical-attack receipts, health-update +revision/age, canonical equipment snapshots/commands, and exact-incarnation +ghost deletion. MossTank owns the complete Monsters expression/action model, +debuff tracker, elemental/shape spell catalog, range/density selection, +weapon/offhand policy, temporary blacklist, and both VTank ghost algorithms. +Focused evidence at this checkpoint: 104 MossTank tests, 20 Runtime action/ +target tests, and an isolated Release App build all pass with zero failures or +warnings. MT2 remains open for automatic physical power and the four item- +backed combat families. + +MT2 checkpoint 2 (2026-08-27): the official VTank assembly and live GameInfoDB +feed were inspected directly. Item appraisal SpellBooks are now retained; +plugins receive ordered combat chat and exact item UseDone receipts; the +source planner implements `dz.b.CompareTo` for SpellLevel/Skill preference; +the 72 official phials, lenses, cast-on-strike weapons and pets are executable +and profile-gated; proc success waits for the actual `You cast ... on ...` +line. `hi.cs` automatic attack power, including Recklessness clamping, is +ported verbatim. Items/Consumables profiles are atomically persisted through a +new per-manifest plugin-storage contract. Focused evidence: 131 MossTank tests, +the storage/chat/App tests, and an isolated Release App build pass. MT2 remains +open only for target-database `Auto` damage selection and the connected gate; +missing-grenade crafting is deliberately MT4 transaction scope. + +MT2 automated closeout (2026-08-27): `Auto` now consumes the official 59-name +override and 103-species preference tables. The ordered element decision +outranks spell shape/tier, drives profiled physical weapon selection, and feeds +automatic attack power and vulnerability policy. Unknown targets preserve +VTank's final Pierce→Bludgeon→Slash→Acid→Lightning→Cold→Fire fallback. Focused +evidence after the closeout and named-profile foundation: 146 MossTank tests; +isolated Release App build 0 warnings / 0 errors. The connected MT2 combat +matrix remains part of the later combined user gate. + +### MT3 — buff, heal and resource parity + +- [x] named macro profiles, buff exclusions/item buffs/top-off foundation; +- [x] all three vital threshold tiers and canonical fellowship vitals; +- [x] profiled kits/consumables and worn-item mana recharge; +- [x] VTank ManaStone/ManaTank acquisition and exact-receipt fill behavior; +- [x] conversions, self/item/fellow dispel response, and critical/normal/idle + component plus six-category consumable upkeep. + +### MT4 — inventory, craft and transactions + +- [x] AutoStack/AutoCram and the official 757-row VTank craft database; +- [x] generalized use/apply/give/move/split/stack/drop transaction API with + receipts and busy arbitration; +- [x] retail 0x027D salvage and authoritative current-vendor sale paths; +- [x] same-input authoritative split crafting, all three split priorities and + exact VTank door/lockpick policy. + +### MT5 — looting and extensible rule engine + +- [x] corpse lifecycle/ID waits, exact 30-attempt/200-second open blacklist, + 60-minute cache, 100-second public ownership, fellow Share Loot and rare-only + policy; +- [x] ordered first-match raw/projected-property expressions plus Keep, + KeepUpTo, Read, Salvage, Sell, ManaStone, ManaTank and User1–User5; +- [x] canonical appraisal/pickup/salvage/vendor seams, unknown-scroll fallback, + and exact VTank salvage workmanship bands with 40-attempt abandonment; +- [x] independent By-char and named native loot profile documents; +- [x] exact VTClassic `.utl` v0/v1 importer/exporter, every structured + requirement, forward-compatible length blocks, and profile-owned salvage + ranges/value modes; +- [x] external loot-classifier plugin capability. + +MT5 functional closeout (2026-08-27): the graphical host now exposes corpse +discovery, raw item properties, canonical appraisal/pickup, learned-spell +membership, fellowship Share Loot, retail salvage (0x027D), and current-vendor +sale through additive BCL-only interfaces. MossTank owns all policy and waits +for authoritative receipts/object removal; no action reports success at +dispatch. The official VTank corpse timers, rare/fellow ownership branches, +unknown-scroll difficulty check, mana-stone pairing, salvage-bag workmanship +bands, and bugged-bag retry ceiling were ported from the official decompiled +source. Focused evidence: 184 MossTank tests, 21 inventory-wire/session tests, +134 App automation/item/UI tests, and an isolated Release App build with zero +warnings/errors. File interoperability remains an MT9 compatibility tail, not +a reason to hold Route/Navigation. + +### MT6 — navigation + +- [x] canonical move/follow/turn/charged-jump/checkpoint host primitives; +- [x] circular, linear, once and Target/follow routes, including VTank's + endpoint reversal, destructive Once traversal and follow-around-corners; +- [x] every decoded nav node (0..9), closed-door/lockpick policy, vendor and + repeated NPC use, portal re-entry protection and combat/nav priority; +- [x] independent By-char and named native route profiles; +- [x] exact `uTank2 NAV 1.2` importer/exporter. + +MT6 functional closeout (2026-08-27): the additive navigation API projects +VTank coordinates, live and server-accepted player position, object +reacquisition, door state, portal state, and typed movement levels through the +one Runtime command interpreter. MossTank owns the exact four route modes and +ten node types. Steering ports `fd.cs`'s 4° turn threshold, far 45° and near +15° forward cones; checkpoints use `gr.cs`'s accepted-position gate and +15-second nudge; Target mode ports `gl.cs` breadcrumb pruning; doors port +`b7.cs`'s defaults (disabled, 20 m ID, 4 m open, −50 lockpick threshold). +Portal2/UseNPC reacquire exact-name objects near the saved point, NPC use waits +for tell/give chat, jumps align to their stored heading before charge/release, +and Once removes completed rows exactly like VTank. Evidence: 204 MossTank +tests, focused App navigation projection tests, and isolated Release App build +with zero warnings/errors. Legacy file interop remains an MT9 compatibility +tail and does not hold the expression engine. + +### MT7 — expressions + +- [x] immutable AST, typed values, budgets and diagnostics; +- [x] UtilityBelt grammar semantics including lists/dicts/slices; +- [x] implement/alias/explicitly disposition the 260-function audit ledger; +- [x] VTank option/expression command diagnostics; +- [x] parser, evaluator, persistence and capability-security gates. + +### MT8 — meta engine and runtime views + +- [x] complete condition/action vocabulary, nested composition, once-per-entry, + call/return and watchdog; +- [x] chat capture variables and option access; +- [x] plugin-authored runtime views over the retained markup contract; +- [x] native meta profile; +- [x] exact VTank CondAct `.met` importer/exporter, including recursive rules, + embedded NAV and the historical CreateView record quirk. + +### MT9 — fellowship, profiles, commands and polish + +- [x] tell-driven recruitment, waiting-list, status/location commands, and + two-minute kick/ban/giveleader/setopen voting over canonical fellowship + commands; +- [x] helper healing, fellowship corpse permissions and shared target views; +- [x] macro-profile foundation: true per-character `By char` documents, named + create/copy/clear/select, mine-only filtering, hot loading, atomic manifest- + scoped storage, and complete current combat/buff/vitals/monster/item state; +- [x] independent navigation/loot/meta profile documents remain with MT5/MT6/MT8; +- [x] exact 137-name typed VTank option catalog/defaults and durable + `/vt opt setinall` across every indexed named/character macro profile; +- [x] all documented `/vt` command names are locally registered and handled; +- [x] exact `.nav`, `.met`, and `.utl` dumps/import-export; +- [x] privileged debug-operation semantics (`clearlocks`, `clearbusy`, + `fakeimp`) use canonical owners and authoritative lifetime cleanup; +- [x] first-run guidance, native/VTank profile migration and corrupt-profile + recovery with append-only raw-data preservation; +- [x] accessibility and scaling polish; +- [ ] performance soak, reconnect/lifecycle and multi-client gates. + +## MT1 execution order + +1. Add BCL-only combat records/interfaces with inert defaults. +2. Extend Runtime hostile query with exact position/heading snapshots. +3. Bind App's automation surface to the canonical action/spell owners. +4. Implement/test MossTank's deterministic combat controller. +5. Replace the small panel with dashboard/settings markup and generic markup + affordances needed by the design. +6. Run narrow tests, Release build, broad tests; record exact evidence here. + +## Closeout evidence + +MT1 code-complete 2026-08-26 and user-passed 2026-08-27. The additive BCL-only contract is +`CombatAutomation.cs`; older API-v1 implementations retain inert default +members. `AppAutomationSurface` borrows the canonical Runtime owners and +projects hostile captures, combat state, physical press/release attempts, +targeted casting and learned direct offensive spells. MossTank's +`CombatController` owns priority, target lock, range/angle/both selection, +mode entry, power-bar timing and magic choice. The dashboard/settings markup +uses the retained plugin registry; generic markup now supports bound child +visibility/enabled state and button colors. + +Automated evidence: + +- focused MossTank: 54 passed / 0 failed; +- complete Runtime: 1,854 passed / 0 failed; +- repository-owned hermetic Release gate: **15,775 passed / 0 skipped / + 0 failed across 14 assemblies**; +- Release build: 0 warnings / 0 errors; +- the original MossTank XML documents parsed successfully before the gate. + +MTUI code-complete 2026-08-27. `PluginPanelDescriptor` and authenticated +`PluginUiOwner` carry presentation metadata through Core's transactional +plugin lifetime; App mounts the stable panel as a `RetailWindowHandle` and the +generic `PluginSidePanel` owns only hide/restore UI. The one-window MossTank +shell uses real retained tabs/toggles/sliders. Focused evidence: 18 App/plugin +tests and 56 MossTank tests passed; isolated Release App build passed with +0 warnings / 0 errors. Broader hermetic evidence: Core 4,720/4,720 and Runtime +1,854/1,854 passed; App passed 6,441/6,442 with the sole failure in the +unrelated pre-existing landblock recenter assertion +`OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid`. Its +connected visual gate remains open. + +MT3/MT4 resource closeout 2026-08-27: crafting now runs through VTank's three +ordered tiers: critical component/consumable recovery, normal component and +general profile crafting, then no-target idle component and six-category +kit/food stock targets. Same-input recipes wait for both the authoritative +split receipt and publication of two distinct stacks before applying. The +official `IdleCraftCount_*` underscore names, 4/20/20 component defaults, and +2/2/2 kit plus 15/15/15 food targets persist in named/By-char profiles. +Self-cast and item dispels port `c8.cs`/`cx.cs`; fellowship Awakener selection +ports `af.cs`, including exact training, Arcane Lore, 5 m, spell-3179 and +summed-vulnerability-quality gates. The additive shared duration-spell ledger +matches VTank's confirmed local/external `LogSpellCast` model and clears on +session detach. Evidence: 277/277 MossTank tests, 12/12 focused App automation +tests, and isolated Release App build with zero warnings/errors. + +MT7–MT9 checkpoint 2026-08-27: MossTank registers all 260 audited +UtilityBelt public expression names over the typed evaluator, and the Meta +runtime/editor, dynamic views, embedded routes, command execution and durable +variable scopes are integrated. The host now provides an unload-safe generic +plugin-command registry; `/vt` follows the same local command route from typed +chat, launcher login commands and no-window clients. The exact official +four-line command catalog and 137-row typed option database are present; +`setinall` rewrites every indexed named/character profile. Run Macro is now a +master lifecycle distinct from Enable Combat, and command jumps align before +charging. The additive fellowship API projects the canonical retail commands; +MossTank owns VTank's tell commands, wait list, spam limit, near-player +recruitment, leader transition cleanup and two-minute voting. Evidence at this +checkpoint: 261/261 MossTank tests, 18/18 runnable focused App/plugin tests, +and isolated Release App build with zero warnings/errors. Four additional +GraphicalPluginSession tests could not locate the repository when deliberately +run from an isolated OutputPath; this is test-harness path behavior, not a +product failure. Connected shelf/UI/fellowship and combined automation gates +remain open. + +Legacy-profile checkpoint 2026-08-27: native JSON remains MossTank's durable +working format, while every save also emits a genuine VTank compatibility +file. `uTank2 NAV 1.2` routes and CondAct `.met` files round-trip exactly; +the Meta writer was independently accepted and canonicalized byte-identically +by the public `metaf` reference compiler. VTClassic `.utl` v0/v1 now retains +length-delimited unknown requirements/blocks, executes all 31 published +requirement types (including the DAT-resolved ordered-palette color family), +and applies per-material salvage ranges/value modes to the real 0x027D combine +planner. Native-only text rules export disabled rather than becoming +VTClassic's dangerous empty-requirement match-all. Evidence: 290/290 MossTank +tests, 13/13 focused App/plugin tests, and isolated Release App build with zero +warnings/errors. + +External-loot checkpoint 2026-08-27: the BCL-only host now owns an unload-safe +classifier registry. Classifier ids are namespaced to the registering plugin, +all registrations are disposed transactionally with that plugin's session, +and exceptions are isolated at the registry boundary. MossTank exposes the +available engines in Profiles, persists the selection with the macro profile, +and runs Keep/KeepUpTo/Read/Salvage/Sell/User1–User5 decisions through its +existing authoritative corpse executor. An unavailable engine never silently +changes policy by falling back to VTClassic. Evidence: 2 focused Core registry +tests, 55 focused MossTank loot/panel/markup tests, and isolated Release App +build with zero warnings/errors. + +Options/debug checkpoint 2026-08-27: the VTank Options page now uses the +verbatim four-column control arrangement. Normal automatic rebuff, the +separate idle top-off window, Attack→Approach distance navigation, and final +Idle Peace fallback were ported from `fz.cs`, `cLogic.cs`, `g8.cs`, `eb.cs` +and `cm.cs`; Force Buff and Cancel Force Buff remain distinct actions. The +Advanced Options button opens the full ordered 137-setting table. `/vt +clearbusy` decrements exactly one Runtime-owned inventory busy reference, +`clearlocks` clears only MossTank's transient policy locks, and `fakeimp` +records VTank's local 3,000-second Gossamer Flesh debug marker without forging +a server cast. External classifiers now receive authoritative `OnLooted` and +`OnItemRemoved` lifecycle callbacks after inventory publication. Evidence: +298/298 MossTank tests and an isolated Release App build with zero warnings +and zero errors. + +Final automated API/options checkpoint 2026-08-27: every one of the 137 +official advanced-option names has an explicit writable live-policy mapping; +the full catalog, official defaults, case-insensitive lookup and durable +profile propagation are covered. The Monsters page now exposes the three +distinct official cycles for Damage type, Ex. Vuln and PetDmg rather than one +shared internal enum. Prismatic remains an ammunition policy while preserving +automatic magic-element selection; Fists uses Tusker Fists only while its +enchantment is active. `DoJiggle` now ports VTank's PreviousSelection followed +by alternating NextPlayer/PreviousPlayer at 131 ms and no longer moves the +character. `ShowCollisionDebug` publishes bounded projectile samples through +the BCL-only API and renders transient red/green markers in the retained UI. +`WhoYouGonnaCall` is intentionally stored but inert, matching the official +source's explicit `No Function` disposition. + +The plugin API now projects combat, magic, equipment/items, looting, +fellowship, enchantments, navigation, world objects/time, login, network peer +state, recovery, projectile diagnostics and selection through canonical +Runtime/App owners. Startup peer tags are parsed once by `RuntimeOptions`, +portable data paths come from `ApplicationPathSet`, and both graphical and +headless plugin hosts load fixtures correctly from isolated output graphs. +Latest hermetic evidence: App 6,592 passed / 94 environment-dependent skips; +Runtime 1,863/1,863; Core 4,911/4,911; Core.Net 1,042/1,042; Headless +171/171; UI abstractions 880/880; MossTank 320/320 — **15,779 passed, zero +failed** across the selected automated lanes. The Release App build completed +with zero warnings and zero errors. Excluded gates are explicit: manual/live +lanes, Linux-only tests on this Windows host, the machine-local stale bake-tool +4 PAK test, and one registered pre-existing tower-ascent known failure. The +generic shelf, VTank shell, minimization-while-running, reconnect, live combat, +multi-client peer expressions, and collision-marker appearance remain owed in +the combined connected user gate. + +Connected shelf/shell gate 2026-08-27: the first isolated Release launch found +that App's plugin-copy target still assumed each plugin's conventional `bin` +directory when a custom `OutputPath` was active. That caused the packaged +MossTank DLL/markup to be stale even though the root build outputs were current. +Build and publish now resolve both first-party plugin targets through MSBuild's +`GetTargetPath`; MossTank markup copies directly from its source. The rebuilt +package's MossTank DLL and XML matched their build/source SHA-256 hashes and +the boundary regression passed 5/5. + +The next live launch exposed a retained-markup contract mismatch: one field +reused an `Action` button binding where `onsubmit` requires `Action`, +preventing the complete plugin window from mounting. MossTank now has a typed +submit action and its markup contract test validates every interactive binding's +delegate shape. A later visual pass also caught three unsupported inline label +bindings on Meta; all are now whole-value properties, and the contract rejects +future inline interpolation. Focused MossTank evidence is 321/321; isolated +Release build `app-release22` is zero-warning/zero-error with exact packaged +artifact hashes. + +The connected `app-release22` gate then passed: all nine tabs mounted and were +visually inspected; Meta rendered `State: Default`, `N: 0`, and `N2: 0`; the +right-edge `MT` shelf button was fully reachable; minimize hid only the window; +while hidden the live buff pass advanced from 91/97 to 77/97; restore showed +`Stop Macro` and the changed live status; the macro stopped normally. Logs show +92 server-confirmed `UseDone err=0` casts and no plugin/UI exception. Shift+Esc +completed the full logout presentation and returned to character selection. +This supersedes the earlier statement that the shelf, shell, minimization, and +basic reconnect/lifecycle presentation were wholly unproven. At that checkpoint, +still owed were +the accessibility/scale closeout, longer performance/reconnect soak, live +hostile combat matrix, two-client peer expressions/fellowship, and collision- +marker appearance. + +Accessibility/reconnect/peer checkpoint 2026-08-27: textless and terse controls +now carry runtime-bound retained tooltips, and the common window owner clamps +plugin panels to the current viewport (including the 800x600 oversize case). +Focused evidence is 325/325 MossTank tests, 16/16 retained-UI tooltip/geometry +tests, and isolated Release `app-release23` with zero warnings/errors. The live +client displayed the Monster Range help text, completed a same-character +logout/re-entry, restarted the macro, and completed another 92 server-confirmed +casts. Working/private memory stayed approximately 1.59/1.84 GiB across the +combined soak rather than climbing with casts or reconnect. + +The local peer API also passed real two-process expressions in both directions: +the secondary `+Horan` evaluated +`dictgetitem[listgetitem[netclients['mosstank-guard-primary'],0],'Name']` and +received `+Acdream`, while the earlier reciprocal gate returned `+Horan` to +the primary; both heartbeat documents contained the expected names, tags, +vitals and positions. + +That broader gate exposed separate client defect #451. First-chance cdb proof +located it in GLFW's Win32 event pump: temporary cross-process input-queue +attachment let `GetActiveWindow` return the other acdream process's HWND; +GLFW's shared `L"GLFW"` property then returned the other process's private +`_GLFWwindow*`, which the caller dereferenced. `app-release24` installs the +current-process HWND guard at GLFW's own import slot before `glfwInit`; its four +focused tests pass. Two rebuilt graphical clients then entered world, survived +100 rapid forced activation switches—the exact old trigger—and remained +responsive through a 30-minute combined soak with no native error. Issue #451 +remains in-progress only until both sessions complete a graceful-exit gate. + +The secondary-owned fellowship gate also passed: `+Acdream` created +`mosstankgate`, `+Horan` joined, both canonical rosters contained both members, +and the secondary evaluated `getfellowshipcount[]` as `2`. + +Still owed here: the hostile combat matrix and collision-marker appearance. + +Final local validation checkpoint 2026-08-27: the complete Release solution +build passed with zero warnings and zero errors. Focused MossTank passed +325/325 and the App plugin/API/UI/GLFW set passed 35/35. The conservative +Windows hermetic filter passed 15,083 non-network tests; Core.Net then passed +1,042/1,042 in its isolated lane, for 16,125 passing selected tests. The first +max-parallel combined invocation made Core.Net's timing-sensitive two-percent +packet-loss soak exhaust its wall-clock headroom; the same case and complete +Core.Net lane passed immediately when isolated. No MossTank, plugin API, plugin +UI, Runtime-owner, or #451 guard test failed. + +Live hostile discovery checkpoint 2026-08-27: the first surrounded-monster +gate exposed two coupled compatibility defects. Retail's classic `* Lure` +vulnerability names were absent from the debuff classifier, so an attack-only +profile could misclassify Piercing Lure's "piercing damage" description as a +direct attack. The classifier now recognizes all seven classic elemental Lure +families (while excluding the distinct Lure Blade item spell), and the attack +catalog defensively rejects every host-authored debuff. Target evaluation also +now ports official `dz::a`'s previous-target tie-break after priority and manual +TargetLock: a valid chosen monster remains selected while the character turns, +instead of angle rescans alternating between surrounding monsters. The new +Lure/attack and target-stability regressions bring the focused MossTank lane to +337/337. Connected re-test remains part of the hostile combat gate. + +## Requirement-level completion audit (2026-08-27) + +Completion is deliberately **not** claimed while live evidence remains missing. +The authoritative requirement/evidence map is: + +| Objective requirement | Current evidence | Audit result | +| --- | --- | --- | +| Functionally complete VTank behavior | MT2–MT9 implementation ledger; 337 MossTank behavior/format/expression tests; connected MT1 autocombat acceptance | Proven for implemented policy and formats; the combined hostile physical/magic matrix remains live-unproven | +| Visually verbatim nine-tab VTank surface | `mosstank.xml` contains the exact Options, Profiles, Vitals, Monsters, Items, Consumables, Buffs, Route, Meta order; all nine tabs mounted in `app-release22` | Proven for shell/tab presence and first comparison; projectile debug-marker appearance remains live-unproven | +| Every visible control has real behavior | 190 interactive controls expose 202 bindings (191 unique); `MossTankMarkupContractTests` resolves every binding, verifies delegate shape, and rejects handlerless controls; 137/137 advanced options have explicit writable mappings | Proven statically and by focused controller tests. `WhoYouGonnaCall` intentionally stores its value but performs no action because the official VTank source labels it `No Function` | +| Generic plugin sidepanel; minimizing must not stop plugins | retained `PluginSidePanel`/window-manager tests plus connected hide/restore gate where the hidden buff pass advanced from 91/97 to 77/97 | Proven | +| Modern acdream plugin APIs over canonical owners | additive BCL-only combat, magic, equipment, item, loot, fellowship, enchantment, navigation, object, world-time, login, network, recovery, projectile, selection, storage, command and classifier contracts; 35 focused App/API/UI tests and 16,125 selected Release tests | Proven for the graphical live host; older/no-window implementations explicitly report unavailable and never fabricate success | +| UtilityBelt-compatible expression superset | immutable evaluator tests; all 260 audited public names registered; host-action, object, fellowship, time, login/network, UI, persistence, collection and meta tests | Proven by catalog and semantic family tests; bidirectional two-client network expressions passed live | +| Lifecycle, reconnect, multi-client stability | same-character reconnect and hidden execution passed; peer expressions and two-member fellowship passed; #451 exact trigger survived 100 focus switches and a 30-minute dual-client soak | Proven through soak; #451 cannot close until both current sessions exit gracefully | + +Open completion gates: (1) hostile physical and offensive-magic behavior against +a live target at valid configured range; (2) visible green/red projectile +collision markers with `ShowCollisionDebug`; (3) graceful exit of both current +soak clients with no native or managed failure. These are evidence gaps, not +redefined-away acceptance criteria. diff --git a/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md b/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md new file mode 100644 index 00000000..2556a7f6 --- /dev/null +++ b/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md @@ -0,0 +1,447 @@ +# MossTank research: Virindi Tank parity and UtilityBelt expressions + +Date: 2026-08-26 + +This report is the requirements baseline for turning MossTank from the small +self-buffing sample into acdream's full automation plugin. The product target is +deliberately broad: **all Virindi Tank functionality**, with UtilityBelt's more +capable expression dialect as the scripting baseline. File compatibility is a +separate decision; behavioral capability is not. + +## 1. Evidence and limits + +The following primary VTank pages were read and cross-checked (the live wiki +and its indexed historical revisions were both used where a mirror was +temporarily unavailable): + +- `http://virindi.net/wiki/index.php/Virindi_Tank` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Standard_Options` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Advanced_Options` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Commands` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Meta_System` +- `http://virindi.net/wiki/index.php/Meta_Expressions` +- `https://utilitybelt.gitlab.io/docs/expressions/` + +The 2026-08-27 MT2 follow-up also verified the exact documented distinctions +that drive the combat scheduler: + +- A+R requires `MinimumRingTargets` inside Ring Range; R without A rings with + any configured target inside range and falls back to standard war outside; +- `UseArcs` prefers an arc over a bolt only at/above `ArcRange`; +- `Void Basic`, `Drain Auto`, and `Harm` are distinct Monsters damage choices; +- `GhostMonsterSpellAttemptCount` counts spell attempts which never start, + while `BlacklistMonsterAttemptCount` counts successful attacks which miss; +- the health-tracker ghost detector is independent and applies to melee, + missile, and magic. + +Sources: the official `Virindi_Tank_Standard_Options`, +`Virindi_Tank_Advanced_Options`, `Virindi_Tank_FAQ`, `Options_List`, and +`Virindi_Tank_Changelog` pages listed above. + +For UtilityBelt, documentation was checked against the primary source rather +than relying on the generated web page alone. The inspected repository was +`https://gitlab.com/utilitybelt/utilitybelt`, commit +`5fe9825a82f38047737768fd92c61dd47d88e467` (2026-03-05). The grammar is +`UtilityBelt/Lib/Expressions/MetaExpressions.g4`; every method carrying an +`ExpressionMethod` attribute was enumerated. The source is MIT licensed. + +This report extends, rather than replaces, +`2026-07-29-vtank-plugin-automation-requirements.md`. That earlier report +already decoded VTank's `.met`, `.nav`, and `.utl` structures from primary +sources and remains the format reference. + +## 2. Complete VTank capability map + +### 2.1 Combat + +VTank is a priority-driven combat controller, not merely an auto-attack loop. +Its supported combat family includes: + +- melee, missile, mage, hybrid, two-handed, Void and Summoning characters; +- Life harm/martyr attacks, grenades, lenses, cast-on-strike weapons, streaks; +- automatic damage/weapon choice and monster-specific weapon, offhand and pet + element overrides; +- monster rules with `DEFAULT` plus ordered first-match expressions; +- per-rule priority from ignore (`-1`) through `4`, attack/debuff flags, + damage type, attack height, ring/streak choices, and void curses; +- target selection by distance, angular deviation, or the hybrid method using + angle inside a configurable cutoff and distance outside it; +- target lock, blacklist/retry behavior, and ghost-target retirement after + failed casts or missing health updates; +- debuff scheduling by one target, priority group, or all targets before + attack; spell-level versus skill-based debuff choice and reapply windows; +- automatic ring use by nearby-target density and arc/bolt choice by range; +- melee high/middle/low attacks, automatic or explicit power, Recklessness; +- pet density, element, refill and test behavior. + +VTank's macro scheduler checks multiple action lists in priority order. Combat +therefore cannot be implemented as an isolated timer: healing, buffing, +navigation, looting, fellowship assistance and combat all need one arbiter. + +### 2.2 Buffing and vitals + +- automatic trained attribute/skill buffs, protections, banes, auras, + regeneration and configured extra buffs; +- protection/bane profile sets, exclusions, level/tier selection, signed + skill-over-difficulty thresholds, force buff and idle top-off; +- time-remaining rebuff and persisted item-buff duration knowledge; +- combat, idle and fellowship-helper vital thresholds; +- kits, vital transfers, post-switch recharge behavior and special healing + items; +- self-dispel in response to high-level vulnerabilities. + +MossTank's current buff engine already owns the first useful subset: known +self buffs, tier/difficulty choice, in-force enchantment timing, force buff, +and stamina/mana upkeep. It remains plugin policy over host primitives. + +### 2.3 Inventory and crafting + +- AutoStack and AutoCram; +- pea splitting and priority rules; +- crafting of kits, foods, arrowheads and special ammunition; +- mana-stone acquisition, filling and application to equipped items; +- lockpick selection and use; +- component, consumable, tool and ammunition upkeep. + +### 2.4 Looting + +- corpse approach/open/retry/timeout/blacklist; +- all/fellow/rare loot modes and priority boosts; +- appraisal/ID wait, unknown-scroll reading and salvage combining; +- a loot-plugin seam, with VTClassic as the canonical ordered, first-match + rule engine over raw and computed item properties; +- actions including no-loot, keep, keep-up-to, salvage, sell, read and custom + user actions. + +The host must expose object property bags, appraisal completion and +transaction primitives. Rule ordering and loot-profile policy belong in +MossTank. + +### 2.5 Navigation + +- circular, linear, once/runback and follow routes; +- points, portals, recalls, pauses, chat, vendor, repeated NPC talk/use, + server-confirmed checkpoints and charged/shift/strafe jumps; +- closest-entry, reversal, arrival/off-course ranges, door use and + follow-around-corners; +- combat/nav priority interaction. + +Route storage belongs to the plugin. The host owes move-to, follow, turn, +jump, use and authoritative-arrival primitives. + +Direct inspection of the official assembly on 2026-08-27 pinned the route +contract more tightly: + +- `eNavType` is Circular, Linear, Target and Once; Once destructively removes + its first completed row and Linear deliberately visits each endpoint once + while flipping direction; +- `eWaypointType` assigns Point/Portal/Recall/Pause/ChatCommand/OpenVendor/ + Portal2/UseNPC/Checkpoint/Jump to numeric ids 0..9; +- `fd.cs` turns outside 4°, moves while turning only within 45° beyond 3 m or + 15° inside 3 m, and stops at `NavCloseStopRange` (default 2 m); +- `gr.cs` compares the checkpoint against the last server position rather + than client prediction and nudges forward after 15 seconds without an + acknowledgement; +- `gl.cs` records the followed player's path by approximately 9.6 cm and + drops old breadcrumbs when the follower comes within 2.4 m of a later path + segment, preserving follow-around-corners; +- `e9.cs` and `fa.cs` reacquire exact-name/class objects within 2.5 m of the + stored position. Portal2 retries when portal exit remains within 15 m of + its origin; UseNPC repeats until the named NPC tells or gives to the player; +- `b7.cs` is a rule independent of the route node list. `OpenDoors` defaults + false; it IDs doors at 20 m, opens at 4 m, and accepts a lock when Lockpick + is at least `difficulty - 50` using an owned lockpick. + +These are plugin policies over additive canonical projections, not a second +movement model. The host applies semantic movement intent through Runtime's +existing command interpreter and supplies the accepted server position needed +only by Checkpoint. + +### 2.6 Fellowship and social automation + +- tell-driven recruitment and waiting lists; +- fellowship leader/member/state queries and leader replacement voting; +- fellowship healing, corpse permissions and coordinated target/debuff policy; +- multi-client composition through chat rather than a privileged macro API. + +### External loot-classifier seam + +VTank loads one `LootPluginBase`, asks `DoesPotentialItemNeedID`, and then +calls `GetLootDecision(GameItemInfo)`. Its public result vocabulary is +NoLoot, Keep, Salvage, Sell, Read, User1–User5 and KeepUpTo with `Data1` as the +limit. MossTank modernizes discovery into a host-owned classifier registry: +plugins register a namespaced classifier for their own lifetime, while +MossTank remains the corpse/appraisal/pickup/action executor. The selected +engine is durable policy; if it unloads, MossTank returns no classifier match +instead of silently applying the built-in profile. + +Direct inspection of the official `hv.cs` also shows that a custom loot +plugin's per-item action is retained only after the item enters owned +inventory and is removed when the item leaves. The modern registry therefore +has matching `OnLooted` and `OnItemRemoved` callbacks. MossTank invokes them +only from authoritative inventory publication/removal, never when pickup is +merely dispatched. + +### Options-page scheduler findings + +The official Options controls are not merely presentation aliases: + +- `fz.cs` runs the ordinary `RebuffTimeRemainingSeconds` rule before combat; +- `cLogic.cs` runs a second `IdleBuffTopoffTimeSeconds` pass only behind + `IdleBuffTopoff`, after attack/loot work has gone idle; +- the PRETARGETAPPROACH `g8` rule navigates only between `AttackDistance` and + `ApproachDistance`, and requires both combat and navigation to be enabled; +- `cm.cs` changes to Peace only as the final no-target/no-work fallback. + +The UI displays AC-distance settings multiplied by 240. MossTank stores metres +in its typed controllers and converts only at the VTank option boundary. + +### 2.7 Meta state machine + +- named states beginning at `Default`; +- state-local rules, each firing once per state entry; +- nested conditions (`All`, `Any`, `Not`) and conditions for chat regex, + inventory, timers, nav state, death, vendors, monsters, buffs, coordinates, + portals, burden, route distance, expressions and captured chat groups; +- actions for state transition, chat, grouped actions, embedded navigation, + call/return stack, expression execution, expression-derived chat, watchdogs, + option read/write and runtime-created views; +- a roughly 293 ms decision cadence plus evaluation when the macro asks for + its next action. + +### 2.8 Profiles, commands and companion behavior + +- independent settings, navigation, loot and meta profiles; global and + per-character variants; hot loading and automatic persistence; +- command parity for macro state, options, buffing, meta, item testing, + property dumps, monster/spell diagnostics, route editing, attack power and + debug output; +- extensibility equivalent to VTClassic, VI2, item tools, follower/status HUD, + alerts and cross-character inventory. Some belong as separate acdream + plugins, but MossTank's API must permit them without privileged host code. + +## 3. Expression language target + +### 3.1 Why UtilityBelt is the baseline + +VTank expressions are enough to power classic metas, but UtilityBelt preserves +the familiar syntax while adding typed lists and dictionaries, slicing, +higher-order collection functions, broader object queries and more action +primitives. MossTank should implement the UtilityBelt-compatible semantic +superset and offer a VTank compatibility mode for old expressions. + +### 3.2 Grammar and evaluation semantics + +The audited UtilityBelt grammar supports: + +- multiple `;`-separated statements, returning the final result; +- session (`$`), persistent (`@`) and global (`&`) variables; +- decimal and hexadecimal numbers, booleans and two string forms; +- function calls using `name[...]`; +- typed values: number, string, boolean, list, dictionary, coordinate, world + object, stopwatch and UI control; +- list/string/dictionary indexing, slices and negative indices; +- complement, shifts, bitwise operators, exponentiation, arithmetic, regex + match (`#`), comparison, short-circuit `&&` and `||`; +- registered function metadata, arity/type validation and documented return + types; +- collection creation/mutation/copying plus map/filter/reduce/sort/range. + +Implementation requirements follow directly: parse into an immutable AST; +compile or interpret without ambient reflection; use explicit value kinds; +short-circuit logical nodes; attach cancellation and an instruction budget; +make all world/action functions capabilities supplied by the MossTank engine; +and serialize only persistent/global variable stores. + +### 3.3 Audited UtilityBelt function catalog (260 declarations) + +The declaration count includes aliases/overloads. Grouped by capability, the +public names are: + +- **language/conversion/math:** `abs`, `acos`, `asin`, `atan`, `atan2`, + `ceiling`, `chr`, `cnumber`, `cos`, `cosh`, `cstr`, `cstrf`, `floor`, + `hexstr`, `iif`, `ifthen`, `isfalse`, `istrue`, `lumavg`, `lumtotal`, + `ord`, `randint`, `round`, `sin`, `sinh`, `sqrt`, `strlen`, `tan`, `tanh`, + `tostring`, `vitae`; +- **variables:** `getvar`, `setvar`, `testvar`, `touchvar`, `clearvar`, + `clearallvars` and the corresponding `pvar` and `gvar` families; +- **execution/chat:** `exec`, `delayexec`, `clearexec`, `echo`, `chatbox`, + `chatboxpaste`; +- **lists:** `listcreate`, `listadd`, `listinsert`, `listremove`, + `listremoveat`, `listgetitem`, `listcontains`, `listindexof`, + `listlastindexof`, `listcopy`, `listreverse`, `listpop`, `listcount`, + `listclear`, `listfilter`, `listmap`, `listreduce`, `listsort`, + `listfromrange`; +- **dictionaries:** `dictcreate`, `dictgetitem`, `dictadditem`, `dicthaskey`, + `dictremovekey`, `dictkeys`, `dictvalues`, `dictsize`, `dictclear`, + `dictcopy`; +- **time/location:** `getdatetimelocal`, `getdatetimeutc`, `getunixtime`, + `getworldname`, `getplayercoordinates`, `getplayerlandblock`, + `getplayerlandcell`, coordinate parse/get/distance/string functions, + stopwatch functions, and the eleven `getgame*`/day/night functions; +- **character:** raw typed property reads, base/buffed skills, training level, + base/current/buffed-max vitals, base/buffed attributes, burden, free slots, + cooldown expiration, account hash and character index; +- **spells/components:** `getknownspells`, `getisspellknown`, + `getcancastspell_buff`, `getcancastspell_hunt`, `getspellexpiration`, + `getspellexpirationbyname`, `spelldata`, `spellname`, `componentdata`, + `componentname`; +- **world objects:** validity/data/ID-time, raw typed properties, identity, + health/vitals, spells, coordinates, selection/player/open-container, door + state, nearest monster/door/by class/name/template, and `wobjectfindall*` + variants over world, landscape, inventory and containers; +- **actions:** select, use, apply, give, equip wand, cast, cast-on-target, + move, split and drop; +- **combat/movement:** combat state get/set, busy state, equipped weapon type, + heading/get-heading-to, motion get/set/clear and portal-state query; +- **inventory/loot/salvage:** counts by name/regex/type, give-profile, + unopened corpse queries, `ustadd`, `ustopen`, `ustsalvage`; +- **fellowship/quest/XP:** thirteen fellowship queries, quest state/progress, + seven XP-meter operations; +- **UI/options/network/login:** status HUD, view/control get/set/visibility, + VT option/meta get/set, macro status, UtilityBelt options, regex capture, + network clients and next-login control. + +This catalog is a compatibility test ledger. Each name must eventually be +implemented, deliberately aliased, or marked unsupported with a documented +reason; silent omission is not acceptable. + +## 4. acdream mapping after the 2026-08 campaigns + +The 2026-07 report's architecture remains correct, but its gap table is stale. +The Runtime now owns inventory transactions, selection, combat mode and power +state, casting, fellowship, allegiance, vendor and secure-trade state. MossTank +already consumes a small BCL-only `IAutomationSurface` for vitals, skills, +spells, enchantments, casting and local chat. + +The gaps relevant to the first autocombat milestone are narrower: + +| Need | Canonical owner today | Plugin gap | +|---|---|---| +| hostile query and live position | `RuntimeEntityDirectory` + `ClientObjectTable` | no target snapshot/query | +| health and selected target | `RuntimeActionState` | no combat view | +| melee/missile charge/release | `RuntimeCombatAttackState` | no command surface | +| combat-mode transition | `RuntimeCombatModeState` | no command surface | +| known offensive spells | `Spellbook` | only self buffs are enumerated | +| target-specific cast | selection + `RuntimeSpellCastState` | possible only by composing two old services | +| polished plugin controls | retained `IUiRegistry` markup | markup lacks bound visibility/enabled/style affordances | + +The first implementation therefore does not need a second runtime bridge or a +second object model. It needs a narrow additive projection of those exact +owners. + +## 5. Decisions for MossTank + +1. MossTank remains an ordinary plugin. It never references App, Runtime, + rendering, networking or DAT assemblies. +2. The host API exposes snapshots and attempt-style commands; MossTank owns + target scoring, rule ordering, spell/attack choice and timing. +3. The first combat milestone supports melee, missile and direct offensive + magic, target lock, range/angle/hybrid scoring, priority rules, attack + height and power. Navigation, weapon swapping, debuffs, vulnerabilities, + pets and monster expressions are later combat slices, not hidden stubs. +4. Expressions will use UtilityBelt's richer typed semantics. Compatibility + is defined by parser/evaluator tests and the audited function ledger, not by + copying UtilityBelt implementation code. +5. Native MossTank profiles will be versioned JSON. Importers for VTank files + can be added later without constraining the internal model. +6. The UI uses acdream's retained plugin UI contract. Missing generic controls + should improve that contract/markup rather than making MossTank depend on a + presentation implementation. + +### 5.1 Follow-up implementation findings (2026-08-27) + +VTank's official `e0.d(name)` first looks in `MonsterDamageOverrides`, then +maps the monster to `SpeciesDamages`; `ga.g(...)` walks that ordered preference +list and finally tries the unlisted elements 0..6. acdream already projects +retail `CreatureType` as `PluginCombatTarget.SpeciesId`, so MossTank can bypass +VTank's name-to-species compatibility table while preserving the same ordered +damage result. Exact name overrides still win. The imported official feed has +59 overrides and 103 species rows. + +### 5.2 Official inventory and loot findings (2026-08-27) + +The official VTank assembly and its GameInfoDB were inspected rather than +inferring behavior from the UI labels: + +- `el.cs`/`cf.cs` supply 757 exact craft rows; prerequisites are recursive and + share the canonical item-use transaction; +- `fo.cs` identifies every corpse before selection, parses `Killed by ...`, + admits the player's own corpse immediately, admits a Share Loot fellow + immediately, waits 100 seconds for a non-sharing fellow or unrelated public + corpse, and never crosses ownership on another player's rare-generating + corpse; +- the default corpse-open retry contract is 30 attempts, then a 200-second + blacklist; completed corpse records expire after 60 minutes; +- `hv.cs` applies the ordered loot rule first, then falls back to readable + unknown scrolls and automatic mana-stone/tank acquisition; +- `dy.cs` proves that ManaTank is a mana-bearing donor target, not a worn-item + recharge consumable. A ManaStone is used on that donor when its mana is at + least `ManaTankMinimumMana` (default 1000); +- `c7.cs` combines only same-material salvage bags in exact workmanship bands + `<7`, `7–<9`, `9–<10`, and exactly `10`; one bugged source is abandoned after + 40 failed combine attempts; +- `gmSalvageUI::Salvage` calls + `CM_Inventory::Event_CreateTinkeringTool`: game action `0x027D`, tool id, + then `PackableList` (count plus ordered item ids). This same + operation handles ordinary source salvage and salvage-bag combination. + +The native implementation keeps settings, loot, route, and meta documents +independent, matching VTank's profile model while using versioned JSON as the +working format. It also emits and imports exact compatibility files: `uTank2 +NAV 1.2`, CondAct `.met`, and VTClassic `UTL 1` (plus legacy UTL v0 reads). +The UTL port preserves unknown length-delimited requirement and extra-block +payloads, executes the complete 31-type requirement vocabulary, and carries +the `SalvageCombine` material ranges/value modes into the live combine planner. +VTClassic's color rules use the original ordered ObjDesc subpalettes and the +original sample index `length*16 + offset*32 + 8`, resolved from portal DAT +palette colors rather than approximated from icon pixels. + +Profiles are implemented over manifest-scoped JSON with exact VTank files as +an interchange/export layer: `By char` hashes the canonical character name into a distinct +document, named profiles are explicit shared snapshots, and the index records +owner plus per-character active selection. Create/copy/clear/select all hot- +load the same mutable policy owners already borrowed by the controllers. The +generic retained markup contract gained editable fields and retail dropdown +menus for this editor; later Monsters, Loot, Route, and Meta editors reuse the +same controls. + +## 6. Acceptance boundary for “autocombat ported” + +The milestone is complete when an in-world MossTank panel can enable/disable +combat, periodically capture canonical hostile targets, preserve a valid +locked target, choose a target by configured range/angle/hybrid policy and +priority, enter the equipped default combat mode, drive retail's physical +press/charge/release state machine at configured height/power, or cast the +best usable learned direct offensive spell in magic mode. It must stop cleanly +on session loss, invalid/dead/out-of-range targets, and user disable; it must +not duplicate Runtime state or issue overlapping requests. + +Full VTank parity is the campaign target. This acceptance boundary is only the +first executable slice requested for this work session. + +## 7. Official binary combat-item findings (2026-08-27) + +The official `vt.tar.gz` update was decompiled for behavior research and the +live GameInfoDB v9 feed was read directly. The decisive implementations are +`dz.cs` (debuff source selection), `ga.cs` (item classification), `gs.cs` +(caster-item confirmation), `bo.cs` (physical/proc confirmation), and `hi.cs` +(attack-power policy). + +- `dz.b.CompareTo` ranks spell quality then source skill/spellcraft for + `SpellLevel`, reverses those two for `Skill`, and gives a learned spell the + final tie. Spell quality is normally spell difficulty. +- Caster items activate on the target. Melee/missile proc weapons are equipped + and repeatedly attack at power 0/1 respectively. Neither path counts as + applied until color-7 combat chat matches `^You cast (.*) on .*$`. +- Grenades are missile-class items with CombatUse 0 and `Phial` in the name; + the official database contains exactly 72 names across eight material tiers, + with Alchemy requirements 75..400 and spellcraft 100..520. +- Normal physical attack power is not a smooth heuristic. `hi.cs` emits the + exact 0, .2, .49, .5 or 1 values for slash/pierce hybrid arrangements, then + clamps to .11..90 when trained Recklessness is enabled. + +These findings require three host facts VTank formerly obtained through +Decal: retained per-item appraisal SpellBooks, ordered transcript capture, and +an explicit combat-mode command. They are additive BCL plugin contracts; +MossTank retains all source-choice and retry policy. diff --git a/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md b/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md new file mode 100644 index 00000000..87c0f26e --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md @@ -0,0 +1,95 @@ +# MossTank autocombat design + +Date: 2026-08-26 + +## Outcome + +Ship the first VTank-class MossTank milestone: a polished in-client controller +that performs safe automatic melee, missile or direct-spell combat while all +policy remains in the plugin and all authoritative state/actions remain in +Runtime. + +## Architecture + +```text +Runtime canonical owners + entity directory + object table + selection + combat + spellbook + | + v +AppAutomationSurface (borrowed projection, no ownership) + PluginCombatTarget[] + PluginCombatSnapshot + attempt commands + | + v +MossTank CombatController (policy/state machine) + scan -> score/lock -> mode -> charge/cast -> wait -> repeat + | + v +retained plugin panel (bindings only) +``` + +`AcDream.Plugin.Abstractions` stays BCL-only. New interfaces use records, +enums, arrays/lists and primitives only. Existing interfaces gain default +members where needed so API v1 plugins remain loadable. + +## API additions + +- `PluginCombatTarget`: id, name, weenie class, distance, signed relative + angle, health-known and health fraction. +- `PluginCombatSnapshot`: selected id, mode, charge/request state, power and + server-pending state. +- `ICombatAutomation`: immutable hostile snapshot plus explicit mode, + begin/release/abort attempts. +- `ISpellCatalog.KnownAttackSpells`: learned, direct offensive spells. +- `IAutomationSurface.Combat`: the combat group. + +Attempt results distinguish unavailable, invalid target, wrong mode, busy, +transition started and sent/started. This avoids `bool` APIs whose `false` +cannot tell a plugin whether to wait, retry, reselect or stop. + +## Target snapshots + +`RuntimeHostileTargetQuery` is extended with a snapshot capture method. It +borrows the same entity directory and `ClientObjectTable` used by gameplay, +filters with the same `CombatTargetPolicy`, and computes distance and relative +heading using retail's `MoveToMath` helpers. Hidden, no-draw, dead and +cell-less entities are excluded. The App surface refreshes at bounded cadence +and publishes one immutable list reference; retained UI reads do not scan the +world or allocate. + +## Combat controller + +States: + +1. `Off`: no automation command may be emitted. +2. `Acquire`: keep a valid lock or choose the lowest score. +3. `Mode`: request the equipped default combat mode and wait for confirmation. +4. `PhysicalCharge`: select, set power, press height, then wait until the + canonical meter reaches desired power before release. +5. `MagicCast`: select and cast the chosen known offensive spell. +6. `Wait`: wait while physical server response, repeat state or magic busy is + active, then reacquire/repeat. + +Target scoring first applies ordered rules (initial slice supplies a default +priority and an ignore-name list), then applies the configured selection +method. Target lock keeps the current target while it remains admissible. + +The controller never fabricates success. Health and disappearance retire a +target; timeouts return to `Acquire`; session loss transitions to `Off` and +aborts an in-progress physical build. + +## UI + +The main window becomes a dashboard rather than a single force-buff button: +macro toggle, current target/mode, state, vitals, combat settings, buff action +and settings navigation. Generic markup gains bound child visibility/enabled +and color/style attributes so active controls read as active without App types +leaking into the plugin. + +## Verification + +- pure controller tests for selection policies, lock, mode transition, + charge/release, busy suppression, magic choice, disable and session loss; +- Runtime query tests for filter, range, distance, relative angle and health; +- App projection tests for caching and command mapping where practical; +- markup parser tests for new generic bindings; +- MossTank, Runtime, App and complete Release solution gates. diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index ec9fe281..3ccc68c8 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -112,13 +112,17 @@ AfterTargets="Build" Condition="'$(IsCrossTargetingBuild)' != 'true'"> - <_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) - <_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier) <_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke + + + - <_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) - <_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier) <_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke + + + - <_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) - <_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier) <_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank + + + - <_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) - <_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier) <_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank + + + ? RenderPackDiagnostics = null, - string? ScreenshotsDirectory = null) + string? ScreenshotsDirectory = null, + AppAutomationSurface? Automation = null) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -429,6 +430,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory return false; activeSession.SendSell(vendorGuid, items); return true; + }, + sendSalvage: (toolGuid, itemGuids) => + { + if (session.CurrentSession is not { } activeSession || !session.IsInWorld) + return false; + activeSession.SendSalvage(toolGuid, itemGuids); + return true; }); } @@ -1230,7 +1238,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory $"Screenshot failed: {error}", RetailLogTextType.ClientLocal); } - }); + }, + ProjectileDebugSamples: d.Automation is null + ? null + : d.Automation.CaptureProjectileDebugSamples); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 2317eb30..da60f580 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -83,6 +83,7 @@ internal sealed record SessionPlayerDependencies( CombatFeedbackSlot CombatFeedback, TransferableResourceSlot PortalTunnelFallback, Action Log, + Func? TryHandlePluginCommand, /// Campaign LA slice LA1: the shared per-session status-event /// writer, no-op when was /// not configured. @@ -112,6 +113,7 @@ internal sealed record SessionPlayerResult( DatSpawnClaimHydrationClassifier SpawnClaimHydration, LiveSessionController LiveSession, LiveEntityHydrationController Hydration, + LiveEntityDeletionController Deletion, LiveEntityNetworkUpdateController NetworkUpdates, LiveEntityLivenessController Liveness, LiveEntitySessionController SessionEvents, @@ -357,7 +359,8 @@ internal sealed class SessionPlayerCompositionPhase // LiveSessionCommandSurface has no dependencies of its own, so // hoisting its construction is inert; the later site now reuses // this instance instead of constructing a second one. - var liveSessionCommands = new LiveSessionCommandSurface(); + var liveSessionCommands = new LiveSessionCommandSurface( + d.TryHandlePluginCommand); var settingsTargets = new RuntimeSettingsTargets( new SilkRuntimeDisplayWindowTarget(d.Window), live.DrawDispatcher, @@ -1332,6 +1335,7 @@ internal sealed class SessionPlayerCompositionPhase spawnClaimClassifier, liveSession, hydration, + deletion, networkUpdates, liveness, sessionEvents, diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index 876ac7ad..10bf5a8f 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -63,7 +63,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource return default; if (_movement.HasCommandInput) - return _movement.CommandInput; + return _movement.CommandInput with { IsPersistentCommand = true }; if (_dispatcher is not { } dispatcher) return default; diff --git a/src/AcDream.App/Net/LiveSessionAppSource.cs b/src/AcDream.App/Net/LiveSessionAppSource.cs index f10e4e9e..d8e27646 100644 --- a/src/AcDream.App/Net/LiveSessionAppSource.cs +++ b/src/AcDream.App/Net/LiveSessionAppSource.cs @@ -36,11 +36,17 @@ internal sealed class LiveSessionAppSource /// retained UI may keep this surface, while the displaced route itself becomes /// inert before inbound subscriptions detach. /// -internal sealed class LiveSessionCommandSurface : ICommandBus +internal sealed class LiveSessionCommandSurface : IPluginCommandBus { private readonly object _gate = new(); + private readonly Func? _tryHandlePluginCommand; private LiveSessionCommandRouter? _active; + public LiveSessionCommandSurface(Func? tryHandlePluginCommand = null) + { + _tryHandlePluginCommand = tryHandlePluginCommand; + } + public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route) { ArgumentNullException.ThrowIfNull(route); @@ -65,6 +71,9 @@ internal sealed class LiveSessionCommandSurface : ICommandBus route?.Publish(command); } + public bool TryHandlePluginCommand(string commandLine) => + _tryHandlePluginCommand?.Invoke(commandLine) == true; + private void Release(LiveSessionCommandRouter expected) { expected.Dispose(); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6ea3a7b6..ec26a68b 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -455,6 +455,7 @@ internal sealed class LiveSessionRuntimeFactory OnUseDone: error => { _domain.Inventory.ExternalContainers.ApplyUseDone(error); + _domain.Actions.SpellCast.CompleteUse(error); _domain.Actions.Transactions.CompleteUse(error); }, _domain.Inventory.ItemMana, diff --git a/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs index 15810643..0b195c56 100644 --- a/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs +++ b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs @@ -151,6 +151,13 @@ internal static class GraphicalWindowBackendConfigurator _ => throw new ArgumentOutOfRangeException( nameof(requested)), }); + // #451: InitHint proves the packaged glfw3.dll is loaded but runs + // before glfwInit creates any window or begins polling. This is + // the one safe point to narrow GLFW's GetActiveWindow import so a + // temporarily joined Win32 input queue cannot hand it another + // acdream process's private GLFWwindow pointer. + if (platform.OperatingSystem == GraphicalHostOperatingSystem.Windows) + Win32GlfwActiveWindowGuard.Install(); _glfw = glfw; _configuredProtocol = requested; } diff --git a/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs b/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs new file mode 100644 index 00000000..629ab07d --- /dev/null +++ b/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs @@ -0,0 +1,282 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace AcDream.App.Platform; + +/// +/// Prevents GLFW's Win32 modifier-key repair pass from accepting a window +/// owned by another process. +/// +/// +/// +/// GLFW 3.4's _glfwPollEventsWin32 calls GetActiveWindow, then +/// reads that HWND's process-global GLFW property and dereferences the +/// result as a local _GLFWwindow*. Normally GetActiveWindow can +/// only return a window from this thread's input queue. Windows automation, +/// accessibility software, and some multi-box window managers temporarily +/// join input queues, however, allowing it to return another acdream process's +/// window. Every GLFW process uses the same property name, so GetPropW +/// then succeeds but returns a pointer meaningful only in the other process. +/// The next modifier-key read is an access violation (#451). +/// +/// +/// Patch only GLFW's import-address-table entry for GetActiveWindow. +/// The replacement returns the real active HWND when it belongs to this +/// process and zero otherwise. Zero is GLFW's existing, intentional +/// "nothing to repair" path. No process-global Win32 hook is installed and +/// no other module's User32 calls are changed. +/// +/// +internal static unsafe class Win32GlfwActiveWindowGuard +{ + private const string GlfwModuleName = "glfw3.dll"; + private const string User32ModuleName = "USER32.dll"; + private const string GetActiveWindowImport = "GetActiveWindow"; + private const uint PageReadWrite = 0x04; + private const ushort DosSignature = 0x5A4D; + private const uint PeSignature = 0x00004550; + private const ushort Pe32Magic = 0x010B; + private const ushort Pe32PlusMagic = 0x020B; + private const int ImportDescriptorSize = 20; + + private static readonly uint CurrentProcessId = + checked((uint)Environment.ProcessId); + private static int _installState; + + internal static bool IsInstalled => Volatile.Read(ref _installState) == 1; + + internal static void Install() + { + if (!OperatingSystem.IsWindows() + || Interlocked.CompareExchange(ref _installState, 2, 0) != 0) + { + return; + } + + try + { + nint module = GetModuleHandleW(GlfwModuleName); + if (module == 0 + || !TryFindImportSlot( + module, + User32ModuleName, + GetActiveWindowImport, + out nint slot)) + { + Volatile.Write(ref _installState, -1); + Console.Error.WriteLine( + "windowing: could not install the GLFW foreign-active-window guard"); + return; + } + + nint replacement = (nint)(delegate* unmanaged[Stdcall]) + &GetCurrentProcessActiveWindow; + if (!VirtualProtect( + slot, + checked((nuint)IntPtr.Size), + PageReadWrite, + out uint oldProtection)) + { + Volatile.Write(ref _installState, -1); + Console.Error.WriteLine( + "windowing: GLFW active-window import was not writable"); + return; + } + + try + { + *(nint*)slot = replacement; + } + finally + { + _ = VirtualProtect( + slot, + checked((nuint)IntPtr.Size), + oldProtection, + out _); + } + + Volatile.Write(ref _installState, 1); + Console.WriteLine( + "windowing: GLFW foreign-active-window guard installed (#451)."); + } + catch (Exception failure) + { + Volatile.Write(ref _installState, -1); + Console.Error.WriteLine( + $"windowing: GLFW active-window guard failed: {failure.Message}"); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static nint GetCurrentProcessActiveWindow() + { + nint window = GetActiveWindow(); + if (window == 0) + return 0; + + _ = GetWindowThreadProcessId(window, out uint ownerProcessId); + return AcceptWindow(window, ownerProcessId, CurrentProcessId); + } + + internal static nint AcceptWindow( + nint window, + uint ownerProcessId, + uint currentProcessId) => + window != 0 + && ownerProcessId != 0 + && ownerProcessId == currentProcessId + ? window + : 0; + + private static bool TryFindImportSlot( + nint module, + string importedModule, + string importedFunction, + out nint slot) + { + slot = 0; + byte* image = (byte*)module; + if (*(ushort*)image != DosSignature) + return false; + + int peOffset = *(int*)(image + 0x3C); + if (peOffset <= 0 || *(uint*)(image + peOffset) != PeSignature) + return false; + + byte* optionalHeader = image + peOffset + 24; + ushort magic = *(ushort*)optionalHeader; + int dataDirectoryOffset; + int thunkSize; + ulong ordinalFlag; + if (magic == Pe32PlusMagic) + { + dataDirectoryOffset = 112; + thunkSize = 8; + ordinalFlag = 0x8000000000000000UL; + } + else if (magic == Pe32Magic) + { + dataDirectoryOffset = 96; + thunkSize = 4; + ordinalFlag = 0x80000000UL; + } + else + { + return false; + } + + uint sizeOfImage = *(uint*)(optionalHeader + 56); + uint importRva = *(uint*)(optionalHeader + dataDirectoryOffset + 8); + uint importSize = *(uint*)(optionalHeader + dataDirectoryOffset + 12); + if (!Contains(sizeOfImage, importRva, ImportDescriptorSize)) + return false; + + int descriptorLimit = importSize >= ImportDescriptorSize + ? checked((int)(importSize / ImportDescriptorSize)) + : checked((int)((sizeOfImage - importRva) / ImportDescriptorSize)); + for (int descriptorIndex = 0; + descriptorIndex < descriptorLimit; + descriptorIndex++) + { + byte* descriptor = image + + importRva + + descriptorIndex * ImportDescriptorSize; + uint originalFirstThunk = *(uint*)descriptor; + uint nameRva = *(uint*)(descriptor + 12); + uint firstThunk = *(uint*)(descriptor + 16); + if (originalFirstThunk == 0 && nameRva == 0 && firstThunk == 0) + break; + if (!MatchesAsciiZ(image, sizeOfImage, nameRva, importedModule, true)) + continue; + if (originalFirstThunk == 0 + || !Contains(sizeOfImage, originalFirstThunk, thunkSize) + || !Contains(sizeOfImage, firstThunk, thunkSize)) + { + return false; + } + + int thunkLimit = checked((int)Math.Min( + (sizeOfImage - originalFirstThunk) / (uint)thunkSize, + (sizeOfImage - firstThunk) / (uint)thunkSize)); + for (int thunkIndex = 0; thunkIndex < thunkLimit; thunkIndex++) + { + ulong nameThunk = thunkSize == 8 + ? *(ulong*)(image + originalFirstThunk + thunkIndex * thunkSize) + : *(uint*)(image + originalFirstThunk + thunkIndex * thunkSize); + if (nameThunk == 0) + break; + if ((nameThunk & ordinalFlag) != 0) + continue; + + uint importByNameRva = checked((uint)nameThunk); + if (!Contains(sizeOfImage, importByNameRva, 3) + || !MatchesAsciiZ( + image, + sizeOfImage, + importByNameRva + 2, + importedFunction, + false)) + { + continue; + } + + slot = (nint)(image + firstThunk + thunkIndex * thunkSize); + return true; + } + + return false; + } + + return false; + } + + private static bool Contains(uint imageSize, uint offset, int length) => + length >= 0 + && offset < imageSize + && (ulong)offset + (uint)length <= imageSize; + + private static bool MatchesAsciiZ( + byte* image, + uint imageSize, + uint offset, + string expected, + bool ignoreCase) + { + if (!Contains(imageSize, offset, expected.Length + 1)) + return false; + + for (int i = 0; i < expected.Length; i++) + { + char actual = (char)image[offset + (uint)i]; + char wanted = expected[i]; + if (ignoreCase) + { + actual = char.ToUpperInvariant(actual); + wanted = char.ToUpperInvariant(wanted); + } + if (actual != wanted) + return false; + } + return image[offset + (uint)expected.Length] == 0; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern nint GetModuleHandleW(string moduleName); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool VirtualProtect( + nint address, + nuint size, + uint newProtection, + out uint oldProtection); + + [DllImport("user32.dll")] + private static extern nint GetActiveWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId( + nint window, + out uint processId); +} diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs index 7ad88d74..8667bbbe 100644 --- a/src/AcDream.App/Plugins/AppAutomationSurface.cs +++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs @@ -1,9 +1,22 @@ using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; using AcDream.Core.Player; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.Plugins; +using AcDream.Core.Properties; +using AcDream.Core.Selection; using AcDream.Core.Spells; +using AcDream.Core.World; +using AcDream.Core.CharGen; +using AcDream.Content; using AcDream.Plugin.Abstractions; +using AcDream.App.Runtime; using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; namespace AcDream.App.Plugins; @@ -27,20 +40,66 @@ namespace AcDream.App.Plugins; /// internal sealed class AppAutomationSurface : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat, - IDisposable + ICombatAutomation, IEquipmentAutomation, IItemAutomation, + ILootAutomation, IFellowshipAutomation, IEnchantmentAutomation, + IRuntimeCommunicationObserver, + INavigationAutomation, IWorldObjectAutomation, IWorldTimeAutomation, + ILoginAutomation, INetworkAutomation, IRecoveryAutomation, + IProjectileAutomation, ISelectionAutomation, IDisposable { + private readonly PluginCommandRegistry _pluginCommands; + private const int MaximumPluginChatMessages = 512; + private const double PeerHeartbeatSeconds = 5d; private readonly object _gate = new(); + private readonly IEvents? _events; + private readonly LocalPluginPeerRegistry _peers; + private readonly string[] _peerTags; + private double _peerHeartbeatRemaining; private GameRuntime? _runtime; private RuntimeCommunicationState? _communication; private RuntimeCharacterState? _character; private RuntimeSpellCastState? _cast; private Spellbook? _spellbook; + private MagicCatalog _magicCatalog = MagicCatalog.Empty; private IReadOnlyDictionary _skillNames = new Dictionary(); + private Func _speciesName = static _ => string.Empty; + private IChargenPaletteColorSource? _paletteColors; + private Func? _equip; + private Func? _equipmentBusy; + private Func? _useItem; + private Func? _applyItem; + private Func? _moveItem; + private Func? _mergeItems; + private Func? _dropItem; + private Func? _giveItem; + private Func? _pickupItem; + private Func? _identifyItem; + private Func, bool>? _salvageItems; + private Func? _sellItem; + private Func? _dismissGhost; + private Func? _selectionAction; + private PhysicsEngine? _projectilePhysics; + private IReadOnlyList _projectileDebugSamples = + Array.Empty(); + private long _projectileDebugSamplesExpireAt; + private CurrentGameRuntimeAdapter? _sessionCommands; + private IDisposable? _communicationSubscription; + private readonly List _chatMessages = []; + private ulong _pluginChatSequence; + private long _inventoryCompletionRevision; + private PluginInventoryCompletion _lastInventoryCompletion; + private readonly Dictionary<(uint Target, uint Spell), TrackedEnchantment> + _trackedEnchantments = []; + private long _trackedCastCompletionRevision; private bool _disposed; private IReadOnlyList _knownSelfBuffs = Array.Empty(); + private IReadOnlyList _knownAttackSpells = + Array.Empty(); + private IReadOnlyList _knownCombatSpells = + Array.Empty(); private IReadOnlyList _enchantments = Array.Empty(); @@ -52,6 +111,38 @@ internal sealed class AppAutomationSurface private static readonly string[] AttributeNames = ["Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self"]; + public AppAutomationSurface() + : this(events: null) + { + } + + internal AppAutomationSurface( + IEvents? events, + LocalPluginPeerRegistry? peers = null, + IReadOnlyList? peerTags = null) + { + _pluginCommands = new PluginCommandRegistry((verb, error) => + Console.WriteLine( + $"[PluginCommand:{verb}] {error.GetBaseException().Message}")); + _events = events; + _peers = peers ?? new LocalPluginPeerRegistry(Path.Combine( + AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory, + "plugin-peers")); + _peerTags = (peerTags ?? Array.Empty()) + .Where(static tag => !string.IsNullOrWhiteSpace(tag)) + .Select(static tag => tag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(128) + .ToArray(); + if (_events is not null) + _events.Tick += OnPeerTick; + } + + internal IPluginCommandRegistry PluginCommands => _pluginCommands; + + internal bool TryHandlePluginCommand(string commandLine) => + _pluginCommands.TryHandle(commandLine); + /// /// True only while a character is actually in world. The runtime's own /// lifecycle state is the signal: at character select the gameplay owners @@ -78,6 +169,172 @@ internal sealed class AppAutomationSurface public ISpellCatalog Spells => this; public IMagicCommands Magic => this; public IPluginChat Chat => this; + public ICombatAutomation Combat => this; + public IEquipmentAutomation Equipment => this; + public IItemAutomation Items => this; + public ILootAutomation Loot => this; + public IFellowshipAutomation Fellowship => this; + public IEnchantmentAutomation Enchantments => this; + public INavigationAutomation Navigation => this; + public IWorldObjectAutomation Objects => this; + public IWorldTimeAutomation WorldTime => this; + public ILoginAutomation Login => this; + public INetworkAutomation Network => this; + public IRecoveryAutomation Recovery => this; + public IProjectileAutomation Projectiles => this; + public ISelectionAutomation Selection => this; + + PluginRecoveryResult IRecoveryAutomation.ClearOneBusyReference() + { + GameRuntime? runtime; + lock (_gate) + { + if (_disposed) + return new(false, Message: "The plugin host is disposed."); + runtime = _runtime; + } + if (runtime is null) + return new(false, Message: "No game session is bound."); + + InventoryTransactionState transactions = + runtime.InventoryOwner.Transactions; + int before = transactions.BusyCount; + // VTank's /vt clearbusy calls ga.e(): decrement one reference and + // clamp at zero. CompleteUse has that exact counter transition without + // resetting an unrelated pending inventory request. + transactions.CompleteUse(0u); + return new( + Accepted: true, + PreviousCount: before, + CurrentCount: transactions.BusyCount, + Message: before == 0 + ? "The action busy count was already zero." + : "Cleared one action busy reference."); + } + + bool INetworkAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed; + } + } + + IReadOnlyList INetworkAutomation.CaptureClients() + { + // Production publishes from the host update thread at the bounded + // heartbeat cadence. The no-event path exists only for isolated/test + // hosts and publishes on demand. + if (_events is null) + PublishPeerSnapshot(); + return _peers.CaptureRemoteClients(); + } + + bool ILoginAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _runtime is not null; + } + } + + uint ILoginAutomation.NextLoginObjectId + { + get + { + lock (_gate) + return _runtime?.Session.NextLoginCharacterId ?? 0u; + } + } + + IReadOnlyList ILoginAutomation.CaptureRoster() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || _disposed) + return Array.Empty(); + + IRuntimeCharacterSelectionView view = runtime.Session.CharacterSelection; + RuntimeCharacterSelectionSnapshot snapshot = view.Snapshot; + var result = new PluginLoginCharacter[snapshot.RosterCount]; + for (int index = 0; index < result.Length; index++) + { + if (!view.TryGetAt(index, out RuntimeCharacterSelectionEntry entry)) + return Array.Empty(); + result[index] = new PluginLoginCharacter( + entry.CharacterId, + entry.Name, + entry.ActiveIndex, + entry.IsPendingDelete); + } + return result; + } + + bool ILoginAutomation.SetNextLogin(uint characterObjectId) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Session.TrySetNextLogin(characterObjectId) == true; + } + + bool ILoginAutomation.ClearNextLogin() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Session.ClearNextLogin() == true; + } + + PluginWorldTimeSnapshot IWorldTimeAutomation.Snapshot + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return default; + + double rawTicks = runtime.EnvironmentOwner.WorldTime.NowTicks; + DerethCalendar calendar = runtime.EnvironmentOwner.WorldTime.Calendar; + DerethDateTime.Calendar value = calendar.ToCalendar(rawTicks); + int hour = (int)value.Hour; + bool isDay = hour is >= 4 and < 12; + double shiftedTicks = Math.Max(0d, rawTicks) + + calendar.OriginOffsetTicks; + double gameTicks = shiftedTicks + + DerethDateTime.ZeroYear * DerethDateTime.YearTicks; + double withinHour = shiftedTicks + - Math.Floor(shiftedTicks / DerethDateTime.HourTicks) + * DerethDateTime.HourTicks; + double untilNight = isDay + ? ((12 - hour) * DerethDateTime.HourTicks - withinHour) / 60d + : 0d; + int dayHour = hour <= 4 ? hour + 16 : hour; + double untilDay = !isDay + ? ((20 - dayHour) * DerethDateTime.HourTicks - withinHour) / 60d + : 0d; + return new PluginWorldTimeSnapshot( + true, + gameTicks, + value.Year, + (int)value.Month, + value.Day, + hour, + FormatCalendarName(value.Month.ToString()), + FormatCalendarName(value.Hour.ToString()), + isDay, + Math.Max(0d, untilDay), + Math.Max(0d, untilNight)); + } + } + + private static string FormatCalendarName(string value) => value + .Replace("AndHalf", "-and-Half", StringComparison.Ordinal); /// Bind the surface to the runtime's gameplay owners. public void Bind( @@ -95,11 +352,17 @@ internal sealed class AppAutomationSurface DetachLocked(); _runtime = runtime; _communication = runtime.CommunicationOwner; + _communicationSubscription = + runtime.CommunicationOwner.Events.Subscribe(this); _character = character; _cast = cast; _spellbook = spellbook; spellbook.SpellbookChanged += OnSpellbookChanged; spellbook.EnchantmentsChanged += OnEnchantmentsChanged; + runtime.InventoryOwner.Transactions.RequestCompleted += + OnInventoryRequestCompleted; + runtime.InventoryOwner.Transactions.RequestFailed += + OnInventoryRequestFailed; } RebuildSpellbook(); @@ -118,16 +381,143 @@ internal sealed class AppAutomationSurface _skillNames = skillNames; } + /// Supply the immutable retail spell/component DAT catalog. + public void BindMagicCatalog(MagicCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + lock (_gate) + _magicCatalog = catalog; + } + + /// + /// Bind the current graphical session's typed command adapter. Runtime + /// gameplay owners are process-stable, but the live command bus is + /// session-scoped and is therefore replaced on every new session. + /// + public void BindSessionCommands(CurrentGameRuntimeAdapter commands) + { + ArgumentNullException.ThrowIfNull(commands); + lock (_gate) + _sessionCommands = commands; + } + + /// Supply retail creature-enum display names from portal.dat. + public void BindSpeciesNameResolver(Func resolver) + { + ArgumentNullException.ThrowIfNull(resolver); + lock (_gate) + _speciesName = resolver; + } + + public void BindPaletteColorResolver(IChargenPaletteColorSource resolver) + { + ArgumentNullException.ThrowIfNull(resolver); + lock (_gate) + _paletteColors = resolver; + } + + /// + /// Bind the graphical host's one ItemInteraction/AutoWield owner. Kept + /// separate from Runtime binding because retained interaction composition + /// finishes later during window load. + /// + public void BindEquipment( + Func equip, + Func isBusy) + { + ArgumentNullException.ThrowIfNull(equip); + ArgumentNullException.ThrowIfNull(isBusy); + lock (_gate) + { + _equip = equip; + _equipmentBusy = isBusy; + } + } + + public void BindItems( + Func useItem, + Func applyItem, + Func moveItem, + Func mergeItems, + Func dropItem, + Func giveItem, + Func pickupItem, + Func identifyItem, + Func, bool>? salvageItems = null, + Func? sellItem = null) + { + ArgumentNullException.ThrowIfNull(useItem); + ArgumentNullException.ThrowIfNull(applyItem); + ArgumentNullException.ThrowIfNull(moveItem); + ArgumentNullException.ThrowIfNull(mergeItems); + ArgumentNullException.ThrowIfNull(dropItem); + ArgumentNullException.ThrowIfNull(giveItem); + ArgumentNullException.ThrowIfNull(pickupItem); + ArgumentNullException.ThrowIfNull(identifyItem); + lock (_gate) + { + _useItem = useItem; + _applyItem = applyItem; + _moveItem = moveItem; + _mergeItems = mergeItems; + _dropItem = dropItem; + _giveItem = giveItem; + _pickupItem = pickupItem; + _identifyItem = identifyItem; + _salvageItems = salvageItems; + _sellItem = sellItem; + } + } + + public void BindGhostDeletion(Func dismissGhost) + { + ArgumentNullException.ThrowIfNull(dismissGhost); + lock (_gate) + _dismissGhost = dismissGhost; + } + + /// + /// Bind the process-stable Runtime collision world used by ordinary client + /// physics. The plugin receives only bounded detached query results. + /// + public void BindProjectileCollision(PhysicsEngine physics) + { + ArgumentNullException.ThrowIfNull(physics); + lock (_gate) + _projectilePhysics = physics; + } + + public void BindSelectionActions( + Func execute) + { + ArgumentNullException.ThrowIfNull(execute); + lock (_gate) + _selectionAction = execute; + } + public void Unbind() { lock (_gate) DetachLocked(); + _peers.Withdraw(); _knownSelfBuffs = Array.Empty(); + _knownAttackSpells = Array.Empty(); + _knownCombatSpells = Array.Empty(); _enchantments = Array.Empty(); } private void DetachLocked() { + if (_runtime is { } runtime) + { + runtime.InventoryOwner.Transactions.RequestFailed -= + OnInventoryRequestFailed; + runtime.InventoryOwner.Transactions.RequestCompleted -= + OnInventoryRequestCompleted; + } + _communicationSubscription?.Dispose(); + _communicationSubscription = null; + _chatMessages.Clear(); if (_spellbook is not null) { _spellbook.SpellbookChanged -= OnSpellbookChanged; @@ -138,8 +528,113 @@ internal sealed class AppAutomationSurface _cast = null; _runtime = null; _communication = null; + _dismissGhost = null; + _trackedEnchantments.Clear(); + _trackedCastCompletionRevision = 0; + _projectileDebugSamples = Array.Empty(); + _projectileDebugSamplesExpireAt = 0; } + private void OnPeerTick(double elapsedSeconds) + { + _peerHeartbeatRemaining -= Math.Max(0d, elapsedSeconds); + if (_peerHeartbeatRemaining > 0d) + return; + _peerHeartbeatRemaining = PeerHeartbeatSeconds; + PublishPeerSnapshot(); + } + + private void PublishPeerSnapshot() + { + if (!IsAvailable) + { + _peers.Withdraw(); + return; + } + + ICharacterInfo character = this; + PluginNavigationSnapshot navigation = + ((INavigationAutomation)this).Snapshot; + if (!navigation.IsAvailable || character.ObjectId == 0u) + { + _peers.Withdraw(); + return; + } + + try + { + _peers.Publish(new PluginNetworkClient( + _peers.ClientId, + character.ObjectId, + character.Name, + character.WorldName, + navigation.Position, + _peerTags, + character.CurrentHealth, + character.CurrentMana, + character.CurrentStamina, + character.MaxHealth, + character.MaxMana, + character.MaxStamina, + navigation.Position.HeadingDegrees)); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private void OnInventoryRequestCompleted(PendingInventoryRequest request) + { + lock (_gate) + { + if (_disposed) + return; + _lastInventoryCompletion = new PluginInventoryCompletion( + ++_inventoryCompletionRevision, + Project(request.Kind), + request.ItemId, + 0u); + } + } + + private void OnInventoryRequestFailed( + PendingInventoryRequest request, + uint weenieError) + { + lock (_gate) + { + if (_disposed) + return; + _lastInventoryCompletion = new PluginInventoryCompletion( + ++_inventoryCompletionRevision, + Project(request.Kind), + request.ItemId, + weenieError); + } + } + + private static PluginInventoryCommandKind Project(InventoryRequestKind kind) => + kind switch + { + InventoryRequestKind.Pickup => PluginInventoryCommandKind.Pickup, + InventoryRequestKind.PutInContainer => + PluginInventoryCommandKind.PutInContainer, + InventoryRequestKind.SplitToContainer => + PluginInventoryCommandKind.SplitToContainer, + InventoryRequestKind.Merge => PluginInventoryCommandKind.Merge, + InventoryRequestKind.Move => PluginInventoryCommandKind.Move, + InventoryRequestKind.DropToWorld => + PluginInventoryCommandKind.DropToWorld, + InventoryRequestKind.SplitToWorld => + PluginInventoryCommandKind.SplitToWorld, + InventoryRequestKind.Wield => PluginInventoryCommandKind.Wield, + InventoryRequestKind.Give => PluginInventoryCommandKind.Give, + _ => PluginInventoryCommandKind.Unknown, + }; + private void OnSpellbookChanged() => RebuildSpellbook(); private void OnEnchantmentsChanged() => RebuildEnchantments(); @@ -152,28 +647,64 @@ internal sealed class AppAutomationSurface if (spellbook is null) { _knownSelfBuffs = Array.Empty(); + _knownAttackSpells = Array.Empty(); + _knownCombatSpells = Array.Empty(); return; } - var built = new List(); + var buffs = new List(); + var attacks = new List(); + var combat = new List(); foreach (uint spellId in spellbook.LearnedSpells) { if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta)) continue; + if (meta.IsOffensive || meta.IsDebuff) + combat.Add(Project(meta)); // Beneficial and not a debuff is the whole filter. Requiring the // self-targeted flag here is what hid every bane: they are cast by // selecting yourself, and the flag only says "needs no selection". // Whether a given target accepts the spell is EvaluateGate's job. if (!meta.IsBeneficial || meta.IsDebuff || meta.IsUntargeted) + { + // MT1 intentionally projects direct attacks only. Rings, + // debuffs, streaks and harm/martyr policy are MT2, but they + // remain present in TryGet so later policy can inspect them. + if (meta.IsOffensive + && !meta.IsDebuff + && !meta.IsBeneficial + && !meta.IsSelfTargeted + && !meta.IsUntargeted + && meta.TargetMask != 0u) + { + attacks.Add(Project(meta)); + } continue; - built.Add(Project(meta)); + } + buffs.Add(Project(meta)); } - built.Sort(static (a, b) => + buffs.Sort(static (a, b) => a.Family != b.Family ? a.Family.CompareTo(b.Family) : b.Tier.CompareTo(a.Tier)); - _knownSelfBuffs = built; + attacks.Sort(static (a, b) => + { + int tier = b.Tier.CompareTo(a.Tier); + return tier != 0 + ? tier + : b.Difficulty.CompareTo(a.Difficulty); + }); + _knownSelfBuffs = buffs; + _knownAttackSpells = attacks; + combat.Sort(static (a, b) => + { + int tier = b.Tier.CompareTo(a.Tier); + return tier != 0 + ? tier + : string.CompareOrdinal(a.Name, b.Name); + }); + _knownCombatSpells = combat; } private void RebuildEnchantments() @@ -219,7 +750,23 @@ internal sealed class AppAutomationSurface SchoolSkillId(meta.SchoolId), meta.Description, meta.IsSelfTargeted, - meta.IsBeneficial); + meta.IsBeneficial) + { + IsDebuff = meta.IsDebuff, + IsOffensive = meta.IsOffensive, + IsFellowship = meta.IsFellowship, + IsUntargeted = meta.IsUntargeted, + RequiresTurnTo = meta.Family is not (>= 222u and <= 235u) + && !meta.IsUntargeted, + IsProjectile = meta.IsProjectile, + IsDamageOverTime = (meta.Flags & (uint)SpellFlags.DamageOverTime) != 0, + RawFlags = meta.Flags, + SpellType = meta.SpellType, + TargetMask = meta.TargetMask, + BaseRangeConstant = meta.BaseRangeConstant, + BaseRangeModifier = meta.BaseRangeModifier, + FormulaComponentIds = meta.FormulaComponents, + }; /// /// Magic school to the SKILL id that governs it. MagicSchool is @@ -240,6 +787,95 @@ internal sealed class AppAutomationSurface // ── ICharacterInfo ──────────────────────────────────────────────────── public bool IsInWorld => IsAvailable; + public string Name + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return string.Empty; + uint playerId = runtime.PlayerIdentity.ServerGuid; + return runtime.InventoryOwner.Objects.Get(playerId)?.Name + ?? string.Empty; + } + } + + public string WorldName + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.CharacterSelection.Snapshot.WorldName ?? string.Empty; + } + } + + public string AccountName + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.CharacterSelection.Snapshot.AccountName ?? string.Empty; + } + } + + public int CharacterIndex + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null + || !runtime.CharacterSelection.TryGet( + runtime.PlayerIdentity.ServerGuid, + out RuntimeCharacterSelectionEntry character)) + { + return -1; + } + return character.ActiveIndex; + } + } + + public int Level + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return 0; + return runtime.InventoryOwner.Objects + .Get(runtime.PlayerIdentity.ServerGuid)? + .Properties.GetInt((uint)PropertyInt.Level) ?? 0; + } + } + + public int MainPackFreeSlots + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return 0; + uint playerId = runtime.PlayerIdentity.ServerGuid; + int occupied = runtime.InventoryOwner.Objects.Objects.Count(item => + item.ContainerId == playerId + && ClassifyObject(item) is not ( + PluginObjectClass.Container or PluginObjectClass.Foci) + && item.CurrentlyEquippedLocation == 0); + return Math.Max(0, 102 - occupied); + } + } + public uint ObjectId { get @@ -257,6 +893,20 @@ internal sealed class AppAutomationSurface public uint MaxStamina => Vital(LocalPlayerState.VitalKind.Stamina).Maximum; public uint CurrentMana => Vital(LocalPlayerState.VitalKind.Mana).Current; public uint MaxMana => Vital(LocalPlayerState.VitalKind.Mana).Maximum; + public int SummoningMastery + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return 0; + uint playerId = runtime.PlayerIdentity.ServerGuid; + return runtime.InventoryOwner.Objects.Get(playerId)?.Properties.GetInt( + (uint)PropertyInt.SummoningMastery) ?? 0; + } + } private (uint Current, uint Maximum) Vital(LocalPlayerState.VitalKind kind) { @@ -325,8 +975,16 @@ internal sealed class AppAutomationSurface skill = default; return false; } + uint baseLevel = snapshot.CurrentLevel; + uint currentLevel = checked((uint)Math.Max( + 0, + character.LocalPlayer.GetEffectiveSkill(skillId) + ?? checked((int)baseLevel))); skill = new PluginSkillInfo( - skillId, name, Training(snapshot.Status), snapshot.CurrentLevel); + skillId, name, Training(snapshot.Status), currentLevel) + { + Base = baseLevel, + }; return true; } @@ -357,8 +1015,18 @@ internal sealed class AppAutomationSurface for (int kind = 0; kind < AttributeNames.Length; kind++) { if (character.View.TryGetAttribute(kind, out var attribute)) + { + uint effective = checked((uint)Math.Max( + 0, + character.LocalPlayer.GetEffectiveAttribute( + (LocalPlayerState.AttributeKind)kind) + ?? checked((int)attribute.Current))); built.Add(new PluginAttributeInfo( - kind, AttributeNames[kind], attribute.Current)); + kind, AttributeNames[kind], effective) + { + Base = attribute.Current, + }); + } } return built; } @@ -366,6 +1034,16 @@ internal sealed class AppAutomationSurface // ── ISpellCatalog ───────────────────────────────────────────────────── public IReadOnlyList KnownSelfBuffs => _knownSelfBuffs; + public IReadOnlyList KnownAttackSpells => _knownAttackSpells; + public IReadOnlyList KnownCombatSpells => _knownCombatSpells; + + public bool IsKnown(uint spellId) + { + Spellbook? spellbook; + lock (_gate) + spellbook = _spellbook; + return spellbook?.LearnedSpells.Contains(spellId) == true; + } public bool TryGet(uint spellId, out PluginSpellInfo info) { @@ -382,7 +1060,93 @@ internal sealed class AppAutomationSurface return false; } + public bool TryGetComponent(uint componentId, out PluginSpellComponentInfo info) + { + MagicCatalog catalog; + lock (_gate) + catalog = _magicCatalog; + if (catalog.TryGetComponentBySpellComponentId( + componentId, + out SpellComponentDescriptor descriptor)) + { + info = new PluginSpellComponentInfo( + descriptor.SpellComponentId, + descriptor.WeenieClassId, + descriptor.Name, + descriptor.BurnRate, + descriptor.GestureId, + descriptor.GestureSpeed, + descriptor.IconId, + descriptor.Category, + descriptor.Type, + descriptor.Word); + return true; + } + info = default; + return false; + } + // ── IPluginChat ─────────────────────────────────────────────────────── + public IReadOnlyList CaptureMessages(ulong afterSequence) + { + lock (_gate) + { + if (_chatMessages.Count == 0) + return Array.Empty(); + var result = new List(); + foreach (PluginChatMessage message in _chatMessages) + { + if (message.Sequence > afterSequence) + result.Add(message); + } + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + } + + public double GetCooldownRemaining(uint cooldownId) + { + Spellbook? spellbook; + GameRuntime? runtime; + lock (_gate) + { + spellbook = _spellbook; + runtime = _runtime; + } + if (spellbook is null || runtime is null || cooldownId == 0u) + return 0d; + return spellbook.OnCooldown( + cooldownId, + runtime.Clock.SimulationTimeSeconds, + out double remaining) + ? Math.Max(0d, remaining) + : 0d; + } + + public void OnChat(in RuntimeCommunicationEvent delta) + { + lock (_gate) + { + if (_disposed || _communication is null) + return; + RuntimeChatEntry entry = delta.Entry; + _chatMessages.Add(new PluginChatMessage( + ++_pluginChatSequence, + entry.SenderGuid, + entry.Kind, + entry.Sender, + entry.Text, + entry.ChannelName)); + if (_chatMessages.Count > MaximumPluginChatMessages) + { + _chatMessages.RemoveRange( + 0, + _chatMessages.Count - MaximumPluginChatMessages); + } + } + } + /// /// Routed to retail's ClientLocal log type (0x1A) — the channel the client /// uses for its own notices. Nothing reaches the server, so a plugin cannot @@ -398,6 +1162,842 @@ internal sealed class AppAutomationSurface communication?.AddText(text, RetailLogTextType.ClientLocal); } + public bool Submit(string text) + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + return commands?.SubmitChatText(text) == true; + } + + bool ISelectionAutomation.Execute(PluginSelectionAction action) + { + Func? execute; + lock (_gate) + execute = _disposed ? null : _selectionAction; + return execute?.Invoke(action) == true; + } + + // ── IProjectileAutomation ─────────────────────────────────────────── + bool IProjectileAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _projectilePhysics is not null && IsAvailable; + } + } + + PluginProjectilePathResult IProjectileAutomation.EvaluatePath( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => EvaluateProjectilePathRequest( + targetObjectId, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks, + captureDiagnostics: false); + + PluginProjectilePathResult IProjectileAutomation.EvaluatePathWithDiagnostics( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => EvaluateProjectilePathRequest( + targetObjectId, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks, + captureDiagnostics: true); + + void IProjectileAutomation.ShowDebugSamples( + IReadOnlyList samples) + { + ArgumentNullException.ThrowIfNull(samples); + const int maximumMarkers = 4096; + var detached = new List( + Math.Min(samples.Count, maximumMarkers)); + for (int index = 0; index < samples.Count && index < maximumMarkers; index++) + { + PluginProjectileDebugSample sample = samples[index]; + if (!float.IsFinite(sample.WorldPosition.X) + || !float.IsFinite(sample.WorldPosition.Y) + || !float.IsFinite(sample.WorldPosition.Z) + || !float.IsFinite(sample.Radius) + || sample.Radius <= 0f) + { + continue; + } + detached.Add(sample); + } + lock (_gate) + { + if (_disposed) + return; + _projectileDebugSamples = detached.Count == 0 + ? Array.Empty() + : detached.ToArray(); + // VTank rebuilds these shapes on every collision pass. A short + // grace interval keeps them visible between automation ticks + // without turning one query into persistent world state. + _projectileDebugSamplesExpireAt = Environment.TickCount64 + 350; + } + } + + internal IReadOnlyList + CaptureProjectileDebugSamples() + { + lock (_gate) + { + if (_disposed + || Environment.TickCount64 > _projectileDebugSamplesExpireAt) + { + return Array.Empty(); + } + return _projectileDebugSamples; + } + } + + private PluginProjectilePathResult EvaluateProjectilePathRequest( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks, + bool captureDiagnostics) + { + GameRuntime? runtime; + PhysicsEngine? physics; + lock (_gate) + { + runtime = _runtime; + physics = _projectilePhysics; + } + if (runtime is null || physics is null || !IsAvailable) + return new(PluginProjectilePathStatus.Unavailable); + if (targetObjectId == 0u + || !float.IsFinite(projectileRadius) + || projectileRadius <= 0f + || !float.IsFinite(stepDistance) + || stepDistance <= 0f + || maximumCollisionChecks <= 0) + { + return new(PluginProjectilePathStatus.InvalidTarget); + } + + uint localId = runtime.PlayerIdentity.ServerGuid; + if (!runtime.EntityObjects.Entities.TryGetActive( + localId, + out RuntimeEntityRecord local) + || !runtime.EntityObjects.Entities.TryGetActive( + targetObjectId, + out RuntimeEntityRecord target) + || local.PhysicsBody is not { } localBody + || target.PhysicsBody is not { } targetBody + || localBody.CellPosition.ObjCellId == 0u) + { + return new(PluginProjectilePathStatus.InvalidTarget); + } + + try + { + return EvaluateProjectilePath( + physics, + localId, + localBody, + targetObjectId, + targetBody, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks, + captureDiagnostics); + } + catch (Exception error) + { + return new( + PluginProjectilePathStatus.Error, + Notice: error.GetBaseException().Message); + } + } + + private static PluginProjectilePathResult EvaluateProjectilePath( + PhysicsEngine physics, + uint localObjectId, + PhysicsBody local, + uint targetObjectId, + PhysicsBody target, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float radius, + float stepDistance, + int maximumChecks, + bool captureDiagnostics) + { + System.Numerics.Vector3 baseDelta = target.Position - local.Position; + var horizontal = new System.Numerics.Vector2(baseDelta.X, baseDelta.Y); + float horizontalDistance = horizontal.Length(); + if (!float.IsFinite(horizontalDistance) + || horizontalDistance <= PhysicsGlobals.EPSILON) + { + return new(PluginProjectilePathStatus.InvalidTarget); + } + + System.Numerics.Vector2 direction = horizontal / horizontalDistance; + float sourceForward = kind switch + { + PluginProjectilePathKind.Arc => 0.44f, + PluginProjectilePathKind.Missile => 0.61f, + _ => 0.66f, + }; + float sourceHeight = kind == PluginProjectilePathKind.Arc ? 1.8f : 1.2f; + float targetHeightMeters = targetHeight switch + { + PluginAttackHeight.Low => 0.3f, + PluginAttackHeight.High => 1.5f, + _ => 0.9f, + }; + var current = local.Position + new System.Numerics.Vector3( + direction.X * sourceForward, + direction.Y * sourceForward, + sourceHeight); + var destination = target.Position + new System.Numerics.Vector3( + 0f, + 0f, + targetHeightMeters); + System.Numerics.Vector3 delta = destination - current; + horizontal = new System.Numerics.Vector2(delta.X, delta.Y); + horizontalDistance = horizontal.Length(); + if (horizontalDistance <= PhysicsGlobals.EPSILON) + return new(PluginProjectilePathStatus.Clear); + direction = horizontal / horizontalDistance; + + float speed = kind switch + { + PluginProjectilePathKind.Arc => 37.5185f, + PluginProjectilePathKind.Missile => 46f, + _ => 100f, + }; + float totalTime = horizontalDistance / speed; + float verticalSpeed = kind == PluginProjectilePathKind.Straight + ? delta.Z / totalTime + : (delta.Z + 4.9f * totalTime * totalTime) / totalTime; + var velocity = new System.Numerics.Vector3( + direction.X * speed, + direction.Y * speed, + verticalSpeed); + float elapsed = 0f; + uint cellId = local.CellPosition.ObjCellId; + var probeBody = new PhysicsBody + { + State = PhysicsStateFlags.Missile + | PhysicsStateFlags.Inelastic + | PhysicsStateFlags.ReportCollisions, + }; + List? debugSamples = captureDiagnostics + ? new List( + Math.Min(maximumChecks, 512)) + : null; + + for (int check = 1; check <= maximumChecks; check++) + { + float remaining = MathF.Max(0f, totalTime - elapsed); + if (remaining <= PhysicsGlobals.EPSILON) + { + return WithProjectileDebugSamples( + new(PluginProjectilePathStatus.Clear, check - 1), + debugSamples); + } + float velocityMagnitude = velocity.Length(); + if (!float.IsFinite(velocityMagnitude) + || velocityMagnitude <= PhysicsGlobals.EPSILON) + { + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.Error, + check - 1, + Notice: "The projectile trajectory became invalid."), + debugSamples); + } + float quantum = MathF.Min(remaining, stepDistance / velocityMagnitude); + System.Numerics.Vector3 next = current + velocity * quantum; + if (quantum >= remaining - PhysicsGlobals.EPSILON) + next = destination; + + ResolveResult resolved = physics.ResolveWithTransition( + current, + next, + cellId, + radius, + sphereHeight: 0f, + stepUpHeight: 0f, + stepDownHeight: 0f, + isOnGround: false, + body: probeBody, + moverFlags: ObjectInfoState.PathClipped, + movingEntityId: localObjectId, + localSphereOrigin: System.Numerics.Vector3.Zero, + designatedTargetId: targetObjectId); + float requestedDistance = System.Numerics.Vector3.Distance(current, next); + float deliveredDistance = System.Numerics.Vector3.Distance( + current, + resolved.Position); + bool stopped = !resolved.Ok + || resolved.CollidedWithEnvironment + || resolved.LastCollidedObjectId != 0u + || resolved.CollisionNormalValid + || deliveredDistance + 0.01f < requestedDistance; + bool targetHit = resolved.LastCollidedObjectId == targetObjectId; + debugSamples?.Add(new PluginProjectileDebugSample( + resolved.Position, + targetHit || !stopped, + radius)); + if (targetHit) + { + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.Clear, + check, + targetObjectId), + debugSamples); + } + if (stopped) + { + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.Blocked, + check, + resolved.LastCollidedObjectId), + debugSamples); + } + + current = resolved.Position; + cellId = resolved.CellId; + elapsed += quantum; + if (kind != PluginProjectilePathKind.Straight) + velocity.Z -= 9.8f * quantum; + } + + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.BudgetExceeded, + maximumChecks, + Notice: "The projectile collision-check budget was exhausted."), + debugSamples); + } + + private static PluginProjectilePathResult WithProjectileDebugSamples( + PluginProjectilePathResult result, + List? samples) => samples is null + ? result + : result with { DebugSamples = samples.ToArray() }; + + // ── INavigationAutomation ───────────────────────────────────────────── + PluginNavigationSnapshot INavigationAutomation.Snapshot + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return default; + + RuntimeMovementSnapshot movement = runtime.Movement.Snapshot; + if (!movement.HasController) + return default; + RuntimePortalSnapshot portal = runtime.Portal.Snapshot; + PluginNavigationPosition livePosition = + ProjectNavigationPosition(movement.Position); + PluginNavigationPosition confirmedPosition = livePosition; + ulong confirmedRevision = 0UL; + if (runtime.EntityObjects.Entities.TryGetActive( + runtime.PlayerIdentity.ServerGuid, + out RuntimeEntityRecord localRecord) + && ConvertPosition(localRecord.Snapshot.Position) is { } accepted) + { + confirmedPosition = ProjectNavigationPosition(accepted); + confirmedRevision = localRecord.PositionAuthorityVersion; + } + return new PluginNavigationSnapshot( + IsAvailable: true, + IsPortalSpace: portal.Kind != RuntimePortalKind.None + && !portal.Completed + && !portal.Cancelled, + LocalObjectId: runtime.PlayerIdentity.ServerGuid, + Position: livePosition, + IsMoving: movement.Velocity.LengthSquared() > 0.0001f + || movement.HasCommandInput, + IsAirborne: movement.IsAirborne) + { + ConfirmedPosition = confirmedPosition, + ConfirmedPositionRevision = confirmedRevision, + }; + } + } + + public bool TryGetObject(uint objectId, out PluginNavigationObject value) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable || objectId == 0u) + { + value = default; + return false; + } + + RuntimeMovementSnapshot movement = runtime.Movement.Snapshot; + if (objectId == runtime.PlayerIdentity.ServerGuid) + { + value = new PluginNavigationObject( + objectId, + runtime.InventoryOwner.Objects.Get(objectId)?.Name + ?? string.Empty, + ProjectNavigationPosition(movement.Position)); + return movement.HasController; + } + + if (!runtime.EntityObjects.Entities.TryGetActive( + objectId, + out RuntimeEntityRecord record)) + { + value = default; + return false; + } + + Position? position = record.PhysicsBody?.CellPosition + ?? ConvertPosition(record.Snapshot.Position); + if (position is not { } current) + { + value = default; + return false; + } + value = new PluginNavigationObject( + objectId, + runtime.InventoryOwner.Objects.Get(objectId)?.Name + ?? record.Snapshot.Name + ?? $"0x{objectId:X8}", + ProjectNavigationPosition(current)); + value = EnrichNavigationObject( + value, + runtime.InventoryOwner.Objects.Get(objectId)); + return true; + } + + public bool TryFindObject( + string name, + in PluginNavigationPosition near, + double maximumDistanceMeters, + out PluginNavigationObject value) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null + || !IsAvailable + || string.IsNullOrWhiteSpace(name) + || !double.IsFinite(maximumDistanceMeters) + || maximumDistanceMeters < 0d) + { + value = default; + return false; + } + + double nearestDistance = maximumDistanceMeters; + PluginNavigationObject nearest = default; + bool found = false; + foreach (RuntimeEntityRecord record in runtime.EntityObjects.Entities.ActiveRecords) + { + uint objectId = record.ServerGuid; + string candidateName = runtime.InventoryOwner.Objects.Get(objectId)?.Name + ?? record.Snapshot.Name + ?? string.Empty; + if (!candidateName.Equals(name, StringComparison.OrdinalIgnoreCase)) + continue; + + Position? source = record.PhysicsBody?.CellPosition + ?? ConvertPosition(record.Snapshot.Position); + if (source is not { } position) + continue; + PluginNavigationPosition candidate = ProjectNavigationPosition(position); + double distance = near.HorizontalDistanceMeters(candidate); + if (distance > nearestDistance) + continue; + + nearestDistance = distance; + nearest = EnrichNavigationObject( + new PluginNavigationObject(objectId, candidateName, candidate), + runtime.InventoryOwner.Objects.Get(objectId)); + found = true; + } + + value = nearest; + return found; + } + + public IReadOnlyList CaptureObjects() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + var result = new List(); + foreach (RuntimeEntityRecord record in runtime.EntityObjects.Entities.ActiveRecords) + { + Position? source = record.PhysicsBody?.CellPosition + ?? ConvertPosition(record.Snapshot.Position); + if (source is not { } position) + continue; + ClientObject? item = runtime.InventoryOwner.Objects.Get(record.ServerGuid); + string name = item?.Name + ?? record.Snapshot.Name + ?? $"0x{record.ServerGuid:X8}"; + result.Add(EnrichNavigationObject( + new PluginNavigationObject( + record.ServerGuid, + name, + ProjectNavigationPosition(position)), + item)); + } + result.Sort(static (left, right) => left.ObjectId.CompareTo(right.ObjectId)); + return result; + } + + public PluginNavigationCommandStatus SetMovementIntent( + in PluginMovementIntent intent) + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + if (commands is null || !IsAvailable) + return PluginNavigationCommandStatus.Unavailable; + RuntimeCommandResult result = commands.MovementCommands.SetIntent( + commands.Generation, + new MovementInput( + intent.Forward, + intent.Backward, + intent.StrafeLeft, + intent.StrafeRight, + intent.TurnLeft, + intent.TurnRight, + intent.Run, + MouseDeltaX: 0f, + intent.Jump)); + return result.Status == RuntimeCommandStatus.Accepted + ? PluginNavigationCommandStatus.Accepted + : PluginNavigationCommandStatus.Rejected; + } + + public PluginNavigationCommandStatus ClearMovementIntent() + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + if (commands is null || !IsAvailable) + return PluginNavigationCommandStatus.Unavailable; + RuntimeCommandResult result = commands.MovementCommands.ClearIntent( + commands.Generation); + return result.Status == RuntimeCommandStatus.Accepted + ? PluginNavigationCommandStatus.Accepted + : PluginNavigationCommandStatus.Rejected; + } + + internal static PluginNavigationPosition ProjectNavigationPosition( + Position position) + { + uint cellId = position.ObjCellId; + uint blockX = (cellId >> 24) & 0xFFu; + uint blockY = (cellId >> 16) & 0xFFu; + System.Numerics.Vector3 local = position.Frame.Origin; + return new PluginNavigationPosition( + cellId, + (((double)blockX - 127d) * 192d + local.X - 84d) / 240d, + (((double)blockY - 127d) * 192d + local.Y - 84d) / 240d, + local.Z / 240d, + MoveToMath.GetHeading(position.Frame.Orientation), + (cellId & 0xFFFFu) is >= 1u and <= 0x40u); + } + + // ── IWorldObjectAutomation ──────────────────────────────────────────── + bool IWorldObjectAutomation.IsAvailable => IsAvailable; + + uint IWorldObjectAutomation.OpenContainerObjectId + { + get + { + lock (_gate) + return _runtime?.InventoryOwner.ExternalContainers + .CurrentContainerId ?? 0u; + } + } + + IReadOnlyList IWorldObjectAutomation.CaptureObjects() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + var result = new List(); + var captured = new HashSet(); + foreach (RuntimeEntityRecord record in + runtime.EntityObjects.Entities.ActiveRecords.ToArray()) + { + ClientObject? item = objects.Get(record.ServerGuid); + result.Add(ProjectWorldObject(runtime, record, item, playerId)); + captured.Add(record.ServerGuid); + } + foreach (ClientObject item in objects.Objects) + { + if (!captured.Add(item.ObjectId)) + continue; + result.Add(ProjectWorldObject(runtime, null, item, playerId)); + } + result.Sort(static (left, right) => left.ObjectId.CompareTo(right.ObjectId)); + return result; + } + + bool IWorldObjectAutomation.TryGet( + uint objectId, + out PluginWorldObject value) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable || objectId == 0u) + { + value = default; + return false; + } + + runtime.EntityObjects.Entities.TryGetActive( + objectId, + out RuntimeEntityRecord? record); + ClientObject? item = runtime.InventoryOwner.Objects.Get(objectId); + if (record is null && item is null) + { + value = default; + return false; + } + value = ProjectWorldObject( + runtime, + record, + item, + runtime.PlayerIdentity.ServerGuid); + return true; + } + + bool IWorldObjectAutomation.TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + ClientObject? item = runtime?.InventoryOwner.Objects.Get(objectId); + if (runtime is null || !IsAvailable || item is null) + { + properties = default; + return false; + } + properties = CaptureProperties(item.Properties); + return true; + } + + PluginItemCommandResult IWorldObjectAutomation.Identify(uint objectId) => + ((ILootAutomation)this).Identify(objectId); + + private PluginWorldObject ProjectWorldObject( + GameRuntime runtime, + RuntimeEntityRecord? record, + ClientObject? item, + uint playerId) + { + uint objectId = record?.ServerGuid ?? item!.ObjectId; + Position? source = record?.PhysicsBody?.CellPosition + ?? (record is null ? null : ConvertPosition(record.Snapshot.Position)); + bool owned = item is not null + && IsPlayerOwned(item, playerId, runtime.InventoryOwner.Objects); + IReadOnlyList activeSpells = objectId == playerId + ? _enchantments.Select(static enchantment => enchantment.SpellId).ToArray() + : Array.Empty(); + uint publicFlags = item?.PublicWeenieBitfield ?? 0u; + return new PluginWorldObject( + objectId, + item?.WeenieClassId ?? 0u, + item?.Name ?? record?.Snapshot.Name ?? $"0x{objectId:X8}", + ClassifyObject(item), + (uint)(item?.Type ?? ItemType.None), + item?.ContainerId ?? 0u, + item?.WielderId ?? 0u) + { + IsOwned = owned, + IsLandscape = source is not null + && !owned + && (item?.ContainerId ?? 0u) == 0u + && (item?.WielderId ?? 0u) == 0u, + HasPosition = source is not null, + Position = source is { } position + ? ProjectNavigationPosition(position) + : default, + HasAppraisalData = item is not null && HasPropertyData(item.Properties), + LastIdTime = item?.LastAppraisalTimeMs ?? 0, + IsDoorOpen = (publicFlags & (uint)PublicWeenieFlags.Door) != 0u + && (item?.Properties.GetBool((uint)PropertyBool.Open) ?? false), + StackSize = Math.Max(1, item?.StackSize ?? 1), + ItemsCapacity = item?.ItemsCapacity ?? 0, + ContainersCapacity = item?.ContainersCapacity ?? 0, + SpellIds = item?.AppraisedSpellIds.Count > 0 + ? item.AppraisedSpellIds.ToArray() + : Array.Empty(), + ActiveSpellIds = activeSpells, + }; + } + + private static bool HasPropertyData(PropertyBundle properties) => + properties.Ints.Count != 0 + || properties.Int64s.Count != 0 + || properties.Bools.Count != 0 + || properties.Floats.Count != 0 + || properties.Strings.Count != 0 + || properties.DataIds.Count != 0 + || properties.InstanceIds.Count != 0; + + /// + /// Exact Virindi/Decal ObjectClass priority from VTank's fu.a(). + /// PublicWeenieDesc flags override ItemType, then writable and creature + /// refinements distinguish books/scrolls/NPCs/combat pets. + /// + internal static PluginObjectClass ClassifyObject(ClientObject? item) + { + if (item is null) + return PluginObjectClass.Unknown; + uint type = (uint)item.Type; + uint flags = item.PublicWeenieBitfield ?? 0u; + PluginObjectClass result = type switch + { + _ when (type & 0x00000001u) != 0u => PluginObjectClass.MeleeWeapon, + _ when (type & 0x00000002u) != 0u => PluginObjectClass.Armor, + _ when (type & 0x00000004u) != 0u => PluginObjectClass.Clothing, + _ when (type & 0x00000008u) != 0u => PluginObjectClass.Jewelry, + _ when (type & 0x00000010u) != 0u => PluginObjectClass.Monster, + _ when (type & 0x00000020u) != 0u => PluginObjectClass.Food, + _ when (type & 0x00000040u) != 0u => PluginObjectClass.Money, + _ when (type & 0x00000080u) != 0u => PluginObjectClass.Misc, + _ when (type & 0x00000100u) != 0u => PluginObjectClass.MissileWeapon, + _ when (type & 0x00000200u) != 0u => PluginObjectClass.Container, + _ when (type & 0x00000400u) != 0u => PluginObjectClass.Bundle, + _ when (type & 0x00000800u) != 0u => PluginObjectClass.Gem, + _ when (type & 0x00001000u) != 0u => PluginObjectClass.SpellComponent, + _ when (type & 0x00004000u) != 0u => PluginObjectClass.Key, + _ when (type & 0x00008000u) != 0u => PluginObjectClass.WandStaffOrb, + _ when (type & 0x00010000u) != 0u => PluginObjectClass.Portal, + _ when (type & 0x00040000u) != 0u => PluginObjectClass.TradeNote, + _ when (type & 0x00080000u) != 0u => PluginObjectClass.ManaStone, + _ when (type & 0x00100000u) != 0u => PluginObjectClass.Services, + _ when (type & 0x00200000u) != 0u => PluginObjectClass.Plant, + _ when (type & 0x00400000u) != 0u => PluginObjectClass.BaseCooking, + _ when (type & 0x00800000u) != 0u => PluginObjectClass.BaseAlchemy, + _ when (type & 0x01000000u) != 0u => PluginObjectClass.BaseFletching, + _ when (type & 0x02000000u) != 0u => PluginObjectClass.CraftedCooking, + _ when (type & 0x04000000u) != 0u => PluginObjectClass.CraftedAlchemy, + _ when (type & 0x08000000u) != 0u => PluginObjectClass.CraftedFletching, + _ when (type & 0x20000000u) != 0u => PluginObjectClass.Ust, + _ when (type & 0x40000000u) != 0u => PluginObjectClass.Salvage, + _ => PluginObjectClass.Unknown, + }; + + result = flags switch + { + _ when (flags & 0x00000008u) != 0u => PluginObjectClass.Player, + _ when (flags & 0x00000200u) != 0u => PluginObjectClass.Vendor, + _ when (flags & 0x00001000u) != 0u => PluginObjectClass.Door, + _ when (flags & 0x00002000u) != 0u => PluginObjectClass.Corpse, + _ when (flags & 0x00004000u) != 0u => PluginObjectClass.Lifestone, + _ when (flags & 0x00008000u) != 0u => PluginObjectClass.Food, + _ when (flags & 0x00010000u) != 0u => PluginObjectClass.HealingKit, + _ when (flags & 0x00020000u) != 0u => PluginObjectClass.Lockpick, + _ when (flags & 0x00040000u) != 0u => PluginObjectClass.Portal, + _ when (flags & 0x00800000u) != 0u => PluginObjectClass.Foci, + _ when (flags & 0x00000001u) != 0u => PluginObjectClass.Container, + _ => result, + }; + + if ((type & 0x00002000u) != 0u && result == PluginObjectClass.Unknown) + { + result = (flags & 0x00000002u) != 0u + ? PluginObjectClass.Journal + : (flags & 0x00000004u) != 0u + ? PluginObjectClass.Sign + : (flags & 0x0000000Fu) != 0u + ? PluginObjectClass.Book + : result; + } + if ((type & 0x00002000u) != 0u && item.SpellId is > 0u) + result = PluginObjectClass.Scroll; + if (result == PluginObjectClass.Monster && (flags & 0x10u) == 0u) + result = PluginObjectClass.Npc; + if (result == PluginObjectClass.Monster && (flags & 0x04000000u) != 0u) + result = PluginObjectClass.CombatPet; + return result; + } + + private static PluginNavigationObject EnrichNavigationObject( + in PluginNavigationObject value, + ClientObject? item) + { + if (item is null) + return value; + bool hasOpen = item.Properties.Bools.TryGetValue( + (uint)PropertyBool.Open, + out bool isOpen); + bool hasLocked = item.Properties.Bools.TryGetValue( + (uint)PropertyBool.Locked, + out bool isLocked); + return value with + { + IsDoor = ((PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u) + & PublicWeenieFlags.Door) != 0, + IsOpen = hasOpen && isOpen, + IsLocked = hasLocked && isLocked, + HasLockState = hasOpen || hasLocked, + LockDifficulty = item.Properties.GetInt( + (uint)PropertyInt.ResistLockpick), + }; + } + + private static Position? ConvertPosition( + AcDream.Core.Net.Messages.CreateObject.ServerPosition? position) => + position is not { } value + ? null + : new Position( + value.LandblockId, + new System.Numerics.Vector3( + value.PositionX, + value.PositionY, + value.PositionZ), + new System.Numerics.Quaternion( + value.RotationX, + value.RotationY, + value.RotationZ, + value.RotationW)); + // ── IMagicCommands ──────────────────────────────────────────────────── /// /// True while an action the server has not acknowledged is in flight. @@ -456,16 +2056,1693 @@ internal sealed class AppAutomationSurface return cast is not null && cast.Cast(spellId) == CastRequestResult.Sent; } + public PluginCastCompletion LastCompletion + { + get + { + ObserveSuccessfulLocalCast(); + RuntimeSpellCastState? cast; + lock (_gate) + cast = _cast; + RuntimeSpellCastCompletion completion = + cast?.LastCompletion ?? default; + return new PluginCastCompletion( + completion.Revision, + completion.SpellId, + completion.TargetObjectId, + completion.WeenieError); + } + } + + public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) + { + if (!SelectExplicitTarget(targetObjectId)) + return PluginCastGate.Refused; + return EvaluateGate(spellId); + } + + public bool Cast(uint spellId, uint targetObjectId) => + SelectExplicitTarget(targetObjectId) && Cast(spellId); + + // ── IEquipmentAutomation ───────────────────────────────────────────── + bool IEquipmentAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _equip is not null && IsAvailable; + } + } + + bool IEquipmentAutomation.IsBusy + { + get + { + Func? busy; + lock (_gate) + busy = _equipmentBusy; + return busy?.Invoke() == true; + } + } + + public IReadOnlyList CaptureOwnedEquipment() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (playerId == 0u) + return Array.Empty(); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + var built = new List(); + foreach (ClientObject item in objects.Objects) + { + if (item.ValidLocations == EquipMask.None + || !IsPlayerOwned(item, playerId, objects)) + { + continue; + } + built.Add(new PluginEquipmentItem( + item.ObjectId, + item.GetAppropriateName(), + (uint)item.Type, + (uint)item.ValidLocations, + (uint)item.CurrentlyEquippedLocation, + item.ContainerId, + item.WielderId, + item.CombatUse ?? 0, + item.Properties.GetInt((uint)PropertyInt.DamageType), + item.Properties.GetInt((uint)PropertyInt.WeaponSkill), + item.Properties.GetInt((uint)PropertyInt.Damage), + item.Properties.GetFloat((uint)PropertyFloat.DamageVariance)) + { + AmmoType = item.AmmoType ?? (uint)Math.Max( + 0, + item.Properties.GetInt((uint)PropertyInt.AmmoType)), + StackSize = Math.Max(1, item.StackSize), + WeaponType = item.Properties.GetInt( + (uint)PropertyInt.WeaponType), + }); + } + built.Sort(static (left, right) => + { + int equipped = right.IsEquipped.CompareTo(left.IsEquipped); + if (equipped != 0) + return equipped; + int name = string.CompareOrdinal(left.Name, right.Name); + return name != 0 + ? name + : left.ObjectId.CompareTo(right.ObjectId); + }); + return built; + } + + public PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) + { + Func? equip; + Func? busy; + GameRuntime? runtime; + lock (_gate) + { + equip = _equip; + busy = _equipmentBusy; + runtime = _runtime; + } + if (equip is null || runtime is null || !IsAvailable) + return new(PluginEquipmentCommandStatus.Unavailable); + if (objectId == 0u + || runtime.InventoryOwner.Objects.Get(objectId) is not { } item + || item.ValidLocations == EquipMask.None) + { + return new(PluginEquipmentCommandStatus.InvalidItem); + } + if (item.CurrentlyEquippedLocation != EquipMask.None + && (requestedLocation == 0u + || ((uint)item.CurrentlyEquippedLocation & requestedLocation) + == requestedLocation)) + { + return new(PluginEquipmentCommandStatus.AlreadyEquipped); + } + if (busy?.Invoke() == true) + return new(PluginEquipmentCommandStatus.Busy); + return equip(objectId, requestedLocation) + ? new(PluginEquipmentCommandStatus.Started) + : new(PluginEquipmentCommandStatus.Refused); + } + + // ── IItemAutomation ────────────────────────────────────────────────── + bool IItemAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _useItem is not null + && _applyItem is not null && IsAvailable; + } + } + + bool IItemAutomation.IsBusy + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime is not null + && !runtime.InventoryOwner.Transactions.CanBeginRequest; + } + } + + int IItemAutomation.ActiveOwnedPetCount + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return 0; + uint playerId = runtime.PlayerIdentity.ServerGuid; + int count = 0; + foreach (ClientObject candidate in runtime.InventoryOwner.Objects.Objects) + { + if (candidate.PetOwnerId == playerId + && (candidate.Type & ItemType.Creature) != 0) + { + count++; + } + } + return count; + } + } + + PluginItemUseCompletion IItemAutomation.LastCompletion + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + RuntimeItemUseCompletion completion = + runtime?.ActionOwner.Transactions.LastItemUseCompletion ?? default; + return new PluginItemUseCompletion( + completion.Revision, + completion.SourceObjectId, + completion.TargetObjectId, + completion.WeenieError); + } + } + + PluginInventoryCompletion IItemAutomation.LastInventoryCompletion + { + get + { + lock (_gate) + return _lastInventoryCompletion; + } + } + + public uint ActiveVendorObjectId + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.InventoryOwner.Vendor.VendorId ?? 0u; + } + } + + public IReadOnlyList CaptureOwnedItems() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (playerId == 0u) + return Array.Empty(); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + var built = new List(); + foreach (ClientObject item in objects.Objects) + { + if (!IsPlayerOwned(item, playerId, objects)) + continue; + built.Add(new PluginInventoryItem( + item.ObjectId, + item.WeenieClassId, + item.GetAppropriateName(), + (uint)item.Type, + item.ContainerId, + item.WielderId, + (uint)item.ValidLocations, + (uint)item.CurrentlyEquippedLocation, + item.Useability ?? 0u, + item.TargetType ?? 0u, + item.PublicWeenieBitfield ?? 0u, + item.StackSize, + item.Structure, + item.MaxStructure, + item.SpellId + ?? (item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.Spell, + out uint itemSpell) ? itemSpell : 0u), + item.Properties.GetInt((uint)PropertyInt.PetClass), + item.Properties.GetInt((uint)PropertyInt.SummoningMastery), + item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.ProcSpell, + out uint procSpell) ? procSpell : 0u, + item.Properties.GetBool((uint)PropertyBool.ProcSpellSelfTargeted), + item.Properties.GetFloat((uint)PropertyFloat.ProcSpellRate), + item.Properties.GetInt((uint)PropertyInt.WeaponSkill), + item.Properties.GetInt((uint)PropertyInt.DamageType), + item.Properties.GetInt((uint)PropertyInt.Damage), + item.Properties.GetFloat((uint)PropertyFloat.DamageVariance), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkill), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillLevel), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillSpec)) + { + CombatUse = item.CombatUse ?? 0, + ItemSpellcraft = item.Properties.GetInt( + (uint)PropertyInt.ItemSpellcraft), + WieldRequirements = item.Properties.GetInt( + (uint)PropertyInt.WieldRequirements), + WieldSkillType = item.Properties.GetInt( + (uint)PropertyInt.WieldSkilltype), + WieldDifficulty = item.Properties.GetInt( + (uint)PropertyInt.WieldDifficulty), + AttackType = item.Properties.GetInt( + (uint)PropertyInt.AttackType), + WeaponType = item.Properties.GetInt( + (uint)PropertyInt.WeaponType), + BoosterVital = item.Properties.GetInt( + (uint)PropertyInt.BoosterEnum), + BoostValue = item.Properties.GetInt( + (uint)PropertyInt.BoostValue), + HealKitModifier = item.Properties.GetFloat( + (uint)PropertyFloat.HealkitMod), + AppraisedSpellIds = item.AppraisedSpellIds.Count == 0 + ? Array.Empty() + : item.AppraisedSpellIds.ToArray(), + GearDamage = item.Properties.GetInt((uint)PropertyInt.GearDamage), + GearDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearDamageResist), + GearCriticalChance = item.Properties.GetInt( + (uint)PropertyInt.GearCrit), + GearCriticalResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritResist), + GearCriticalDamage = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamage), + GearCriticalDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamageResist), + MaximumStackSize = item.StackSizeMax, + ContainerSlot = item.ContainerSlot, + ItemsCapacity = item.ItemsCapacity, + ContainersCapacity = item.ContainersCapacity, + Burden = item.Burden, + Value = item.Value, + ItemCurrentMana = item.Properties.GetInt( + (uint)PropertyInt.ItemCurMana), + ItemMaximumMana = item.Properties.GetInt( + (uint)PropertyInt.ItemMaxMana), + Workmanship = item.Workmanship, + MaterialType = item.MaterialType ?? 0u, + ObjectClass = ClassifyObject(item), + Palettes = ProjectPalettes(runtime, item.ObjectId), + }); + } + built.Sort(static (left, right) => + { + int name = string.CompareOrdinal(left.Name, right.Name); + return name != 0 ? name : left.ObjectId.CompareTo(right.ObjectId); + }); + return built; + } + + public bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + { + properties = default; + return false; + } + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) + { + properties = default; + return false; + } + PropertyBundle source = item!.Properties; + properties = new PluginItemProperties( + new Dictionary(source.Ints), + new Dictionary(source.Int64s), + new Dictionary(source.Bools), + new Dictionary(source.Floats), + new Dictionary(source.Strings), + new Dictionary(source.DataIds), + new Dictionary(source.InstanceIds)); + return true; + } + + public PluginItemCommandResult Use(uint objectId) + => DispatchItem(objectId, 0u); + + public PluginItemCommandResult Apply(uint objectId, uint targetObjectId) + => DispatchItem(objectId, targetObjectId); + + public PluginItemCommandResult MoveToContainer( + uint objectId, + uint containerObjectId, + uint amount = 0u, + int placement = 0) + { + Func? move; + GameRuntime? runtime; + lock (_gate) + { + move = _moveItem; + runtime = _runtime; + } + if (runtime is null || move is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) + return new(PluginItemCommandStatus.InvalidItem); + if (containerObjectId == 0u + || objects.Get(containerObjectId) is not { } container + || (containerObjectId != playerId + && !IsPlayerOwned(container, playerId, objects))) + { + return new(PluginItemCommandStatus.InvalidTarget); + } + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return move(objectId, containerObjectId, amount, placement) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Merge( + uint sourceObjectId, + uint targetObjectId, + uint amount = 0u) + { + Func? merge; + GameRuntime? runtime; + lock (_gate) + { + merge = _mergeItems; + runtime = _runtime; + } + if (runtime is null || merge is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, sourceObjectId, out ClientObject? source)) + return new(PluginItemCommandStatus.InvalidItem); + if (!TryGetOwned(objects, playerId, targetObjectId, out _)) + return new(PluginItemCommandStatus.InvalidTarget); + if (!ValidAmount(source!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return merge(sourceObjectId, targetObjectId, amount) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Drop(uint objectId, uint amount = 0u) + { + Func? drop; + GameRuntime? runtime; + lock (_gate) + { + drop = _dropItem; + runtime = _runtime; + } + if (runtime is null || drop is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (!TryGetOwned( + objects, + runtime.PlayerIdentity.ServerGuid, + objectId, + out ClientObject? item)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return drop(objectId, amount) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Give( + uint objectId, + uint targetObjectId, + uint amount = 0u) + { + Func? give; + GameRuntime? runtime; + lock (_gate) + { + give = _giveItem; + runtime = _runtime; + } + if (runtime is null || give is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (!TryGetOwned( + objects, + runtime.PlayerIdentity.ServerGuid, + objectId, + out ClientObject? item)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (targetObjectId == 0u || objects.Get(targetObjectId) is null) + return new(PluginItemCommandStatus.InvalidTarget); + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return give(objectId, targetObjectId, amount) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Salvage( + uint toolObjectId, + IReadOnlyList itemObjectIds) + { + Func, bool>? salvage; + GameRuntime? runtime; + lock (_gate) + { + salvage = _salvageItems; + runtime = _runtime; + } + if (runtime is null || salvage is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + if (itemObjectIds is null || itemObjectIds.Count == 0) + return new(PluginItemCommandStatus.InvalidItem); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, toolObjectId, out ClientObject? tool) + || (tool!.Type & ItemType.TinkeringTool) == 0) + { + return new(PluginItemCommandStatus.InvalidTarget); + } + foreach (uint itemObjectId in itemObjectIds) + { + if (!TryGetOwned(objects, playerId, itemObjectId, out _)) + return new(PluginItemCommandStatus.InvalidItem); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return salvage(toolObjectId, itemObjectIds) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Sell(uint objectId, uint amount = 0u) + { + Func? sell; + GameRuntime? runtime; + lock (_gate) + { + sell = _sellItem; + runtime = _runtime; + } + if (runtime is null || sell is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + uint vendorId = runtime.InventoryOwner.Vendor.VendorId; + if (vendorId == 0u) + return new(PluginItemCommandStatus.InvalidTarget, "No vendor is open."); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) + return new(PluginItemCommandStatus.InvalidItem); + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + int quantity = checked((int)(amount == 0u + ? (uint)Math.Max(1, item!.StackSize) + : amount)); + int perUnitValue = VendorPricing.PerUnitValue(item!.Value, item.StackSize); + VendorShopProfile profile = runtime.InventoryOwner.Vendor.Profile; + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: objects.GetContents(objectId).Count, + itemTypeMask: (uint)item.Type, + perUnitValue, + profile.MerchandiseItemTypes, + profile.MerchandiseMinValue, + profile.MerchandiseMaxValue, + item.PublicWeenieBitfield ?? 0u); + if (rejection != VendorSellRejection.None) + { + return new PluginItemCommandResult( + PluginItemCommandStatus.Refused, + VendorSellAcceptability.MessageFor(rejection)); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return sell(vendorId, objectId, quantity) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + private PluginItemCommandResult DispatchItem( + uint objectId, + uint targetObjectId) + { + Func? use; + Func? apply; + GameRuntime? runtime; + lock (_gate) + { + use = _useItem; + apply = _applyItem; + runtime = _runtime; + } + if (runtime is null || use is null || apply is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (objectId == 0u + || objects.Get(objectId) is not { } item + || !IsPlayerOwned(item, playerId, objects)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (targetObjectId != 0u && objects.Get(targetObjectId) is null) + return new(PluginItemCommandStatus.InvalidTarget); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + bool started = targetObjectId == 0u + ? use(objectId) + : apply(objectId, targetObjectId); + return started + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + private static bool IsPlayerOwned( + ClientObject item, + uint playerId, + ClientObjectTable objects) + { + if (item.WielderId == playerId || item.ContainerId == playerId) + return true; + uint parentId = item.ContainerId; + for (int depth = 0; parentId != 0u && depth < 4; depth++) + { + ClientObject? parent = objects.Get(parentId); + if (parent is null) + return false; + if (parent.WielderId == playerId || parent.ContainerId == playerId) + return true; + parentId = parent.ContainerId; + } + return false; + } + + private static bool TryGetOwned( + ClientObjectTable objects, + uint playerId, + uint objectId, + out ClientObject? item) + { + item = objectId == 0u ? null : objects.Get(objectId); + return item is not null && IsPlayerOwned(item, playerId, objects); + } + + private static bool ValidAmount(ClientObject item, uint amount) => + amount == 0u || amount <= (uint)Math.Max(1, item.StackSize); + + // ── ILootAutomation ────────────────────────────────────────────────── + bool ILootAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _useItem is not null + && _pickupItem is not null + && _identifyItem is not null + && IsAvailable; + } + } + + bool ILootAutomation.IsBusy + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime is not null + && !runtime.InventoryOwner.Transactions.CanBeginRequest; + } + } + + uint ILootAutomation.RequestedContainerId + { + get + { + lock (_gate) + return _runtime?.InventoryOwner.ExternalContainers + .RequestedContainerId ?? 0u; + } + } + + uint ILootAutomation.CurrentContainerId + { + get + { + lock (_gate) + return _runtime?.InventoryOwner.ExternalContainers + .CurrentContainerId ?? 0u; + } + } + + PluginItemUseCompletion ILootAutomation.LastItemUseCompletion => + ((IItemAutomation)this).LastCompletion; + + PluginInventoryCompletion ILootAutomation.LastInventoryCompletion + { + get + { + lock (_gate) + return _lastInventoryCompletion; + } + } + + PluginAppraisalState ILootAutomation.Appraisal + { + get + { + lock (_gate) + { + RuntimeInteractionTransactionState? transactions = + _runtime?.ActionOwner.Transactions; + return transactions is null + ? default + : new PluginAppraisalState( + transactions.Revision, + transactions.AwaitingAppraisalId, + transactions.CurrentAppraisalId); + } + } + } + + public IReadOnlyList CaptureCorpses( + float maximumDistance) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable + || float.IsNaN(maximumDistance) + || maximumDistance <= 0f) + { + return Array.Empty(); + } + + ExternalContainerState external = + runtime.InventoryOwner.ExternalContainers; + var result = new List(); + foreach (ClientObject candidate in runtime.InventoryOwner.Objects.Objects) + { + if (((PublicWeenieFlags)(candidate.PublicWeenieBitfield ?? 0u) + & PublicWeenieFlags.Corpse) == 0 + || candidate.ContainerId != 0u + || !RuntimeFriendlyTargetQuery.TryGetDistance( + runtime, + candidate.ObjectId, + out float distance) + || distance > maximumDistance) + { + continue; + } + + result.Add(new PluginLootContainer( + candidate.ObjectId, + candidate.WeenieClassId, + candidate.GetAppropriateName(), + distance, + external.HasCorpseBeenOpened(candidate.ObjectId), + external.RequestedContainerId == candidate.ObjectId, + external.CurrentContainerId == candidate.ObjectId) + { + LongDescription = candidate.Properties.GetString( + (uint)PropertyString.LongDesc), + IsGeneratedRare = candidate.Properties.GetBool( + (uint)PropertyBool.CorpseGeneratedRare), + IsIdentified = candidate.Properties.Strings.ContainsKey( + (uint)PropertyString.LongDesc), + }); + } + result.Sort(static (left, right) => + { + int distance = left.Distance.CompareTo(right.Distance); + return distance != 0 + ? distance + : left.ObjectId.CompareTo(right.ObjectId); + }); + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + public IReadOnlyList CaptureCurrentContents() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + if (root == 0u) + return Array.Empty(); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + var result = new List(); + var visited = new HashSet { root }; + CaptureContainerTree(runtime, objects, root, visited, result); + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + bool ILootAutomation.TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + { + properties = default; + return false; + } + + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (root == 0u + || !CaptureContainerIds(objects, root).Contains(objectId) + || objects.Get(objectId) is not { } item) + { + properties = default; + return false; + } + properties = CaptureProperties(item.Properties); + return true; + } + + public PluginItemCommandResult Open(uint containerObjectId) + { + GameRuntime? runtime; + Func? use; + lock (_gate) + { + runtime = _runtime; + use = _useItem; + } + if (runtime is null || use is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + if (containerObjectId == 0u + || runtime.InventoryOwner.Objects.Get(containerObjectId) + is not { } container + || ((PublicWeenieFlags)(container.PublicWeenieBitfield ?? 0u) + & (PublicWeenieFlags.Corpse | PublicWeenieFlags.Openable)) == 0) + { + return new(PluginItemCommandStatus.InvalidTarget); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return use(containerObjectId) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Identify(uint objectId) + { + GameRuntime? runtime; + Func? identify; + lock (_gate) + { + runtime = _runtime; + identify = _identifyItem; + } + if (runtime is null || identify is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + ClientObject? item = objectId == 0u ? null : objects.Get(objectId); + bool corpse = item is not null + && ((PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u) + & PublicWeenieFlags.Corpse) != 0; + bool currentContent = root != 0u + && CaptureContainerIds(objects, root).Contains(objectId); + if (item is null || (!corpse && !currentContent)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return identify(objectId) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Pickup(uint objectId, bool mainPack = false) + { + GameRuntime? runtime; + Func? pickup; + lock (_gate) + { + runtime = _runtime; + pickup = _pickupItem; + } + if (runtime is null || pickup is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (objectId == 0u + || root == 0u + || objects.Get(objectId) is null + || !CaptureContainerIds(objects, root).Contains(objectId)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return pickup(objectId, mainPack) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + private static HashSet CaptureContainerIds( + ClientObjectTable objects, + uint root) + { + var result = new HashSet(); + var pending = new Stack(); + pending.Push(root); + while (pending.Count != 0) + { + uint containerId = pending.Pop(); + foreach (uint childId in objects.GetContents(containerId)) + { + if (!result.Add(childId)) + continue; + if (objects.Get(childId) is { } child + && (child.ItemsCapacity != 0 + || child.ContainersCapacity != 0 + || (child.Type & ItemType.Container) != 0)) + { + pending.Push(childId); + } + } + } + return result; + } + + private void CaptureContainerTree( + GameRuntime runtime, + ClientObjectTable objects, + uint containerId, + HashSet visited, + List result) + { + foreach (uint childId in objects.GetContents(containerId)) + { + if (!visited.Add(childId) + || objects.Get(childId) is not { } child) + { + continue; + } + result.Add(ProjectInventoryItem(runtime, child)); + if (child.ItemsCapacity != 0 + || child.ContainersCapacity != 0 + || (child.Type & ItemType.Container) != 0) + { + CaptureContainerTree(runtime, objects, childId, visited, result); + } + } + } + + private static PluginItemProperties CaptureProperties(PropertyBundle source) => + new( + new Dictionary(source.Ints), + new Dictionary(source.Int64s), + new Dictionary(source.Bools), + new Dictionary(source.Floats), + new Dictionary(source.Strings), + new Dictionary(source.DataIds), + new Dictionary(source.InstanceIds)); + + private PluginInventoryItem ProjectInventoryItem( + GameRuntime runtime, + ClientObject item) => + new( + item.ObjectId, + item.WeenieClassId, + item.GetAppropriateName(), + (uint)item.Type, + item.ContainerId, + item.WielderId, + (uint)item.ValidLocations, + (uint)item.CurrentlyEquippedLocation, + item.Useability ?? 0u, + item.TargetType ?? 0u, + item.PublicWeenieBitfield ?? 0u, + item.StackSize, + item.Structure, + item.MaxStructure, + item.SpellId + ?? (item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.Spell, + out uint itemSpell) ? itemSpell : 0u), + item.Properties.GetInt((uint)PropertyInt.PetClass), + item.Properties.GetInt((uint)PropertyInt.SummoningMastery), + item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.ProcSpell, + out uint procSpell) ? procSpell : 0u, + item.Properties.GetBool((uint)PropertyBool.ProcSpellSelfTargeted), + item.Properties.GetFloat((uint)PropertyFloat.ProcSpellRate), + item.Properties.GetInt((uint)PropertyInt.WeaponSkill), + item.Properties.GetInt((uint)PropertyInt.DamageType), + item.Properties.GetInt((uint)PropertyInt.Damage), + item.Properties.GetFloat((uint)PropertyFloat.DamageVariance), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkill), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillLevel), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillSpec)) + { + CombatUse = item.CombatUse ?? 0, + ItemSpellcraft = item.Properties.GetInt( + (uint)PropertyInt.ItemSpellcraft), + WieldRequirements = item.Properties.GetInt( + (uint)PropertyInt.WieldRequirements), + WieldSkillType = item.Properties.GetInt( + (uint)PropertyInt.WieldSkilltype), + WieldDifficulty = item.Properties.GetInt( + (uint)PropertyInt.WieldDifficulty), + AttackType = item.Properties.GetInt((uint)PropertyInt.AttackType), + WeaponType = item.Properties.GetInt((uint)PropertyInt.WeaponType), + BoosterVital = item.Properties.GetInt((uint)PropertyInt.BoosterEnum), + BoostValue = item.Properties.GetInt((uint)PropertyInt.BoostValue), + HealKitModifier = item.Properties.GetFloat( + (uint)PropertyFloat.HealkitMod), + AppraisedSpellIds = item.AppraisedSpellIds.Count == 0 + ? Array.Empty() + : item.AppraisedSpellIds.ToArray(), + GearDamage = item.Properties.GetInt((uint)PropertyInt.GearDamage), + GearDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearDamageResist), + GearCriticalChance = item.Properties.GetInt( + (uint)PropertyInt.GearCrit), + GearCriticalResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritResist), + GearCriticalDamage = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamage), + GearCriticalDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamageResist), + MaximumStackSize = item.StackSizeMax, + ContainerSlot = item.ContainerSlot, + ItemsCapacity = item.ItemsCapacity, + ContainersCapacity = item.ContainersCapacity, + Burden = item.Burden, + Value = item.Value, + ItemCurrentMana = item.Properties.GetInt((uint)PropertyInt.ItemCurMana), + ItemMaximumMana = item.Properties.GetInt((uint)PropertyInt.ItemMaxMana), + Workmanship = item.Workmanship, + MaterialType = item.MaterialType ?? 0u, + ObjectClass = ClassifyObject(item), + Palettes = ProjectPalettes(runtime, item.ObjectId), + }; + + private IReadOnlyList ProjectPalettes( + GameRuntime runtime, + uint objectId) + { + IChargenPaletteColorSource? colors; + lock (_gate) + colors = _paletteColors; + if (colors is null + || !runtime.EntityObjects.Entities.TryGetActive( + objectId, + out RuntimeEntityRecord record) + || record.Snapshot.SubPalettes.Count == 0) + { + return Array.Empty(); + } + + var result = new PluginPaletteInfo[record.Snapshot.SubPalettes.Count]; + for (int index = 0; index < result.Length; index++) + { + var palette = record.Snapshot.SubPalettes[index]; + int sampleIndex = (palette.Length * 16) + (palette.Offset * 32) + 8; + _ = colors.TryGetColor( + palette.SubPaletteId, + sampleIndex, + out var rgb); + result[index] = new PluginPaletteInfo( + palette.SubPaletteId, + palette.Offset, + palette.Length, + rgb.R, + rgb.G, + rgb.B); + } + return result; + } + + // ── IFellowshipAutomation ──────────────────────────────────────────── + public bool IsInFellowship + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.IsInFellowship == true; + } + } + + public string FellowshipName + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.Name ?? string.Empty; + } + } + + string IFellowshipAutomation.Name => FellowshipName; + + public uint LeaderObjectId + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.LeaderGuid ?? 0u; + } + } + + public bool IsOpen + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.IsOpen == true; + } + } + + public bool IsLocked + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.Locked == true; + } + } + + public int MemberCount + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.MemberCount ?? 0; + } + } + + public IReadOnlyList CaptureMembers() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable + || !runtime.Fellowship.Snapshot.IsInFellowship) + { + return Array.Empty(); + } + + uint self = runtime.PlayerIdentity.ServerGuid; + var result = new List(); + foreach (RuntimeFellowMemberSnapshot member + in runtime.Fellowship.GetMembers()) + { + if (member.Guid == self + || !RuntimeFriendlyTargetQuery.TryGetDistance( + runtime, + member.Guid, + out float distance)) + { + continue; + } + result.Add(new PluginFellowMember( + member.Guid, + member.Name, + member.CurrentHealth, + member.MaxHealth, + member.CurrentStamina, + member.MaxStamina, + member.CurrentMana, + member.MaxMana, + distance) + { + ShareLoot = member.ShareLoot, + }); + } + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + public IReadOnlyList CaptureRoster() => + CaptureFellowshipMembers(includeSelf: true); + + public PluginFellowshipCommandResult Create( + string name, + bool shareExperience) => InvokeFellowship(commands => + commands.FellowshipCommands.Create( + commands.Generation, + name, + shareExperience)); + + public PluginFellowshipCommandResult Recruit(uint targetObjectId) => + InvokeFellowship(commands => commands.FellowshipCommands.Recruit( + commands.Generation, + targetObjectId)); + + public PluginFellowshipCommandResult Dismiss(uint targetObjectId) => + InvokeFellowship(commands => commands.FellowshipCommands.Dismiss( + commands.Generation, + targetObjectId)); + + public PluginFellowshipCommandResult Quit(bool disband) => + InvokeFellowship(commands => commands.FellowshipCommands.Quit( + commands.Generation, + disband)); + + public PluginFellowshipCommandResult AssignLeader(uint targetObjectId) => + InvokeFellowship(commands => commands.FellowshipCommands.AssignLeader( + commands.Generation, + targetObjectId)); + + public PluginFellowshipCommandResult SetOpen(bool isOpen) => + InvokeFellowship(commands => commands.FellowshipCommands.SetOpen( + commands.Generation, + isOpen)); + + private PluginFellowshipCommandResult InvokeFellowship( + Func invoke) + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + if (commands is null || !IsAvailable) + return new(PluginFellowshipCommandStatus.Unavailable); + RuntimeCommandResult result = invoke(commands); + return new(result.Status switch + { + RuntimeCommandStatus.Accepted => PluginFellowshipCommandStatus.Accepted, + RuntimeCommandStatus.Rejected => PluginFellowshipCommandStatus.Rejected, + _ => PluginFellowshipCommandStatus.Unavailable, + }); + } + + private IReadOnlyList CaptureFellowshipMembers(bool includeSelf) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable + || !runtime.Fellowship.Snapshot.IsInFellowship) + { + return Array.Empty(); + } + + uint self = runtime.PlayerIdentity.ServerGuid; + var result = new List(); + foreach (RuntimeFellowMemberSnapshot member in runtime.Fellowship.GetMembers()) + { + if (!includeSelf && member.Guid == self) + continue; + float distance = 0f; + if (member.Guid != self + && !RuntimeFriendlyTargetQuery.TryGetDistance( + runtime, + member.Guid, + out distance)) + { + continue; + } + result.Add(new PluginFellowMember( + member.Guid, + member.Name, + member.CurrentHealth, + member.MaxHealth, + member.CurrentStamina, + member.MaxStamina, + member.CurrentMana, + member.MaxMana, + distance) + { + ShareLoot = member.ShareLoot, + }); + } + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + // ── IEnchantmentAutomation ────────────────────────────────────────── + public IReadOnlyList Capture(uint targetObjectId) + { + if (targetObjectId == 0u) + return Array.Empty(); + ObserveSuccessfulLocalCast(); + DateTimeOffset now = DateTimeOffset.UtcNow; + lock (_gate) + { + PruneTrackedEnchantments(now); + PluginTrackedEnchantment[] result = _trackedEnchantments + .Where(pair => pair.Key.Target == targetObjectId) + .Select(pair => new PluginTrackedEnchantment( + pair.Key.Target, + pair.Key.Spell, + pair.Value.Family, + pair.Value.Quality, + pair.Value.IsUntargeted, + Math.Max(0d, (pair.Value.ExpiresAt - now).TotalSeconds))) + .OrderBy(static entry => entry.Family) + .ThenBy(static entry => entry.SpellId) + .ToArray(); + return result.Length == 0 + ? Array.Empty() + : result; + } + } + + public bool ReportCast( + uint targetObjectId, + uint spellId, + double durationSeconds) + { + if (targetObjectId == 0u + || spellId == 0u + || !double.IsFinite(durationSeconds) + || durationSeconds <= 0d) + { + return false; + } + + lock (_gate) + { + if (_disposed + || _spellbook is null + || !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)) + { + return false; + } + TrackEnchantment( + targetObjectId, + metadata, + durationSeconds, + DateTimeOffset.UtcNow); + return true; + } + } + + private void ObserveSuccessfulLocalCast() + { + lock (_gate) + { + RuntimeSpellCastCompletion completion = + _cast?.LastCompletion ?? default; + if (completion.Revision == 0 + || completion.Revision <= _trackedCastCompletionRevision) + { + return; + } + _trackedCastCompletionRevision = completion.Revision; + if (!completion.IsSuccess + || completion.TargetObjectId == 0u + || _spellbook is null + || !_spellbook.TryGetMetadata( + completion.SpellId, + out SpellMetadata metadata) + || metadata.Duration <= 0f) + { + return; + } + TrackEnchantment( + completion.TargetObjectId, + metadata, + metadata.Duration, + DateTimeOffset.UtcNow); + } + } + + private void TrackEnchantment( + uint targetObjectId, + SpellMetadata metadata, + double durationSeconds, + DateTimeOffset now) + { + var tracked = new TrackedEnchantment( + metadata.Family, + metadata.Difficulty, + metadata.IsUntargeted, + now.AddSeconds(durationSeconds)); + (uint Target, uint Spell) key = (targetObjectId, metadata.SpellId); + if (!_trackedEnchantments.TryGetValue(key, out TrackedEnchantment old) + || tracked.ExpiresAt > old.ExpiresAt) + { + _trackedEnchantments[key] = tracked; + } + } + + private void PruneTrackedEnchantments(DateTimeOffset now) + { + foreach ((uint Target, uint Spell) key in + _trackedEnchantments + .Where(pair => pair.Value.ExpiresAt <= now) + .Select(static pair => pair.Key) + .ToArray()) + { + _trackedEnchantments.Remove(key); + } + } + + // ── ICombatAutomation ──────────────────────────────────────────────── + public PluginCombatSnapshot Snapshot + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return default; + + RuntimeActionSnapshot action = runtime.ActionOwner.View.Snapshot; + RuntimeCombatAttackSnapshot attack = action.CombatAttack; + return new PluginCombatSnapshot( + action.SelectedObjectId, + Project(action.CombatMode), + Project(attack.RequestedHeight), + attack.DesiredPower, + attack.PowerBarLevel, + attack.BuildInProgress, + attack.RequestInProgress, + attack.ServerResponsePending, + attack.RepeatAttackInProgress) + { + CompletionRevision = attack.CompletionRevision, + CompletionSequence = attack.CompletionSequence, + CompletionWeenieError = attack.CompletionWeenieError, + }; + } + } + + public IReadOnlyList CaptureHostileTargets( + float maximumDistance) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + IReadOnlyList captured = + RuntimeHostileTargetQuery.Capture(runtime, maximumDistance); + if (captured.Count == 0) + return Array.Empty(); + + var projected = new PluginCombatTarget[captured.Count]; + Func speciesName; + lock (_gate) + speciesName = _speciesName; + for (int i = 0; i < captured.Count; i++) + { + RuntimeHostileTargetSnapshot target = captured[i]; + projected[i] = new PluginCombatTarget( + target.ObjectId, + target.Name, + target.WeenieClassId, + target.Distance, + target.RelativeAngleDegrees, + target.IsHealthKnown, + target.HealthFraction) + { + SpeciesId = target.SpeciesId, + SpeciesName = speciesName(target.SpeciesId), + MaximumHealth = target.MaximumHealth, + HasShield = target.HasShield, + Incarnation = target.Incarnation, + HealthRevision = target.HealthRevision, + SecondsSinceHealthUpdate = target.SecondsSinceHealthUpdate, + }; + } + return projected; + } + + public PluginCombatCommandResult EnterDefaultMode() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + if (runtime.ActionOwner.Combat.CurrentMode != CombatMode.NonCombat) + return new(PluginCombatCommandStatus.AlreadyReady); + + RuntimeCombatModeRequestResult result = runtime.ActionOwner.CombatMode.Toggle(); + return result.Status switch + { + RuntimeCombatModeRequestStatus.Sent => new( + PluginCombatCommandStatus.ModeChangeSent), + RuntimeCombatModeRequestStatus.Rejected => new( + PluginCombatCommandStatus.Refused, result.Notice), + _ => new(PluginCombatCommandStatus.Unavailable, result.Notice), + }; + } + + public PluginCombatCommandResult EnterMode(PluginCombatMode mode) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + CombatMode requested = mode switch + { + PluginCombatMode.Peace => CombatMode.NonCombat, + PluginCombatMode.Melee => CombatMode.Melee, + PluginCombatMode.Missile => CombatMode.Missile, + PluginCombatMode.Magic => CombatMode.Magic, + _ => (CombatMode)(-1), + }; + if ((int)requested < 0) + return new(PluginCombatCommandStatus.Refused, "Invalid combat mode."); + if (runtime.ActionOwner.Combat.CurrentMode == requested) + return new(PluginCombatCommandStatus.AlreadyReady); + + RuntimeCombatModeRequestResult result = + runtime.ActionOwner.CombatMode.Request(requested); + return result.Status switch + { + RuntimeCombatModeRequestStatus.Sent => new( + PluginCombatCommandStatus.ModeChangeSent), + RuntimeCombatModeRequestStatus.Rejected => new( + PluginCombatCommandStatus.Refused, result.Notice), + _ => new(PluginCombatCommandStatus.Unavailable, result.Notice), + }; + } + + public PluginCombatCommandResult DismissGhostTarget(uint targetObjectId) + { + Func? dismiss; + lock (_gate) + dismiss = _dismissGhost; + if (dismiss is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + return dismiss(targetObjectId) + ? new(PluginCombatCommandStatus.Stopped) + : new(PluginCombatCommandStatus.InvalidTarget); + } + + public PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, + PluginAttackHeight height, + float power) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + if (!RuntimeHostileTargetQuery.IsHostile(runtime, targetObjectId)) + return new(PluginCombatCommandStatus.InvalidTarget); + if (!CombatInputPlanner.SupportsTargetedAttack( + runtime.ActionOwner.Combat.CurrentMode)) + { + return new(PluginCombatCommandStatus.WrongMode); + } + + RuntimeCombatAttackState attack = runtime.ActionOwner.CombatAttack; + if (attack.AttackRequestInProgress + || attack.AttackServerResponsePending + || attack.RepeatAttackInProgress) + { + return new(PluginCombatCommandStatus.Busy); + } + + runtime.ActionOwner.Selection.Select( + targetObjectId, + SelectionChangeSource.Plugin); + attack.SetDesiredPower(Math.Clamp(power, 0f, 1f)); + attack.PressAttack(Project(height)); + return attack.AttackRequestInProgress + ? new(PluginCombatCommandStatus.Started) + : new(PluginCombatCommandStatus.Refused); + } + + public PluginCombatCommandResult ReleasePhysicalAttack() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + + RuntimeCombatAttackState attack = runtime.ActionOwner.CombatAttack; + if (!attack.AttackRequestInProgress) + return new(PluginCombatCommandStatus.Refused); + attack.ReleaseAttack(); + return new(PluginCombatCommandStatus.Released); + } + + public PluginCombatCommandResult AbortPhysicalAttack() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return new(PluginCombatCommandStatus.Unavailable); + runtime.ActionOwner.CombatAttack.AbortAutomaticAttack(); + return new(PluginCombatCommandStatus.Stopped); + } + + private bool SelectExplicitTarget(uint targetObjectId) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable || targetObjectId == 0u + || !runtime.EntityObjects.Entities.TryGetActive(targetObjectId, out _)) + { + return false; + } + runtime.ActionOwner.Selection.Select( + targetObjectId, + SelectionChangeSource.Plugin); + return true; + } + + private static PluginCombatMode Project(CombatMode mode) => mode switch + { + CombatMode.NonCombat => PluginCombatMode.Peace, + CombatMode.Melee => PluginCombatMode.Melee, + CombatMode.Missile => PluginCombatMode.Missile, + CombatMode.Magic => PluginCombatMode.Magic, + _ => PluginCombatMode.Unknown, + }; + + private static PluginAttackHeight Project(AttackHeight height) => height switch + { + AttackHeight.High => PluginAttackHeight.High, + AttackHeight.Low => PluginAttackHeight.Low, + _ => PluginAttackHeight.Medium, + }; + + private static AttackHeight Project(PluginAttackHeight height) => height switch + { + PluginAttackHeight.High => AttackHeight.High, + PluginAttackHeight.Low => AttackHeight.Low, + _ => AttackHeight.Medium, + }; + + private readonly record struct TrackedEnchantment( + uint Family, + int Quality, + bool IsUntargeted, + DateTimeOffset ExpiresAt); + public void Dispose() { + if (_events is not null) + _events.Tick -= OnPeerTick; lock (_gate) { if (_disposed) return; _disposed = true; + _equip = null; + _equipmentBusy = null; + _useItem = null; + _applyItem = null; + _moveItem = null; + _mergeItems = null; + _dropItem = null; + _giveItem = null; + _pickupItem = null; + _identifyItem = null; + _salvageItems = null; + _sellItem = null; + _selectionAction = null; DetachLocked(); } _knownSelfBuffs = Array.Empty(); + _knownAttackSpells = Array.Empty(); + _knownCombatSpells = Array.Empty(); _enchantments = Array.Empty(); + _peers.Dispose(); } } diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index ad5fe525..06cf8f02 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -10,7 +10,10 @@ public sealed class AppPluginHost : IPluginHost IEvents events, ISelectionService selection, IUiRegistry ui, - IAutomationSurface automation) + IAutomationSurface automation, + IPluginStorage? storage = null, + IPluginCommandRegistry? commands = null, + IPluginLootClassifierRegistry? lootClassifiers = null) { Log = log; State = state; @@ -18,6 +21,10 @@ public sealed class AppPluginHost : IPluginHost Selection = selection; Ui = ui; Automation = automation; + Storage = storage ?? NoOpPluginStorage.Instance; + Commands = commands ?? NoOpPluginCommandRegistry.Instance; + LootClassifiers = lootClassifiers + ?? NoOpPluginLootClassifierRegistry.Instance; } public bool HasUi => true; @@ -27,4 +34,7 @@ public sealed class AppPluginHost : IPluginHost public ISelectionService Selection { get; } public IUiRegistry Ui { get; } public IAutomationSurface Automation { get; } + public IPluginStorage Storage { get; } + public IPluginCommandRegistry Commands { get; } + public IPluginLootClassifierRegistry LootClassifiers { get; } } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs index dc3c565e..3ec34095 100644 --- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -11,18 +11,36 @@ namespace AcDream.App.Plugins; /// public sealed class BufferedUiRegistry : IScopedUiRegistry { - public readonly record struct Pending(string MarkupPath, object Binding) + public readonly record struct Pending( + PluginUiOwner Owner, + PluginPanelDescriptor Descriptor, + string MarkupPath, + object Binding) { internal long RegistrationId { get; init; } + internal string? MarkupContent { get; init; } + + /// Stable, manifest-scoped retained-window persistence key. + public string WindowName => + $"plugin:{Owner.Id}:{Descriptor.WindowId}"; } - private sealed class Registration(string markupPath, object binding) + private sealed class Registration( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding, + string? markupContent = null) { + internal PluginUiOwner Owner { get; } = owner; + internal PluginPanelDescriptor Descriptor { get; } = descriptor; internal string MarkupPath { get; } = markupPath; internal object Binding { get; } = binding; + internal string? MarkupContent { get; } = markupContent; internal bool Drained { get; set; } internal UiRoot? Root { get; set; } internal UiElement? Element { get; set; } + internal Action? WindowCleanup { get; set; } } private readonly object _gate = new(); @@ -32,15 +50,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry public void AddMarkupPanel(string markupPath, object binding) => _ = RegisterMarkupPanel(markupPath, binding); + public void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + => _ = RegisterPanel( + new PluginUiOwner("unscoped", descriptor.Title), + descriptor, + markupPath, + binding); + + public IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) => RegisterPanel( + new PluginUiOwner("unscoped", descriptor.Title), + descriptor, + markupPath, + binding); + + public IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => RegisterPanelContent( + new PluginUiOwner("unscoped", descriptor.Title), + descriptor, + markupContent, + binding); + + public bool ViewExists(string viewName) => + ViewExists(new PluginUiOwner("unscoped", "Plugin"), viewName); + + public bool IsViewVisible(string viewName) => + IsViewVisible(new PluginUiOwner("unscoped", "Plugin"), viewName); + + public bool ControlExists(string viewName, string controlName) => + ControlExists( + new PluginUiOwner("unscoped", "Plugin"), viewName, controlName); + + public bool SetControlLabel( + string viewName, + string controlName, + string label) => SetControlLabel( + new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, label); + + public bool SetControlVisible( + string viewName, + string controlName, + bool visible) => SetControlVisible( + new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, visible); + public IDisposable RegisterMarkupPanel(string markupPath, object binding) + => RegisterPanel( + new PluginUiOwner("legacy", "Plugin"), + new PluginPanelDescriptor( + Path.GetFileNameWithoutExtension(markupPath), + Path.GetFileNameWithoutExtension(markupPath)), + markupPath, + binding); + + public IDisposable RegisterPanel( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding) { + ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title); ArgumentException.ThrowIfNullOrWhiteSpace(markupPath); ArgumentNullException.ThrowIfNull(binding); long id; lock (_gate) { id = checked(++_nextRegistrationId); - _registrations.Add(id, new Registration(markupPath, binding)); + _registrations.Add( + id, + new Registration(owner, descriptor, markupPath, binding)); + } + return new RegistrationToken(this, id); + } + + public IDisposable RegisterPanelContent( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupContent, + object binding) + { + ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title); + ArgumentException.ThrowIfNullOrWhiteSpace(markupContent); + ArgumentNullException.ThrowIfNull(binding); + long id; + lock (_gate) + { + id = checked(++_nextRegistrationId); + _registrations.Add( + id, + new Registration( + owner, + descriptor, + $"", + binding, + markupContent)); } return new RegistrationToken(this, id); } @@ -57,10 +174,13 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry continue; registration.Drained = true; pending.Add(new Pending( + registration.Owner, + registration.Descriptor, registration.MarkupPath, registration.Binding) { RegistrationId = id, + MarkupContent = registration.MarkupContent, }); } return pending; @@ -88,6 +208,27 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry root.RemoveChild(element); } + /// + /// Publishes the window-manager half of a mounted registration. Disposal + /// may race between retained-tree mount and window registration, so a late + /// publication cleans itself up immediately when ownership is already gone. + /// + internal void CompleteWindowMount(Pending pending, Action cleanup) + { + ArgumentNullException.ThrowIfNull(cleanup); + bool stillRegistered; + lock (_gate) + { + stillRegistered = _registrations.TryGetValue( + pending.RegistrationId, + out Registration? registration); + if (stillRegistered) + registration!.WindowCleanup = cleanup; + } + if (!stillRegistered) + cleanup(); + } + internal void FailMount(Pending pending) => Remove(pending.RegistrationId); internal int RegistrationCount @@ -99,18 +240,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry } } + public bool ViewExists(PluginUiOwner owner, string viewName) + { + lock (_gate) + return FindRegistrationLocked(owner, viewName) is not null; + } + + public bool IsViewVisible(PluginUiOwner owner, string viewName) + { + UiElement? view; + lock (_gate) + view = FindRegistrationLocked(owner, viewName)?.Element; + return view?.Visible == true; + } + + public bool ControlExists( + PluginUiOwner owner, + string viewName, + string controlName) => + FindControl(owner, viewName, controlName) is not null; + + public bool SetControlLabel( + PluginUiOwner owner, + string viewName, + string controlName, + string label) + { + UiElement? control = FindControl(owner, viewName, controlName); + switch (control) + { + case UiSimpleButton button: + button.TextSource = null; + button.Text = label; + return true; + case UiMarkupToggle toggle: + toggle.TextSource = null; + toggle.Text = label; + return true; + case UiLabel text: + text.TextSource = null; + text.Text = label; + return true; + default: + return false; + } + } + + public bool SetControlVisible( + PluginUiOwner owner, + string viewName, + string controlName, + bool visible) + { + UiElement? control = FindControl(owner, viewName, controlName); + if (control is null) + return false; + control.VisibleSource = null; + control.Visible = visible; + return true; + } + + private UiElement? FindControl( + PluginUiOwner owner, + string viewName, + string controlName) + { + UiElement? view; + lock (_gate) + view = FindRegistrationLocked(owner, viewName)?.Element; + return view is null ? null : FindByName(view, controlName); + } + + private Registration? FindRegistrationLocked( + PluginUiOwner owner, + string viewName) => _registrations.Values.FirstOrDefault(registration => + registration.Owner == owner + && (registration.Descriptor.WindowId.Equals( + viewName, StringComparison.Ordinal) + || registration.Descriptor.Title.Equals( + viewName, StringComparison.Ordinal))); + + private static UiElement? FindByName(UiElement root, string name) + { + if (root.Name?.Equals(name, StringComparison.Ordinal) == true) + return root; + foreach (UiElement child in root.Children) + { + UiElement? found = FindByName(child, name); + if (found is not null) + return found; + } + return null; + } + private void Remove(long id) { UiRoot? root; UiElement? element; + Action? windowCleanup; lock (_gate) { if (!_registrations.Remove(id, out Registration? registration)) return; root = registration.Root; element = registration.Element; + windowCleanup = registration.WindowCleanup; } + windowCleanup?.Invoke(); if (root is not null && element is not null) root.RemoveChild(element); } diff --git a/src/AcDream.App/Plugins/FilePluginStorage.cs b/src/AcDream.App/Plugins/FilePluginStorage.cs new file mode 100644 index 00000000..85a0c60a --- /dev/null +++ b/src/AcDream.App/Plugins/FilePluginStorage.cs @@ -0,0 +1,86 @@ +using System.Text; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// Crash-safe filesystem implementation behind scoped plugin keys. +internal sealed class FilePluginStorage : IPluginStorage +{ + private readonly string _root; + + internal FilePluginStorage(string root) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + _root = Path.GetFullPath(root); + } + + public bool IsAvailable => true; + + public string? ReadText(string key) + { + string path = Resolve(key); + return File.Exists(path) + ? File.ReadAllText(path, Encoding.UTF8) + : null; + } + + public IReadOnlyList List(string prefix) + { + string directory = Resolve(prefix); + if (!Directory.Exists(directory)) + return Array.Empty(); + return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(_root, path) + .Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public void WriteText(string key, string content) + { + ArgumentNullException.ThrowIfNull(content); + string path = Resolve(key); + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + string temporary = Path.Combine( + directory, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(temporary, content, new UTF8Encoding(false)); + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + public bool Delete(string key) + { + string path = Resolve(key); + if (!File.Exists(path)) + return false; + File.Delete(path); + return true; + } + + private string Resolve(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (Path.IsPathRooted(key)) + throw new ArgumentException("Plugin storage keys must be relative.", nameof(key)); + string path = Path.GetFullPath(Path.Combine(_root, key)); + string relative = Path.GetRelativePath(_root, path); + if (Path.IsPathRooted(relative) + || relative.Equals("..", StringComparison.Ordinal) + || relative.StartsWith( + ".." + Path.DirectorySeparatorChar, + StringComparison.Ordinal)) + { + throw new ArgumentException("Plugin storage key escapes its root.", nameof(key)); + } + return path; + } +} diff --git a/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs b/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs new file mode 100644 index 00000000..17c7b37e --- /dev/null +++ b/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Small cross-process peer roster for plugins. UtilityBelt used a local TCP +/// relay; acdream uses bounded heartbeat documents in the user's local app +/// data, which provides the same machine-local discovery without a privileged +/// daemon or a fixed port. The files carry data only—never commands. +/// +internal sealed class LocalPluginPeerRegistry : IDisposable +{ + internal static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(15); + private const long MaximumDocumentBytes = 64 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly string _directory; + private readonly string _path; + private readonly TimeProvider _time; + private readonly Guid _instanceId; + private bool _disposed; + + public LocalPluginPeerRegistry( + string directory, + TimeProvider? timeProvider = null, + Guid? instanceId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + _directory = Path.GetFullPath(directory); + _time = timeProvider ?? TimeProvider.System; + _instanceId = instanceId ?? Guid.NewGuid(); + _path = Path.Combine(_directory, $"peer-{_instanceId:N}.json"); + ClientId = BitConverter.ToUInt32(_instanceId.ToByteArray(), 0); + if (ClientId == 0u) + ClientId = 1u; + } + + public uint ClientId { get; private set; } + + public void Publish(in PluginNetworkClient client) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Directory.CreateDirectory(_directory); + var document = PeerDocument.From( + client with { ClientId = ClientId }, + _instanceId, + _time.GetUtcNow().ToUnixTimeMilliseconds()); + string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporary, JsonSerializer.Serialize(document, JsonOptions)); + File.Move(temporary, _path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + public IReadOnlyList CaptureRemoteClients() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!Directory.Exists(_directory)) + return Array.Empty(); + long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter) + .ToUnixTimeMilliseconds(); + var result = new List(); + foreach (string file in Directory.EnumerateFiles( + _directory, + "peer-*.json", + SearchOption.TopDirectoryOnly)) + { + if (file.Equals(_path, StringComparison.OrdinalIgnoreCase)) + continue; + try + { + var info = new FileInfo(file); + if (info.Length is <= 0 or > MaximumDocumentBytes) + continue; + PeerDocument? document = JsonSerializer.Deserialize( + File.ReadAllText(file), + JsonOptions); + if (document is null + || document.InstanceId == _instanceId + || document.UpdatedUnixMs < newestAllowed + || document.ClientId == 0u + || document.PlayerId == 0u + || string.IsNullOrWhiteSpace(document.Name) + || document.Name.Length > 128 + || document.WorldName is null + || document.WorldName.Length > 128 + || document.Tags is null + || document.Tags.Length > 128 + || !double.IsFinite(document.EastWest) + || !double.IsFinite(document.NorthSouth) + || !double.IsFinite(document.Elevation) + || !float.IsFinite(document.Heading)) + { + continue; + } + result.Add(document.ToClient()); + } + catch (IOException) + { + // A peer can atomically replace or remove its own heartbeat + // between enumeration and read. It will reappear next scan. + } + catch (UnauthorizedAccessException) + { + } + catch (JsonException) + { + } + } + return result + .OrderBy(static client => client.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static client => client.ClientId) + .ToArray(); + } + + public void Withdraw() + { + if (_disposed) + return; + try + { + if (File.Exists(_path)) + File.Delete(_path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + public void Dispose() + { + if (_disposed) + return; + Withdraw(); + _disposed = true; + } + + private sealed class PeerDocument + { + public Guid InstanceId { get; set; } + public long UpdatedUnixMs { get; set; } + public uint ClientId { get; set; } + public uint PlayerId { get; set; } + public string Name { get; set; } = string.Empty; + public string WorldName { get; set; } = string.Empty; + public string[] Tags { get; set; } = []; + public uint CellId { get; set; } + public double EastWest { get; set; } + public double NorthSouth { get; set; } + public double Elevation { get; set; } + public bool IsOutdoor { get; set; } + public float Heading { get; set; } + public uint CurrentHealth { get; set; } + public uint CurrentMana { get; set; } + public uint CurrentStamina { get; set; } + public uint MaxHealth { get; set; } + public uint MaxMana { get; set; } + public uint MaxStamina { get; set; } + + public static PeerDocument From( + in PluginNetworkClient client, + Guid instanceId, + long updatedUnixMs) => new() + { + InstanceId = instanceId, + UpdatedUnixMs = updatedUnixMs, + ClientId = client.ClientId, + PlayerId = client.PlayerId, + Name = client.Name, + WorldName = client.WorldName, + Tags = client.Tags + .Where(static tag => !string.IsNullOrWhiteSpace(tag)) + .Select(static tag => tag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(128) + .ToArray(), + CellId = client.Position.CellId, + EastWest = client.Position.EastWest, + NorthSouth = client.Position.NorthSouth, + Elevation = client.Position.Elevation, + IsOutdoor = client.Position.IsOutdoor, + Heading = client.Heading, + CurrentHealth = client.CurrentHealth, + CurrentMana = client.CurrentMana, + CurrentStamina = client.CurrentStamina, + MaxHealth = client.MaxHealth, + MaxMana = client.MaxMana, + MaxStamina = client.MaxStamina, + }; + + public PluginNetworkClient ToClient() => new( + ClientId, + PlayerId, + Name, + WorldName, + new PluginNavigationPosition( + CellId, + EastWest, + NorthSouth, + Elevation, + Heading, + IsOutdoor), + Tags, + CurrentHealth, + CurrentMana, + CurrentStamina, + MaxHealth, + MaxMana, + MaxStamina, + Heading); + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 32c3fecf..b736280d 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -160,7 +160,13 @@ using IDisposable atmosphericPackRegistration = renderPackRegistry.Register( "spv"))); // Constructed here and handed to both sides: GameWindow binds it to the live // session's Runtime owners, the plugin host exposes it to plugins. -using var automation = new AcDream.App.Plugins.AppAutomationSurface(); +using var automation = new AcDream.App.Plugins.AppAutomationSurface( + worldEvents, + new AcDream.App.Plugins.LocalPluginPeerRegistry(Path.Combine( + applicationPaths.DataDirectory, + "plugin-peers")), + runtimeOptions.PluginTags); +var lootClassifiers = new AcDream.Core.Plugins.PluginLootClassifierRegistry(); using var window = new GameWindow( runtimeOptions, worldGameState, @@ -175,7 +181,11 @@ var host = new AppPluginHost( worldEvents, window.Selection, uiRegistry, - automation); + automation, + new FilePluginStorage( + Path.Combine(applicationPaths.ConfigDirectory, "plugins")), + automation.PluginCommands, + lootClassifiers); GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( applicationPaths, runtimeOptions.Plugins, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b454dd94..6fba8f40 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -715,6 +715,7 @@ public sealed class GameWindow : // reset across generations. Re-binding per session would be re-binding // the same two references. _automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast); + _automation?.BindProjectileCollision(_physicsEngine); _localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState( _runtime.PlayerIdentity); _updateFrameClock = new AcDream.App.Update.UpdateFrameClock( @@ -960,6 +961,10 @@ public sealed class GameWindow : // zero skills with no error to explain it. if (_automation is null) return; + _automation.BindSpeciesNameResolver( + AcDream.App.UI.Layout.CreatureDisplayNameResolver.Load(value).Resolve); + _automation.BindPaletteColorResolver( + new AcDream.Content.CharGen.ChargenAppearanceCatalog(value)); if (!value.TryGet(0x0E000004u, out var skillTable) || skillTable is null) { @@ -983,8 +988,11 @@ public sealed class GameWindow : "prepared asset source"); void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog( - MagicCatalog value) => + MagicCatalog value) + { PublishCompositionOwner(ref _magicCatalog, value, "magic catalog"); + _automation?.BindMagicCatalog(value); + } void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader( AcDream.Core.Physics.IAnimationLoader value) => @@ -1144,6 +1152,25 @@ public sealed class GameWindow : _combatAttackController = result.CombatAttack; _externalContainerLifecycle = result.ExternalContainerLifecycle; _itemInteractionController = result.ItemInteraction; + _automation?.BindEquipment( + (itemId, requestedLocation) => + result.ItemInteraction.TryWieldItem( + itemId, + (AcDream.Core.Items.EquipMask)requestedLocation), + () => result.ItemInteraction.IsAutoWieldBusy); + _automation?.BindItems( + result.ItemInteraction.TryUseItemForAutomation, + result.ItemInteraction.TryApplyItem, + result.ItemInteraction.TryMoveItemForAutomation, + result.ItemInteraction.TryMergeItemsForAutomation, + result.ItemInteraction.TryDropItemForAutomation, + result.ItemInteraction.TryGiveItemForAutomation, + result.ItemInteraction.PlaceWorldItemInBackpack, + result.ItemInteraction.TryAppraiseForAutomation, + result.ItemInteraction.TrySalvageItemsForAutomation, + (vendorId, itemId, amount) => result.ItemInteraction.TrySell( + vendorId, + [(amount, itemId)])); _interactionUiLateBindings = result.LateBindings; _magicRuntime = result.Magic; if (result.RetainedUi is { } retained) @@ -1219,6 +1246,17 @@ public sealed class GameWindow : _retailSelectionScene = result.SelectionScene; _worldSelectionQuery = result.SelectionQuery; _selectionInteractions = result.SelectionInteractions; + _automation?.BindSelectionActions(action => + result.SelectionInteractions.HandleInputAction(action switch + { + AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousSelection => + InputAction.SelectionPreviousSelection, + AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousPlayer => + InputAction.SelectionPreviousPlayer, + AcDream.Plugin.Abstractions.PluginSelectionAction.NextPlayer => + InputAction.SelectionNextPlayer, + _ => InputAction.None, + })); _retainedUiGameplayBinding = result.RetainedGameplay; _paperdollViewportRenderer = result.PaperdollRenderer; _paperdollFramePresenter = result.PaperdollPresenter; @@ -1272,6 +1310,7 @@ public sealed class GameWindow : _worldReveal = result.WorldReveal; _spawnClaimHydration = result.SpawnClaimHydration; _liveEntityHydration = result.Hydration; + _automation?.BindGhostDeletion(result.Deletion.DeleteClientGhost); _liveEntityNetworkUpdates = result.NetworkUpdates; _liveEntityLiveness = result.Liveness; _liveEntitySessionEvents = result.SessionEvents; @@ -1282,6 +1321,7 @@ public sealed class GameWindow : _playerModeAutoEntry = result.PlayerModeAutoEntry; _localPlayerTeleport = result.LocalTeleport; _liveSessionHost = result.SessionHost; + _automation?.BindSessionCommands(result.GameRuntime); _gameplayInputActions = result.GameplayActions; _sessionPlayerBindings = result.RuntimeBindings; } @@ -1529,7 +1569,8 @@ public sealed class GameWindow : () => WorldTime.CurrentCalendar, settingsDevTools.RenderPacks, _renderPackDiagnostics.CaptureDiagnostics, - _applicationPaths.ScreenshotsDirectory), + _applicationPaths.ScreenshotsDirectory, + _automation), _retailUiLease, this).Compose( platformResult, @@ -1653,6 +1694,9 @@ public sealed class GameWindow : _combatFeedback, _portalTunnelFallback, Console.WriteLine, + _automation is null + ? null + : _automation.TryHandlePluginCommand, _statusWriter), this).Compose( hostInputCamera, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 76ed43e5..e12fd765 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -2,6 +2,7 @@ using AcDream.App.Interaction; using AcDream.App.Net; using AcDream.Core.CharGen; using AcDream.Runtime; +using AcDream.Runtime.Chat; using AcDream.Runtime.Session; using AcDream.Runtime.World; using AcDream.UI.Abstractions; @@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter { private readonly GameRuntime _runtime; private readonly CurrentGameRuntimeCommandAdapter _commands; + private readonly ICommandBus _commandBus; private readonly CharacterSelectionProjection _characterSelection; private readonly CharacterCreationProjection _characterCreation; private readonly IDisposable _hostLease; @@ -39,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter ArgumentNullException.ThrowIfNull(commands); ArgumentNullException.ThrowIfNull(selection); + _commandBus = commands; _hostLease = runtime.AcquireHostLease( "graphical game-runtime command adapter"); try @@ -137,6 +140,24 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeAllegianceCommands AllegianceCommands => _commands; IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands; + /// + /// Automation entrance to the same parser used by the retail chat field. + /// Plugins see this only through the BCL-only IPluginChat contract. + /// + internal bool SubmitChatText(string text) + { + if (!IsActive || string.IsNullOrWhiteSpace(text)) + return false; + SubmitOutcome outcome = ChatCommandRouter.Submit( + text, + new RuntimeChatCommandFeedback(_runtime.CommunicationOwner), + _commandBus, + ChatChannelKind.Say); + return outcome is not (SubmitOutcome.Empty + or SubmitOutcome.UnknownCommand + or SubmitOutcome.Dropped); + } + public RuntimeStateCheckpoint CaptureCheckpoint() { RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint(); diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 53fb6a53..41790ed5 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -122,6 +122,11 @@ public sealed record RuntimeOptions( public uint? PreparedAssetEffectiveRecipeVersion { get; init; } + /// Optional machine-local peer tags advertised to other plugin + /// instances. Parsed once here so the live automation surface never reads + /// process configuration directly. + public IReadOnlyList PluginTags { get; init; } = []; + /// /// Build options from the process environment. Used by /// Program.cs at startup. @@ -247,7 +252,10 @@ public sealed record RuntimeOptions( StatusFilePath: null, Plugins: null, LoginCommands: [], - LoginCommandDelayMs: 500); + LoginCommandDelayMs: 500) + { + PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")), + }; } /// @@ -367,6 +375,15 @@ public sealed record RuntimeOptions( private static string? NullIfEmpty(string? s) => string.IsNullOrEmpty(s) ? null : s; + private static IReadOnlyList ParsePluginTags(string? value) => + (value ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .Where(static tag => tag.Length <= 128) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(128) + .ToArray(); + private static int? TryParseInt(string? s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null; diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 6e767cab..d27c6ecb 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -75,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable // dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell. private readonly Func, uint, bool>? _sendBuyAll; private readonly Func, bool>? _sendSell; + private readonly Func, bool>? _sendSalvage; private readonly RuntimeInteractionTransactionState _runtimeTransactions; private readonly InventoryTransactionState _transactions; @@ -120,7 +121,8 @@ public sealed class ItemInteractionController : IDisposable Func, uint, bool>? sendBuyAll = null, Func, bool>? sendSell = null, Action? interfaceText = null, - Action? sendStackableMerge = null) + Action? sendStackableMerge = null, + Func, bool>? sendSalvage = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -155,6 +157,7 @@ public sealed class ItemInteractionController : IDisposable _sendBuy = sendBuy; _sendBuyAll = sendBuyAll; _sendSell = sendSell; + _sendSalvage = sendSalvage; _interactionState = interactionState ?? throw new ArgumentNullException(nameof(interactionState)); _runtimeTransactions = runtimeTransactions @@ -500,6 +503,197 @@ public sealed class ItemInteractionController : IDisposable }); } + /// + /// Plugin-facing form of retail's put/split-to-container attempts. It + /// borrows this controller's exact transaction gate and wire delegates; + /// plugins supply policy, never a second optimistic inventory model. + /// + public bool TryMoveItemForAutomation( + uint itemId, + uint containerId, + uint amount = 0u, + int placement = 0) + { + if (itemId == 0u + || containerId == 0u + || _sendPutItemInContainer is null + || _objects.Get(itemId) is not { } item + || !IsOwnedByPlayer(itemId) + || (containerId != _playerGuid() && !IsOwnedByPlayer(containerId))) + { + return false; + } + + uint fullStack = (uint)Math.Max(1, item.StackSize); + uint requested = amount == 0u ? fullStack : amount; + if (requested == 0u || requested > fullStack) + return false; + if (requested < fullStack) + { + return TrySplitToContainer( + itemId, + containerId, + (uint)Math.Max(0, placement), + requested); + } + + return TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + itemId, + () => + { + _sendPutItemInContainer(itemId, containerId, placement); + return true; + }); + } + + /// + /// Plugin-facing retail stack merge. The shared planner performs the same + /// WCID, maximum-size, staged-trade, and transfer-size checks as a drag. + /// + public bool TryMergeItemsForAutomation( + uint sourceItemId, + uint targetItemId, + uint amount = 0u) + { + if (_sendStackableMerge is null + || !IsOwnedByPlayer(sourceItemId) + || !IsOwnedByPlayer(targetItemId) + || _objects.Get(sourceItemId) is not { } source + || _objects.Get(targetItemId) is not { } target) + { + return false; + } + + int requested = amount > int.MaxValue ? int.MaxValue : (int)amount; + StackMergePlan? plan = StackMergePlanner.Plan( + ToStackMergeItem(source), + ToStackMergeItem(target), + CanMakeInventoryRequest, + requested); + if (plan is not { } merge) + return false; + + return TryDispatchInventoryRequest( + InventoryRequestKind.Merge, + sourceItemId, + () => + { + _sendStackableMerge( + merge.SourceObjectId, + merge.TargetObjectId, + merge.Amount); + MergeAttempted?.Invoke( + merge.SourceObjectId, + merge.TargetObjectId); + return true; + }); + } + + /// Plugin-facing retail full-stack drop or split-to-world. + public bool TryDropItemForAutomation(uint itemId, uint amount = 0u) + { + if (!IsOwnedByPlayer(itemId) || _objects.Get(itemId) is not { } item) + return false; + + uint fullStack = (uint)Math.Max(1, item.StackSize); + uint requested = amount == 0u ? fullStack : amount; + if (requested == 0u || requested > fullStack) + return false; + InventoryRequestKind kind = requested < fullStack + ? InventoryRequestKind.SplitToWorld + : InventoryRequestKind.DropToWorld; + return TryDispatchInventoryRequest( + kind, + itemId, + () => + { + if (requested < fullStack) + { + if (_sendSplitToWorld is null) + return false; + _sendSplitToWorld(itemId, requested); + } + else + { + if (_sendDrop is null) + return false; + _sendDrop(itemId); + } + return true; + }); + } + + /// Plugin-facing retail Give attempt with an exact stack amount. + public bool TryGiveItemForAutomation( + uint itemId, + uint targetId, + uint amount = 0u) + { + if (_sendGive is null + || targetId == 0u + || targetId == _playerGuid() + || _objects.Get(targetId) is null + || !IsOwnedByPlayer(itemId) + || _objects.Get(itemId) is not { } item) + { + return false; + } + + uint fullStack = (uint)Math.Max(1, item.StackSize); + uint requested = amount == 0u ? fullStack : amount; + if (requested == 0u || requested > fullStack) + return false; + return TryDispatchInventoryRequest( + InventoryRequestKind.Give, + itemId, + () => + { + _sendGive(targetId, itemId, requested); + return true; + }); + } + + /// + /// Plugin-facing form of gmSalvageUI::Salvage. Retail validates an owned + /// tinkering tool and a non-empty ordered list of suitable owned source + /// items, then sends 0x027D without entering the ordinary one-item move + /// transaction. The server owns the final material and option checks. + /// + public bool TrySalvageItemsForAutomation( + uint toolId, + IReadOnlyList itemIds) + { + if (_sendSalvage is null + || toolId == 0u + || itemIds is null + || itemIds.Count == 0 + || !CanMakeInventoryRequest + || !IsOwnedByPlayer(toolId) + || _objects.Get(toolId) is not { } tool + || (tool.Type & ItemType.TinkeringTool) == 0) + { + return false; + } + + var distinct = new HashSet(); + foreach (uint itemId in itemIds) + { + if (itemId == 0u + || itemId == toolId + || !distinct.Add(itemId) + || !IsOwnedByPlayer(itemId) + || _objects.Get(itemId) is not { } item + || item.MaterialType is null or 0u + || item.Structure >= 100 + || ((item.PublicWeenieBitfield ?? 0u) & 0xFF000000u) != 0u) + { + return false; + } + } + return _sendSalvage(toolId, itemIds); + } + /// /// Increments retail's shared ClientUISystem busy reference after a /// request issued by another retained controller has been sent. The @@ -655,6 +849,21 @@ public sealed class ItemInteractionController : IDisposable _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine); } + /// + /// Plugin/automation appraisal through the one retail appraisal owner. + /// It does not mutate selection or open/raise the examination window. + /// + public bool TryAppraiseForAutomation(uint objectId) + { + if (objectId == 0u + || _sendExamine is null + || _objects.Get(objectId) is null) + { + return false; + } + return _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine); + } + /// /// Accepts only the pending or current appraisal, matching /// gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0. @@ -750,6 +959,76 @@ public sealed class ItemInteractionController : IDisposable return ExecuteUseActions(decision.Actions); } + /// + /// Plugin/automation entry for an ordinary item request. Unlike interactive + /// activation, it never turns a use request into wielding, sorting, or a + /// modal target cursor, and returns true only when a wire Use was issued. + /// + public bool TryUseItemForAutomation(uint itemGuid) + { + if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item) + return false; + if (ItemUseability.IsTargeted(item.Useability ?? ItemUseability.Undef)) + return false; + if (!ConsumeUseThrottle()) + return false; + if (!EnsureInventoryRequestReady()) + return false; + + var input = new ItemUsePolicyInput( + Snapshot(item), + _playerGuid(), + _groundObjectId(), + CanMakeInventoryRequest, + _activeVendorId(), + BypassClassification: true, + UseCurrentSelection: false, + SelectedTarget: null, + ConfirmVolatileRareUses: true, + InNonCombatMode: _inNonCombatMode()); + ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input); + bool sends = decision.Actions.Any(static action => + action.Kind == ItemPolicyActionKind.SendUse); + return sends && ExecuteUseActions(decision.Actions); + } + + /// + /// Plugin/automation entry for a targeted item action. It follows the same + /// retail compatibility, throttle, busy-reference and UseDone ownership as + /// choosing a target through the interactive target cursor, without + /// installing a modal cursor state that automation cannot safely own. + /// + public bool TryApplyItem(uint itemGuid, uint targetGuid) + { + if (itemGuid == 0u || targetGuid == 0u) + return false; + if (_objects.Get(itemGuid) is not { } item + || _objects.Get(targetGuid) is not { } target) + { + return false; + } + if (!ConsumeUseThrottle()) + return true; + if (!EnsureInventoryRequestReady()) + return false; + + var input = new ItemUsePolicyInput( + Snapshot(item), + _playerGuid(), + _groundObjectId(), + CanMakeInventoryRequest, + _activeVendorId(), + BypassClassification: true, + UseCurrentSelection: true, + SelectedTarget: Snapshot(target), + ConfirmVolatileRareUses: true, + InNonCombatMode: _inNonCombatMode()); + ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input); + bool sends = decision.Actions.Any(static action => + action.Kind == ItemPolicyActionKind.SendUseWithTarget); + return sends && ExecuteUseActions(decision.Actions); + } + /// /// Retail keyboard pickup entry point. CPlayerSystem::PlaceInBackpack /// publishes the waiting destination slot before issuing the move request, @@ -1095,6 +1374,24 @@ public sealed class ItemInteractionController : IDisposable return _autoWield.TryWield(item, targetMask); } + /// + /// Plugin/automation entry into the exact same AutoWield transaction used + /// by inventory activation and paperdoll drops. + /// + public bool TryWieldItem(uint itemGuid, EquipMask requestedMask = EquipMask.None) + { + if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item) + return false; + if (!EnsureInventoryRequestReady()) + return false; + return requestedMask == EquipMask.None + ? _autoWield.TryWield(item) + : _autoWield.TryWield(item, requestedMask); + } + + public bool IsAutoWieldBusy => + _autoWield.IsBusy || !_transactions.CanBeginRequest; + /// User combat-mode input supersedes AutoWield's retained mode. public void NotifyExplicitCombatModeRequest() => _autoWield.NotifyExplicitCombatModeRequest(); diff --git a/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs b/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs new file mode 100644 index 00000000..3d271ddd --- /dev/null +++ b/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs @@ -0,0 +1,137 @@ +using System.Numerics; +using AcDream.Core.Selection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.UI.Layout; + +/// +/// Retained-view projection of VTank's ShowCollisionDebug shapes. +/// The collision query remains in the canonical physics world; this owner +/// only projects its detached per-quantum samples into the already-open UI +/// phase, avoiding a nested Vulkan backbuffer pass. +/// +internal sealed class ProjectileDebugOverlayController +{ + private static readonly Vector4 ClearColor = new(0f, 1f, 0f, 0.95f); + private static readonly Vector4 BlockedColor = new(1f, 0f, 0f, 0.95f); + + private readonly UiPanel _root; + private readonly Func> _samples; + private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> + _camera; + private readonly List _markers = []; + + private ProjectileDebugOverlayController( + UiPanel root, + Func> samples, + Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) + { + _root = root; + _samples = samples; + _camera = camera; + } + + internal static ProjectileDebugOverlayController Mount( + UiRoot host, + Func> samples, + Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(samples); + ArgumentNullException.ThrowIfNull(camera); + var root = new UiPanel + { + Name = "PluginProjectileDebugOverlay", + BackgroundColor = Vector4.Zero, + BorderColor = Vector4.Zero, + ClickThrough = true, + Visible = false, + ZOrder = -9_999, + Anchors = AnchorEdges.None, + }; + host.AddChild(root); + return new ProjectileDebugOverlayController(root, samples, camera); + } + + internal void Tick() + { + IReadOnlyList samples = _samples(); + var camera = _camera(); + if (samples.Count == 0 + || camera.Viewport.X <= 0f + || camera.Viewport.Y <= 0f) + { + HideAll(); + return; + } + + EnsureMarkerCount(samples.Count); + _root.Left = 0f; + _root.Top = 0f; + _root.Width = camera.Viewport.X; + _root.Height = camera.Viewport.Y; + int visible = 0; + for (int index = 0; index < samples.Count; index++) + { + PluginProjectileDebugSample sample = samples[index]; + if (!ScreenProjection.TryProjectSphereToScreenRect( + sample.WorldPosition, + sample.Radius, + camera.View, + camera.Projection, + camera.Viewport, + out Vector2 minimum, + out Vector2 maximum, + out _, + minSidePixels: 4f) + || maximum.X < 0f + || maximum.Y < 0f + || minimum.X > camera.Viewport.X + || minimum.Y > camera.Viewport.Y) + { + continue; + } + + UiPanel marker = _markers[visible++]; + marker.Left = MathF.Max(0f, minimum.X); + marker.Top = MathF.Max(0f, minimum.Y); + marker.Width = MathF.Max( + 1f, + MathF.Min(camera.Viewport.X, maximum.X) - marker.Left); + marker.Height = MathF.Max( + 1f, + MathF.Min(camera.Viewport.Y, maximum.Y) - marker.Top); + marker.BorderColor = sample.IsClear ? ClearColor : BlockedColor; + marker.Visible = true; + } + for (int index = visible; index < _markers.Count; index++) + _markers[index].Visible = false; + _root.Visible = visible > 0; + } + + private void EnsureMarkerCount(int count) + { + while (_markers.Count < count) + { + var marker = new UiPanel + { + Name = $"PluginProjectileDebugMarker{_markers.Count}", + BackgroundColor = Vector4.Zero, + BorderColor = ClearColor, + BorderThickness = 1.5f, + ClickThrough = true, + Visible = false, + Anchors = AnchorEdges.None, + }; + _markers.Add(marker); + _root.AddChild(marker); + } + } + + private void HideAll() + { + _root.Visible = false; + for (int index = 0; index < _markers.Count; index++) + _markers[index].Visible = false; + } +} diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index c45d3727..17c72a01 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -14,6 +14,12 @@ namespace AcDream.App.UI; /// public static class MarkupDocument { + // Retail's generic runtime-text tooltip skin. Plugin controls have no + // LayoutDesc of their own, so a tooltip= attribute explicitly opts them + // into the same popup that game-code SetTooltip call sites use. + private const uint RuntimeTooltipRootElementId = 0x10000397u; + private const uint RuntimeTooltipLayoutDid = 0x21000041u; + /// Raw XML markup for a single panel. /// Object whose public properties are bound to {PropName} attributes. /// Surface id → (GL handle, width, height) for chrome sprites. @@ -74,13 +80,47 @@ public static class MarkupDocument } foreach (var el in root.Elements()) + AddElement(panel, el, binding, resolve, datFont); + return panel; + } + + private static void AddElement( + UiElement parent, + XElement el, + object binding, + Func resolve, + UiDatFont? datFont) + { + switch (el.Name.LocalName) { - switch (el.Name.LocalName) - { - case "meter": + case "group": + var group = new UiPanel + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + BackgroundColor = el.Attribute("background") is null + ? Vector4.Zero + : Color((string?)el.Attribute("background")), + BorderColor = el.Attribute("border") is null + ? Vector4.Zero + : Color((string?)el.Attribute("border")), + BorderThickness = el.Attribute("border") is null ? 0f : 1f, + // Transparent layout groups do not claim empty space, while + // their interactive descendants remain hittable. + ClickThrough = true, + }; + ApplyCommon(group, el, binding); + parent.AddChild(group); + foreach (XElement child in el.Elements()) + AddElement(group, child, binding, resolve, datFont); + break; + + case "meter": var cur = BindUint((string?)el.Attribute("cur"), binding); var max = BindUint((string?)el.Attribute("max"), binding); - panel.AddChild(new UiMeter + var meter = new UiMeter { Left = F(el, "x"), Top = F(el, "y"), @@ -97,10 +137,12 @@ public static class MarkupDocument FrontLeft = Hex((string?)el.Attribute("frontleft")), FrontTile = Hex((string?)el.Attribute("fronttile")), FrontRight = Hex((string?)el.Attribute("frontright")), - }); + }; + ApplyCommon(meter, el, binding); + parent.AddChild(meter); break; - case "label": + case "label": // Text may be a literal or a {Binding}. Bound labels re-read // their property every frame through the Func, so a plugin // updates its status line by assigning a property rather @@ -114,10 +156,11 @@ public static class MarkupDocument }; if (el.Attribute("color") is not null) label.TextColor = Color((string?)el.Attribute("color")); - panel.AddChild(label); + ApplyCommon(label, el, binding); + parent.AddChild(label); break; - case "button": + case "button": // onclick binds to an Action property on the binding // object. Resolved once at build time: a button whose // handler silently failed to bind is a bug worth failing @@ -151,13 +194,243 @@ public static class MarkupDocument button.TextSource = BindString(caption, binding); if (el.Attribute("color") is not null) button.TextColor = Color((string?)el.Attribute("color")); + if (el.Attribute("background") is not null) + button.BackgroundColor = Color( + (string?)el.Attribute("background")); + if (el.Attribute("border") is not null) + button.BorderColor = Color( + (string?)el.Attribute("border")); + ApplyCommon(button, el, binding); if (onClick is not null) button.Click += onClick; - panel.AddChild(button); + parent.AddChild(button); break; - } + + case "tab": + string? tabClickName = (string?)el.Attribute("onclick"); + Action? tabClick = BindAction(tabClickName, binding); + if (tabClickName is not null && tabClick is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + var tab = new UiMarkupTabButton + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + Text = (string?)el.Attribute("text") ?? string.Empty, + DatFont = datFont, + SelectedSource = BindRequiredBoolReader( + (string?)el.Attribute("selected"), + binding, + "tab selected"), + }; + ApplyCommon(tab, el, binding); + if (tabClick is not null) + tab.Click += tabClick; + parent.AddChild(tab); + break; + + case "toggle": + string? toggleClickName = (string?)el.Attribute("onclick"); + Action? toggleClick = BindAction(toggleClickName, binding); + if (toggleClickName is not null && toggleClick is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + string? toggleCaption = (string?)el.Attribute("text"); + var toggle = new UiMarkupToggle + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + Text = toggleCaption ?? string.Empty, + TextSource = BindString(toggleCaption, binding), + CheckedSource = BindRequiredBoolReader( + (string?)el.Attribute("checked"), + binding, + "toggle checked"), + DatFont = datFont, + Toggle = toggleClick, + }; + if (el.Attribute("color") is not null) + toggle.TextColor = Color((string?)el.Attribute("color")); + ApplyCommon(toggle, el, binding); + parent.AddChild(toggle); + break; + + case "slider": + string? changeName = (string?)el.Attribute("onchange"); + Action? changed = BindFloatAction(changeName, binding); + if (changeName is not null && changed is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + var slider = new UiScrollbar + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + Horizontal = true, + SpriteResolve = resolve, + ScalarPositionSource = BindFloat( + (string?)el.Attribute("value"), + binding), + ScalarChanged = changed, + }; + RetailScrollbarChrome.ApplyHorizontal(slider); + ApplyCommon(slider, el, binding); + parent.AddChild(slider); + break; + + case "field": + string? fieldChangeName = (string?)el.Attribute("onchange"); + Action? fieldChanged = BindStringAction( + fieldChangeName, + binding); + if (fieldChangeName is not null && fieldChanged is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + string? submitName = (string?)el.Attribute("onsubmit"); + Action? submitted = BindStringAction(submitName, binding); + if (submitName is not null && submitted is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + var field = new UiField + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + DatFont = datFont, + BackgroundColor = el.Attribute("background") is null + ? new Vector4(0f, 0f, 0f, 0.9f) + : Color((string?)el.Attribute("background")), + TextColor = el.Attribute("color") is null + ? new Vector4(0.91f, 0.87f, 0.76f, 1f) + : Color((string?)el.Attribute("color")), + MaxCharacters = Math.Max(1, I(el, "maxlength", 128)), + ClearOnSubmit = B(el, "clearonsubmit", false), + RecordHistory = false, + OnTextChanged = fieldChanged, + OnSubmit = submitted, + }; + field.SetText(BindString((string?)el.Attribute("text"), binding)()); + ApplyCommon(field, el, binding); + parent.AddChild(field); + break; + + case "menu": + string? menuChangeName = (string?)el.Attribute("onchange"); + Action? menuChanged = BindStringAction( + menuChangeName, + binding); + if (menuChangeName is not null && menuChanged is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + Func> menuItems = BindStringList( + (string?)el.Attribute("items"), + binding, + "menu items"); + Func menuSelected = BindString( + (string?)el.Attribute("selected"), + binding); + var menu = new UiMenu + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + DatFont = datFont, + SpriteResolve = resolve, + RowsPerColumn = Math.Max(1, I(el, "rows", 7)), + RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), + ColumnWidth = Math.Max(20f, F(el, "w")), + OpenUpward = B(el, "openupward", false), + TextIndent = 6f, + ButtonTextIndent = 6f, + NormalSprite = 0x06004D65u, + PressedSprite = 0x06004D66u, + PopupBgSprite = 0x0600124Cu, + ItemNormalSprite = 0x0600124Eu, + ItemHighlightSprite = 0x0600124Du, + ButtonLabelProvider = () => menuSelected() ?? string.Empty, + OnSelect = payload => + { + if (payload is string value) + menuChanged?.Invoke(value); + }, + }; + void RefreshMenu() + { + menu.Items = menuItems() + .Select(static value => new UiMenu.MenuItem(value, value)) + .ToArray(); + menu.Selected = menuSelected(); + } + RefreshMenu(); + menu.BeforeOpen = RefreshMenu; + ApplyCommon(menu, el, binding); + parent.AddChild(menu); + break; + + case "list": + string? listChangeName = (string?)el.Attribute("onchange"); + Action? listChanged = BindIntAction(listChangeName, binding); + if (listChangeName is not null && listChanged is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + var list = new UiMarkupList + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), + DatFont = datFont, + ItemsSource = BindStringList( + (string?)el.Attribute("items"), + binding, + "list items"), + ItemColorsSource = BindUintList( + (string?)el.Attribute("colors"), + binding, + "list colors"), + SelectedIndexSource = BindRequiredIntReader( + (string?)el.Attribute("selected"), + binding, + "list selected"), + SelectionChanged = listChanged, + }; + ApplyCommon(list, el, binding); + parent.AddChild(list); + break; } - return panel; } /// @@ -197,13 +470,197 @@ public static class MarkupDocument return () => (property.GetValue(binding) as Action)?.Invoke(); } + private static Action? BindFloatAction( + string? attribute, + object binding) + { + if (attribute is null || !IsBinding(attribute)) + return null; + + string name = attribute[1..^1]; + PropertyInfo? property = binding.GetType().GetProperty(name); + if (property is null + || !typeof(Action).IsAssignableFrom(property.PropertyType)) + return null; + return value => (property.GetValue(binding) as Action)?.Invoke(value); + } + + private static Action? BindStringAction( + string? attribute, + object binding) + { + if (attribute is null || !IsBinding(attribute)) + return null; + + string name = attribute[1..^1]; + PropertyInfo? property = binding.GetType().GetProperty(name); + if (property is null + || !typeof(Action).IsAssignableFrom(property.PropertyType)) + { + return null; + } + return value => (property.GetValue(binding) as Action)?.Invoke(value); + } + + private static Action? BindIntAction(string? attribute, object binding) + { + if (attribute is null || !IsBinding(attribute)) + return null; + PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]); + if (property is null + || !typeof(Action).IsAssignableFrom(property.PropertyType)) + { + return null; + } + return value => (property.GetValue(binding) as Action)?.Invoke(value); + } + + private static Func> BindStringList( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) + throw new FormatException($"{context} must be a string-list binding"); + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null + || !typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + throw new FormatException( + $"{expression} did not resolve to an IEnumerable property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is IEnumerable values + ? values.ToArray() + : Array.Empty(); + } + + private static Func> BindUintList( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression)) + return static () => Array.Empty(); + if (!IsBinding(expression)) + throw new FormatException($"{context} must be a uint-list binding"); + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null + || !typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + throw new FormatException( + $"{expression} did not resolve to an IEnumerable property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is IEnumerable values + ? values.ToArray() + : Array.Empty(); + } + private static bool IsBinding(string value) => value.Length > 2 && value[0] == '{' && value[^1] == '}'; + private static void ApplyCommon( + UiElement element, + XElement source, + object binding) + { + element.Name = (string?)source.Attribute("name") + ?? (string?)source.Attribute("id"); + BindBool((string?)source.Attribute("visible"), binding, + value => element.Visible = value, + sourceReader => element.VisibleSource = sourceReader); + BindBool((string?)source.Attribute("enabled"), binding, + value => element.Enabled = value, + sourceReader => element.EnabledSource = sourceReader); + + string? tooltip = (string?)source.Attribute("tooltip"); + if (!string.IsNullOrWhiteSpace(tooltip)) + { + element.RuntimeTooltipTextSource = BindString(tooltip, binding); + element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId; + element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid; + element.AuthoredTooltipEnabled = true; + } + } + + private static void BindBool( + string? expression, + object binding, + Action setLiteral, + Action> setSource) + { + if (string.IsNullOrWhiteSpace(expression)) + return; + if (!IsBinding(expression)) + { + if (bool.TryParse(expression, out bool literal)) + setLiteral(literal); + return; + } + + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null || property.PropertyType != typeof(bool)) + { + throw new FormatException( + $"{expression} did not resolve to a bool property on " + + binding.GetType().Name); + } + setSource(() => property.GetValue(binding) is true); + } + + private static Func BindRequiredBoolReader( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) + throw new FormatException($"{context} must be a bool binding"); + + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null || property.PropertyType != typeof(bool)) + { + throw new FormatException( + $"{expression} did not resolve to a bool property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is true; + } + + private static Func BindRequiredIntReader( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) + throw new FormatException($"{context} must be an int binding"); + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null || property.PropertyType != typeof(int)) + { + throw new FormatException( + $"{expression} did not resolve to an int property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is int value ? value : -1; + } + private static float F(XElement e, string attr) => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f; + private static float FOr(XElement e, string attr, float fallback) + => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, + CultureInfo.InvariantCulture, out float value) ? value : fallback; + + private static int I(XElement e, string attr, int fallback) + => int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int value) ? value : fallback; + + private static bool B(XElement e, string attr, bool fallback) + => bool.TryParse((string?)e.Attribute(attr), out bool value) + ? value + : fallback; + /// /// Parses #AARRGGBB → RGBA (alpha first, matching /// controls.ini convention). Falls back to opaque white on bad input. diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs new file mode 100644 index 00000000..364839a2 --- /dev/null +++ b/src/AcDream.App/UI/PluginSidePanel.cs @@ -0,0 +1,355 @@ +using System.Numerics; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Host-owned shelf for running gameplay plugins. A shelf button changes only +/// presentation visibility; it never touches plugin enable/session lifetime. +/// +public sealed class PluginSidePanel : UiPanel, IDisposable +{ + private const float OuterPadding = 4f; + private const float ButtonExtent = 28f; + private const float ButtonGap = 4f; + private const float DefaultTop = 116f; + + private readonly RetailWindowManager _windows; + private readonly Func _resolve; + private readonly UiDatFont? _font; + private readonly Dictionary _entries = []; + private bool _disposed; + private float _lastLayoutHeight = -1f; + + public PluginSidePanel( + RetailWindowManager windows, + Func resolve, + UiDatFont? font) + { + _windows = windows ?? throw new ArgumentNullException(nameof(windows)); + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _font = font; + + Width = ButtonExtent + OuterPadding * 2f; + Height = OuterPadding * 2f; + Top = DefaultTop; + Anchors = AnchorEdges.None; + Draggable = false; + Resizable = false; + BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f); + BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f); + BorderThickness = 1f; + Visible = false; + + _windows.WindowUnregistered += OnWindowUnregistered; + } + + /// Number of live plugin-window entries, exposed for gates. + public int EntryCount => _entries.Count; + + /// + /// Adds one manifest-scoped plugin window and its minimize affordance. + /// Duplicate handles are idempotent. + /// + public void Add( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + RetailWindowHandle handle) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(handle); + if (_entries.ContainsKey(handle)) + return; + + // Plugin windows are ordinary retained windows, but unlike imported + // retail windows they have no authored MoveTo override. Keep their + // chrome reachable at the minimum 800x600 canvas and after a display + // resize. An oversized window follows retail's top-left-priority rule: + // pin to zero rather than stranding the title/minimize controls. + handle.OuterFrame.ConstrainDragToParent = true; + handle.OuterFrame.ConstrainResizeToParent = true; + KeepWindowReachable(handle); + + var button = new PluginShelfButton( + descriptor, + owner.DisplayName, + handle, + _resolve, + _font) + { + Width = ButtonExtent, + Height = ButtonExtent, + }; + button.Click += () => + { + if (handle.IsVisible) + handle.Hide(); + else + handle.Show(); + }; + + var minimize = new PluginMinimizeButton(handle, _font) + { + Left = MathF.Max(8f, handle.OuterFrame.Width - 23f), + Top = 3f, + Width = 18f, + Height = 17f, + Anchors = AnchorEdges.Top | AnchorEdges.Right, + }; + handle.OuterFrame.AddChild(minimize); + + _entries.Add(handle, new ShelfEntry(button, minimize)); + AddChild(button); + Reflow(); + } + + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + + // Screen-edge dock: root bounds become authoritative at draw time, so + // compute this from the live parent rather than capturing an anchor + // margin while the pre-first-frame root still measures 0x0. + if (Parent is { } parent) + { + float availableHeight = MathF.Max( + ButtonExtent + OuterPadding * 2f, + parent.Height - Top - OuterPadding); + if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f) + { + _lastLayoutHeight = availableHeight; + Reflow(availableHeight); + } + Left = MathF.Max(0f, parent.Width - Width - OuterPadding); + } + + foreach (RetailWindowHandle handle in _entries.Keys) + KeepWindowReachable(handle); + + // The shelf remains reachable even after ordinary windows are raised. + if (Parent is { } root) + { + int highest = 0; + foreach (UiElement sibling in root.Children) + { + if (!ReferenceEquals(sibling, this)) + highest = Math.Max(highest, sibling.ZOrder); + } + if (ZOrder <= highest) + ZOrder = highest == int.MaxValue ? highest : highest + 1; + } + } + + private void OnWindowUnregistered(RetailWindowHandle handle) + { + if (!_entries.Remove(handle, out ShelfEntry entry)) + return; + + RemoveChild(entry.Button); + if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame)) + handle.OuterFrame.RemoveChild(entry.Minimize); + entry.Button.DisposeSubscriptions(); + Reflow(); + } + + private static void KeepWindowReachable(RetailWindowHandle handle) + { + if (handle.OuterFrame.Parent is not { } parent + || parent.Width <= 0f + || parent.Height <= 0f) + { + return; + } + + float left = Math.Clamp( + handle.Left, + 0f, + MathF.Max(0f, parent.Width - handle.Width)); + float top = Math.Clamp( + handle.Top, + 0f, + MathF.Max(0f, parent.Height - handle.Height)); + if (left != handle.Left || top != handle.Top) + handle.MoveTo(left, top); + } + + private void Reflow(float maximumHeight = float.PositiveInfinity) + { + int maximumRows = float.IsPositiveInfinity(maximumHeight) + ? Math.Max(1, _entries.Count) + : Math.Max( + 1, + (int)MathF.Floor( + (maximumHeight - OuterPadding * 2f + ButtonGap) + / (ButtonExtent + ButtonGap))); + int index = 0; + foreach (ShelfEntry entry in _entries.Values) + { + int column = index / maximumRows; + int row = index % maximumRows; + entry.Button.Left = OuterPadding + + column * (ButtonExtent + ButtonGap); + entry.Button.Top = OuterPadding + + row * (ButtonExtent + ButtonGap); + index++; + } + + int rows = Math.Min(index, maximumRows); + int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows; + Width = OuterPadding * 2f + + columns * ButtonExtent + + Math.Max(0, columns - 1) * ButtonGap; + Height = OuterPadding * 2f + + rows * ButtonExtent + + Math.Max(0, rows - 1) * ButtonGap; + Visible = index > 0; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _windows.WindowUnregistered -= OnWindowUnregistered; + + foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries) + { + entry.Button.DisposeSubscriptions(); + if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame)) + handle.OuterFrame.RemoveChild(entry.Minimize); + } + _entries.Clear(); + Visible = false; + } + + private readonly record struct ShelfEntry( + PluginShelfButton Button, + PluginMinimizeButton Minimize); + + private sealed class PluginShelfButton : UiSimpleButton + { + private static readonly Vector4 HiddenBackground = + new(0.025f, 0.025f, 0.02f, 0.96f); + private static readonly Vector4 VisibleBackground = + new(0.09f, 0.19f, 0.055f, 0.96f); + private static readonly Vector4 HiddenBorder = + new(0.48f, 0.38f, 0.14f, 1f); + private static readonly Vector4 VisibleBorder = + new(0.76f, 0.64f, 0.25f, 1f); + + private readonly RetailWindowHandle _handle; + private readonly Func _resolve; + private readonly uint _iconSurfaceId; + private readonly string _tooltip; + + internal PluginShelfButton( + PluginPanelDescriptor descriptor, + string ownerDisplayName, + RetailWindowHandle handle, + Func resolve, + UiDatFont? font) + { + _handle = handle; + _resolve = resolve; + _iconSurfaceId = descriptor.IconSurfaceId; + _tooltip = string.Equals(descriptor.Title, ownerDisplayName, + StringComparison.Ordinal) + ? descriptor.Title + : $"{ownerDisplayName} — {descriptor.Title}"; + Text = _iconSurfaceId == 0 + ? Initials(descriptor.IconText, descriptor.Title) + : string.Empty; + DatFont = font; + Outline = true; + BorderThickness = 1f; + _handle.Shown += OnVisibilityChanged; + _handle.Hidden += OnVisibilityChanged; + RefreshPresentation(); + } + + public override string? GetTooltipText() => _tooltip; + + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + RefreshPresentation(); + } + + protected override void OnDraw(UiRenderContext ctx) + { + base.OnDraw(ctx); + if (_iconSurfaceId == 0) + return; + + (uint texture, int width, int height) = _resolve(_iconSurfaceId); + if (texture == 0 || width <= 0 || height <= 0) + return; + float extent = MathF.Min(Width - 6f, Height - 6f); + ctx.DrawSprite( + texture, + (Width - extent) * 0.5f, + (Height - extent) * 0.5f, + extent, + extent, + 0f, + 0f, + 1f, + 1f, + Vector4.One); + } + + internal void DisposeSubscriptions() + { + _handle.Shown -= OnVisibilityChanged; + _handle.Hidden -= OnVisibilityChanged; + } + + private void OnVisibilityChanged(RetailWindowHandle _) => + RefreshPresentation(); + + private void RefreshPresentation() + { + BackgroundColor = _handle.IsVisible + ? VisibleBackground + : HiddenBackground; + BorderColor = _handle.IsVisible ? VisibleBorder : HiddenBorder; + } + + private static string Initials(string? requested, string title) + { + if (!string.IsNullOrWhiteSpace(requested)) + return requested.Trim()[..Math.Min(3, requested.Trim().Length)]; + + string[] words = title.Split( + ' ', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (words.Length == 0) + return "?"; + if (words.Length == 1) + return words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant(); + return string.Concat(words.Take(2).Select(static word => + char.ToUpperInvariant(word[0]))); + } + } + + private sealed class PluginMinimizeButton : UiSimpleButton + { + private readonly RetailWindowHandle _handle; + + internal PluginMinimizeButton(RetailWindowHandle handle, UiDatFont? font) + { + _handle = handle; + Text = "–"; + DatFont = font; + Outline = true; + BackgroundColor = new Vector4(0.02f, 0.02f, 0.015f, 0.94f); + BorderColor = new Vector4(0.58f, 0.46f, 0.17f, 1f); + BorderThickness = 1f; + Click += () => _handle.Hide(); + } + + public override string? GetTooltipText() => "Minimize to plugin sidepanel"; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8ac69a8e..2683d01e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -25,6 +25,7 @@ using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Vitals; using AcDream.UI.Abstractions.Input; +using AcDream.Plugin.Abstractions; using DatReaderWriter; using Silk.NET.Input; @@ -531,7 +532,9 @@ public sealed record RetailUiRuntimeBindings( CharacterSelectionRuntimeBindings? CharacterSelection = null, // Campaign CC slice CC4: sibling of CharacterSelection above. CharacterCreationRuntimeBindings? CharacterCreation = null, - Action? CaptureScreenshot = null); + Action? CaptureScreenshot = null, + Func>? + ProjectileDebugSamples = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -553,9 +556,11 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; + private ProjectileDebugOverlayController? _projectileDebugOverlay; private Layout.VitalsSideBySideController? _vitalsSideBySide; private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterCreationUiMountCoordinator? _characterCreationMount; + private PluginSidePanel? _pluginSidePanel; private IDisposable? _characterSheetSubscription; private Layout.CharacterTitlesController? _characterTitlesController; private ResourceShutdownTransaction? _shutdown; @@ -602,6 +607,7 @@ public sealed class RetailUiRuntime : IDisposable RetailUiRuntimeBindings bindings = _bindings; MountFpsDisplay(); MountVividTargetIndicator(); + MountProjectileDebugOverlay(); MountVitals(); MountRadar(); MountChat(); @@ -935,6 +941,7 @@ public sealed class RetailUiRuntime : IDisposable Layout.UiMediaClock.Advance(deltaSeconds); FpsController?.Tick(); _vividTargetIndicator?.Tick(); + _projectileDebugOverlay?.Tick(); _vitalsSideBySide?.Tick(); SpellbookWindowController?.Tick(); AppraisalController?.Tick(deltaSeconds); @@ -1655,6 +1662,18 @@ public sealed class RetailUiRuntime : IDisposable : "[D.2b] vivid target indicator mounted from client-enum category 0x10000009."); } + private void MountProjectileDebugOverlay() + { + if (_bindings.ProjectileDebugSamples is not { } samples) + return; + _projectileDebugOverlay = ProjectileDebugOverlayController.Mount( + Host.Root, + samples, + _bindings.VividTarget.Camera); + Console.WriteLine( + "[PluginUI] projectile collision debug overlay mounted."); + } + private void MountVitals() { ImportedLayout? layout = Import(0x2100006Cu); @@ -4608,16 +4627,64 @@ public sealed class RetailUiRuntime : IDisposable { try { - string xml = File.ReadAllText(panel.MarkupPath); - UiElement element = MarkupDocument.Build( + string xml = panel.MarkupContent + ?? File.ReadAllText(panel.MarkupPath); + UiNineSlicePanel element = MarkupDocument.Build( xml, panel.Binding, _bindings.Assets.ResolveSprite, _bindings.Assets.Controls, _bindings.Assets.DefaultFont); + + if (Host.WindowManager.TryGet(panel.WindowName, out _)) + { + throw new InvalidOperationException( + $"Plugin window '{panel.WindowName}' is already registered. " + + "Window ids must be unique within one plugin."); + } + + // Markup's root visibility is an availability gate (for example, + // a world-only panel), while the descriptor/persisted state is the + // user's minimize choice. Keep those two axes independent so an + // availability transition never disables the running plugin or + // forgets that the user wanted its window open. + Func? availability = element.VisibleSource; + var visibility = new PluginWindowVisibilityController( + availability, + panel.Descriptor.StartVisible); + element.VisibleSource = visibility.ShouldBeVisible; + element.Visible = visibility.ShouldBeVisible(); + Host.Root.AddChild(element); + // Publish ownership immediately after the tree mutation. Any + // later registration/sidepanel failure then rolls the mounted + // subtree back through FailMount instead of leaking it. _bindings.Plugins.CompleteMount(panel, Host.Root, element); - Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); + RetailWindowHandle handle = Host.WindowManager.Register( + panel.WindowName, + element, + element, + visibility); + _bindings.Plugins.CompleteWindowMount( + panel, + () => Host.WindowManager.Unregister(panel.WindowName)); + + if (panel.Descriptor.ShowInSidePanel) + { + if (_pluginSidePanel is null) + { + _pluginSidePanel = new PluginSidePanel( + Host.WindowManager, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont); + Host.Root.AddChild(_pluginSidePanel); + } + _pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle); + } + + Console.WriteLine( + $"[D.2b] plugin UI window loaded: {panel.WindowName} " + + $"({panel.MarkupPath})"); } catch (Exception ex) { @@ -5312,6 +5379,7 @@ public sealed class RetailUiRuntime : IDisposable { _characterSheetSubscription?.Dispose(); _characterTitlesController?.Dispose(); + _pluginSidePanel?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; WindowLockPresentation.Dispose(); WindowOpacity.Dispose(); @@ -5339,6 +5407,36 @@ public sealed class RetailUiRuntime : IDisposable _disposed = _shutdown.IsComplete; } + /// + /// Separates plugin availability from the user's minimized/open choice. + /// Window-manager callbacks update only the latter; a false availability + /// predicate hides temporarily without forgetting the requested state. + /// + private sealed class PluginWindowVisibilityController( + Func? availability, + bool startVisible) : IRetainedPanelController + { + private bool _requestedVisible = startVisible; + + internal bool ShouldBeVisible() => + _requestedVisible && (availability?.Invoke() ?? true); + + public void OnShown() => _requestedVisible = true; + + public void OnHidden() + { + // Hidden because the markup's availability gate went false is + // temporary. Hidden while available is a real minimize/restore- + // persistence transition and changes the requested state. + if (availability?.Invoke() ?? true) + _requestedVisible = false; + } + + public void Dispose() + { + } + } + internal static ResourceShutdownTransaction CreateShutdownTransaction( Action disposeAutomation, Action disposePersistence, diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 7d95d961..e0c3874f 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -55,7 +55,7 @@ public abstract class UiElement public uint DatElementId { get; internal set; } /// Human-readable name for debugging / FindByName. - public string? Name { get; init; } + public string? Name { get; set; } /// /// GF-13 (Campaign CC gate round 1, Batch A): mirrors @@ -274,6 +274,12 @@ public abstract class UiElement /// public Func? VisibleSource { get; set; } + /// + /// Optional live enabled reader. Declarative plugin controls use this to + /// expose unavailable/busy state without retaining presentation objects. + /// + public Func? EnabledSource { get; set; } + /// /// If true, will set focus here on click, /// routing WM_KEYDOWN / WM_CHAR to as @@ -642,7 +648,19 @@ public abstract class UiElement /// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString" /// (vtable +0x88) to render the tooltip body. /// - public virtual string? GetTooltipText() => null; + /// + /// Runtime-created/plugin-markup widgets have no LayoutDesc property bag from + /// which to import P0x49. They use this live source instead; the markup host + /// still supplies retail's shared tooltip-popup locator, so presentation stays + /// inside the common retained tooltip pipeline rather than becoming plugin UI. + /// + public Func? RuntimeTooltipTextSource { get; set; } + + public virtual string? GetTooltipText() + { + string? text = RuntimeTooltipTextSource?.Invoke(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } // ── Framework entry points (internal, called by UiRoot) ───────────── @@ -763,6 +781,8 @@ public abstract class UiElement if (VisibleSource is { } visibility) Visible = visibility(); if (!Visible) return; + if (EnabledSource is { } enabled) + Enabled = enabled(); OnTick(dt); for (int i = 0; i < _children.Count; i++) _children[i].TickSelfAndChildren(dt); diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 00ac7e2c..57f502b3 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -110,6 +110,13 @@ public sealed class UiField : UiElement public Action? OnSubmit { get; set; } public Action? OnFocusGained { get; set; } public Action? OnFocusLost { get; set; } + /// + /// Live text mutation callback used by retained plugin markup. This is + /// deliberately separate from submit/focus-loss: editors need their + /// binding model to track typing so an adjacent button can consume the + /// current value without reaching into the widget tree. + /// + public Action? OnTextChanged { get; set; } private string _textValue = ""; @@ -127,8 +134,11 @@ public sealed class UiField : UiElement get => _textValue; set { + if (string.Equals(_textValue, value, StringComparison.Ordinal)) + return; _textValue = value; _textVersion++; + OnTextChanged?.Invoke(value); } } diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs new file mode 100644 index 00000000..6e9d0125 --- /dev/null +++ b/src/AcDream.App/UI/UiMarkupList.cs @@ -0,0 +1,96 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Lightweight, data-bound string list for plugin markup. It owns only row +/// selection and scroll position; the plugin binding remains the sole owner of +/// rows and selected index. This deliberately avoids exposing App widget types +/// through the BCL plugin contract. +/// +public sealed class UiMarkupList : UiElement +{ + public Func> ItemsSource { get; set; } = + static () => Array.Empty(); + public Func> ItemColorsSource { get; set; } = + static () => Array.Empty(); + public Func SelectedIndexSource { get; set; } = static () => -1; + public Action? SelectionChanged { get; set; } + public UiDatFont? DatFont { get; set; } + public float RowHeight { get; set; } = 18f; + public float Padding { get; set; } = 3f; + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f); + public Vector4 BorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f); + public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f); + public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f); + + private int _topRow; + + public override bool HandlesClick => true; + + protected override void OnDraw(UiRenderContext context) + { + IReadOnlyList items = ItemsSource(); + IReadOnlyList itemColors = ItemColorsSource(); + int visibleRows = VisibleRows; + int selected = SelectedIndexSource(); + if (selected >= 0 && selected < items.Count) + { + if (selected < _topRow) + _topRow = selected; + else if (selected >= _topRow + visibleRows) + _topRow = selected - visibleRows + 1; + } + ClampTop(items.Count, visibleRows); + + context.DrawFill(0f, 0f, Width, Height, BackgroundColor); + context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f); + int end = Math.Min(items.Count, _topRow + visibleRows); + for (int index = _topRow; index < end; index++) + { + float y = (index - _topRow) * RowHeight; + if (index == selected) + context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor); + string text = items[index]; + Vector4 textColor = index < itemColors.Count + ? Rgb(itemColors[index]) + : TextColor; + float textY = y + MathF.Max(0f, + (RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f); + if (DatFont is { } font) + context.DrawStringDat(font, text, Padding, textY, textColor, true); + else + context.DrawString(text, Padding, textY, textColor); + } + } + + public override bool OnEvent(in UiEvent e) + { + IReadOnlyList items = ItemsSource(); + if (e.Type == UiEventType.Scroll) + { + _topRow -= Math.Sign(e.Data0); + ClampTop(items.Count, VisibleRows); + return true; + } + if (e.Type != UiEventType.MouseDown || !Enabled) + return false; + int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight)); + int index = _topRow + row; + if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count) + SelectionChanged?.Invoke(index); + return true; + } + + private int VisibleRows => Math.Max(1, (int)MathF.Floor( + Height / MathF.Max(1f, RowHeight))); + + private void ClampTop(int count, int visibleRows) => + _topRow = Math.Clamp(_topRow, 0, Math.Max(0, count - visibleRows)); + + private static Vector4 Rgb(uint value) => new( + ((value >> 16) & 0xFFu) / 255f, + ((value >> 8) & 0xFFu) / 255f, + (value & 0xFFu) / 255f, + 1f); +} diff --git a/src/AcDream.App/UI/UiMarkupTabButton.cs b/src/AcDream.App/UI/UiMarkupTabButton.cs new file mode 100644 index 00000000..c6ef28f8 --- /dev/null +++ b/src/AcDream.App/UI/UiMarkupTabButton.cs @@ -0,0 +1,47 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Compact KSML tab used by plugin windows. It deliberately uses the retained +/// input/font path and VTank's text-strip presentation rather than introducing +/// a plugin-owned renderer. +/// +public sealed class UiMarkupTabButton : UiSimpleButton +{ + private static readonly Vector4 ActiveText = + new(0.94f, 0.76f, 0.18f, 1f); + private static readonly Vector4 NormalText = + new(0.78f, 0.76f, 0.67f, 1f); + private static readonly Vector4 DisabledText = + new(0.34f, 0.33f, 0.29f, 1f); + private static readonly Vector4 Underline = + new(0.77f, 0.59f, 0.12f, 1f); + + public Func? SelectedSource { get; set; } + + public bool IsSelected => SelectedSource?.Invoke() ?? false; + + public UiMarkupTabButton() + { + BackgroundColor = Vector4.Zero; + BorderColor = Vector4.Zero; + BorderThickness = 0f; + Outline = true; + } + + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + TextColor = !Enabled + ? DisabledText + : IsSelected ? ActiveText : NormalText; + } + + protected override void OnDraw(UiRenderContext ctx) + { + base.OnDraw(ctx); + if (IsSelected) + ctx.DrawFill(2f, Height - 2f, MathF.Max(0f, Width - 4f), 1f, Underline); + } +} diff --git a/src/AcDream.App/UI/UiMarkupToggle.cs b/src/AcDream.App/UI/UiMarkupToggle.cs new file mode 100644 index 00000000..bae133f0 --- /dev/null +++ b/src/AcDream.App/UI/UiMarkupToggle.cs @@ -0,0 +1,75 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// KSML boolean toggle with the compact lamp-and-caption presentation used by +/// VTank. State and action remain reflected BCL bindings owned by the plugin. +/// +public sealed class UiMarkupToggle : UiElement +{ + private static readonly Vector4 CheckedOuter = + new(0.36f, 0.58f, 0.12f, 1f); + private static readonly Vector4 CheckedInner = + new(0.52f, 1f, 0.08f, 1f); + private static readonly Vector4 UncheckedOuter = + new(0.26f, 0.22f, 0.13f, 1f); + private static readonly Vector4 UncheckedInner = + new(0.38f, 0.34f, 0.23f, 1f); + + public string Text { get; set; } = string.Empty; + public Func? TextSource { get; set; } + public Func? CheckedSource { get; set; } + public UiDatFont? DatFont { get; set; } + public Vector4 TextColor { get; set; } = + new(0.86f, 0.84f, 0.74f, 1f); + public Action? Toggle { get; set; } + + public bool IsChecked => CheckedSource?.Invoke() ?? false; + + public override bool HandlesClick => true; + + public override bool OnEvent(in UiEvent e) + { + if (e.Type != UiEventType.Click || !Enabled) + return false; + Toggle?.Invoke(); + return true; + } + + protected override void OnDraw(UiRenderContext ctx) + { + Vector4 outer = IsChecked ? CheckedOuter : UncheckedOuter; + Vector4 inner = IsChecked ? CheckedInner : UncheckedInner; + DrawLamp(ctx, 1f, MathF.Max(1f, (Height - 11f) * 0.5f), outer, inner); + + string caption = TextSource?.Invoke() ?? Text; + Vector4 color = Enabled + ? TextColor + : new Vector4(TextColor.X, TextColor.Y, TextColor.Z, 0.42f); + float y = DatFont is { } font + ? (Height - font.LineHeight) * 0.5f + : 1f; + if (DatFont is { } dat) + ctx.DrawStringDat(dat, caption, 17f, y, color, outline: true); + else + ctx.DrawString(caption, 17f, y, color); + } + + private static void DrawLamp( + UiRenderContext ctx, + float x, + float y, + Vector4 outer, + Vector4 inner) + { + // Five bands form the small circular indicator without introducing a + // plugin bitmap or a new renderer primitive. + ctx.DrawFill(x + 3f, y, 5f, 1f, outer); + ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer); + ctx.DrawFill(x, y + 3f, 11f, 5f, outer); + ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer); + ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer); + ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner); + } +} diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 56114730..f2b7237b 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -74,7 +74,9 @@ public sealed class UiMenu : UiElement string? live = TooltipTextProvider?.Invoke(); if (!string.IsNullOrWhiteSpace(live)) return live; - return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + return string.IsNullOrWhiteSpace(TooltipText) + ? base.GetTooltipText() + : TooltipText; } public int RowsPerColumn { get; set; } = 7; // items per column (dat item template); diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index d6569b3c..8f8fa702 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -37,6 +37,12 @@ public sealed class UiScrollbar : UiElement /// public float ScalarPosition { get; private set; } public Action? ScalarChanged { get; set; } + /// + /// Optional live scalar reader used by plugin markup. It is sampled while + /// no thumb gesture is active so external/profile changes reach the widget + /// without fighting the value under the user's cursor. + /// + public Func? ScalarPositionSource { get; set; } public bool Horizontal { get; set; } /// True while a thumb drag is in progress (between a thumb-hit @@ -94,6 +100,13 @@ public sealed class UiScrollbar : UiElement public void SetScalarPosition(float position) => ScalarPosition = Math.Clamp(position, 0f, 1f); + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + if (!_draggingThumb && ScalarPositionSource?.Invoke() is { } value) + SetScalarPosition(value); + } + /// Settable tooltip, surfaced through the shared /// hover pipeline — the SAME /// pattern already established @@ -105,7 +118,9 @@ public sealed class UiScrollbar : UiElement /// public override string? GetTooltipText() => - string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + string.IsNullOrWhiteSpace(TooltipText) + ? base.GetTooltipText() + : TooltipText; /// RenderSurface id → (GL tex, w, h). 0 id = skip. public Func? SpriteResolve { get; set; } diff --git a/src/AcDream.App/World/LiveEntityDeletionController.cs b/src/AcDream.App/World/LiveEntityDeletionController.cs index 8bc67bf0..95021483 100644 --- a/src/AcDream.App/World/LiveEntityDeletionController.cs +++ b/src/AcDream.App/World/LiveEntityDeletionController.cs @@ -65,6 +65,21 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink return removed || removedDormant; } + /// + /// VTank ghost cleanup: synthesize the exact current incarnation delete, + /// then use the same complete teardown transaction as a server DeleteObject. + /// + public bool DeleteClientGhost(uint serverGuid) + { + if (serverGuid == 0u + || serverGuid == _identity.ServerGuid + || !_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)) + { + return false; + } + return Delete(new DeleteObject.Parsed(serverGuid, record.Generation)); + } + public bool Prune(LiveEntityPruneCandidate candidate) { if (!_runtime.TryGetRecord( diff --git a/src/AcDream.Content/MagicCatalog.cs b/src/AcDream.Content/MagicCatalog.cs index f6a8f52a..0992ea9d 100644 --- a/src/AcDream.Content/MagicCatalog.cs +++ b/src/AcDream.Content/MagicCatalog.cs @@ -15,7 +15,15 @@ public sealed record SpellComponentDescriptor( uint WeenieClassId, string Name, uint Category, - uint IconId); + uint IconId) +{ + public uint SpellComponentId { get; init; } + public double BurnRate { get; init; } + public uint GestureId { get; init; } + public double GestureSpeed { get; init; } + public string Type { get; init; } = string.Empty; + public string Word { get; init; } = string.Empty; +} /// /// Process-shareable projection of retail's spell, component, and @@ -142,7 +150,15 @@ public sealed class MagicCatalog wcid, pair.Value.Name.Value, pair.Value.Category, - pair.Value.Icon.DataId); + pair.Value.Icon.DataId) + { + SpellComponentId = pair.Key, + BurnRate = pair.Value.CDM, + GestureId = pair.Value.Gesture, + GestureSpeed = pair.Value.Time, + Type = pair.Value.Type.ToString(), + Word = pair.Value.Text.Value, + }; } } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 3ce26e23..f34d9723 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -1004,9 +1004,14 @@ public static class GameEventWiring { var p = AppraiseInfoParser.TryParse(e.Payload.Span); if (p is null) return; - // Merge parsed properties into the item if we know about it. + // Retain the property tables and the item's own spell manifest as + // one projection. VTank consults the latter for proc weapons. if (p.Value.Success && items.Get(p.Value.Guid) is not null) - items.UpdateProperties(p.Value.Guid, p.Value.Properties); + items.UpdateAppraisal( + p.Value.Guid, + p.Value.Properties, + p.Value.SpellBook, + clientTime()); if (p.Value.CreatureProfile is { HealthMax: > 0u } creature) combat.OnUpdateHealth( p.Value.Guid, @@ -1020,8 +1025,8 @@ public static class GameEventWiring // spellbook arrives via PlayerDescription (0x0013), which uses // a different wire format (see WorldSession + LocalPlayerState // — feeds vitals from PrivateUpdateVital instead). - // The appraised spellbook belongs to that item. The local player's - // learned spell manifest arrives only in PlayerDescription. + // The appraised spellbook now belongs to that item. The local + // player's learned manifest arrives only in PlayerDescription. }); // ── Player ──────────────────────────────────────────────── diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index c88a9cef..8bf41bd6 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -27,6 +27,7 @@ public static class InventoryActions public const uint DropItemOpcode = 0x001Bu; public const uint NoLongerViewingContentsOpcode = 0x0195u; public const uint SetInscriptionOpcode = 0x00BFu; + public const uint CreateTinkeringToolOpcode = 0x027Du; /// /// Merge stack A into stack B of the same item type. Server validates @@ -205,4 +206,48 @@ public static class InventoryActions text.CopyTo(body, 18); return body; } + + /// + /// Salvage one or more carried items with an owned salvage tool. Retail + /// CM_Inventory::Event_CreateTinkeringTool @ 0x006AB830 writes the + /// tool id followed by PackableList<unsigned long>: u32 count, + /// then the object ids in list order. The server replies with GameEvent + /// 0x02B4 SalvageOperationsResult and removes accepted source items. + /// + public static byte[] BuildCreateTinkeringTool( + uint seq, + uint toolGuid, + IReadOnlyList itemGuids) + { + ArgumentNullException.ThrowIfNull(itemGuids); + if (toolGuid == 0u) + throw new ArgumentOutOfRangeException(nameof(toolGuid)); + if (itemGuids.Count == 0) + throw new ArgumentException( + "At least one item is required for salvage.", + nameof(itemGuids)); + + byte[] body = new byte[20 + (itemGuids.Count * sizeof(uint))]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(8), + CreateTinkeringToolOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), toolGuid); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(16), + checked((uint)itemGuids.Count)); + for (int index = 0; index < itemGuids.Count; index++) + { + uint itemGuid = itemGuids[index]; + if (itemGuid == 0u) + throw new ArgumentException( + "Salvage item ids must be non-zero.", + nameof(itemGuids)); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(20 + (index * sizeof(uint))), + itemGuid); + } + return body; + } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 08efe5c0..6bc9ea59 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -3268,6 +3268,19 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount)); } + /// + /// Send retail CreateTinkeringTool (0x027D), the salvage operation used by + /// gmSalvageUI and VTank. + /// + public void SendSalvage(uint toolGuid, IReadOnlyList itemGuids) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildCreateTinkeringTool( + seq, + toolGuid, + itemGuids)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 diff --git a/src/AcDream.Core.Net/packages.win-x64.lock.json b/src/AcDream.Core.Net/packages.win-x64.lock.json index c2855510..64561bb2 100644 --- a/src/AcDream.Core.Net/packages.win-x64.lock.json +++ b/src/AcDream.Core.Net/packages.win-x64.lock.json @@ -2,55 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "BCnEncoder.Net": { - "type": "Direct", - "requested": "[2.2.1, )", - "resolved": "2.2.1", - "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", - "dependencies": { - "CommunityToolkit.HighPerformance": "8.4.0" - } - }, - "Chorizite.Core": { - "type": "Direct", - "requested": "[0.0.18, )", - "resolved": "0.0.18", - "contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==", - "dependencies": { - "Autofac": "8.4.0", - "Chorizite.ACProtocol": "1.0.1", - "Chorizite.Common": "1.0.3", - "Chorizite.DatReaderWriter": "1.0.0", - "FontStashSharp": "1.3.10", - "Microsoft.Diagnostics.Runtime": "3.1.512801", - "Microsoft.Extensions.Logging.Abstractions": "9.0.9", - "NJsonSchema": "11.5.1", - "SixLabors.ImageSharp": "3.1.11", - "SixLabors.ImageSharp.Drawing": "2.1.7" - } - }, - "Chorizite.DatReaderWriter": { - "type": "Direct", - "requested": "[2.1.7, )", - "resolved": "2.1.7", - "contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==", - "dependencies": { - "DotNet.Standard.Common": "2.0.1", - "ZLibDotNet": "0.1.1" - } - }, - "Serilog": { - "type": "Direct", - "requested": "[4.0.2, )", - "resolved": "4.0.2", - "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" - }, - "StbImageSharp": { - "type": "Direct", - "requested": "[2.30.16, )", - "resolved": "2.30.16", - "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" - }, "Autofac": { "type": "Transitive", "resolved": "8.4.0", @@ -245,9 +196,57 @@ "resolved": "0.1.1", "contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg==" }, + "acdream.core": { + "type": "Project", + "dependencies": { + "AcDream.Plugin.Abstractions": "[1.0.0, )", + "BCnEncoder.Net": "[2.2.1, )", + "Chorizite.Core": "[0.0.18, )", + "Chorizite.DatReaderWriter": "[2.1.7, )", + "Serilog": "[4.0.2, )", + "StbImageSharp": "[2.30.16, )" + } + }, "acdream.plugin.abstractions": { "type": "Project" }, + "BCnEncoder.Net": { + "type": "CentralTransitive", + "requested": "[2.2.1, )", + "resolved": "2.2.1", + "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", + "dependencies": { + "CommunityToolkit.HighPerformance": "8.4.0" + } + }, + "Chorizite.Core": { + "type": "CentralTransitive", + "requested": "[0.0.18, )", + "resolved": "0.0.18", + "contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==", + "dependencies": { + "Autofac": "8.4.0", + "Chorizite.ACProtocol": "1.0.1", + "Chorizite.Common": "1.0.3", + "Chorizite.DatReaderWriter": "1.0.0", + "FontStashSharp": "1.3.10", + "Microsoft.Diagnostics.Runtime": "3.1.512801", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9", + "NJsonSchema": "11.5.1", + "SixLabors.ImageSharp": "3.1.11", + "SixLabors.ImageSharp.Drawing": "2.1.7" + } + }, + "Chorizite.DatReaderWriter": { + "type": "CentralTransitive", + "requested": "[2.1.7, )", + "resolved": "2.1.7", + "contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==", + "dependencies": { + "DotNet.Standard.Common": "2.0.1", + "ZLibDotNet": "0.1.1" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", "requested": "[9.0.9, )", @@ -257,12 +256,24 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9" } }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.0.2, )", + "resolved": "4.0.2", + "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" + }, "SixLabors.ImageSharp": { "type": "CentralTransitive", "requested": "[3.1.12, )", "resolved": "3.1.11", "contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw==" }, + "StbImageSharp": { + "type": "CentralTransitive", + "requested": "[2.30.16, )", + "resolved": "2.30.16", + "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" + }, "StbTrueTypeSharp": { "type": "CentralTransitive", "requested": "[1.26.12, )", diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 42a99890..40ea3b74 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -251,6 +251,21 @@ public sealed class ClientObject /// Retail PublicWeenieDesc._spellID; used by caster endowments. public uint? SpellId { get; set; } /// + /// Spell ids retained from this item's latest successful + /// IdentifyObjectResponse SpellBook block. These are item spells, + /// not the local character's learned spellbook; VTank uses them to + /// classify cast-on-strike weapons and item-cast debuffs. + /// + public IReadOnlyList AppraisedSpellIds { get; internal set; } = + Array.Empty(); + /// + /// Monotonic millisecond tick at which the latest successful identify + /// response was received. This is Decal's per-world-object + /// LastIdTime, retained on the canonical object so it disappears + /// with that exact object lifetime. + /// + public int LastAppraisalTimeMs { get; internal set; } + /// /// Retail PublicWeenieDesc._cooldown_id. Positive values name a /// shared item-cooldown group whose player enchantment id is /// CooldownId + 0x8000. diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 0faeca46..8b75e595 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -774,6 +774,45 @@ public sealed class ClientObjectTable public bool UpdateProperties(uint itemId, PropertyBundle incoming) { if (!_objects.TryGetValue(itemId, out var item)) return false; + MergeProperties(item, incoming); + ApplyCooldownProperties(item, incoming); + ObjectUpdated?.Invoke(item); + return true; + } + + /// + /// Atomically retains every successful item-appraisal result: the typed + /// property tables and the per-item SpellBook block. Publishing one update + /// prevents observers from seeing properties without their matching spell + /// manifest (or the reverse). + /// + public bool UpdateAppraisal( + uint itemId, + PropertyBundle incoming, + IReadOnlyList spellIds, + double receivedAtSeconds = 0d) + { + ArgumentNullException.ThrowIfNull(incoming); + ArgumentNullException.ThrowIfNull(spellIds); + if (!_objects.TryGetValue(itemId, out var item)) return false; + MergeProperties(item, incoming); + item.AppraisedSpellIds = spellIds.Count == 0 + ? Array.Empty() + : spellIds.ToArray(); + if (double.IsFinite(receivedAtSeconds) && receivedAtSeconds >= 0d) + { + long milliseconds = checked((long)Math.Round( + receivedAtSeconds * 1000d, + MidpointRounding.AwayFromZero)); + item.LastAppraisalTimeMs = unchecked((int)milliseconds); + } + ApplyCooldownProperties(item, incoming); + ObjectUpdated?.Invoke(item); + return true; + } + + private static void MergeProperties(ClientObject item, PropertyBundle incoming) + { foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; @@ -781,9 +820,6 @@ public sealed class ClientObjectTable foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; - ApplyCooldownProperties(item, incoming); - ObjectUpdated?.Invoke(item); - return true; } /// diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 103e4d20..014df169 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -2383,7 +2383,11 @@ public sealed class PhysicsEngine body is not null ? PhysicsResolveCapture.Snapshot(body) : null); } - return resolveResult; + return resolveResult with + { + LastCollidedObjectId = ci.LastCollidedObjectGuid ?? 0u, + CollidedWithEnvironment = ci.CollidedWithEnvironment, + }; } finally { diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs index 28733b8d..49d2b2f5 100644 --- a/src/AcDream.Core/Physics/ResolveResult.cs +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -58,4 +58,16 @@ public readonly record struct ResolveResult( /// Full cell that owns . uint ContactPlaneCellId = 0, /// Whether the accepted contact plane is water. - bool ContactPlaneIsWater = false); + bool ContactPlaneIsWater = false) +{ + /// + /// Last live object touched by this transition, or zero for environment- + /// only/no collision. This is detached collision evidence, not an impact + /// side effect; projectile-awareness callers use it to distinguish the + /// designated target from an intervening creature or prop. + /// + public uint LastCollidedObjectId { get; init; } + + /// Whether resident environment geometry blocked the sweep. + public bool CollidedWithEnvironment { get; init; } +} diff --git a/src/AcDream.Core/Plugins/PluginCommandRegistry.cs b/src/AcDream.Core/Plugins/PluginCommandRegistry.cs new file mode 100644 index 00000000..a162bb8b --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginCommandRegistry.cs @@ -0,0 +1,142 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// +/// Shared host implementation of the additive plugin-command contract. +/// Registrations are exact leases; callbacks are invoked outside the registry +/// lock so a handler may submit chat or unregister itself without deadlocking. +/// +public sealed class PluginCommandRegistry : IPluginCommandRegistry +{ + private readonly object _gate = new(); + private readonly Dictionary _registrations = + new(StringComparer.OrdinalIgnoreCase); + private readonly Action? _onFailure; + + public PluginCommandRegistry(Action? onFailure = null) + { + _onFailure = onFailure; + } + + public IDisposable Register(string verb, Action handler) + { + string normalized = NormalizeVerb(verb); + ArgumentNullException.ThrowIfNull(handler); + var registration = new Registration(this, normalized, handler); + lock (_gate) + { + if (_registrations.ContainsKey(normalized)) + { + throw new InvalidOperationException( + $"Plugin command '{normalized}' is already registered."); + } + _registrations.Add(normalized, registration); + } + return registration; + } + + /// Try to consume one complete command-shaped line. + public bool TryHandle(string rawText) + { + if (string.IsNullOrWhiteSpace(rawText)) + return false; + string trimmed = rawText.Trim(); + if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@')) + return false; + + int separator = trimmed.IndexOfAny([' ', '\t'], 1); + string verb = separator < 0 + ? trimmed[1..] + : trimmed[1..separator]; + if (verb.Length == 0) + return false; + + Registration? registration; + lock (_gate) + _registrations.TryGetValue(verb, out registration); + if (registration is null) + return false; + + string arguments = separator < 0 + ? string.Empty + : trimmed[(separator + 1)..].Trim(); + try + { + registration.Invoke(new PluginCommand( + registration.Verb, + arguments, + trimmed)); + } + catch (Exception error) + { + try + { + _onFailure?.Invoke(registration.Verb, error); + } + catch + { + // Diagnostics observe plugin code; they cannot poison chat. + } + } + return true; + } + + private static string NormalizeVerb(string verb) + { + ArgumentException.ThrowIfNullOrWhiteSpace(verb); + string normalized = verb.Trim().TrimStart('/', '@'); + if (normalized.Length is < 1 or > 32 + || normalized.Any(static value => !char.IsLetterOrDigit(value))) + { + throw new ArgumentException( + "Plugin command verbs must contain 1-32 letters or digits.", + nameof(verb)); + } + return normalized; + } + + private void Remove(Registration expected) + { + lock (_gate) + { + if (_registrations.TryGetValue(expected.Verb, out Registration? current) + && ReferenceEquals(current, expected)) + { + _registrations.Remove(expected.Verb); + } + } + } + + private sealed class Registration( + PluginCommandRegistry owner, + string verb, + Action handler) : IDisposable + { + private readonly object _gate = new(); + private PluginCommandRegistry? _owner = owner; + private Action? _handler = handler; + + internal string Verb { get; } = verb; + + internal void Invoke(PluginCommand command) + { + Action? callback; + lock (_gate) + callback = _handler; + callback?.Invoke(command); + } + + public void Dispose() + { + PluginCommandRegistry? currentOwner; + lock (_gate) + { + currentOwner = _owner; + _owner = null; + _handler = null; + } + currentOwner?.Remove(this); + } + } +} diff --git a/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs b/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs new file mode 100644 index 00000000..ecccadf8 --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs @@ -0,0 +1,151 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// Process-local transactional registry for external loot plugins. +public sealed class PluginLootClassifierRegistry : IPluginLootClassifierRegistry +{ + private readonly object _gate = new(); + private readonly Dictionary _entries = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Available + { + get + { + lock (_gate) + { + return _entries.Values + .Select(static entry => entry.Info) + .OrderBy(static info => info.DisplayName, + StringComparer.OrdinalIgnoreCase) + .ThenBy(static info => info.Id, + StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + } + + public IDisposable Register( + string classifierId, + string displayName, + IPluginLootClassifier classifier) + { + ArgumentException.ThrowIfNullOrWhiteSpace(classifierId); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentNullException.ThrowIfNull(classifier); + string id = classifierId.Trim(); + var entry = new Entry( + new PluginLootClassifierInfo(id, displayName.Trim()), + classifier); + lock (_gate) + { + if (!_entries.TryAdd(id, entry)) + { + throw new InvalidOperationException( + $"Loot classifier '{id}' is already registered."); + } + } + return new Registration(this, id, entry); + } + + public bool TryClassify( + string classifierId, + in PluginLootClassificationContext context, + out PluginLootClassification classification) + { + Entry? entry; + lock (_gate) + _entries.TryGetValue(classifierId ?? string.Empty, out entry); + if (entry is null) + { + classification = default; + return false; + } + try + { + classification = entry.Classifier.Classify(context); + return true; + } + catch + { + classification = default; + return false; + } + } + + public bool TryNotifyLooted( + string classifierId, + in PluginLootedItem item) + { + if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier)) + return false; + try + { + classifier.OnLooted(item); + return true; + } + catch + { + return false; + } + } + + public bool TryNotifyItemRemoved(string classifierId, uint objectId) + { + if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier)) + return false; + try + { + classifier.OnItemRemoved(objectId); + return true; + } + catch + { + return false; + } + } + + private bool TryGetClassifier( + string classifierId, + out IPluginLootClassifier classifier) + { + classifier = null!; + if (string.IsNullOrWhiteSpace(classifierId)) + return false; + lock (_gate) + { + if (!_entries.TryGetValue(classifierId.Trim(), out Entry? entry)) + return false; + classifier = entry.Classifier; + return true; + } + } + + private void Remove(string id, Entry expected) + { + lock (_gate) + { + if (_entries.TryGetValue(id, out Entry? current) + && ReferenceEquals(current, expected)) + { + _entries.Remove(id); + } + } + } + + private sealed record Entry( + PluginLootClassifierInfo Info, + IPluginLootClassifier Classifier); + + private sealed class Registration( + PluginLootClassifierRegistry owner, + string id, + Entry entry) : IDisposable + { + private PluginLootClassifierRegistry? _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)? + .Remove(id, entry); + } +} diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index 82d1a2d3..4aa5b6b2 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -258,7 +258,10 @@ public sealed class PluginSession : IDisposable { foreach (PluginDiscoveryResult candidate in available) { - var scope = new ScopedPluginHost(_host); + var scope = new ScopedPluginHost( + _host, + candidate.Manifest!.Id, + candidate.Manifest.DisplayName); ScopedRenderPackRegistry? renderPackScope = candidate.Manifest!.Declares(PluginKind.RenderPack) && _renderPacks is not null diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 9cd8179b..852bf063 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -13,14 +13,30 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable private readonly ScopedEvents _events; private readonly ScopedSelectionService _selection; private readonly ScopedUiRegistry _ui; + private readonly ScopedPluginStorage _storage; + private readonly ScopedPluginCommandRegistry _commands; + private readonly ScopedLootClassifierRegistry _lootClassifiers; private bool _disposed; - internal ScopedPluginHost(IPluginHost inner) + internal ScopedPluginHost( + IPluginHost inner, + string pluginId, + string pluginDisplayName) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName); _events = new ScopedEvents(inner.Events); _selection = new ScopedSelectionService(inner.Selection); - _ui = new ScopedUiRegistry(inner.Ui); + _ui = new ScopedUiRegistry( + inner.Ui, + new PluginUiOwner(pluginId, pluginDisplayName)); + _storage = new ScopedPluginStorage(inner.Storage, pluginId); + _commands = new ScopedPluginCommandRegistry(inner.Commands); + _lootClassifiers = new ScopedLootClassifierRegistry( + inner.LootClassifiers, + pluginId, + pluginDisplayName); } public bool HasUi => _inner.HasUi; @@ -29,6 +45,9 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable public IEvents Events => _events; public ISelectionService Selection => _selection; public IUiRegistry Ui => _ui; + public IPluginStorage Storage => _storage; + public IPluginCommandRegistry Commands => _commands; + public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers; /// /// Delegated rather than scoped, unlike , @@ -45,6 +64,48 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable /// public IAutomationSurface Automation => _inner.Automation; + private sealed class ScopedPluginStorage( + IPluginStorage inner, + string pluginId) : IPluginStorage + { + public bool IsAvailable => inner.IsAvailable; + public string? ReadText(string key) => + inner.ReadText(ScopedKey(key)); + public IReadOnlyList List(string prefix) + { + string scopedPrefix = ScopedKey(prefix); + string ownerPrefix = pluginId + Path.DirectorySeparatorChar; + return inner.List(scopedPrefix) + .Select(key => key.Replace('/', Path.DirectorySeparatorChar)) + .Where(key => key.StartsWith( + ownerPrefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + .Select(key => key[ownerPrefix.Length..] + .Replace(Path.DirectorySeparatorChar, '/')) + .ToArray(); + } + public void WriteText(string key, string content) => + inner.WriteText(ScopedKey(key), content); + public bool Delete(string key) => inner.Delete(ScopedKey(key)); + + private static string ValidateKey(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (Path.IsPathRooted(key) + || key.Contains("..", StringComparison.Ordinal) + || key.Contains('\\')) + { + throw new ArgumentException("Invalid plugin storage key.", nameof(key)); + } + return key.Replace('/', Path.DirectorySeparatorChar); + } + + private string ScopedKey(string key) => + Path.Combine(pluginId, ValidateKey(key)); + } + public void Dispose() { if (_disposed) @@ -53,6 +114,127 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable _events.Dispose(); _selection.Dispose(); _ui.Dispose(); + _commands.Dispose(); + _lootClassifiers.Dispose(); + } + + private sealed class ScopedLootClassifierRegistry( + IPluginLootClassifierRegistry inner, + string pluginId, + string pluginDisplayName) + : IPluginLootClassifierRegistry, + IDisposable + { + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + public IReadOnlyList Available => + inner.Available; + + public IDisposable Register( + string classifierId, + string displayName, + IPluginLootClassifier classifier) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(classifierId); + string local = classifierId.Trim(); + if (local.Contains('/') || local.Contains('\\')) + { + throw new ArgumentException( + "A classifier id cannot contain a path separator.", + nameof(classifierId)); + } + string effectiveName = string.IsNullOrWhiteSpace(displayName) + ? pluginDisplayName + : displayName.Trim(); + IDisposable registration = inner.Register( + $"{pluginId}/{local}", + effectiveName, + classifier); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return registration; + } + } + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedLootClassifierRegistry)); + } + + public bool TryClassify( + string classifierId, + in PluginLootClassificationContext context, + out PluginLootClassification classification) => + inner.TryClassify(classifierId, context, out classification); + + public bool TryNotifyLooted( + string classifierId, + in PluginLootedItem item) => + inner.TryNotifyLooted(classifierId, item); + + public bool TryNotifyItemRemoved( + string classifierId, + uint objectId) => + inner.TryNotifyItemRemoved(classifierId, objectId); + + public void Dispose() + { + IDisposable[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + for (int index = registrations.Length - 1; index >= 0; index--) + registrations[index].Dispose(); + } + } + + private sealed class ScopedPluginCommandRegistry(IPluginCommandRegistry inner) + : IPluginCommandRegistry, + IDisposable + { + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + public IDisposable Register(string verb, Action handler) + { + ObjectDisposedException.ThrowIf(_disposed, this); + IDisposable registration = inner.Register(verb, handler); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return registration; + } + } + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedPluginCommandRegistry)); + } + + public void Dispose() + { + IDisposable[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + for (int index = registrations.Length - 1; index >= 0; index--) + registrations[index].Dispose(); + } } private sealed class ScopedSelectionService(ISelectionService inner) @@ -297,22 +479,92 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable private sealed class ScopedUiRegistry : IUiRegistry, IDisposable { private readonly IScopedUiRegistry _inner; + private readonly PluginUiOwner _owner; private readonly object _gate = new(); private readonly List _registrations = []; private bool _disposed; - internal ScopedUiRegistry(IUiRegistry inner) + internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner) { _inner = inner as IScopedUiRegistry ?? throw new InvalidOperationException( "Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back."); + _owner = owner; } public void AddMarkupPanel(string markupPath, object binding) { - IDisposable registration = _inner.RegisterMarkupPanel( + AddRegistration(_inner.RegisterPanel( + _owner, + new PluginPanelDescriptor( + Path.GetFileNameWithoutExtension(markupPath), + _owner.DisplayName), markupPath, - binding); + binding)); + } + + public void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + ArgumentNullException.ThrowIfNull(descriptor); + AddRegistration(_inner.RegisterPanel( + _owner, + descriptor, + markupPath, + binding)); + } + + public IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + ArgumentNullException.ThrowIfNull(descriptor); + return TrackRegistration(_inner.RegisterPanel( + _owner, + descriptor, + markupPath, + binding)); + } + + public IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) + { + ArgumentNullException.ThrowIfNull(descriptor); + return TrackRegistration(_inner.RegisterPanelContent( + _owner, + descriptor, + markupContent, + binding)); + } + + public bool ViewExists(string viewName) => + _inner.ViewExists(_owner, viewName); + + public bool IsViewVisible(string viewName) => + _inner.IsViewVisible(_owner, viewName); + + public bool ControlExists(string viewName, string controlName) => + _inner.ControlExists(_owner, viewName, controlName); + + public bool SetControlLabel( + string viewName, + string controlName, + string label) => + _inner.SetControlLabel(_owner, viewName, controlName, label); + + public bool SetControlVisible( + string viewName, + string controlName, + bool visible) => + _inner.SetControlVisible(_owner, viewName, controlName, visible); + + private void AddRegistration(IDisposable registration) + { lock (_gate) { if (!_disposed) @@ -326,6 +578,31 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable throw new ObjectDisposedException(nameof(ScopedUiRegistry)); } + private IDisposable TrackRegistration(IDisposable registration) + { + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return new IndividualRegistration(this, registration); + } + } + + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedUiRegistry)); + } + + private void RemoveRegistration(IDisposable registration) + { + lock (_gate) + { + if (!_registrations.Remove(registration)) + return; + } + registration.Dispose(); + } + public void Dispose() { IDisposable[] registrations; @@ -344,5 +621,15 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable catch { } } } + + private sealed class IndividualRegistration( + ScopedUiRegistry owner, + IDisposable registration) : IDisposable + { + private ScopedUiRegistry? _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)? + .RemoveRegistration(registration); + } } } diff --git a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs index bafa7a94..0fa42185 100644 --- a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs @@ -15,7 +15,7 @@ internal sealed class HeadlessMovementInputSource( public MovementInput Capture() { if (_movement.HasCommandInput) - return _movement.CommandInput; + return _movement.CommandInput with { IsPersistentCommand = true }; return new MovementInput( Forward: _movement.AutoRunActive, Run: true); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index a0140d36..46ab383f 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -341,7 +341,13 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); - var chatCommandSurface = new LiveChatCommandSurface(); + var pluginCommands = new AcDream.Core.Plugins.PluginCommandRegistry( + (verb, error) => diagnostics.Failure( + descriptor.Id, + $"plugin-command-{verb}", + error)); + var chatCommandSurface = new LiveChatCommandSurface( + pluginCommands.TryHandle); var loginCommands = new LoginCommandSequence( descriptor.LoginCommands, TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs), @@ -359,7 +365,8 @@ internal sealed class HeadlessSessionHost : IDisposable statusWriter, descriptor.Id, pluginRoots ?? [], - descriptor.Plugins); + descriptor.Plugins, + pluginCommands); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( @@ -1201,6 +1208,7 @@ internal sealed class HeadlessSessionHost : IDisposable { Runtime.InventoryOwner.ExternalContainers .ApplyUseDone(error); + Runtime.ActionOwner.SpellCast.CompleteUse(error); Runtime.ActionOwner.Transactions.CompleteUse(error); }, Runtime.InventoryOwner.ItemMana, diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 45b99503..23909378 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -38,15 +38,18 @@ internal sealed class HeadlessPluginHost internal HeadlessPluginHost( GameRuntime runtime, - IPluginLogger logger) + IPluginLogger logger, + IPluginCommandRegistry? commands = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); Log = logger ?? throw new ArgumentNullException(nameof(logger)); + Commands = commands ?? NoOpPluginCommandRegistry.Instance; _eventSubscription = runtime.Subscribe(this); } public bool HasUi => false; public IPluginLogger Log { get; } + public IPluginCommandRegistry Commands { get; } public IGameState State => this; public IEvents Events => this; public ISelectionService Selection => _runtime.ActionOwner.Selection; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index 6791670f..9110cb07 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -44,7 +44,8 @@ internal sealed class HeadlessPluginSession : IDisposable SessionStatusWriter statusWriter, string sessionId, IEnumerable roots, - IReadOnlyList? allowList) + IReadOnlyList? allowList, + IPluginCommandRegistry? commands = null) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(diagnostics); @@ -57,7 +58,8 @@ internal sealed class HeadlessPluginSession : IDisposable new HeadlessPluginLogger( diagnostics, sessionId, - () => runtime.Generation.Value)); + () => runtime.Generation.Value), + commands); var plugins = new PluginSession( host, status => Report(statusWriter, sessionId, status), diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index 23a0a00c..dd7a11d3 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -53,7 +53,38 @@ public readonly record struct PluginSpellInfo( uint School, string Description, bool IsSelfTargeted, - bool IsBeneficial); + bool IsBeneficial) +{ + /// Retail spell-table classification, projected without policy. + public bool IsDebuff { get; init; } + public bool IsOffensive { get; init; } + public bool IsFellowship { get; init; } + public bool IsUntargeted { get; init; } + /// + /// VTank's spell-facing rule: targeted spells require facing except the + /// authored family range 222..235. + /// + public bool RequiresTurnTo { get; init; } + public bool IsProjectile { get; init; } + public bool IsDamageOverTime { get; init; } + public uint RawFlags { get; init; } + public int SpellType { get; init; } + public uint TargetMask { get; init; } + public float BaseRangeConstant { get; init; } + public float BaseRangeModifier { get; init; } + /// + /// Retail formula component ids in authored order. Plugins can inspect + /// requirements without importing client/Core spell types. + /// + public IReadOnlyList FormulaComponentIds { get; init; } = + Array.Empty(); + /// + /// VTank's spell quality. It is the portal spell difficulty unless its + /// official GameInfoDB override supplies a replacement. + /// + public int? QualityOverride { get; init; } + public int Quality => QualityOverride ?? Difficulty; +} /// One enchantment currently in force on the local player. public readonly record struct PluginActiveEnchantment( @@ -62,18 +93,39 @@ public readonly record struct PluginActiveEnchantment( int Tier, double SecondsRemaining); +/// One immutable entry from retail SpellComponentTable 0x0E00000F. +public readonly record struct PluginSpellComponentInfo( + uint ComponentId, + uint WeenieClassId, + string Name, + double BurnRate, + uint GestureId, + double GestureSpeed, + uint IconId, + uint SortKey, + string Type, + string Word); + /// One of the character's skills, named from the retail skill table. public readonly record struct PluginSkillInfo( uint SkillId, string Name, PluginSkillTraining Training, - uint Current); + uint Current) +{ + /// Unenchanted retail skill level before vitae and spell mods. + public uint Base { get; init; } = Current; +} /// One primary attribute. is 0..5. public readonly record struct PluginAttributeInfo( int Kind, string Name, - uint Current); + uint Current) +{ + /// Unenchanted primary-attribute value. + public uint Base { get; init; } = Current; +} /// Why a cast would or would not be accepted right now. public enum PluginCastGate @@ -93,6 +145,28 @@ public interface ICharacterInfo { bool IsInWorld { get; } + /// + /// Stable in-world character name. Empty when unavailable. Plugins use it + /// for VTank-compatible "By char" profile scoping; it is identity data, + /// not a presentation-owned label. + /// + string Name => string.Empty; + + /// Server-advertised world name used to scope global variables. + string WorldName => string.Empty; + + /// Authenticated account name; expression surfaces expose only its hash. + string AccountName => string.Empty; + + /// Retail roster slot for this character, or -1 when unavailable. + int CharacterIndex => -1; + + /// Current character level. + int Level => 0; + + /// Unused ordinary slots in the main pack. + int MainPackFreeSlots => 0; + /// /// The local player's own object id, or 0 when not in world. Needed to /// target yourself: retail's banes are Item Enchantments whose description @@ -108,6 +182,12 @@ public interface ICharacterInfo uint CurrentMana { get; } uint MaxMana { get; } + /// + /// Retail PropertyInt.SummoningMastery: 0 undef/geomancer, 1 primalist, + /// 2 necromancer, 3 naturalist. + /// + int SummoningMastery => 0; + /// Skills the character has, with training state and current level. IReadOnlyList Skills { get; } @@ -141,18 +221,75 @@ public interface ISpellCatalog /// IReadOnlyList KnownSelfBuffs { get; } + /// + /// Learned direct offensive spells. Debuffs and beneficial spells are + /// excluded; the plugin owns which attack spell to choose. + /// + IReadOnlyList KnownAttackSpells => + Array.Empty(); + + /// + /// Every learned offensive or debuff spell, including untargeted rings, + /// streaks and damage-over-time lines. The host supplies data; the plugin + /// decides which names/families implement its combat policy. + /// + IReadOnlyList KnownCombatSpells => + Array.Empty(); + + /// + /// Whether the character has learned this exact spell id. + /// answers a different question: it can resolve metadata for spells that + /// are not in the character's spellbook, such as a scroll being appraised. + /// + bool IsKnown(uint spellId) => false; + bool TryGet(uint spellId, out PluginSpellInfo info); + + /// Resolve retail's spell-component id, not its inventory WCID. + bool TryGetComponent(uint componentId, out PluginSpellComponentInfo info) + { + info = default; + return false; + } + + /// Seconds remaining for one retail shared cooldown id. + double GetCooldownRemaining(uint cooldownId) => 0d; } /// Writing to the player's chat window. +public readonly record struct PluginChatMessage( + ulong Sequence, + uint SenderObjectId, + int Kind, + string Sender, + string Text, + string ChannelName); + +/// Reading confirmed chat and writing client-local notices. public interface IPluginChat { + /// + /// Ordered transcript messages newer than . + /// The cursor is host-session independent and monotonically increases for + /// the lifetime of this automation surface. VTank uses actual combat lines + /// such as "You cast ... on ..." to confirm item and weapon procs. + /// + IReadOnlyList CaptureMessages(ulong afterSequence) => + Array.Empty(); + /// /// Post a client-local system line, the channel retail uses for the /// client's own notices. It is local to this client: nothing is sent to the /// server and no other player sees it. /// void PostSystemMessage(string text); + + /// + /// Submit text through the client's normal retail chat-command parser. + /// Commands, emotes, tells, and ordinary speech therefore use the same + /// route as text entered in the main chat field. + /// + bool Submit(string text) => false; } /// Casting, with a preflight so a plugin need not guess. @@ -160,6 +297,13 @@ public interface IMagicCommands { bool IsCasting { get; } + /// + /// Last server-completed cast request. Revision changes exactly once when + /// the matching UseDone arrives; zero means the host cannot supply cast + /// receipts. A dispatched request is not reported as success early. + /// + PluginCastCompletion LastCompletion => default; + PluginCastGate EvaluateGate(uint spellId); /// @@ -167,6 +311,13 @@ public interface IMagicCommands /// not whether the spell ultimately lands, which the server decides. /// bool Cast(uint spellId); + + /// Evaluate a cast against an explicit target atomically. + PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) => + PluginCastGate.Refused; + + /// Select and cast on one explicit target in the same host call. + bool Cast(uint spellId, uint targetObjectId) => false; } /// @@ -187,6 +338,52 @@ public interface IAutomationSurface ISpellCatalog Spells { get; } IMagicCommands Magic { get; } IPluginChat Chat { get; } + + /// + /// Target queries and physical-combat attempts. The default keeps plugins + /// compiled against API v1 binary-compatible with hosts that do not yet + /// provide combat automation. + /// + ICombatAutomation Combat => NoOpAutomationSurface.Instance; + + /// Owned equipment reads and confirmed AutoWield attempts. + IEquipmentAutomation Equipment => NoOpAutomationSurface.Instance; + + /// Carried-item reads and canonical use/apply attempts. + IItemAutomation Items => NoOpAutomationSurface.Instance; + + /// External-container discovery and canonical corpse looting. + ILootAutomation Loot => NoOpAutomationSurface.Instance; + + /// Authoritative fellowship vitals for helper spell policy. + IFellowshipAutomation Fellowship => NoOpAutomationSurface.Instance; + + /// Shared confirmed duration-spell observations by target. + IEnchantmentAutomation Enchantments => NoOpAutomationSurface.Instance; + + /// Canonical position reads and command-interpreter movement. + INavigationAutomation Navigation => NoOpAutomationSurface.Instance; + + /// General canonical object discovery and raw property access. + IWorldObjectAutomation Objects => NoOpAutomationSurface.Instance; + + /// Runtime-owned Dereth calendar and day/night projection. + IWorldTimeAutomation WorldTime => NoOpAutomationSurface.Instance; + + /// Account roster and one-shot post-logout character entry. + ILoginAutomation Login => NoOpAutomationSurface.Instance; + + /// Other local acdream clients discovered by the host. + INetworkAutomation Network => NoOpAutomationSurface.Instance; + + /// Explicit VTank-compatible stuck-action recovery. + IRecoveryAutomation Recovery => NoOpAutomationSurface.Instance; + + /// Bounded projectile collision probes over the live world. + IProjectileAutomation Projectiles => NoOpAutomationSurface.Instance; + + /// Canonical retail previous/next selection actions. + ISelectionAutomation Selection => NoOpAutomationSurface.Instance; } /// @@ -194,7 +391,13 @@ public interface IAutomationSurface /// and every command refuses, so a plugin can keep one code path. /// public sealed class NoOpAutomationSurface - : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat + : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, + IPluginChat, ICombatAutomation + , IEquipmentAutomation, IItemAutomation, ILootAutomation, + IFellowshipAutomation, IEnchantmentAutomation, INavigationAutomation + , IWorldObjectAutomation, IWorldTimeAutomation, ILoginAutomation, + INetworkAutomation, IRecoveryAutomation, IProjectileAutomation + , ISelectionAutomation { public static NoOpAutomationSurface Instance { get; } = new(); @@ -207,11 +410,41 @@ public sealed class NoOpAutomationSurface public ISpellCatalog Spells => this; public IMagicCommands Magic => this; public IPluginChat Chat => this; + public ICombatAutomation Combat => this; + public IEquipmentAutomation Equipment => this; + public IItemAutomation Items => this; + public ILootAutomation Loot => this; + public IFellowshipAutomation Fellowship => this; + public IEnchantmentAutomation Enchantments => this; + public INavigationAutomation Navigation => this; + public IWorldObjectAutomation Objects => this; + public IWorldTimeAutomation WorldTime => this; + public ILoginAutomation Login => this; + public INetworkAutomation Network => this; + public IRecoveryAutomation Recovery => this; + public IProjectileAutomation Projectiles => this; + public ISelectionAutomation Selection => this; public void PostSystemMessage(string text) { } + public bool Submit(string text) => false; + + PluginNavigationSnapshot INavigationAutomation.Snapshot => default; + public bool TryGetObject( + uint objectId, + out PluginNavigationObject value) + { + value = default; + return false; + } + public PluginNavigationCommandStatus SetMovementIntent( + in PluginMovementIntent intent) => + PluginNavigationCommandStatus.Unavailable; + public PluginNavigationCommandStatus ClearMovementIntent() => + PluginNavigationCommandStatus.Unavailable; + public bool IsInWorld => false; public uint ObjectId => 0; public uint CurrentHealth => 0; @@ -220,6 +453,7 @@ public sealed class NoOpAutomationSurface public uint MaxStamina => 0; public uint CurrentMana => 0; public uint MaxMana => 0; + public int SummoningMastery => 0; public IReadOnlyList Skills { get; } = Array.Empty(); public IReadOnlyList Attributes { get; } = @@ -228,6 +462,10 @@ public sealed class NoOpAutomationSurface Array.Empty(); public IReadOnlyList KnownSelfBuffs { get; } = Array.Empty(); + public IReadOnlyList KnownAttackSpells { get; } = + Array.Empty(); + public IReadOnlyList KnownCombatSpells { get; } = + Array.Empty(); public bool TryGetSkill(uint skillId, out PluginSkillInfo skill) { @@ -242,6 +480,64 @@ public sealed class NoOpAutomationSurface } public bool IsCasting => false; + public PluginCastCompletion LastCompletion => default; public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Unavailable; public bool Cast(uint spellId) => false; + public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) => + PluginCastGate.Unavailable; + public bool Cast(uint spellId, uint targetObjectId) => false; + + public PluginCombatSnapshot Snapshot => default; + public IReadOnlyList CaptureHostileTargets( + float maximumDistance) => Array.Empty(); + public PluginCombatCommandResult EnterDefaultMode() => new( + PluginCombatCommandStatus.Unavailable); + bool IEquipmentAutomation.IsAvailable => false; + bool IEquipmentAutomation.IsBusy => false; + public IReadOnlyList CaptureOwnedEquipment() => + Array.Empty(); + public PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) => + new(PluginEquipmentCommandStatus.Unavailable); + bool IItemAutomation.IsAvailable => false; + bool IItemAutomation.IsBusy => false; + int IItemAutomation.ActiveOwnedPetCount => 0; + PluginItemUseCompletion IItemAutomation.LastCompletion => default; + PluginItemUseCompletion ILootAutomation.LastItemUseCompletion => default; + PluginInventoryCompletion ILootAutomation.LastInventoryCompletion => default; + PluginAppraisalState ILootAutomation.Appraisal => default; + public IReadOnlyList CaptureOwnedItems() => + Array.Empty(); + public PluginItemCommandResult Use(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + public PluginItemCommandResult Apply(uint objectId, uint targetObjectId) => + new(PluginItemCommandStatus.Unavailable); + public IReadOnlyList CaptureCorpses( + float maximumDistance) => Array.Empty(); + public IReadOnlyList CaptureCurrentContents() => + Array.Empty(); + public PluginItemCommandResult Open(uint containerObjectId) => + new(PluginItemCommandStatus.Unavailable); + public PluginItemCommandResult Identify(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + public PluginItemCommandResult Pickup(uint objectId, bool mainPack = false) => + new(PluginItemCommandStatus.Unavailable); + public bool IsInFellowship => false; + public IReadOnlyList CaptureMembers() => + Array.Empty(); + public IReadOnlyList Capture( + uint targetObjectId) => Array.Empty(); + public bool ReportCast( + uint targetObjectId, + uint spellId, + double durationSeconds) => false; + PluginWorldTimeSnapshot IWorldTimeAutomation.Snapshot => default; + public PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, PluginAttackHeight height, float power) => new( + PluginCombatCommandStatus.Unavailable); + public PluginCombatCommandResult ReleasePhysicalAttack() => new( + PluginCombatCommandStatus.Unavailable); + public PluginCombatCommandResult AbortPhysicalAttack() => new( + PluginCombatCommandStatus.Unavailable); } diff --git a/src/AcDream.Plugin.Abstractions/CombatAutomation.cs b/src/AcDream.Plugin.Abstractions/CombatAutomation.cs new file mode 100644 index 00000000..1c33be77 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/CombatAutomation.cs @@ -0,0 +1,152 @@ +namespace AcDream.Plugin.Abstractions; + +/// Presentation-independent combat mode projected to a plugin. +public enum PluginCombatMode +{ + Unknown = 0, + Peace, + Melee, + Missile, + Magic, +} + +/// Retail's three physical attack heights. +public enum PluginAttackHeight +{ + High = 1, + Medium = 2, + Low = 3, +} + +/// One canonical hostile candidate at the instant it was captured. +public readonly record struct PluginCombatTarget( + uint ObjectId, + string Name, + uint WeenieClassId, + float Distance, + float RelativeAngleDegrees, + bool IsHealthKnown, + float HealthFraction) +{ + /// Retail PropertyInt CreatureType (2), or zero when unknown. + public int SpeciesId { get; init; } + + /// Retail creature-enum display name used by VTank's species variable. + public string SpeciesName { get; init; } = string.Empty; + + /// Spawn/appraisal maximum HP, or zero until the host knows it. + public int MaximumHealth { get; init; } + + /// + /// VTank's dynamic hasshield value: true when the target currently has an + /// equipped object whose object class is Armor. + /// + public bool HasShield { get; init; } + public ushort Incarnation { get; init; } + + /// Monotonic revision of the last server health update. + public long HealthRevision { get; init; } + + /// + /// Seconds since the last server health update at capture time, or + /// positive infinity when health has never been reported. + /// + public double SecondsSinceHealthUpdate { get; init; } = + double.PositiveInfinity; +} + +/// The canonical local combat/attack state visible to a plugin. +public readonly record struct PluginCombatSnapshot( + uint SelectedObjectId, + PluginCombatMode Mode, + PluginAttackHeight AttackHeight, + float DesiredPower, + float PowerBarLevel, + bool BuildInProgress, + bool RequestInProgress, + bool ServerResponsePending, + bool RepeatAttackInProgress) +{ + /// Revision of the last physical AttackDone receipt. + public long CompletionRevision { get; init; } + public uint CompletionSequence { get; init; } + public uint CompletionWeenieError { get; init; } +} + +/// Why an automation combat command did or did not proceed. +public enum PluginCombatCommandStatus +{ + Unavailable = 0, + InvalidTarget, + WrongMode, + Busy, + AlreadyReady, + ModeChangeSent, + Started, + Released, + Stopped, + Refused, +} + +public readonly record struct PluginCombatCommandResult( + PluginCombatCommandStatus Status, + string? Notice = null) +{ + public bool Accepted => Status is + PluginCombatCommandStatus.AlreadyReady + or PluginCombatCommandStatus.ModeChangeSent + or PluginCombatCommandStatus.Started + or PluginCombatCommandStatus.Released + or PluginCombatCommandStatus.Stopped; +} + +/// +/// Host combat primitives. The host owns no macro policy: it projects the +/// canonical candidates/state and attempts the exact retail input operations +/// MossTank asks for. +/// +public interface ICombatAutomation +{ + PluginCombatSnapshot Snapshot { get; } + + /// + /// Capture currently valid hostile creatures no farther than + /// meters from the local player. + /// Snapshot semantics: the returned list is never mutated in place. + /// + IReadOnlyList CaptureHostileTargets(float maximumDistance); + + /// + /// Enter the combat mode implied by currently equipped items. If already + /// in any combat mode this reports . + /// + PluginCombatCommandResult EnterDefaultMode(); + + /// + /// Request one explicit retail combat mode. VTank needs this after + /// selecting a caster, melee proc weapon, or grenade; the host still owns + /// and sends the canonical mode transition. + /// + PluginCombatCommandResult EnterMode(PluginCombatMode mode) => + new(PluginCombatCommandStatus.Unavailable); + + /// + /// Select , set the desired power and + /// press the retail attack-height input. Release is a separate command so + /// a plugin can wait for the real power bar. + /// + PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, + PluginAttackHeight height, + float power); + + PluginCombatCommandResult ReleasePhysicalAttack(); + PluginCombatCommandResult AbortPhysicalAttack(); + + /// + /// Retire a client-side ghost through the host's canonical entity teardown + /// path. This never sends a server delete and must reject the local player. + /// + PluginCombatCommandResult DismissGhostTarget(uint targetObjectId) => + new(PluginCombatCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs b/src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs new file mode 100644 index 00000000..778d7bff --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs @@ -0,0 +1,35 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One duration spell observed on a world object. This is a timer ledger, not +/// an authoritative server enchantment registry: retail VTank built the same +/// view from confirmed local casts and casts reported by cooperating plugins. +/// +public readonly record struct PluginTrackedEnchantment( + uint TargetObjectId, + uint SpellId, + uint Family, + int Quality, + bool IsUntargeted, + double SecondsRemaining); + +/// +/// Shared per-client duration-spell ledger. The host records successful local +/// casts automatically. Plugins that perform casts outside the host's normal +/// command surface can report their confirmed result, matching VTank's public +/// LogSpellCast(target, spell, duration) capability. +/// +public interface IEnchantmentAutomation +{ + IReadOnlyList Capture(uint targetObjectId) => + Array.Empty(); + + /// + /// Report a confirmed duration spell. Dispatch attempts must not be + /// reported; is the effective duration. + /// + bool ReportCast( + uint targetObjectId, + uint spellId, + double durationSeconds) => false; +} diff --git a/src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs b/src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs new file mode 100644 index 00000000..0b1928f9 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs @@ -0,0 +1,64 @@ +namespace AcDream.Plugin.Abstractions; + +/// One owned item that can participate in VTank equipment policy. +public readonly record struct PluginEquipmentItem( + uint ObjectId, + string Name, + uint ItemType, + uint ValidLocations, + uint EquippedLocation, + uint ContainerObjectId, + uint WielderObjectId, + byte CombatUse, + int DamageType, + int WeaponSkill, + int Damage, + double DamageVariance) +{ + public bool IsEquipped => EquippedLocation != 0u; + /// Retail AMMO_TYPE bit from PublicWeenieDesc. + public uint AmmoType { get; init; } + public int StackSize { get; init; } = 1; + public int WeaponType { get; init; } +} + +public enum PluginEquipmentCommandStatus +{ + Unavailable = 0, + InvalidItem, + Busy, + AlreadyEquipped, + Started, + Refused, +} + +public readonly record struct PluginEquipmentCommandResult( + PluginEquipmentCommandStatus Status, + string? Notice = null) +{ + public bool Accepted => Status is + PluginEquipmentCommandStatus.AlreadyEquipped + or PluginEquipmentCommandStatus.Started; +} + +/// +/// Borrowed inventory equipment view and one request through the client's +/// canonical confirmed AutoWield transaction. +/// +public interface IEquipmentAutomation +{ + bool IsAvailable => false; + bool IsBusy => false; + + IReadOnlyList CaptureOwnedEquipment() => + Array.Empty(); + + /// + /// Zero asks retail AutoWield to choose; otherwise this is the exact + /// retail INVENTORY_LOC bit requested by a profile. + /// + PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) => + new(PluginEquipmentCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs b/src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs new file mode 100644 index 00000000..767120e3 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs @@ -0,0 +1,72 @@ +namespace AcDream.Plugin.Abstractions; + +/// One authoritative fellowship-roster entry plus live range. +public readonly record struct PluginFellowMember( + uint ObjectId, + string Name, + uint CurrentHealth, + uint MaxHealth, + uint CurrentStamina, + uint MaxStamina, + uint CurrentMana, + uint MaxMana, + float Distance) +{ + /// + /// The member's authoritative fellowship Share Loot bit. VTank permits + /// immediate corpse access for a fellow only when this bit is set; a + /// non-sharing fellow's corpse remains protected for retail's 100-second + /// public-loot interval. + /// + public bool ShareLoot { get; init; } +} + +public enum PluginFellowshipCommandStatus +{ + Unavailable = 0, + Accepted, + Rejected, +} + +public readonly record struct PluginFellowshipCommandResult( + PluginFellowshipCommandStatus Status) +{ + public bool Accepted => Status == PluginFellowshipCommandStatus.Accepted; +} + +/// +/// Group state and generation-gated retail fellowship commands. Recruitment, +/// waiting lists, voting, and social policy remain plugin behavior; the host +/// only exposes the canonical wire operations already used by the retail UI. +/// +public interface IFellowshipAutomation +{ + bool IsInFellowship => false; + string Name => string.Empty; + uint LeaderObjectId => 0u; + bool IsOpen => false; + bool IsLocked => false; + int MemberCount => 0; + IReadOnlyList CaptureMembers() => + Array.Empty(); + + /// + /// Complete authoritative roster in server insertion order, including the + /// local player. Use for helper/healer policy + /// that intentionally excludes self. + /// + IReadOnlyList CaptureRoster() => CaptureMembers(); + + PluginFellowshipCommandResult Create(string name, bool shareExperience) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult Recruit(uint targetObjectId) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult Dismiss(uint targetObjectId) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult Quit(bool disband) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult AssignLeader(uint targetObjectId) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult SetOpen(bool isOpen) => + new(PluginFellowshipCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 570ab128..5b030725 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -20,6 +20,19 @@ public interface IPluginHost IEvents Events { get; } ISelectionService Selection { get; } IUiRegistry Ui { get; } + /// + /// Locally handled slash/at commands. Hosts without command routing expose + /// an inert registry so an API-v1 plugin can retain one code path. + /// + IPluginCommandRegistry Commands => NoOpPluginCommandRegistry.Instance; + /// + /// Durable storage scoped by the host to this plugin's manifest id. + /// No-window/test hosts may explicitly expose the inert implementation. + /// + IPluginStorage Storage => NoOpPluginStorage.Instance; + /// Unload-safe external VTank-style loot classifiers. + IPluginLootClassifierRegistry LootClassifiers => + NoOpPluginLootClassifierRegistry.Instance; /// /// Character reads, spell data and casting. Hosts with no live session diff --git a/src/AcDream.Plugin.Abstractions/IPluginStorage.cs b/src/AcDream.Plugin.Abstractions/IPluginStorage.cs new file mode 100644 index 00000000..aa77b067 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IPluginStorage.cs @@ -0,0 +1,22 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Per-plugin durable text storage. The host scopes keys to the authenticated +/// manifest id, so a plugin cannot collide with another plugin's profile. +/// +public interface IPluginStorage +{ + bool IsAvailable => false; + string? ReadText(string key) => null; + /// Relative file keys beneath one relative prefix. + IReadOnlyList List(string prefix) => Array.Empty(); + void WriteText(string key, string content) => + throw new NotSupportedException("Plugin storage is unavailable."); + bool Delete(string key) => false; +} + +public sealed class NoOpPluginStorage : IPluginStorage +{ + public static NoOpPluginStorage Instance { get; } = new(); + private NoOpPluginStorage() { } +} diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs index ca587dcf..900971a3 100644 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -1,5 +1,47 @@ namespace AcDream.Plugin.Abstractions; +/// +/// Stable, presentation-neutral description of one top-level plugin window. +/// The graphical host uses this metadata for its plugin sidepanel and retained +/// window registry; no App/UI type crosses the plugin boundary. +/// +/// +/// Stable id within the owning plugin. It is part of the persisted window-layout +/// key, so it must not be localized or changed between releases. +/// +/// User-facing window title. +public sealed record PluginPanelDescriptor(string WindowId, string Title) +{ + /// + /// Optional one-to-three-character fallback drawn in the sidepanel button + /// when no DAT icon is supplied. The host derives initials from + /// when this is empty. + /// + public string? IconText { get; init; } + + /// + /// Optional installed-client RenderSurface DID. Zero asks the host to draw + /// instead. Plugins never receive the resulting GPU + /// resource and remain BCL-only. + /// + public uint IconSurfaceId { get; init; } + + /// + /// Initial visibility used only when no per-character persisted layout is + /// available. Hiding the window never disables the plugin. + /// + public bool StartVisible { get; init; } = true; + + /// Whether this window receives a button in the shared sidepanel. + public bool ShowInSidePanel { get; init; } = true; +} + +/// +/// Host-authenticated plugin identity attached to registrations by the scoped +/// plugin lifetime. Plugins cannot choose or spoof this value. +/// +public readonly record struct PluginUiOwner(string Id, string DisplayName); + /// /// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + /// a binding object exposing the data properties the markup binds to, and @@ -13,6 +55,52 @@ public interface IUiRegistry /// Absolute path to the plugin's panel markup file. /// Object whose properties the markup's {Bindings} resolve against. void AddMarkupPanel(string markupPath, object binding); + + /// + /// Registers a first-class plugin window. The host keeps the plugin lifetime + /// independent from the window's visible/minimized state. + /// + /// + /// Defaulting to the API-v1 method keeps older/custom hosts source-compatible; + /// acdream's graphical scoped host overrides this route and preserves all + /// descriptor metadata. + /// + void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + => AddMarkupPanel(markupPath, binding); + + /// + /// Registers a window whose lifetime may be ended independently while the + /// plugin keeps running. Disposing the token removes the retained window + /// and its sidepanel entry. + /// + IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + AddPanel(descriptor, markupPath, binding); + return NoOpUiRegistration.Instance; + } + + /// + /// Registers an independently removable window from in-memory KSML. This + /// is the BCL-only seam used by VTank-compatible Meta Create View actions; + /// plugins do not need to create temporary files or import App types. + /// + IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + + /// Queries this plugin's own registered view by title or stable id. + bool ViewExists(string viewName) => false; + bool IsViewVisible(string viewName) => false; + bool ControlExists(string viewName, string controlName) => false; + bool SetControlLabel(string viewName, string controlName, string label) => false; + bool SetControlVisible(string viewName, string controlName, bool visible) => false; } /// @@ -25,6 +113,55 @@ public interface IUiRegistry public interface IScopedUiRegistry : IUiRegistry { IDisposable RegisterMarkupPanel(string markupPath, object binding); + + /// + /// Host-only scoped registration carrying the manifest-derived owner. + /// Disposal removes both the retained window and its sidepanel entry. + /// + IDisposable RegisterPanel( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + => RegisterMarkupPanel(markupPath, binding); + + /// Host-owned registration for in-memory plugin markup. + IDisposable RegisterPanelContent( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + + bool ViewExists(PluginUiOwner owner, string viewName) => false; + bool IsViewVisible(PluginUiOwner owner, string viewName) => false; + bool ControlExists( + PluginUiOwner owner, + string viewName, + string controlName) => false; + bool SetControlLabel( + PluginUiOwner owner, + string viewName, + string controlName, + string label) => false; + bool SetControlVisible( + PluginUiOwner owner, + string viewName, + string controlName, + bool visible) => false; +} + +/// Shared empty registration returned by UI-less/legacy hosts. +public sealed class NoOpUiRegistration : IDisposable +{ + public static NoOpUiRegistration Instance { get; } = new(); + + private NoOpUiRegistration() + { + } + + public void Dispose() + { + } } /// @@ -44,9 +181,38 @@ public sealed class NoOpUiRegistry : IScopedUiRegistry { } + public void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + } + + public IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) => NoOpUiRegistration.Instance; + + public IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + public IDisposable RegisterMarkupPanel(string markupPath, object binding) => NoOpRegistration.Instance; + public IDisposable RegisterPanel( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding) => NoOpRegistration.Instance; + + public IDisposable RegisterPanelContent( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + private sealed class NoOpRegistration : IDisposable { internal static NoOpRegistration Instance { get; } = new(); diff --git a/src/AcDream.Plugin.Abstractions/ItemAutomation.cs b/src/AcDream.Plugin.Abstractions/ItemAutomation.cs new file mode 100644 index 00000000..4709a50c --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/ItemAutomation.cs @@ -0,0 +1,245 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One ordered VTClassic-compatible subpalette sample from an object's model +/// description. RGB is sampled at retail/VTank's representative index. +/// +public readonly record struct PluginPaletteInfo( + uint PaletteId, + byte Offset, + byte Length, + byte Red, + byte Green, + byte Blue); + +/// +/// One carried item from the character's canonical inventory object table. +/// The deliberately raw retail ids let general plugins classify new server +/// content without taking a dependency on acdream's Core enums. +/// +public readonly record struct PluginInventoryItem( + uint ObjectId, + uint WeenieClassId, + string Name, + uint ItemType, + uint ContainerObjectId, + uint WielderObjectId, + uint ValidLocations, + uint EquippedLocation, + uint Useability, + uint TargetType, + uint PublicFlags, + int StackSize, + int Structure, + int MaximumStructure, + uint SpellId, + int PetClass, + int SummoningMastery, + uint ProcSpellId, + bool ProcSpellSelfTargeted, + double ProcSpellRate, + int WeaponSkill, + int DamageType, + int Damage, + double DamageVariance, + int UseRequiresSkill, + int UseRequiresSkillLevel, + int UseRequiresSkillSpecialized) +{ + public bool IsEquipped => EquippedLocation != 0u; + public bool IsPetDevice => PetClass != 0; + public bool HasCastOnStrike => ProcSpellId != 0u && ProcSpellRate > 0d; + public int CombatUse { get; init; } + public int ItemSpellcraft { get; init; } + public int WieldRequirements { get; init; } + public int WieldSkillType { get; init; } + public int WieldDifficulty { get; init; } + public int AttackType { get; init; } + public int WeaponType { get; init; } + /// + /// Retail PropertyInt.BoosterEnum: current Health/Stamina/Mana are + /// 2/4/6. VTank uses this to classify both kits and food without relying + /// on localized item names. + /// + public int BoosterVital { get; init; } + public int BoostValue { get; init; } + public double HealKitModifier { get; init; } + public IReadOnlyList AppraisedSpellIds { get; init; } = + Array.Empty(); + public int GearDamage { get; init; } + public int GearDamageResistance { get; init; } + public int GearCriticalChance { get; init; } + public int GearCriticalResistance { get; init; } + public int GearCriticalDamage { get; init; } + public int GearCriticalDamageResistance { get; init; } + /// Retail PublicWeenieDesc maximum stack size. + public int MaximumStackSize { get; init; } = 1; + /// Current zero-based slot inside . + public int ContainerSlot { get; init; } = -1; + /// Number of ordinary item slots when this object is a container. + public int ItemsCapacity { get; init; } + /// Number of nested-container slots when this object is a container. + public int ContainersCapacity { get; init; } + /// Current total burden of this object or stack. + public int Burden { get; init; } + public int Value { get; init; } + public int ItemCurrentMana { get; init; } + public int ItemMaximumMana { get; init; } + public float Workmanship { get; init; } + public uint MaterialType { get; init; } + /// Virindi/Decal's stable object class, not ItemType flags. + public PluginObjectClass ObjectClass { get; init; } + public IReadOnlyList Palettes { get; init; } = + Array.Empty(); +} + +/// +/// On-demand copy of an item's raw retail property tables. Loot and expression +/// engines can understand future server content without making every ordinary +/// inventory scan clone seven dictionaries per item. +/// +public readonly record struct PluginItemProperties( + IReadOnlyDictionary Ints, + IReadOnlyDictionary Int64s, + IReadOnlyDictionary Bools, + IReadOnlyDictionary Floats, + IReadOnlyDictionary Strings, + IReadOnlyDictionary DataIds, + IReadOnlyDictionary InstanceIds); + +/// One server UseDone for a plugin-issued item action. +public readonly record struct PluginItemUseCompletion( + long Revision, + uint SourceObjectId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + +public enum PluginItemCommandStatus +{ + Unavailable = 0, + InvalidItem, + InvalidTarget, + Busy, + Started, + Refused, +} + +public readonly record struct PluginItemCommandResult( + PluginItemCommandStatus Status, + string? Notice = null) +{ + public bool Accepted => Status == PluginItemCommandStatus.Started; +} + +/// The retail inventory request that produced a completion receipt. +public enum PluginInventoryCommandKind +{ + Unknown = 0, + Pickup, + PutInContainer, + SplitToContainer, + Merge, + Move, + DropToWorld, + SplitToWorld, + Wield, + Give, +} + +/// +/// Authoritative completion of one plugin or UI inventory transaction. A +/// started command is not success until this revision advances for its source. +/// +public readonly record struct PluginInventoryCompletion( + long Revision, + PluginInventoryCommandKind Kind, + uint SourceObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + +/// +/// Borrowed inventory view and item actions through the client's one retail +/// item-interaction transaction. A successful command means only that the +/// request started; is the server result. +/// +public interface IItemAutomation +{ + bool IsAvailable => false; + bool IsBusy => false; + int ActiveOwnedPetCount => 0; + uint ActiveVendorObjectId => 0u; + PluginItemUseCompletion LastCompletion => default; + PluginInventoryCompletion LastInventoryCompletion => default; + + IReadOnlyList CaptureOwnedItems() => + Array.Empty(); + + bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + properties = default; + return false; + } + + PluginItemCommandResult Use(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + + PluginItemCommandResult Apply(uint objectId, uint targetObjectId) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Move all or an exact partial quantity into a carried container. An + /// amount of zero means the whole current stack. + /// + PluginItemCommandResult MoveToContainer( + uint objectId, + uint containerObjectId, + uint amount = 0u, + int placement = 0) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Merge up to units from source into target. + /// Zero means as much as retail permits. + /// + PluginItemCommandResult Merge( + uint sourceObjectId, + uint targetObjectId, + uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); + + /// Drop all or an exact partial stack on the ground. + PluginItemCommandResult Drop(uint objectId, uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); + + /// Give all or an exact partial stack to a world target. + PluginItemCommandResult Give( + uint objectId, + uint targetObjectId, + uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Salvage one or more owned items with an owned tinkering/salvage tool. + /// The command is the retail 0x027D operation; source-item removal is the + /// authoritative completion signal until a host projects the 0x02B4 + /// material-result details. + /// + PluginItemCommandResult Salvage( + uint toolObjectId, + IReadOnlyList itemObjectIds) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Sell an owned item through the currently-open authoritative vendor. + /// Zero amount means the complete current stack. + /// + PluginItemCommandResult Sell(uint objectId, uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/LoginAutomation.cs b/src/AcDream.Plugin.Abstractions/LoginAutomation.cs new file mode 100644 index 00000000..09ee6ff1 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/LoginAutomation.cs @@ -0,0 +1,25 @@ +namespace AcDream.Plugin.Abstractions; + +/// One character in the account's authoritative login roster. +public readonly record struct PluginLoginCharacter( + uint ObjectId, + string Name, + int ActiveIndex, + bool IsPendingDelete); + +/// +/// Account-roster and one-shot next-login control. The host owns the login +/// transaction; plugins only select or clear the character to enter when the +/// current character returns to character selection. +/// +public interface ILoginAutomation +{ + bool IsAvailable => false; + uint NextLoginObjectId => 0u; + + IReadOnlyList CaptureRoster() => + Array.Empty(); + + bool SetNextLogin(uint characterObjectId) => false; + bool ClearNextLogin() => false; +} diff --git a/src/AcDream.Plugin.Abstractions/LootAutomation.cs b/src/AcDream.Plugin.Abstractions/LootAutomation.cs new file mode 100644 index 00000000..adad2cc3 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/LootAutomation.cs @@ -0,0 +1,69 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One live external container that an automation plugin may approach and use. +/// The host classifies corpses; plugins decide whether and when to loot them. +/// +public readonly record struct PluginLootContainer( + uint ObjectId, + uint WeenieClassId, + string Name, + float Distance, + bool HasBeenOpened, + bool IsRequested, + bool IsCurrent) +{ + public string LongDescription { get; init; } = string.Empty; + public bool IsGeneratedRare { get; init; } + public bool IsIdentified { get; init; } +} + +public readonly record struct PluginAppraisalState( + long Revision, + uint AwaitingObjectId, + uint CurrentObjectId); + +/// +/// Read-only corpse/container discovery plus canonical open and pickup commands. +/// Successful commands mean the request started; completion is reported through +/// or . +/// +public interface ILootAutomation +{ + bool IsAvailable => false; + bool IsBusy => false; + uint RequestedContainerId => 0u; + uint CurrentContainerId => 0u; + PluginItemUseCompletion LastItemUseCompletion => default; + PluginInventoryCompletion LastInventoryCompletion => default; + PluginAppraisalState Appraisal => default; + + IReadOnlyList CaptureCorpses(float maximumDistance) => + Array.Empty(); + + /// + /// Captures the complete currently viewed external-container tree. Entries + /// are ordered depth-first in retail container-slot order. + /// + IReadOnlyList CaptureCurrentContents() => + Array.Empty(); + + bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + properties = default; + return false; + } + + PluginItemCommandResult Open(uint containerObjectId) => + new(PluginItemCommandStatus.Unavailable); + + PluginItemCommandResult Identify(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + + PluginItemCommandResult Pickup( + uint objectId, + bool mainPack = false) => + new(PluginItemCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs b/src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs new file mode 100644 index 00000000..312a923a --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs @@ -0,0 +1,93 @@ +namespace AcDream.Plugin.Abstractions; + +/// VTank's public loot-plugin action vocabulary. +public enum PluginLootAction +{ + NoLoot = 0, + Keep = 1, + Salvage = 2, + Sell = 3, + Read = 4, + User1 = 5, + User2 = 6, + User3 = 7, + User4 = 8, + User5 = 9, + KeepUpTo = 10, +} + +public readonly record struct PluginLootClassificationContext( + PluginInventoryItem Item, + PluginItemProperties Properties, + IReadOnlyList OwnedItems); + +/// A classifier's detached decision. Matched=false means no rule. +public readonly record struct PluginLootClassification( + bool Matched, + PluginLootAction Action, + string RuleName = "", + int Priority = 0, + int KeepCount = 0); + +/// +/// A classified item after the server-confirmed move into owned inventory. +/// This is VTank's custom-action item ledger boundary. +/// +public readonly record struct PluginLootedItem( + PluginInventoryItem Item, + PluginLootAction Action); + +public interface IPluginLootClassifier +{ + PluginLootClassification Classify( + in PluginLootClassificationContext context); + + void OnLooted(in PluginLootedItem item) { } + + void OnItemRemoved(uint objectId) { } +} + +public readonly record struct PluginLootClassifierInfo( + string Id, + string DisplayName); + +/// +/// Machine-local, in-process classifier exchange. Registration lifetime is +/// scoped to the owning plugin by the host; callers never retain an unloaded +/// plugin's classifier. +/// +public interface IPluginLootClassifierRegistry +{ + IReadOnlyList Available => + Array.Empty(); + + IDisposable Register( + string classifierId, + string displayName, + IPluginLootClassifier classifier) => + throw new NotSupportedException("Loot classifiers are unavailable."); + + bool TryClassify( + string classifierId, + in PluginLootClassificationContext context, + out PluginLootClassification classification) + { + classification = default; + return false; + } + + bool TryNotifyLooted( + string classifierId, + in PluginLootedItem item) => false; + + bool TryNotifyItemRemoved( + string classifierId, + uint objectId) => false; +} + +public sealed class NoOpPluginLootClassifierRegistry + : IPluginLootClassifierRegistry +{ + public static NoOpPluginLootClassifierRegistry Instance { get; } = new(); + private NoOpPluginLootClassifierRegistry() { } +} diff --git a/src/AcDream.Plugin.Abstractions/MagicAutomation.cs b/src/AcDream.Plugin.Abstractions/MagicAutomation.cs new file mode 100644 index 00000000..83027534 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/MagicAutomation.cs @@ -0,0 +1,11 @@ +namespace AcDream.Plugin.Abstractions; + +/// One authoritative completion of a spell request. +public readonly record struct PluginCastCompletion( + long Revision, + uint SpellId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} diff --git a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs new file mode 100644 index 00000000..3a3a37c8 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs @@ -0,0 +1,115 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Stable Asheron's Call map coordinate. East/west and north/south use the +/// familiar in-game coordinate scale (for example 33.5S, 72.8E); elevation is +/// expressed in metres. The cell id is retained because indoor coordinates do +/// not have a meaningful outdoor compass label. +/// +public readonly record struct PluginNavigationPosition( + uint CellId, + double EastWest, + double NorthSouth, + double Elevation, + float HeadingDegrees, + bool IsOutdoor) +{ + public double HorizontalDistanceMeters(in PluginNavigationPosition other) + { + double dx = EastWest - other.EastWest; + double dy = NorthSouth - other.NorthSouth; + return Math.Sqrt(dx * dx + dy * dy) * 240d; + } +} + +/// One live object's canonical identity, name, and position. +public readonly record struct PluginNavigationObject( + uint ObjectId, + string Name, + PluginNavigationPosition Position) +{ + public bool IsDoor { get; init; } + public bool IsOpen { get; init; } + public bool IsLocked { get; init; } + public bool HasLockState { get; init; } + public int LockDifficulty { get; init; } +} + +/// The local movement state sampled atomically by a plugin tick. +public readonly record struct PluginNavigationSnapshot( + bool IsAvailable, + bool IsPortalSpace, + uint LocalObjectId, + PluginNavigationPosition Position, + bool IsMoving, + bool IsAirborne) +{ + /// + /// Last position accepted from the server for the local player. Ordinary + /// point navigation uses the live physics position; VTank checkpoints use + /// this acknowledgement so client prediction cannot advance the route. + /// + public PluginNavigationPosition ConfirmedPosition { get; init; } + public ulong ConfirmedPositionRevision { get; init; } +} + +/// +/// Semantic movement levels. They are applied through the same Runtime-owned +/// command-interpreter input state as the keyboard; no plugin-only physics or +/// movement model exists. +/// +public readonly record struct PluginMovementIntent( + bool Forward = false, + bool Backward = false, + bool StrafeLeft = false, + bool StrafeRight = false, + bool TurnLeft = false, + bool TurnRight = false, + bool Run = true, + bool Jump = false); + +public enum PluginNavigationCommandStatus +{ + Unavailable = 0, + Accepted, + Rejected, +} + +/// +/// Host navigation primitives. Route sequencing, path policy, following, and +/// waypoint behavior belong to the plugin (as they did in VTank); the host +/// exposes only canonical positions and command-interpreter movement. +/// +public interface INavigationAutomation +{ + PluginNavigationSnapshot Snapshot { get; } + + bool TryGetObject(uint objectId, out PluginNavigationObject value); + + /// + /// Reacquire a world object whose session-scoped id changed, choosing the + /// nearest exact-name match to a saved route position. VTank uses this for + /// its Portal2 and UseNPC waypoint records instead of trusting a stale id. + /// + bool TryFindObject( + string name, + in PluginNavigationPosition near, + double maximumDistanceMeters, + out PluginNavigationObject value) + { + value = default; + return false; + } + + /// + /// Detached live world-object projection used by plugin-owned proximity + /// policies such as VTank's door opener. Hosts may return an empty list. + /// + IReadOnlyList CaptureObjects() => + Array.Empty(); + + PluginNavigationCommandStatus SetMovementIntent( + in PluginMovementIntent intent); + + PluginNavigationCommandStatus ClearMovementIntent(); +} diff --git a/src/AcDream.Plugin.Abstractions/NetworkAutomation.cs b/src/AcDream.Plugin.Abstractions/NetworkAutomation.cs new file mode 100644 index 00000000..e6628170 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/NetworkAutomation.cs @@ -0,0 +1,28 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One other live acdream client discovered by the host's local peer service. +/// The shape mirrors UtilityBelt's ClientData expression contract. +/// +public readonly record struct PluginNetworkClient( + uint ClientId, + uint PlayerId, + string Name, + string WorldName, + PluginNavigationPosition Position, + IReadOnlyList Tags, + uint CurrentHealth, + uint CurrentMana, + uint CurrentStamina, + uint MaxHealth, + uint MaxMana, + uint MaxStamina, + float Heading); + +/// Read-only discovery of other local acdream client processes. +public interface INetworkAutomation +{ + bool IsAvailable => false; + IReadOnlyList CaptureClients() => + Array.Empty(); +} diff --git a/src/AcDream.Plugin.Abstractions/PluginCommands.cs b/src/AcDream.Plugin.Abstractions/PluginCommands.cs new file mode 100644 index 00000000..85abee36 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/PluginCommands.cs @@ -0,0 +1,48 @@ +namespace AcDream.Plugin.Abstractions; + +/// One locally handled slash/at command submitted by the player. +public readonly record struct PluginCommand( + string Verb, + string Arguments, + string RawText); + +/// +/// Process-local command registration for gameplay plugins. Registered verbs +/// run before an unknown command is sent to the game server, so plugin commands +/// work from typed chat, launcher login commands, and other plugins' normal +/// chat-submit path. +/// +public interface IPluginCommandRegistry +{ + /// + /// Register one bare verb (for example vt, without a leading slash). + /// Matching is case-insensitive and accepts both retail command prefixes. + /// The returned lease removes only this exact registration. + /// + IDisposable Register(string verb, Action handler); +} + +/// Inert command surface for hosts that cannot route local commands. +public sealed class NoOpPluginCommandRegistry : IPluginCommandRegistry +{ + public static NoOpPluginCommandRegistry Instance { get; } = new(); + + private NoOpPluginCommandRegistry() + { + } + + public IDisposable Register(string verb, Action handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(verb); + ArgumentNullException.ThrowIfNull(handler); + return NoOpLease.Instance; + } + + private sealed class NoOpLease : IDisposable + { + public static NoOpLease Instance { get; } = new(); + public void Dispose() + { + } + } +} diff --git a/src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs b/src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs new file mode 100644 index 00000000..19cece09 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs @@ -0,0 +1,91 @@ +using System.Numerics; + +namespace AcDream.Plugin.Abstractions; + +/// The trajectory family VTank asks the client to validate. +public enum PluginProjectilePathKind +{ + Straight = 0, + Arc, + Missile, +} + +/// Why a projectile-path query did or did not admit the shot. +public enum PluginProjectilePathStatus +{ + Unavailable = 0, + Clear, + Blocked, + InvalidTarget, + BudgetExceeded, + Error, +} + +/// One VTank collision-debug marker in client world coordinates. +public readonly record struct PluginProjectileDebugSample( + Vector3 WorldPosition, + bool IsClear, + float Radius); + +/// +/// Detached result of one bounded collision probe. The host reports geometry; +/// the plugin still decides whether to cast, fire, or choose a fallback. +/// +public readonly record struct PluginProjectilePathResult( + PluginProjectilePathStatus Status, + int CollisionChecks = 0, + uint BlockingObjectId = 0u, + string? Notice = null) +{ + public bool IsClear => Status == PluginProjectilePathStatus.Clear; + public IReadOnlyList DebugSamples + { get; init; } = Array.Empty(); +} + +/// +/// Canonical client-world projectile collision projection. Implementations +/// must use the same resident collision world as ordinary client physics and +/// must never fabricate a successful path when that world is unavailable. +/// +public interface IProjectileAutomation +{ + bool IsAvailable => false; + + PluginProjectilePathResult EvaluatePath( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => + new(PluginProjectilePathStatus.Unavailable); + + /// + /// Same bounded query with VTank's optional per-quantum debug markers. + /// Older hosts safely fall back to the ordinary result. + /// + PluginProjectilePathResult EvaluatePathWithDiagnostics( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => + EvaluatePath( + targetObjectId, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks); + + /// + /// Presents a transient copy of diagnostic samples in the game view. + /// Graphical hosts draw VTank's green clear/red blocked markers; headless + /// and older hosts deliberately ignore the request. + /// + void ShowDebugSamples( + IReadOnlyList samples) + { + } +} diff --git a/src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs b/src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs new file mode 100644 index 00000000..d06fa3cb --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs @@ -0,0 +1,21 @@ +namespace AcDream.Plugin.Abstractions; + +/// Result of one explicit operator recovery operation. +public readonly record struct PluginRecoveryResult( + bool Accepted, + int PreviousCount = 0, + int CurrentCount = 0, + string Message = ""); + +/// +/// Narrow debug/recovery access to host-owned action state. Normal plugin +/// policy must wait for authoritative receipts; these operations exist for +/// VTank-compatible operator commands that deliberately recover a stuck +/// client-side reference. +/// +public interface IRecoveryAutomation +{ + PluginRecoveryResult ClearOneBusyReference() => new( + Accepted: false, + Message: "Action recovery is unavailable on this host."); +} diff --git a/src/AcDream.Plugin.Abstractions/SelectionAutomation.cs b/src/AcDream.Plugin.Abstractions/SelectionAutomation.cs new file mode 100644 index 00000000..44df4ae4 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/SelectionAutomation.cs @@ -0,0 +1,18 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Retail target-cycle actions needed by automation which intentionally +/// changes selection. These invoke the same selection query/controller as +/// keyboard bindings; plugins do not synthesize physical key input. +/// +public enum PluginSelectionAction +{ + PreviousSelection = 0, + PreviousPlayer, + NextPlayer, +} + +public interface ISelectionAutomation +{ + bool Execute(PluginSelectionAction action) => false; +} diff --git a/src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs b/src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs new file mode 100644 index 00000000..46cdecac --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs @@ -0,0 +1,117 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Virindi/Decal's stable ObjectClass numbers. These are deliberately distinct +/// from retail's ItemType flags: expressions and imported metas commonly use +/// numeric ObjectClass values (for example 5 = Monster and 24 = Player). +/// +public enum PluginObjectClass +{ + Unknown = 0, + MeleeWeapon = 1, + Armor = 2, + Clothing = 3, + Jewelry = 4, + Monster = 5, + Food = 6, + Money = 7, + Misc = 8, + MissileWeapon = 9, + Container = 10, + Gem = 11, + SpellComponent = 12, + Key = 13, + Portal = 14, + TradeNote = 15, + ManaStone = 16, + Plant = 17, + BaseCooking = 18, + BaseAlchemy = 19, + BaseFletching = 20, + CraftedCooking = 21, + CraftedAlchemy = 22, + CraftedFletching = 23, + Player = 24, + Vendor = 25, + Door = 26, + Corpse = 27, + Lifestone = 28, + HealingKit = 29, + Lockpick = 30, + WandStaffOrb = 31, + Bundle = 32, + Book = 33, + Journal = 34, + Sign = 35, + Housing = 36, + Npc = 37, + Foci = 38, + Salvage = 39, + Ust = 40, + Services = 41, + Scroll = 42, + CombatPet = 43, +} + +/// +/// Detached canonical world-object projection for general plugins and +/// expression engines. The host reports facts; filtering and automation +/// policy remain in the plugin. +/// +public readonly record struct PluginWorldObject( + uint ObjectId, + uint WeenieClassId, + string Name, + PluginObjectClass ObjectClass, + uint ItemType, + uint ContainerObjectId, + uint WielderObjectId) +{ + public bool IsOwned { get; init; } + public bool IsLandscape { get; init; } + public bool HasPosition { get; init; } + public PluginNavigationPosition Position { get; init; } + public bool HasAppraisalData { get; init; } + /// + /// Decal-compatible monotonic millisecond tick of the latest successful + /// identify response for this exact object lifetime. + /// + public int LastIdTime { get; init; } + public bool IsDoorOpen { get; init; } + public int StackSize { get; init; } = 1; + public int ItemsCapacity { get; init; } + public int ContainersCapacity { get; init; } + public IReadOnlyList SpellIds { get; init; } = Array.Empty(); + public IReadOnlyList ActiveSpellIds { get; init; } = Array.Empty(); +} + +/// +/// General object discovery used by UtilityBelt expressions and third-party +/// plugins. It borrows the same Runtime entity directory and ClientObject table +/// as world rendering and inventory; no plugin-specific mirror is introduced. +/// +public interface IWorldObjectAutomation +{ + bool IsAvailable => false; + uint OpenContainerObjectId => 0u; + + IReadOnlyList CaptureObjects() => + Array.Empty(); + + bool TryGet(uint objectId, out PluginWorldObject value) + { + value = default; + return false; + } + + bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + properties = default; + return false; + } + + PluginItemCommandResult Identify(uint objectId) => + new(PluginItemCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs b/src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs new file mode 100644 index 00000000..62a70eff --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs @@ -0,0 +1,20 @@ +namespace AcDream.Plugin.Abstractions; + +/// Authoritative Dereth calendar facts projected from Runtime. +public readonly record struct PluginWorldTimeSnapshot( + bool IsAvailable, + double GameTicks, + int Year, + int Month, + int Day, + int Hour, + string MonthName, + string HourName, + bool IsDay, + double MinutesUntilDay, + double MinutesUntilNight); + +public interface IWorldTimeAutomation +{ + PluginWorldTimeSnapshot Snapshot => default; +} diff --git a/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj b/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj index b5ea7958..a9474e63 100644 --- a/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj +++ b/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj @@ -22,8 +22,7 @@ PreserveNewest - - PreserveNewest - + + diff --git a/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs b/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs new file mode 100644 index 00000000..a1291ac5 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs @@ -0,0 +1,408 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum AttackSpellShape +{ + Direct, + Arc, + Streak, + Ring, + Harm, + Drain, + Martyr, +} + +internal readonly record struct AttackSpellChoice( + PluginSpellInfo Spell, + AttackSpellShape Shape, + MonsterDamageType DamageType, + bool CastWithoutTarget); + +/// +/// VTank's attack vocabulary projected from the learned retail spell table. +/// Shape and element are derived from stable retail spell names/descriptions; +/// the host remains a policy-free provider of canonical DAT metadata. +/// +internal sealed class AttackSpellCatalog +{ + private const uint TuskerFistsSpellId = 0x0B76u; + private readonly AttackSpellChoice[] _choices; + + private AttackSpellCatalog(AttackSpellChoice[] choices) => + _choices = choices; + + public static AttackSpellCatalog Build( + IReadOnlyList spells) + { + ArgumentNullException.ThrowIfNull(spells); + var choices = new List(); + foreach (PluginSpellInfo spell in spells) + { + if (TryClassify(spell, out AttackSpellChoice choice)) + choices.Add(choice); + } + return new AttackSpellCatalog([.. choices]); + } + + /// + /// Returns VTank's preferred spell forms in retry order. Cast feasibility + /// stays with the host's exact gate, so a lower known tier can be selected + /// when the character cannot currently cast the strongest one. + /// + public IReadOnlyList Candidates( + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target, + int nearbyRingTargets, + ICharacterInfo character) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(character); + + MonsterDamageType damageMode = ResolveDamageMode( + actions.DamageType, + character); + bool ringDue = actions.UsesRing + && nearbyRingTargets >= (actions.UsesPrimaryAttack + ? Math.Max(1, settings.MinimumRingTargets) + : 1); + var candidates = new List(); + foreach (AttackSpellChoice choice in _choices) + { + if (!MatchesDamageMode(choice, damageMode)) + continue; + if (choice.Shape == AttackSpellShape.Ring && !ringDue) + continue; + if (choice.Shape != AttackSpellShape.Ring + && !MatchesPrimaryShape(choice.Shape, actions, settings, target)) + { + continue; + } + candidates.Add(choice); + } + + candidates.Sort((left, right) => Compare( + left, + right, + actions with { DamageType = damageMode }, + settings, + target, + ringDue, + character)); + return candidates; + } + + private static int Compare( + AttackSpellChoice left, + AttackSpellChoice right, + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target, + bool ringDue, + ICharacterInfo character) + { + // VTank resolves Auto through GameInfoDB before it chooses the + // bolt/arc/streak form. Element preference therefore outranks spell + // shape and tier; an unavailable preferred element naturally falls + // through to the next candidate in the ordered list. + if (actions.DamageType == MonsterDamageType.Auto) + { + int leftDamage = VtankDamageDatabase.PreferenceIndex( + target, + left.DamageType); + int rightDamage = VtankDamageDatabase.PreferenceIndex( + target, + right.DamageType); + int damage = leftDamage.CompareTo(rightDamage); + if (damage != 0) + return damage; + } + + int leftPreference = Preference( + left.Shape, actions, settings, target, ringDue, character); + int rightPreference = Preference( + right.Shape, actions, settings, target, ringDue, character); + int preferred = leftPreference.CompareTo(rightPreference); + if (preferred != 0) + return preferred; + + int tier = right.Spell.Tier.CompareTo(left.Spell.Tier); + if (tier != 0) + return tier; + int difficulty = right.Spell.Difficulty.CompareTo(left.Spell.Difficulty); + return difficulty != 0 + ? difficulty + : left.Spell.SpellId.CompareTo(right.Spell.SpellId); + } + + private static int Preference( + AttackSpellShape shape, + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target, + bool ringDue, + ICharacterInfo character) + { + if (ringDue && shape == AttackSpellShape.Ring) + return 0; + + if (actions.DamageType == MonsterDamageType.DrainAuto) + { + bool needsHealth = character.MaxHealth != 0u + && character.CurrentHealth / (double)character.MaxHealth < 0.75d; + if (needsHealth && shape == AttackSpellShape.Drain) + return 1; + if (!needsHealth + && character.MaxHealth != 0u + && character.CurrentHealth / (double)character.MaxHealth >= 0.5d + && shape == AttackSpellShape.Martyr) + { + return 1; + } + return shape switch + { + AttackSpellShape.Drain => 2, + AttackSpellShape.Martyr => 3, + AttackSpellShape.Harm => 4, + _ => 20, + }; + } + + if (actions.UsesStreak) + { + if (shape == AttackSpellShape.Streak) + return 1; + if (settings.UseArcs && target.Distance >= settings.ArcRange) + return shape == AttackSpellShape.Arc ? 2 : 3; + return shape == AttackSpellShape.Direct ? 2 : 3; + } + if (settings.UseArcs && target.Distance >= settings.ArcRange) + { + if (shape == AttackSpellShape.Arc) + return 1; + if (shape == AttackSpellShape.Direct) + return 2; + } + else + { + if (shape == AttackSpellShape.Direct) + return 1; + if (shape == AttackSpellShape.Arc) + return 2; + } + + return shape switch + { + AttackSpellShape.Harm => 1, + AttackSpellShape.Streak => 3, + AttackSpellShape.Arc => 4, + AttackSpellShape.Direct => 5, + _ => 10, + }; + } + + private static bool MatchesPrimaryShape( + AttackSpellShape shape, + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target) + { + if (actions.DamageType == MonsterDamageType.DrainAuto) + { + return shape is AttackSpellShape.Drain + or AttackSpellShape.Martyr + or AttackSpellShape.Harm; + } + if (actions.DamageType == MonsterDamageType.Harm) + return shape == AttackSpellShape.Harm; + if (actions.UsesStreak) + { + // Streak is preferred, not a hard requirement: VTank falls back + // when the matching streak/tier is unknown or presently gated. + return shape is AttackSpellShape.Streak + or AttackSpellShape.Direct + or AttackSpellShape.Arc; + } + return shape is AttackSpellShape.Direct or AttackSpellShape.Arc; + } + + private static bool MatchesDamageMode( + AttackSpellChoice choice, + MonsterDamageType requested) + { + return requested switch + { + MonsterDamageType.Harm => choice.Shape == AttackSpellShape.Harm, + MonsterDamageType.DrainAuto => choice.Shape is AttackSpellShape.Drain + or AttackSpellShape.Martyr + or AttackSpellShape.Harm, + MonsterDamageType.VoidBasic or MonsterDamageType.Nether => + choice.DamageType == MonsterDamageType.Nether, + MonsterDamageType.Auto => choice.DamageType is not MonsterDamageType.Auto + && choice.Shape is not (AttackSpellShape.Harm + or AttackSpellShape.Drain + or AttackSpellShape.Martyr), + _ => choice.DamageType == requested, + }; + } + + private static MonsterDamageType ResolveDamageMode( + MonsterDamageType requested, + ICharacterInfo character) + { + // VTank's ga/hi pair treats Prismatic as an ammunition policy while + // retaining normal GameInfoDB element selection for magic. Fists is + // special only while the Tusker Fists enchantment is active; + // otherwise ga resolves the attack element to Bludgeon. + if (requested == MonsterDamageType.Prismatic) + return MonsterDamageType.Auto; + if (requested == MonsterDamageType.Fists) + { + return character.ActiveEnchantments.Any( + static enchantment => enchantment.SpellId == TuskerFistsSpellId) + ? MonsterDamageType.Fists + : MonsterDamageType.Bludgeon; + } + if (requested != MonsterDamageType.Auto) + return requested; + + bool hasWar = IsTrained(character, 34u); + if (hasWar) + return MonsterDamageType.Auto; + if (IsTrained(character, 43u)) + return MonsterDamageType.VoidBasic; + return IsTrained(character, 33u) + ? MonsterDamageType.DrainAuto + : MonsterDamageType.Auto; + } + + private static bool IsTrained(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + && skill.Training is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized; + + internal static bool TryClassify( + PluginSpellInfo spell, + out AttackSpellChoice choice) + { + string name = Normalize(spell.Name); + AttackSpellShape shape; + MonsterDamageType damage; + + if (spell.SpellId == TuskerFistsSpellId + || name.Equals("Tusker Fists", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Direct; + damage = MonsterDamageType.Fists; + } + else if (name.StartsWith("Harm Other", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Harm; + damage = MonsterDamageType.Harm; + } + else if (name.StartsWith( + "Drain Health Other", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Drain; + damage = MonsterDamageType.DrainAuto; + } + else if (name.StartsWith( + "Martyr's Hecatomb", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Martyr; + damage = MonsterDamageType.DrainAuto; + } + else + { + if (!spell.IsOffensive + || spell.IsBeneficial + || spell.IsDebuff + || spell.IsDamageOverTime + || DebuffSpellCatalog.TryClassify(spell, out _, out _)) + { + choice = default; + return false; + } + + damage = DamageFromText(spell.Description, name); + if (damage == MonsterDamageType.Auto) + { + choice = default; + return false; + } + + if (name.Contains(" Streak", StringComparison.OrdinalIgnoreCase)) + shape = AttackSpellShape.Streak; + else if (name.Contains(" Arc", StringComparison.OrdinalIgnoreCase)) + shape = AttackSpellShape.Arc; + else if ((spell.TargetMask == 0u || spell.IsUntargeted) + && (name.Contains(" Ring", StringComparison.OrdinalIgnoreCase) + || spell.Description.Contains( + "outward from the caster", + StringComparison.OrdinalIgnoreCase))) + { + shape = AttackSpellShape.Ring; + } + else if (spell.TargetMask != 0u || spell.IsProjectile) + shape = AttackSpellShape.Direct; + else + { + choice = default; + return false; + } + } + + choice = new AttackSpellChoice( + spell, + shape, + damage, + shape == AttackSpellShape.Ring + || spell.IsUntargeted + || spell.TargetMask == 0u); + return true; + } + + private static MonsterDamageType DamageFromText( + string description, + string name) + { + string text = string.Concat(description, " ", name); + if (text.Contains("slashing damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Blade", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Slash; + if (text.Contains("piercing damage", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Pierce; + if (text.Contains("bludgeoning damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Shock Wave", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Bludgeon; + if (text.Contains("cold damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Frost", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Cold; + if (text.Contains("fire damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Flame", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Fire; + if (text.Contains("acid damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Acid", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Acid; + if (text.Contains("electric", StringComparison.OrdinalIgnoreCase) + || text.Contains("Lightning", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Electric; + if (text.Contains("nether", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Nether; + // The first six tiers call the piercing line Force Bolt; description + // is authoritative, while this name fallback covers sparse fixtures. + if (text.Contains("Force", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Pierce; + return MonsterDamageType.Auto; + } + + private static string Normalize(string name) + { + const string incantation = "Incantation of "; + return name.StartsWith(incantation, StringComparison.OrdinalIgnoreCase) + ? name[incantation.Length..] + : name; + } +} diff --git a/src/AcDream.Plugins.MossTank/AutoAttackPower.cs b/src/AcDream.Plugins.MossTank/AutoAttackPower.cs new file mode 100644 index 00000000..c4cc97df --- /dev/null +++ b/src/AcDream.Plugins.MossTank/AutoAttackPower.cs @@ -0,0 +1,173 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Verbatim decision tree from official VTank hi.cs immediately before +/// its call to bo.a(target,power,spell). This odd-looking table is +/// intentional: slash/pierce hybrid weapons use different charge points for +/// single, triple-strike, dual-wield and shield arrangements. +/// +internal static class AutoAttackPower +{ + private const uint MeleeWeapon = 0x00000001u; + private const uint MissileWeapon = 0x00000100u; + private const uint ShieldLocation = 0x00200000u; + private const int SlashDamage = 0x0001; + private const int PierceDamage = 0x0002; + private const int TripleSlashAttack = 0x0040; + private const uint RecklessnessSkill = 50u; + + public static float Resolve( + MonsterRuleActions actions, + CombatSettings settings, + ICharacterInfo character, + IReadOnlyList inventory) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(inventory); + if (!settings.AutoAttackPower) + return settings.AttackPower; + + PluginInventoryItem? weapon = FindWeapon(actions, inventory); + if (weapon is not { } selected) + return settings.AttackPower; + if ((selected.ItemType & MissileWeapon) != 0u) + return ClampForRecklessness(1f, settings, character); + if ((selected.ItemType & MeleeWeapon) == 0u) + return settings.AttackPower; + + int requestedDamage = RawDamage(actions.DamageType); + if (requestedDamage is not (SlashDamage or PierceDamage)) + return ClampForRecklessness(1f, settings, character); + + PluginInventoryItem? offhand = FindOffhand(actions, selected, inventory); + bool offhandMelee = offhand is { } held + && (held.ItemType & MeleeWeapon) != 0u; + bool offhandShield = offhand is { } shield + && (shield.EquippedLocation & ShieldLocation) != 0u; + bool slashPierce = (selected.DamageType & (SlashDamage | PierceDamage)) + == (SlashDamage | PierceDamage); + bool tripleSlash = (selected.AttackType & TripleSlashAttack) != 0; + + float power; + if (selected.WeaponType == 1 && !offhandMelee) + { + power = requestedDamage == SlashDamage && slashPierce ? 0.5f : 0f; + } + else if (requestedDamage == PierceDamage && slashPierce && !tripleSlash) + { + power = 0.2f; + } + else if (requestedDamage == PierceDamage + && slashPierce + && tripleSlash + && offhandMelee) + { + power = 0.49f; + } + else if (requestedDamage != PierceDamage + || !slashPierce + || !tripleSlash + || offhandShield) + { + power = 1f; + } + else + { + power = 0.2f; + } + + return ClampForRecklessness(power, settings, character); + } + + private static PluginInventoryItem? FindWeapon( + MonsterRuleActions actions, + IReadOnlyList inventory) + { + PluginInventoryItem? equipped = null; + PluginInventoryItem? named = null; + foreach (PluginInventoryItem item in inventory) + { + if (actions.WeaponObjectId != 0u + && item.ObjectId == actions.WeaponObjectId) + { + return item; + } + if (!string.IsNullOrWhiteSpace(actions.WeaponName) + && item.Name.Equals(actions.WeaponName, StringComparison.Ordinal)) + { + named ??= item; + } + if (item.IsEquipped + && (item.ItemType & (MeleeWeapon | MissileWeapon)) != 0u) + { + equipped ??= item; + } + } + return named ?? equipped; + } + + private static PluginInventoryItem? FindOffhand( + MonsterRuleActions actions, + PluginInventoryItem weapon, + IReadOnlyList inventory) + { + PluginInventoryItem? equipped = null; + PluginInventoryItem? named = null; + foreach (PluginInventoryItem item in inventory) + { + if (item.ObjectId == weapon.ObjectId) + continue; + if (actions.OffhandObjectId != 0u + && item.ObjectId == actions.OffhandObjectId) + { + return item; + } + if (!string.IsNullOrWhiteSpace(actions.OffhandName) + && item.Name.Equals(actions.OffhandName, StringComparison.Ordinal)) + { + named ??= item; + } + if (item.IsEquipped + && ((item.ItemType & MeleeWeapon) != 0u + || (item.EquippedLocation & ShieldLocation) != 0u)) + { + equipped ??= item; + } + } + return named ?? equipped; + } + + private static float ClampForRecklessness( + float power, + CombatSettings settings, + ICharacterInfo character) + { + if (!settings.UseRecklessness + || !character.TryGetSkill( + RecklessnessSkill, + out PluginSkillInfo recklessness) + || recklessness.Training is not ( + PluginSkillTraining.Trained or PluginSkillTraining.Specialized)) + { + return power; + } + return Math.Clamp(power, 0.11f, 0.9f); + } + + private static int RawDamage(MonsterDamageType damage) => damage switch + { + MonsterDamageType.Slash => SlashDamage, + MonsterDamageType.Pierce => PierceDamage, + MonsterDamageType.Bludgeon => 0x0004, + MonsterDamageType.Cold => 0x0008, + MonsterDamageType.Fire => 0x0010, + MonsterDamageType.Acid => 0x0020, + MonsterDamageType.Electric => 0x0040, + MonsterDamageType.Nether => 0x0400, + _ => 0, + }; +} diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs index e9f7b13d..eef0a884 100644 --- a/src/AcDream.Plugins.MossTank/BuffPlan.cs +++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs @@ -5,18 +5,32 @@ namespace AcDream.Plugins.MossTank; /// Settings that shape a buff pass. Defaults follow Virindi Tank's. public sealed class BuffSettings { + public bool Enabled { get; set; } = true; + /// + /// VTank's separate idle top-off rule. The ordinary rebuff rule always + /// uses ; this wider window is only + /// considered after combat, loot, and navigation have found no work. + /// + public bool IdleBuffTopoff { get; set; } + public double IdleBuffTopoffSeconds { get; set; } = 1200.0; /// /// VTank recasts buffs once they drop below five minutes remaining /// ("all buff spells are recast when they go below 5 minutes"). /// public double RebuffWhenUnderSeconds { get; set; } = 300.0; + public double BuffCastRecastSeconds { get; set; } = 30d; + public double BuffCastRecastResetSeconds { get; set; } = 30d; + public bool FastCastBuffs { get; set; } + public bool RandomHelperBuffs { get; set; } + public double RandomHelperIntervalSeconds { get; set; } = 5d; + public string BlacklistedSpellComponents { get; set; } = string.Empty; /// /// How far the casting skill must exceed a spell's difficulty before the /// tier is considered reliable — VTank's /// SpellDiffExcessThreshold-Buff. /// - public int SkillExcessOverDifficulty { get; set; } = 10; + public int SkillExcessOverDifficulty { get; set; } = 5; /// Buff every attribute (VTank's default). public bool BuffAttributes { get; set; } = true; @@ -26,6 +40,8 @@ public sealed class BuffSettings /// their own profile (BuffProfile_Prots) and casts them by default. /// public bool BuffProtections { get; set; } = true; + public string ProtectionElements { get; set; } = "ALFCBPS"; + public int ProtectionProfileMode { get; set; } = 2; /// /// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift @@ -38,6 +54,8 @@ public sealed class BuffSettings /// in their own profile (BuffProfile_Banes) and casts them by default. /// public bool BuffBanes { get; set; } = true; + public string BaneElements { get; set; } = "ALFCBPS"; + public int BaneProfileMode { get; set; } = 2; /// /// The vital regeneration rates — Regeneration (health), Rejuvenation @@ -57,6 +75,15 @@ public sealed class BuffSettings /// "automatically buffs every Attribute and Skill you have trained". /// public bool BuffTrainedSkillsOnly { get; set; } = true; + + /// + /// Minimum current skill at which VTank permits buffing an untrained + /// magic school. These are independent because the three schools can be + /// raised and trained independently. + /// + public int BuffWithUntrainedItemSkill { get; set; } = 80; + public int BuffWithUntrainedCreatureSkill { get; set; } = 80; + public int BuffWithUntrainedLifeSkill { get; set; } = 80; } /// @@ -80,8 +107,12 @@ public static class BuffPlan IReadOnlyList attributes, IReadOnlyList active, BuffSettings settings, - bool force = false) + bool force = false, + double? rebuffWhenUnderSeconds = null, + int characterLevel = 0) { + if (!settings.Enabled && !force) + return []; var trainedSkills = new Dictionary( StringComparer.OrdinalIgnoreCase); foreach (PluginSkillInfo skill in skills) @@ -120,16 +151,29 @@ public static class BuffPlan foreach (BuffLine line in lines) { + uint school = line.Tiers.Count == 0 ? 0u : line.Tiers[0].School; + bool schoolAvailable = IsSchoolAvailable( + school, + skills, + settings, + characterLevel); bool wanted = line.Kind switch { BuffTargetKind.Attribute => - settings.BuffAttributes && attributeNames.Contains(line.TargetName), - BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName), - BuffTargetKind.Protection => settings.BuffProtections, - BuffTargetKind.Aura => settings.BuffAuras, - BuffTargetKind.Bane => settings.BuffBanes, - BuffTargetKind.Regeneration => settings.BuffRegeneration, - BuffTargetKind.Other => settings.BuffOther, + schoolAvailable && settings.BuffAttributes + && attributeNames.Contains(line.TargetName), + BuffTargetKind.Skill => schoolAvailable + && (trainedSkills.ContainsKey(line.TargetName) + || IsMagicSchoolName(line.TargetName)), + BuffTargetKind.Protection => + schoolAvailable && settings.BuffProtections + && ProfileAllows(line, settings, bane: false), + BuffTargetKind.Aura => schoolAvailable && settings.BuffAuras, + BuffTargetKind.Bane => schoolAvailable && settings.BuffBanes + && ProfileAllows(line, settings, bane: true), + BuffTargetKind.Regeneration => + schoolAvailable && settings.BuffRegeneration, + BuffTargetKind.Other => schoolAvailable && settings.BuffOther, _ => false, }; if (!wanted) @@ -141,7 +185,8 @@ public static class BuffPlan if (!force && inForce.TryGetValue(line.Family, out var held) && held.Tier >= pick.Tier - && held.Seconds >= settings.RebuffWhenUnderSeconds) + && held.Seconds >= (rebuffWhenUnderSeconds + ?? settings.RebuffWhenUnderSeconds)) { continue; // already covered at this strength, and not expiring } @@ -167,6 +212,91 @@ public static class BuffPlan return ordered; } + private static bool ProfileAllows( + BuffLine line, + BuffSettings settings, + bool bane) + { + int mode = bane + ? settings.BaneProfileMode + : settings.ProtectionProfileMode; + string enabled = mode switch + { + 1 => bane ? settings.BaneElements : settings.ProtectionElements, + 2 => "ALFCBPS", + 3 => string.Empty, + 4 => "B", + 5 => "BPS", + 6 => "BPSA", + 7 => "ALFC", + 8 => "BPSAC", + _ => "ALFCBPS", + }; + char element = ElementCode(line); + return element == '\0' || enabled.IndexOf(element) >= 0; + } + + private static char ElementCode(BuffLine line) + { + string text = line.TargetName + " " + + (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Name) + + " " + + (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Description); + if (text.Contains("acid", StringComparison.OrdinalIgnoreCase)) + return 'A'; + if (text.Contains("lightning", StringComparison.OrdinalIgnoreCase) + || text.Contains("electric", StringComparison.OrdinalIgnoreCase)) + return 'L'; + if (text.Contains("fire", StringComparison.OrdinalIgnoreCase)) + return 'F'; + if (text.Contains("cold", StringComparison.OrdinalIgnoreCase) + || text.Contains("frost", StringComparison.OrdinalIgnoreCase)) + return 'C'; + if (text.Contains("bludgeon", StringComparison.OrdinalIgnoreCase)) + return 'B'; + if (text.Contains("pierc", StringComparison.OrdinalIgnoreCase)) + return 'P'; + if (text.Contains("slash", StringComparison.OrdinalIgnoreCase)) + return 'S'; + return '\0'; + } + + private static bool IsMagicSchoolName(string name) => + name.Equals("Item Enchantment", StringComparison.OrdinalIgnoreCase) + || name.Equals("Creature Enchantment", StringComparison.OrdinalIgnoreCase) + || name.Equals("Life Magic", StringComparison.OrdinalIgnoreCase); + + private static bool IsSchoolAvailable( + uint school, + IReadOnlyList skills, + BuffSettings settings, + int characterLevel) + { + if (school is not (ItemEnchantmentSkill + or CreatureEnchantmentSkill + or LifeMagicSkill)) + { + return true; + } + foreach (PluginSkillInfo skill in skills) + { + if (skill.SkillId == school + && skill.Training is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized) + { + return true; + } + } + int limit = school switch + { + ItemEnchantmentSkill => settings.BuffWithUntrainedItemSkill, + CreatureEnchantmentSkill => settings.BuffWithUntrainedCreatureSkill, + LifeMagicSkill => settings.BuffWithUntrainedLifeSkill, + _ => int.MaxValue, + }; + return characterLevel <= limit; + } + /// Skill ids of the three schools that carry self-buffs. private const uint CreatureEnchantmentSkill = 31; private const uint ItemEnchantmentSkill = 32; diff --git a/src/AcDream.Plugins.MossTank/CombatController.cs b/src/AcDream.Plugins.MossTank/CombatController.cs new file mode 100644 index 00000000..fe40d9ce --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatController.cs @@ -0,0 +1,2059 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank-style target acquisition and the first autocombat state machine. +/// It owns policy only; every snapshot and action comes from the host's +/// canonical Runtime owners through . +/// +internal sealed class CombatController +{ + private const float PowerReleaseEpsilon = 0.005f; + + private readonly IPluginHost _host; + private readonly CombatSettings _settings; + private readonly VitalSettings _vitalSettings; + private readonly DebuffTracker _debuffs = new(); + private readonly CombatFailureTracker _failures = new(); + private readonly PetAutomation _pets = new(); + private IReadOnlyList _targets = + Array.Empty(); + private IReadOnlyList? _combatSpellSnapshot; + private DebuffSpellCatalog _debuffCatalog = + DebuffSpellCatalog.Build(Array.Empty()); + private AttackSpellCatalog _attackCatalog = + AttackSpellCatalog.Build(Array.Empty()); + private double _now; + private double _untilScan; + private uint _targetId; + private ResolvedMonsterRule _targetRule; + private string _targetName = string.Empty; + private float _targetDistance; + private string _targetText = "Target —"; + private string _modeText = "Mode Unknown"; + private PluginCombatMode _lastMode = PluginCombatMode.Unknown; + private bool _paused; + private long _observedPhysicalCompletion; + private long _observedAttackCastCompletion; + private uint _pendingPhysicalTarget; + private uint _pendingAttackSpell; + private uint _pendingAttackTarget; + private PendingItemDebuff? _pendingItemDebuff; + private ulong _observedChatSequence; + private long _observedItemCompletion; + private bool _combatPolicySuspended; + private bool _approachMovementOwned; + private bool _breakableTurnOwned; + private int _dropToPeaceModeRetries; + private Func? _requestAmmunitionCraft; + private Func? _canCraftAmmunition; + private int _randomDamageIndex; + private long _observedJiggleCastCompletion; + private bool _selectionJiggleActive; + private bool _selectionJigglePreviousPlayer; + private double _nextSelectionJiggleAt; + + private static readonly MonsterDamageType[] RandomDamageCycle = + [ + MonsterDamageType.Pierce, + MonsterDamageType.Bludgeon, + MonsterDamageType.Slash, + MonsterDamageType.Acid, + MonsterDamageType.Electric, + MonsterDamageType.Cold, + MonsterDamageType.Fire, + ]; + + public CombatController( + IPluginHost host, + CombatSettings settings, + VitalSettings? vitalSettings = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _vitalSettings = vitalSettings ?? new VitalSettings(); + } + + public bool Enabled { get; private set; } + public string Status { get; private set; } = "Combat off"; + public string TargetText => _targetText; + public string ModeText => _modeText; + public bool HasTarget => _targetId != 0u; + + public string ButtonText => Enabled ? "Stop Macro" : "Run Macro"; + + public void BindAmmunitionCraftRequest( + Func canCraft, + Func request) + { + _canCraftAmmunition = canCraft + ?? throw new ArgumentNullException(nameof(canCraft)); + _requestAmmunitionCraft = request + ?? throw new ArgumentNullException(nameof(request)); + } + + public void ClearActionLocks() + { + _host.Automation.Combat.AbortPhysicalAttack(); + StopApproachMovement(); + StopBreakableTurnMovement(); + StopSelectionJiggle(); + _pendingPhysicalTarget = 0u; + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + ClearPendingItemDebuff(); + _debuffs.ClearPending(); + _dropToPeaceModeRetries = 0; + _untilScan = 0d; + if (Enabled) + Status = "Action locks cleared"; + } + + public bool RecordFakeImperil(uint targetObjectId) + { + if (targetObjectId == 0u) + return false; + _debuffs.RecordFakeImperil(targetObjectId, _now); + return true; + } + + public void Toggle() + { + if (Enabled) + { + Disable("Macro stopped"); + _host.Automation.Chat.PostSystemMessage("[MossTank] Macro stopped."); + return; + } + + if (!_host.Automation.IsAvailable) + { + Status = "Not in world"; + return; + } + + Enabled = true; + _paused = false; + _combatPolicySuspended = !_settings.Enabled; + _untilScan = 0d; + Status = _settings.Enabled ? "Scanning for targets" : "Combat disabled"; + _host.Automation.Chat.PostSystemMessage("[MossTank] Macro started."); + } + + public void SetPaused(bool paused) + { + if (_paused == paused) + return; + _paused = paused; + if (paused && Enabled) + { + _host.Automation.Combat.AbortPhysicalAttack(); + StopApproachMovement(); + StopBreakableTurnMovement(); + Status = "Paused for buffing"; + } + else if (Enabled) + { + Status = "Scanning for targets"; + _untilScan = 0d; + } + } + + /// + /// One command-driven equipment step for VTank's /vt equipitemsfor. The + /// synthetic target intentionally supplies only the requested name, which + /// matches VTank's own fake world-object evaluation and its documented + /// limitation that operator-heavy rows may not resolve as expected. + /// + public bool EquipOneStepForMonster(string monsterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(monsterName); + var target = new PluginCombatTarget( + 0u, + monsterName.Trim(), + 0u, + 0f, + 0f, + false, + 1f); + _targetName = target.Name; + _targetRule = _settings.ResolveRule(target); + return !TickEquipment(); + } + + public void OnTick(double elapsedSeconds, bool navigationEnabled = true) + { + if (!Enabled) + return; + if (!_host.Automation.IsAvailable) + { + Disable("Session ended"); + return; + } + if (_paused) + return; + if (!_settings.Enabled) + { + if (_combatPolicySuspended) + return; + _combatPolicySuspended = true; + if (_targetId != 0u || _pendingPhysicalTarget != 0u) + _host.Automation.Combat.AbortPhysicalAttack(); + _pendingPhysicalTarget = 0u; + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + ClearPendingItemDebuff(); + _debuffs.Reset(); + _failures.Reset(); + ClearTarget(); + Status = "Combat disabled"; + return; + } + if (_combatPolicySuspended) + { + _combatPolicySuspended = false; + _untilScan = 0d; + Status = "Scanning for targets"; + } + + _now += Math.Max(0d, elapsedSeconds); + PluginCastCompletion castCompletion = + _host.Automation.Magic.LastCompletion; + ObserveSelectionJiggle(castCompletion); + TickSelectionJiggle(); + DebuffCompletion completion = _debuffs.Observe( + castCompletion, + _now); + if (completion.Completed && !completion.Succeeded) + { + Status = $"{completion.SpellName} failed (0x{completion.WeenieError:X})"; + } + _debuffs.ExpirePending(_now); + + PluginCombatSnapshot current = _host.Automation.Combat.Snapshot; + ObserveItemDebuffReceipts(); + ObserveAttackReceipts(current, castCompletion); + if (current.Mode != _lastMode) + { + _lastMode = current.Mode; + _modeText = $"Mode {current.Mode}"; + } + + _untilScan -= Math.Max(0d, elapsedSeconds); + if (_untilScan <= 0d) + { + float acquisitionRange = navigationEnabled + ? Math.Max(_settings.MaximumRange, _settings.ApproachDistance) + : _settings.MaximumRange; + _targets = _host.Automation.Combat.CaptureHostileTargets( + acquisitionRange); + foreach (uint ghost in _failures.ObserveTargets( + _targets, + _now, + _settings)) + { + DismissGhost(ghost); + } + _debuffs.RetainTargets( + _targets.Select(static target => target.ObjectId).ToHashSet()); + _untilScan = Math.Max(0.05d, _settings.ScanIntervalSeconds); + RefreshTarget(); + } + + if (_pendingItemDebuff is not null) + { + TickPendingItemDebuff(current); + return; + } + + if (_targetId == 0u) + { + StopApproachMovement(); + PluginCombatSnapshot idle = _host.Automation.Combat.Snapshot; + if (_settings.IdlePeaceMode + && idle.Mode is not (PluginCombatMode.Unknown + or PluginCombatMode.Peace)) + { + PluginCombatCommandResult result = + _host.Automation.Combat.EnterMode(PluginCombatMode.Peace); + Status = result.Status == PluginCombatCommandStatus.Refused + ? result.Notice ?? "Cannot enter peace mode" + : "Entering peace mode"; + return; + } + Status = "Waiting for a target"; + return; + } + + if (_targetDistance > _settings.MaximumRange) + { + if (navigationEnabled + && _settings.ApproachDistance > _settings.MaximumRange + && _targetDistance <= _settings.ApproachDistance + && TickApproach()) + { + return; + } + + StopApproachMovement(); + Status = $"{_targetName} is out of attack range"; + return; + } + StopApproachMovement(); + + if (_pets.Tick( + _host.Automation.Items, + _host.Automation.Character, + _targets, + _settings, + _now, + out string petStatus)) + { + Status = petStatus; + return; + } + + PluginCombatSnapshot combat = _host.Automation.Combat.Snapshot; + if (TickDebuffs(combat)) + return; + + if (TickEquipment()) + return; + + combat = _host.Automation.Combat.Snapshot; + if (combat.Mode is PluginCombatMode.Unknown or PluginCombatMode.Peace) + { + PluginCombatCommandResult mode = + _host.Automation.Combat.EnterDefaultMode(); + Status = mode.Status == PluginCombatCommandStatus.Refused + ? mode.Notice ?? "Cannot enter combat mode" + : "Entering combat mode"; + return; + } + + // A VTank row may deliberately request debuffs without primary + // attack. Once its requested debuffs are current, remain idle. + if (!_targetRule.Actions.Attacks) + { + Status = $"Debuffs complete for {_targetName}"; + return; + } + + if (combat.Mode == PluginCombatMode.Magic) + { + TickMagic(); + return; + } + + if (combat.Mode is not (PluginCombatMode.Melee or PluginCombatMode.Missile)) + { + Status = $"Unsupported mode: {combat.Mode}"; + return; + } + + TickPhysical(combat); + } + + private void TickPhysical(PluginCombatSnapshot combat) + { + if (combat.ServerResponsePending || combat.RepeatAttackInProgress) + { + Status = $"Attacking {_targetName}"; + return; + } + + if (combat.RequestInProgress) + { + if (combat.BuildInProgress + && combat.PowerBarLevel + PowerReleaseEpsilon + >= combat.DesiredPower) + { + PluginCombatCommandResult release = + _host.Automation.Combat.ReleasePhysicalAttack(); + Status = release.Status == PluginCombatCommandStatus.Released + ? $"Attacking {_targetName}" + : $"Attack release: {release.Status}"; + } + else + { + Status = $"Charging {combat.PowerBarLevel * 100f:0}%"; + } + return; + } + + IReadOnlyList inventory = + _host.Automation.Items.CaptureOwnedItems(); + MonsterRuleActions physicalActions = ResolvePhysicalActions( + _targetRule.Actions, + FindTarget(_targetId), + inventory); + if (combat.Mode == PluginCombatMode.Missile + && !ProjectilePathIsClear( + _targetId, + PluginProjectilePathKind.Missile, + _settings.AttackHeight, + out PluginProjectilePathResult missilePath)) + { + Status = ProjectileStatus(missilePath, _targetName); + return; + } + float desiredPower = AutoAttackPower.Resolve( + physicalActions, + _settings, + _host.Automation.Character, + inventory); + PluginCombatCommandResult begin = + _host.Automation.Combat.BeginPhysicalAttack( + _targetId, + _settings.AttackHeight, + desiredPower); + Status = begin.Status switch + { + PluginCombatCommandStatus.Started => $"Charging {_targetName}", + PluginCombatCommandStatus.Busy => $"Waiting on {_targetName}", + PluginCombatCommandStatus.InvalidTarget => "Target disappeared", + PluginCombatCommandStatus.WrongMode => "Waiting for combat mode", + _ => $"Attack refused: {begin.Status}", + }; + if (begin.Status == PluginCombatCommandStatus.InvalidTarget) + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + else if (begin.Status == PluginCombatCommandStatus.Started) + { + _pendingPhysicalTarget = _targetId; + _failures.BeginAttack( + _targetId, + FindTarget(_targetId).HealthRevision); + } + } + + private void TickMagic() + { + IMagicCommands magic = _host.Automation.Magic; + if (magic.IsCasting) + { + Status = $"Casting at {_targetName}"; + return; + } + + RefreshSpellCatalogs(); + string? projectileRefusal = null; + MonsterRuleActions attackActions = ResolveRandomDamage( + _targetRule.Actions); + IReadOnlyList choices = _attackCatalog.Candidates( + attackActions, + _settings, + FindTarget(_targetId), + CountNearbyRingTargets(), + _host.Automation.Character); + foreach (AttackSpellChoice choice in choices) + { + if (!CanCastHuntSpell(choice.Spell, FindTarget(_targetId))) + continue; + if (choice.Spell.IsProjectile + && !ProjectilePathIsClear( + _targetId, + choice.Shape == AttackSpellShape.Arc + ? PluginProjectilePathKind.Arc + : PluginProjectilePathKind.Straight, + _settings.AttackHeight, + out PluginProjectilePathResult spellPath)) + { + projectileRefusal = ProjectileStatus(spellPath, _targetName); + Status = projectileRefusal; + continue; + } + if (!choice.CastWithoutTarget + && !ReadyForBreakableTurn(choice.Spell, _targetId)) + { + return; + } + PluginCastGate gate = choice.CastWithoutTarget + ? magic.EvaluateGate(choice.Spell.SpellId) + : magic.EvaluateGate(choice.Spell.SpellId, _targetId); + if (gate != PluginCastGate.Ready) + { + continue; + } + bool dispatched = choice.CastWithoutTarget + ? magic.Cast(choice.Spell.SpellId) + : magic.Cast(choice.Spell.SpellId, _targetId); + if (!dispatched) + { + if (_failures.RecordSpellDidNotStart(_targetId, _settings)) + DismissGhost(_targetId); + continue; + } + + Status = choice.Shape == AttackSpellShape.Ring + ? $"{choice.Spell.Name} around {_targetName}" + : $"{choice.Spell.Name} → {_targetName}"; + if (!choice.CastWithoutTarget) + { + _pendingAttackSpell = choice.Spell.SpellId; + _pendingAttackTarget = _targetId; + _failures.BeginAttack( + _targetId, + FindTarget(_targetId).HealthRevision); + } + return; + } + + // Compatibility for older hosts that only implemented the MT1 direct + // attack list and cannot project the richer combat catalog. + IReadOnlyList fallback = + _host.Automation.Spells.KnownAttackSpells; + if (choices.Count == 0 + && attackActions.DamageType == MonsterDamageType.Auto) + { + foreach (PluginSpellInfo spell in fallback) + { + if (!CanCastHuntSpell(spell, FindTarget(_targetId))) + continue; + if (spell.IsProjectile + && !ProjectilePathIsClear( + _targetId, + PluginProjectilePathKind.Straight, + _settings.AttackHeight, + out PluginProjectilePathResult fallbackPath)) + { + projectileRefusal = ProjectileStatus( + fallbackPath, + _targetName); + Status = projectileRefusal; + continue; + } + if (!ReadyForBreakableTurn(spell, _targetId)) + return; + if (magic.EvaluateGate(spell.SpellId, _targetId) + != PluginCastGate.Ready) + { + continue; + } + if (!magic.Cast(spell.SpellId, _targetId)) + { + if (_failures.RecordSpellDidNotStart(_targetId, _settings)) + DismissGhost(_targetId); + continue; + } + _pendingAttackSpell = spell.SpellId; + _pendingAttackTarget = _targetId; + _failures.BeginAttack( + _targetId, + FindTarget(_targetId).HealthRevision); + Status = $"{spell.Name} → {_targetName}"; + return; + } + } + + Status = projectileRefusal + ?? (choices.Count == 0 && fallback.Count == 0 + ? "No direct attack spell known" + : "No usable attack spell"); + } + + private MonsterRuleActions ResolveRandomDamage(MonsterRuleActions actions) + { + if (actions.DamageType != MonsterDamageType.Random) + return actions; + + // hi::a cycles eDamageElement 0..6 in this exact order whenever the + // attack planner is asked for a Random cast. Advancing here (rather + // than persisting a pseudo-random choice) also lets a temporarily + // unavailable element fall through on the following automation tick. + MonsterDamageType damage = RandomDamageCycle[_randomDamageIndex]; + _randomDamageIndex = (_randomDamageIndex + 1) % RandomDamageCycle.Length; + return actions with { DamageType = damage }; + } + + private bool CanCastHuntSpell( + in PluginSpellInfo spell, + in PluginCombatTarget target) + { + if (SpellComponentPolicy.UsesBlacklistedComponent( + _host.Automation.Spells, + spell, + _settings.BlacklistedSpellComponents)) + { + return false; + } + if (spell.School == 0u + || !_host.Automation.Character.TryGetSkill( + spell.School, + out PluginSkillInfo skill)) + { + return true; + } + if (skill.Current < spell.Difficulty + + _settings.HuntSkillExcessOverDifficulty) + { + return false; + } + + float maximumRange = spell.BaseRangeConstant + + (spell.BaseRangeModifier * skill.Current) + - _settings.SpellRangeFudge; + return maximumRange <= 0f + || target.ObjectId == 0u + || target.Distance <= MathF.Min(75f, maximumRange); + } + + private int CountNearbyRingTargets() + { + int count = 0; + foreach (PluginCombatTarget target in _targets) + { + if (target.Distance > _settings.RingDistance) + continue; + ResolvedMonsterRule resolved = _settings.ResolveRule(target); + if (resolved.Priority >= 0 && resolved.Actions.UsesRing) + count++; + } + return count; + } + + private bool TickEquipment() + { + MonsterRuleActions actions = _targetRule.Actions; + bool primaryRequiresWeapon = actions.UsesPrimaryAttack + || actions.UsesRing; + if (!primaryRequiresWeapon && !_settings.SwitchWandsToDebuff) + return false; + IEquipmentAutomation equipment = _host.Automation.Equipment; + if (!equipment.IsAvailable) + { + // Older/no-window hosts explicitly report unavailable. Do not + // turn a missing optional projection into a permanent combat + // deadlock; the currently equipped set remains authoritative. + return false; + } + if (equipment.IsBusy) + { + Status = "Switching equipment"; + return true; + } + + IReadOnlyList items = + equipment.CaptureOwnedEquipment(); + uint desiredWeapon = ResolveEquipmentObjectId( + actions.WeaponObjectId, + actions.WeaponName, + items); + if (desiredWeapon == 0u && actions.DamageType == MonsterDamageType.Auto) + { + desiredWeapon = SelectAutomaticWeapon( + items, + VtankDamageDatabase.Preferences(FindTarget(_targetId)), + _settings); + } + else if (desiredWeapon == 0u) + { + desiredWeapon = SelectAutomaticWeapon( + items, + actions.DamageType, + _settings); + } + + if (TryEquipIfNeeded(equipment, items, desiredWeapon, "weapon")) + return true; + if (TickAmmunition( + equipment, + items, + desiredWeapon, + actions.DamageType)) + { + return true; + } + if (TryEquipIfNeeded( + equipment, + items, + ResolveEquipmentObjectId( + actions.OffhandObjectId, + actions.OffhandName, + items), + "offhand")) + { + return true; + } + return false; + } + + private bool TickAmmunition( + IEquipmentAutomation equipment, + IReadOnlyList equipmentItems, + uint desiredWeapon, + MonsterDamageType configuredDamage) + { + PluginEquipmentItem launcher = equipmentItems.FirstOrDefault( + item => item.ObjectId == desiredWeapon); + int launcherType = VtankAmmunitionDatabase.LauncherType( + launcher.AmmoType); + if (launcherType == 0) + return false; + + MonsterDamageType damage = configuredDamage; + VtankPrismaticAmmoPolicy prismatic = + VtankPrismaticAmmoPolicy.NoPrismatic; + if (damage == MonsterDamageType.Auto) + { + damage = VtankDamageDatabase.Preferences( + FindTarget(_targetId)).FirstOrDefault(); + prismatic = VtankPrismaticAmmoPolicy.Any; + } + else if (damage == MonsterDamageType.Prismatic) + { + prismatic = VtankPrismaticAmmoPolicy.ForcePrismatic; + } + if (damage is MonsterDamageType.None + or MonsterDamageType.VoidBasic + or MonsterDamageType.DrainAuto + or MonsterDamageType.Harm + or MonsterDamageType.Nether) + { + return false; + } + + IReadOnlyList inventory = + _host.Automation.Items.CaptureOwnedItems(); + var counts = inventory + .GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Sum(item => Math.Max(1, item.StackSize)), + StringComparer.OrdinalIgnoreCase); + var craftable = new Dictionary( + StringComparer.OrdinalIgnoreCase); + bool IsAvailable(string name) + { + if (counts.GetValueOrDefault(name) >= 1) + return true; + if (craftable.TryGetValue(name, out bool cached)) + return cached; + bool value = _canCraftAmmunition?.Invoke(name, 1) == true; + craftable[name] = value; + return value; + } + + VtankAmmunitionOption? selected = VtankAmmunitionDatabase.Select( + launcherType, + damage, + prismatic, + _settings.UseSpecialAmmo, + _host.Automation.Character, + IsAvailable); + if (selected is not { } option) + { + Status = $"No {damage} ammunition is available"; + return true; + } + + PluginEquipmentItem currentAmmo = equipmentItems.FirstOrDefault( + static item => item.CombatUse == 3 && item.IsEquipped); + if (string.Equals( + currentAmmo.Name, + option.Name, + StringComparison.Ordinal)) + return false; + + PluginEquipmentItem desiredAmmo = equipmentItems.FirstOrDefault( + item => item.Name.Equals(option.Name, StringComparison.Ordinal) + && item.StackSize > 0); + if (desiredAmmo.ObjectId != 0u) + return TryEquipIfNeeded( + equipment, + equipmentItems, + desiredAmmo.ObjectId, + "ammunition"); + + if (_requestAmmunitionCraft?.Invoke(option.Name, 1) == true) + { + Status = "Crafting " + option.Name; + return true; + } + Status = $"Waiting to craft {option.Name}"; + return true; + } + + private static MonsterRuleActions ResolvePhysicalActions( + MonsterRuleActions actions, + in PluginCombatTarget target, + IReadOnlyList inventory) + { + if (actions.DamageType != MonsterDamageType.Auto) + return actions; + + IReadOnlyList preferences = + VtankDamageDatabase.Preferences(target); + foreach (MonsterDamageType damage in preferences) + { + int mask = RawDamageType(damage); + if (mask == 0) + continue; + foreach (PluginInventoryItem item in inventory) + { + if (item.IsEquipped && (item.DamageType & mask) != 0) + return actions with { DamageType = damage }; + } + } + return preferences.Count == 0 + ? actions + : actions with { DamageType = preferences[0] }; + } + + private bool TryEquipIfNeeded( + IEquipmentAutomation equipment, + IReadOnlyList items, + uint objectId, + string role) + { + if (objectId == 0u) + return false; + + PluginEquipmentItem? desired = null; + foreach (PluginEquipmentItem item in items) + { + if (item.ObjectId == objectId) + { + desired = item; + break; + } + } + if (desired is not { } selected || selected.IsEquipped) + return false; + + PluginCombatMode mode = _host.Automation.Combat.Snapshot.Mode; + if (mode != PluginCombatMode.Peace) + { + _dropToPeaceModeRetries++; + if (_dropToPeaceModeRetries + >= _vitalSettings.DropToPeaceModeRetryCount) + { + _dropToPeaceModeRetries = 0; + PluginEquipmentItem? recovery = SelectRecoveryCaster(items); + if (recovery is not { } caster) + { + const string error = "You must add at least one wand to " + + "your Items profile."; + Disable(error); + _host.Automation.Chat.PostSystemMessage( + "[MossTank] " + error); + return true; + } + + PluginItemCommandResult use = + _host.Automation.Items.Use(caster.ObjectId); + Status = use.Status == PluginItemCommandStatus.Started + ? "Warning: stuck combat state; using " + caster.Name + + " to clear it" + : "Combat-state recovery with " + caster.Name + ": " + + use.Status; + return true; + } + + PluginCombatCommandResult peace = + _host.Automation.Combat.EnterMode(PluginCombatMode.Peace); + Status = peace.Status == PluginCombatCommandStatus.Refused + ? peace.Notice ?? "Cannot enter peace mode to equip " + + selected.Name + : "Entering peace mode to equip " + selected.Name; + return true; + } + + _dropToPeaceModeRetries = 0; + + PluginEquipmentCommandResult result = equipment.Equip(objectId); + if (result.Status is PluginEquipmentCommandStatus.Started + or PluginEquipmentCommandStatus.Busy) + { + Status = $"Equipping {selected.Name}"; + return true; + } + if (result.Status == PluginEquipmentCommandStatus.Refused) + Status = $"Cannot equip {role}: {selected.Name}"; + return false; + } + + private PluginEquipmentItem? SelectRecoveryCaster( + IReadOnlyList items) + { + const uint casterItemType = 0x00008000u; + foreach (PluginEquipmentItem item in items) + { + if (item.ItemType != casterItemType) + continue; + if (_settings.CombatItemObjectIds.Contains(item.ObjectId) + || _settings.CombatItemNames.Contains(item.Name)) + { + return item; + } + } + return null; + } + + private static uint SelectAutomaticWeapon( + IReadOnlyList items, + MonsterDamageType damageType, + CombatSettings settings) + { + const uint weaponReadyMask = 0x03500000u; + int rawDamage = RawDamageType(damageType); + PluginEquipmentItem? best = null; + foreach (PluginEquipmentItem item in items) + { + if (!settings.CombatItemObjectIds.Contains(item.ObjectId) + && !settings.CombatItemNames.Contains(item.Name)) + { + continue; + } + if ((item.ValidLocations & weaponReadyMask) == 0u + || rawDamage == 0 + || (item.DamageType & rawDamage) == 0) + { + continue; + } + if (best is null + || item.Damage > best.Value.Damage + || (item.Damage == best.Value.Damage + && item.IsEquipped + && !best.Value.IsEquipped)) + { + best = item; + } + } + return best?.ObjectId ?? 0u; + } + + private static uint SelectAutomaticWeapon( + IReadOnlyList items, + IReadOnlyList preferences, + CombatSettings settings) + { + foreach (MonsterDamageType damage in preferences) + { + uint objectId = SelectAutomaticWeapon(items, damage, settings); + if (objectId != 0u) + return objectId; + } + return 0u; + } + + private static int RawDamageType(MonsterDamageType damageType) => + damageType switch + { + MonsterDamageType.Slash => 0x0001, + MonsterDamageType.Pierce => 0x0002, + MonsterDamageType.Bludgeon => 0x0004, + MonsterDamageType.Cold => 0x0008, + MonsterDamageType.Fire => 0x0010, + MonsterDamageType.Acid => 0x0020, + MonsterDamageType.Electric => 0x0040, + MonsterDamageType.Nether => 0x0400, + _ => 0, + }; + + private bool TickDebuffs(PluginCombatSnapshot combat) + { + if (_debuffs.HasPending) + { + Status = _host.Automation.Magic.IsCasting + ? $"Casting {_debuffs.PendingName}" + : $"Waiting for {_debuffs.PendingName}"; + return true; + } + + RefreshSpellCatalogs(); + IReadOnlyList items = + _host.Automation.Items.CaptureOwnedItems(); + + foreach (RuleCandidate candidate in DebuffScope()) + { + MonsterRuleActions actions = ResolveAutomaticActions( + candidate.Rule.Actions, + candidate.Target, + items); + IReadOnlyList choices = + CombatItemDebuffPlanner.Candidates( + actions, + _settings, + _host.Automation.Character, + _host.Automation.Spells, + items, + (identity, spell) => _debuffs.IsDue( + candidate.Target.ObjectId, + identity, + spell, + _now, + _settings.DebuffPrecastSeconds)); + + foreach (CombatDebuffSource choice in choices) + { + if (SpellComponentPolicy.UsesBlacklistedComponent( + _host.Automation.Spells, + choice.Spell, + _settings.BlacklistedSpellComponents)) + { + continue; + } + if (!ReadyForBreakableTurn( + choice.Spell, + candidate.Target.ObjectId)) + { + return true; + } + if (choice.Spell.IsProjectile + && !ProjectilePathIsClear( + candidate.Target.ObjectId, + choice.Spell.Name.Contains( + " Arc", + StringComparison.OrdinalIgnoreCase) + ? PluginProjectilePathKind.Arc + : choice.Kind is CombatDebuffSourceKind.Grenade + or CombatDebuffSourceKind.ProcWeapon + ? PluginProjectilePathKind.Missile + : PluginProjectilePathKind.Straight, + PluginAttackHeight.Medium, + out PluginProjectilePathResult debuffPath)) + { + Status = ProjectileStatus( + debuffPath, + candidate.Target.Name); + if (_settings.AllowDebuffFallback) + continue; + return true; + } + if (choice.Kind != CombatDebuffSourceKind.LearnedSpell) + { + DebuffStartResult itemResult = TryStartItemDebuff( + choice, + candidate.Target, + combat, + items, + ResolveInventoryObjectId( + actions.OffhandObjectId, + actions.OffhandName, + items)); + if (itemResult == DebuffStartResult.Handled) + return true; + continue; + } + + if (combat.Mode != PluginCombatMode.Magic) + { + EnterDebuffMode(PluginCombatMode.Magic); + return true; + } + PluginCastGate gate = _host.Automation.Magic.EvaluateGate( + choice.Spell.SpellId, + candidate.Target.ObjectId); + if (gate == PluginCastGate.Busy) + { + Status = "Waiting to debuff"; + return true; + } + if (gate != PluginCastGate.Ready + || !_host.Automation.Magic.Cast( + choice.Spell.SpellId, + candidate.Target.ObjectId)) + { + continue; + } + + _debuffs.Begin( + candidate.Target.ObjectId, + choice.Identity, + choice.Spell, + _now, + _host.Automation.Magic.LastCompletion.Revision); + string targetName = string.IsNullOrWhiteSpace(candidate.Target.Name) + ? $"0x{candidate.Target.ObjectId:X8}" + : candidate.Target.Name; + Status = $"{choice.Spell.Name} → {targetName}"; + return true; + } + } + + return false; + } + + private bool ProjectilePathIsClear( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight height, + out PluginProjectilePathResult result) + { + if (!_settings.UseProjectileAwareness) + { + result = new(PluginProjectilePathStatus.Clear); + return true; + } + result = _settings.ShowCollisionDebug + ? _host.Automation.Projectiles.EvaluatePathWithDiagnostics( + targetObjectId, + kind, + height, + _settings.CollisionProjectileRadius, + _settings.CollisionStepDistance, + _settings.MaximumCollisionChecksPerTick) + : _host.Automation.Projectiles.EvaluatePath( + targetObjectId, + kind, + height, + _settings.CollisionProjectileRadius, + _settings.CollisionStepDistance, + _settings.MaximumCollisionChecksPerTick); + if (_settings.ShowCollisionDebug && result.DebugSamples.Count > 0) + { + _host.Automation.Projectiles.ShowDebugSamples(result.DebugSamples); + _host.Log.Info( + $"MossTank collision {kind}: {result.Status}, " + + $"{result.DebugSamples.Count} marker(s), " + + $"{result.CollisionChecks} check(s)"); + } + return result.IsClear; + } + + private static string ProjectileStatus( + in PluginProjectilePathResult result, + string targetName) + { + string target = string.IsNullOrWhiteSpace(targetName) + ? "target" + : targetName; + return result.Status switch + { + PluginProjectilePathStatus.Blocked when result.BlockingObjectId != 0u => + $"Projectile path to {target} blocked by 0x{result.BlockingObjectId:X8}", + PluginProjectilePathStatus.Blocked => + $"Projectile path to {target} is blocked", + PluginProjectilePathStatus.Unavailable => + "Projectile collision data is unavailable", + PluginProjectilePathStatus.BudgetExceeded => + "Projectile collision-check budget exhausted", + PluginProjectilePathStatus.InvalidTarget => + $"Cannot resolve projectile path to {target}", + PluginProjectilePathStatus.Error => + result.Notice ?? "Projectile collision check failed", + _ => $"Cannot fire at {target}", + }; + } + + private MonsterRuleActions ResolveAutomaticActions( + MonsterRuleActions actions, + in PluginCombatTarget target, + IReadOnlyList inventory) + { + if (actions.DamageType != MonsterDamageType.Auto) + return actions; + + IReadOnlyList preferences = + VtankDamageDatabase.Preferences(target); + const uint weaponReadyMask = 0x03500000u; + foreach (MonsterDamageType damage in preferences) + { + int rawDamage = RawDamageType(damage); + foreach (PluginInventoryItem item in inventory) + { + bool profiled = _settings.CombatItemObjectIds.Contains( + item.ObjectId) + || _settings.CombatItemNames.Contains(item.Name); + if (profiled + && (item.ValidLocations & weaponReadyMask) != 0u + && (item.DamageType & rawDamage) != 0) + { + return actions with { DamageType = damage }; + } + } + } + + ICharacterInfo character = _host.Automation.Character; + if (IsTrained(character, 34u)) + { + return preferences.Count == 0 + ? actions + : actions with { DamageType = preferences[0] }; + } + if (IsTrained(character, 43u)) + return actions with { DamageType = MonsterDamageType.VoidBasic }; + if (IsTrained(character, 33u)) + return actions with { DamageType = MonsterDamageType.DrainAuto }; + return preferences.Count == 0 + ? actions + : actions with { DamageType = preferences[0] }; + } + + private static bool IsTrained(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + && skill.Training is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized; + + private DebuffStartResult TryStartItemDebuff( + CombatDebuffSource source, + PluginCombatTarget target, + PluginCombatSnapshot combat, + IReadOnlyList inventory, + uint desiredOffhand) + { + IEquipmentAutomation equipment = _host.Automation.Equipment; + if (!equipment.IsAvailable) + return DebuffStartResult.Skipped; + if (equipment.IsBusy) + { + Status = $"Equipping {ItemName(source.ItemObjectId, inventory)}"; + return DebuffStartResult.Handled; + } + + PluginInventoryItem item = default; + bool found = false; + foreach (PluginInventoryItem candidate in inventory) + { + if (candidate.ObjectId == source.ItemObjectId) + { + item = candidate; + found = true; + break; + } + } + if (!found) + return DebuffStartResult.Skipped; + + if (source.Kind == CombatDebuffSourceKind.Grenade + && desiredOffhand != 0u) + { + IReadOnlyList equipmentItems = + equipment.CaptureOwnedEquipment(); + PluginEquipmentItem? offhand = null; + foreach (PluginEquipmentItem candidate in equipmentItems) + { + if (candidate.ObjectId == desiredOffhand) + { + offhand = candidate; + break; + } + } + if (offhand is { IsEquipped: false } selectedOffhand) + { + PluginEquipmentCommandResult offhandResult = + equipment.Equip(selectedOffhand.ObjectId); + if (offhandResult.Accepted + || offhandResult.Status == PluginEquipmentCommandStatus.Busy) + { + Status = $"Equipping {selectedOffhand.Name}"; + return DebuffStartResult.Handled; + } + return DebuffStartResult.Skipped; + } + } + + if (!item.IsEquipped) + { + PluginEquipmentCommandResult equip = equipment.Equip(item.ObjectId); + if (equip.Status is PluginEquipmentCommandStatus.Started + or PluginEquipmentCommandStatus.Busy) + { + Status = $"Equipping {item.Name}"; + return DebuffStartResult.Handled; + } + return DebuffStartResult.Skipped; + } + + PluginCombatMode desiredMode = source.Kind switch + { + CombatDebuffSourceKind.CasterItem => PluginCombatMode.Magic, + CombatDebuffSourceKind.Grenade => PluginCombatMode.Missile, + _ when (item.ItemType & 0x00000100u) != 0u => + PluginCombatMode.Missile, + _ => PluginCombatMode.Melee, + }; + if (combat.Mode != desiredMode) + { + EnterDebuffMode(desiredMode); + return DebuffStartResult.Handled; + } + + string targetName = string.IsNullOrWhiteSpace(target.Name) + ? $"0x{target.ObjectId:X8}" + : target.Name; + if (source.Kind == CombatDebuffSourceKind.CasterItem) + { + IItemAutomation itemCommands = _host.Automation.Items; + if (!itemCommands.IsAvailable || itemCommands.IsBusy) + { + Status = $"Waiting to use {item.Name}"; + return DebuffStartResult.Handled; + } + PluginItemCommandResult apply = itemCommands.Apply( + item.ObjectId, + target.ObjectId); + if (!apply.Accepted) + return DebuffStartResult.Skipped; + _pendingItemDebuff = new PendingItemDebuff( + source, + target.ObjectId, + targetName, + item.Name, + _now, + itemCommands.LastCompletion.Revision, + combat.CompletionRevision, + desiredMode, + 0f); + Status = $"{source.Spell.Name} via {item.Name} → {targetName}"; + return DebuffStartResult.Handled; + } + + if (combat.RequestInProgress + || combat.ServerResponsePending + || combat.RepeatAttackInProgress) + { + Status = $"Waiting to fire {item.Name}"; + return DebuffStartResult.Handled; + } + float power = desiredMode == PluginCombatMode.Missile ? 1f : 0f; + PluginCombatCommandResult begin = + _host.Automation.Combat.BeginPhysicalAttack( + target.ObjectId, + PluginAttackHeight.Medium, + power); + if (begin.Status != PluginCombatCommandStatus.Started) + return begin.Status == PluginCombatCommandStatus.Busy + ? DebuffStartResult.Handled + : DebuffStartResult.Skipped; + _pendingItemDebuff = new PendingItemDebuff( + source, + target.ObjectId, + targetName, + item.Name, + _now, + _host.Automation.Items.LastCompletion.Revision, + combat.CompletionRevision, + desiredMode, + power); + Status = $"Charging {item.Name} for {targetName}"; + return DebuffStartResult.Handled; + } + + private static uint ResolveEquipmentObjectId( + uint sessionObjectId, + string durableName, + IReadOnlyList items) + { + if (sessionObjectId != 0u + && items.Any(item => item.ObjectId == sessionObjectId)) + { + return sessionObjectId; + } + if (string.IsNullOrWhiteSpace(durableName)) + return 0u; + foreach (PluginEquipmentItem item in items) + { + if (item.Name.Equals(durableName, StringComparison.Ordinal)) + return item.ObjectId; + } + return 0u; + } + + private static uint ResolveInventoryObjectId( + uint sessionObjectId, + string durableName, + IReadOnlyList items) + { + if (sessionObjectId != 0u + && items.Any(item => item.ObjectId == sessionObjectId)) + { + return sessionObjectId; + } + if (string.IsNullOrWhiteSpace(durableName)) + return 0u; + foreach (PluginInventoryItem item in items) + { + if (item.Name.Equals(durableName, StringComparison.Ordinal)) + return item.ObjectId; + } + return 0u; + } + + private void EnterDebuffMode(PluginCombatMode mode) + { + PluginCombatCommandResult result = + _host.Automation.Combat.EnterMode(mode); + Status = result.Status == PluginCombatCommandStatus.Refused + ? result.Notice ?? $"Cannot enter {mode} mode" + : $"Entering {mode} mode"; + } + + private void ClearPendingItemDebuff() + { + _pendingItemDebuff = null; + } + + private void TickPendingItemDebuff(PluginCombatSnapshot combat) + { + if (_pendingItemDebuff is not { } pending) + return; + if (_now - pending.DispatchedAt >= 15d) + { + _host.Automation.Combat.AbortPhysicalAttack(); + Status = $"{pending.Source.Spell.Name} timed out"; + ClearPendingItemDebuff(); + return; + } + if (pending.Source.Kind == CombatDebuffSourceKind.CasterItem) + { + TickWandCastRecovery(pending); + Status = $"Waiting for {pending.Source.Spell.Name}"; + return; + } + + if (combat.RequestInProgress) + { + if (combat.BuildInProgress + && combat.PowerBarLevel + PowerReleaseEpsilon + >= pending.Power) + { + PluginCombatCommandResult release = + _host.Automation.Combat.ReleasePhysicalAttack(); + Status = release.Status == PluginCombatCommandStatus.Released + ? $"Firing {pending.ItemName}" + : $"Attack release: {release.Status}"; + } + else + { + Status = $"Charging {pending.ItemName}"; + } + return; + } + if (combat.ServerResponsePending || combat.RepeatAttackInProgress) + { + Status = $"Waiting for {pending.Source.Spell.Name}"; + return; + } + if (combat.CompletionRevision > pending.PhysicalCompletionRevision) + { + pending.PhysicalCompletionRevision = combat.CompletionRevision; + pending.AttackCompletedAt ??= _now; + if (combat.CompletionWeenieError != 0u) + { + Status = $"{pending.ItemName} failed (0x{combat.CompletionWeenieError:X})"; + ClearPendingItemDebuff(); + return; + } + } + if (pending.AttackCompletedAt is { } completed + && _now - completed >= 1d) + { + // A proc weapon is allowed to try again until the actual combat + // chat confirms the spell. Grenades are re-resolved from inventory + // because the fired stack/object may have changed. + ClearPendingItemDebuff(); + Status = $"Retrying {pending.Source.Spell.Name}"; + return; + } + Status = $"Waiting for {pending.Source.Spell.Name}"; + } + + private void TickWandCastRecovery(PendingItemDebuff pending) + { + double age = _now - pending.DispatchedAt; + INavigationAutomation movement = _host.Automation.Navigation; + if (_settings.JumpOutWandCasting + && !pending.RecoverySent + && age >= 0.2d) + { + _ = movement.SetMovementIntent(new PluginMovementIntent(Jump: true)); + _ = movement.ClearMovementIntent(); + pending.RecoverySent = true; + return; + } + if (!_settings.DoJiggle || _settings.JumpOutWandCasting) + return; + // VTank's DoJiggle is not movement. gs starts the same 131 ms + // previous-selection/player-cycle controller as ordinary spell casts + // after the wand cast completes. The receipt observer below owns it. + } + + private void ObserveItemDebuffReceipts() + { + foreach (PluginChatMessage message in + _host.Automation.Chat.CaptureMessages(_observedChatSequence)) + { + _observedChatSequence = Math.Max( + _observedChatSequence, + message.Sequence); + if (_pendingItemDebuff is not { } pending + || !IsMatchingCastLine(message.Text, pending.Source.Spell.Name)) + { + continue; + } + _debuffs.RecordApplied( + pending.TargetObjectId, + pending.Source.Identity, + pending.Source.Spell, + _now); + _failures.RecordSuccessfulAttack( + pending.TargetObjectId, + _now, + _settings); + Status = $"{pending.Source.Spell.Name} applied to {pending.TargetName}"; + if (!_settings.JumpOutWandCasting) + StartSelectionJiggle(pending.Source.Spell); + ClearPendingItemDebuff(); + } + + PluginItemUseCompletion itemCompletion = + _host.Automation.Items.LastCompletion; + if (itemCompletion.Revision <= _observedItemCompletion) + return; + _observedItemCompletion = itemCompletion.Revision; + if (_pendingItemDebuff is not { } itemPending + || itemPending.Source.Kind != CombatDebuffSourceKind.CasterItem + || itemCompletion.SourceObjectId != itemPending.Source.ItemObjectId + || itemCompletion.TargetObjectId != itemPending.TargetObjectId + || itemCompletion.IsSuccess) + { + return; + } + Status = $"{itemPending.ItemName} failed (0x{itemCompletion.WeenieError:X})"; + ClearPendingItemDebuff(); + } + + private static bool IsMatchingCastLine(string text, string spellName) => + text.StartsWith($"You cast {spellName} on ", StringComparison.Ordinal); + + private void ObserveSelectionJiggle(in PluginCastCompletion completion) + { + if (_host.Automation.Magic.IsCasting) + { + StopSelectionJiggle(); + return; + } + if (completion.Revision <= _observedJiggleCastCompletion) + return; + _observedJiggleCastCompletion = completion.Revision; + if (completion.IsSuccess + && _host.Automation.Spells.TryGet( + completion.SpellId, + out PluginSpellInfo spell)) + { + StartSelectionJiggle(spell); + } + } + + private void StartSelectionJiggle(in PluginSpellInfo spell) + { + if (!_settings.DoJiggle + || (IsVtankInstantCast(spell) + && spell.School is 34u or 43u)) + { + return; + } + ISelectionAutomation selection = _host.Automation.Selection; + if (!selection.Execute(PluginSelectionAction.PreviousSelection)) + return; + _selectionJiggleActive = true; + _selectionJigglePreviousPlayer = false; + _nextSelectionJiggleAt = _now; + } + + private void TickSelectionJiggle() + { + if (!_selectionJiggleActive || _now < _nextSelectionJiggleAt) + return; + ISelectionAutomation selection = _host.Automation.Selection; + int pulses = 0; + do + { + PluginSelectionAction action = _selectionJigglePreviousPlayer + ? PluginSelectionAction.PreviousPlayer + : PluginSelectionAction.NextPlayer; + if (!selection.Execute(action)) + { + StopSelectionJiggle(); + return; + } + _selectionJigglePreviousPlayer = !_selectionJigglePreviousPlayer; + _nextSelectionJiggleAt += 0.131d; + } + while (_now >= _nextSelectionJiggleAt && ++pulses < 8); + } + + private void StopSelectionJiggle() + { + _selectionJiggleActive = false; + _selectionJigglePreviousPlayer = false; + _nextSelectionJiggleAt = 0d; + } + + private static bool IsVtankInstantCast(in PluginSpellInfo spell) + { + if (spell.Difficulty < 50) + return true; + if (spell.IsUntargeted + && !spell.IsFellowship + && spell.DurationSeconds >= 60f + && spell.School is 31u or 33u) + { + return true; + } + return spell.Family is >= 243u and <= 249u or 639u; + } + + private static string ItemName( + uint objectId, + IReadOnlyList inventory) + { + foreach (PluginInventoryItem item in inventory) + { + if (item.ObjectId == objectId) + return item.Name; + } + return $"0x{objectId:X8}"; + } + + private void RefreshSpellCatalogs() + { + IReadOnlyList spells = + _host.Automation.Spells.KnownCombatSpells; + if (ReferenceEquals(spells, _combatSpellSnapshot)) + return; + _combatSpellSnapshot = spells; + _debuffCatalog = DebuffSpellCatalog.Build(spells); + _attackCatalog = AttackSpellCatalog.Build(spells); + } + + private IReadOnlyList DebuffScope() + { + if (_settings.DebuffEachFirst == DebuffEachFirst.One) + { + return _targetId == 0u + ? Array.Empty() + : [new RuleCandidate( + FindTarget(_targetId), + _targetRule)]; + } + + var candidates = new List(); + foreach (PluginCombatTarget target in _targets) + { + if (target.Distance < _settings.MinimumRange) + continue; + if (_failures.Reason(target.ObjectId, _now) + != CombatSuppressionReason.None) + { + continue; + } + ResolvedMonsterRule rule = _settings.ResolveRule(target); + if (rule.Priority < 0) + continue; + if (_settings.DebuffEachFirst == DebuffEachFirst.Priority + && rule.Priority != _targetRule.Priority) + { + continue; + } + candidates.Add(new RuleCandidate(target, rule)); + } + candidates.Sort(static (left, right) => + { + int priority = right.Rule.Priority.CompareTo(left.Rule.Priority); + if (priority != 0) + return priority; + int distance = left.Target.Distance.CompareTo(right.Target.Distance); + return distance != 0 + ? distance + : left.Target.ObjectId.CompareTo(right.Target.ObjectId); + }); + return candidates; + } + + private PluginCombatTarget FindTarget(uint objectId) + { + foreach (PluginCombatTarget target in _targets) + { + if (target.ObjectId == objectId) + return target; + } + return default; + } + + private void RefreshTarget() + { + if (_targetId != 0u + && _failures.Reason(_targetId, _now) + != CombatSuppressionReason.None) + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + + PluginCombatSnapshot combat = _host.Automation.Combat.Snapshot; + bool actionInFlight = combat.BuildInProgress + || combat.RequestInProgress + || combat.ServerResponsePending + || combat.RepeatAttackInProgress + || _host.Automation.Magic.IsCasting; + if (_targetId != 0u && actionInFlight) + { + if (TryFind(_targetId, out PluginCombatTarget active)) + SetTarget(active, _settings.ResolveRule(active)); + else + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + return; + } + + int highestPriority = -1; + var candidates = new List(); + foreach (PluginCombatTarget target in _targets) + { + if (_failures.Reason(target.ObjectId, _now) + != CombatSuppressionReason.None) + { + continue; + } + ResolvedMonsterRule resolved = _settings.ResolveRule(target); + int priority = resolved.Priority; + if (priority < 0) + continue; + if (priority > highestPriority) + { + highestPriority = priority; + candidates.Clear(); + } + if (priority == highestPriority) + candidates.Add(new RuleCandidate(target, resolved)); + } + + if (candidates.Count == 0) + { + if (_targetId != 0u) + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + return; + } + + // Target Lock gives a manually selected valid monster first refusal, + // but never lets it beat a higher-priority monster rule. + uint selected = _host.Automation.Combat.Snapshot.SelectedObjectId; + if (_settings.TargetLock && selected != 0u) + { + foreach (RuleCandidate candidate in candidates) + { + if (candidate.Target.ObjectId == selected) + { + SetTarget(candidate.Target, candidate.Rule); + return; + } + } + } + + // Official VTank dz::a keeps PluginCore.dz.o.e (the previously + // selected attack target) ahead of the range/angle comparison after + // priority and manual TargetLock have been resolved. Without this + // tie-break, turning changes every candidate's relative angle and can + // make a surrounded character alternate left/right forever. + if (_targetId != 0u) + { + foreach (RuleCandidate candidate in candidates) + { + if (candidate.Target.ObjectId == _targetId) + { + SetTarget(candidate.Target, candidate.Rule); + return; + } + } + } + + IEnumerable ranked = candidates; + if (_settings.SelectionMethod == TargetSelectionMethod.Both) + { + RuleCandidate[] near = candidates + .Where(candidate => + candidate.Target.Distance <= _settings.TargetSelectAngleRange) + .ToArray(); + ranked = near.Length > 0 ? near : candidates; + } + + RuleCandidate chosen = _settings.SelectionMethod switch + { + TargetSelectionMethod.Angle => ranked + .OrderBy(candidate => + MathF.Abs(candidate.Target.RelativeAngleDegrees)) + .ThenBy(candidate => candidate.Target.Distance) + .First(), + TargetSelectionMethod.Both + when ranked is RuleCandidate[] { Length: > 0 } near => near + .OrderBy(candidate => + MathF.Abs(candidate.Target.RelativeAngleDegrees)) + .ThenBy(candidate => candidate.Target.Distance) + .First(), + _ => ranked + .OrderBy(candidate => candidate.Target.Distance) + .ThenBy(candidate => + MathF.Abs(candidate.Target.RelativeAngleDegrees)) + .First(), + }; + SetTarget(chosen.Target, chosen.Rule); + } + + private bool TryFind(uint objectId, out PluginCombatTarget found) + { + foreach (PluginCombatTarget target in _targets) + { + if (target.ObjectId == objectId) + { + found = target; + return true; + } + } + found = default; + return false; + } + + private void SetTarget( + PluginCombatTarget target, + ResolvedMonsterRule resolved) + { + _targetId = target.ObjectId; + _targetRule = resolved; + _targetName = string.IsNullOrWhiteSpace(target.Name) + ? $"0x{target.ObjectId:X8}" + : target.Name; + _targetDistance = target.Distance; + _targetText = $"Target {_targetName} {_targetDistance:0.0}m"; + _failures.BeginEngagement(_targetId, _now); + } + + private void ClearTarget() + { + StopApproachMovement(); + StopBreakableTurnMovement(); + StopSelectionJiggle(); + _targetId = 0u; + _targetRule = default; + _targetName = string.Empty; + _targetDistance = 0f; + _targetText = "Target —"; + } + + private void Disable(string status) + { + _host.Automation.Combat.AbortPhysicalAttack(); + StopApproachMovement(); + StopBreakableTurnMovement(); + Enabled = false; + _paused = false; + _combatPolicySuspended = false; + _targets = Array.Empty(); + _combatSpellSnapshot = null; + _debuffCatalog = DebuffSpellCatalog.Build(Array.Empty()); + _attackCatalog = AttackSpellCatalog.Build(Array.Empty()); + _debuffs.Reset(); + _failures.Reset(); + _observedPhysicalCompletion = 0; + _observedAttackCastCompletion = 0; + _pendingPhysicalTarget = 0u; + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + ClearPendingItemDebuff(); + _observedChatSequence = 0u; + _observedItemCompletion = 0; + _dropToPeaceModeRetries = 0; + _randomDamageIndex = 0; + _observedJiggleCastCompletion = 0; + ClearTarget(); + Status = status; + } + + private bool TickApproach() + { + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot self = navigation.Snapshot; + if (!self.IsAvailable || self.IsPortalSpace + || !navigation.TryGetObject( + _targetId, + out PluginNavigationObject target)) + { + return false; + } + + float desired = NavigationController.DesiredHeading( + self.Position, + target.Position); + float delta = NavigationController.SignedHeadingDelta( + self.Position.HeadingDegrees, + desired); + float absolute = MathF.Abs(delta); + bool turnRight = delta > 4f; + bool turnLeft = delta < -4f; + bool forward = absolute <= 4f + || (_targetDistance > 5f ? absolute <= 45f : absolute <= 15f); + PluginNavigationCommandStatus result = navigation.SetMovementIntent( + new PluginMovementIntent( + Forward: forward, + TurnLeft: turnLeft, + TurnRight: turnRight, + Run: true)); + _approachMovementOwned = + result == PluginNavigationCommandStatus.Accepted; + if (_approachMovementOwned) + Status = $"Approaching {_targetName} ({_targetDistance:0.0}m)"; + return _approachMovementOwned; + } + + private bool ReadyForBreakableTurn( + in PluginSpellInfo spell, + uint targetObjectId) + { + if (!_settings.UseBreakableTurnTo + || !spell.RequiresTurnTo + || targetObjectId == 0u + || targetObjectId == _host.Automation.Character.ObjectId) + { + StopBreakableTurnMovement(); + return true; + } + + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot self = navigation.Snapshot; + if (!self.IsAvailable + || self.IsPortalSpace + || !navigation.TryGetObject( + targetObjectId, + out PluginNavigationObject target)) + { + StopBreakableTurnMovement(); + return true; + } + + float desired = NavigationController.DesiredHeading( + self.Position, + target.Position); + float delta = NavigationController.SignedHeadingDelta( + self.Position.HeadingDegrees, + desired); + if (MathF.Abs(delta) <= 2f) + { + StopBreakableTurnMovement(); + return true; + } + + PluginNavigationCommandStatus result = navigation.SetMovementIntent( + new PluginMovementIntent( + TurnLeft: delta < 0f, + TurnRight: delta > 0f)); + if (result != PluginNavigationCommandStatus.Accepted) + { + StopBreakableTurnMovement(); + return true; + } + _breakableTurnOwned = true; + Status = $"Turning to {_targetName} ({delta:+0.0;-0.0}°)"; + return false; + } + + private void StopBreakableTurnMovement() + { + if (!_breakableTurnOwned) + return; + _host.Automation.Navigation.ClearMovementIntent(); + _breakableTurnOwned = false; + } + + private void StopApproachMovement() + { + if (!_approachMovementOwned) + return; + _ = _host.Automation.Navigation.ClearMovementIntent(); + _approachMovementOwned = false; + } + + private readonly record struct RuleCandidate( + PluginCombatTarget Target, + ResolvedMonsterRule Rule); + + private enum DebuffStartResult + { + Skipped, + Handled, + } + + private sealed class PendingItemDebuff( + CombatDebuffSource source, + uint targetObjectId, + string targetName, + string itemName, + double dispatchedAt, + long itemCompletionRevision, + long physicalCompletionRevision, + PluginCombatMode mode, + float power) + { + public CombatDebuffSource Source { get; } = source; + public uint TargetObjectId { get; } = targetObjectId; + public string TargetName { get; } = targetName; + public string ItemName { get; } = itemName; + public double DispatchedAt { get; } = dispatchedAt; + public long ItemCompletionRevision { get; } = itemCompletionRevision; + public long PhysicalCompletionRevision { get; set; } = + physicalCompletionRevision; + public PluginCombatMode Mode { get; } = mode; + public float Power { get; } = power; + public double? AttackCompletedAt { get; set; } + public bool RecoverySent { get; set; } + public int RecoveryStage { get; set; } + } + + private void ObserveAttackReceipts( + PluginCombatSnapshot combat, + PluginCastCompletion cast) + { + if (combat.CompletionRevision > _observedPhysicalCompletion) + { + _observedPhysicalCompletion = combat.CompletionRevision; + if (_pendingPhysicalTarget != 0u + && combat.CompletionWeenieError == 0u) + { + _failures.RecordSuccessfulAttack( + _pendingPhysicalTarget, + _now, + _settings); + } + _pendingPhysicalTarget = 0u; + } + + if (cast.Revision <= _observedAttackCastCompletion) + return; + _observedAttackCastCompletion = cast.Revision; + if (_pendingAttackSpell == cast.SpellId + && _pendingAttackTarget == cast.TargetObjectId + && cast.IsSuccess) + { + _failures.RecordSuccessfulAttack( + _pendingAttackTarget, + _now, + _settings); + } + if (_pendingAttackSpell == cast.SpellId) + { + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + } + } + + private void DismissGhost(uint objectId) + { + PluginCombatCommandResult result = + _host.Automation.Combat.DismissGhostTarget(objectId); + string suffix = result.Accepted ? "deleted" : "ignored"; + _host.Automation.Chat.PostSystemMessage( + $"[MossTank] Ghost target 0x{objectId:X8} {suffix}."); + if (_targetId == objectId) + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/CombatFailureTracker.cs b/src/AcDream.Plugins.MossTank/CombatFailureTracker.cs new file mode 100644 index 00000000..e4509b65 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatFailureTracker.cs @@ -0,0 +1,173 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum CombatSuppressionReason +{ + None, + Blacklisted, + Ghost, +} + +/// +/// Implements VTank's three distinct unhittable-target guards. “Ghost” is +/// session-persistent until the object disappears; a normal blacklist expires +/// after the configured timeout. +/// +internal sealed class CombatFailureTracker +{ + private readonly Dictionary _entries = []; + + public IReadOnlyList ObserveTargets( + IReadOnlyList targets, + double now, + CombatSettings settings) + { + var live = new HashSet(); + List? newlyGhosted = null; + foreach (PluginCombatTarget target in targets) + { + live.Add(target.ObjectId); + if (!_entries.TryGetValue(target.ObjectId, out Entry? entry) + || entry.Incarnation != target.Incarnation) + { + _entries[target.ObjectId] = entry = new Entry + { + Incarnation = target.Incarnation, + }; + } + entry.LastSeenAt = now; + if (target.HealthRevision != 0 + && target.HealthRevision != entry.HealthRevision) + { + entry.HealthRevision = target.HealthRevision; + entry.SuccessfulMisses = 0; + entry.SpellStartFailures = 0; + } + + if (entry.BlacklistedUntil <= now) + entry.BlacklistedUntil = 0d; + + if (settings.DeleteGhostMonstersByHealthTracker + && entry.EngagedAt is double engagedAt + && now - engagedAt + >= Math.Max(0d, settings.GhostDeleteHealthTrackerSeconds) + && target.IsHealthKnown + && target.SecondsSinceHealthUpdate + >= Math.Max(0d, settings.GhostDeleteHealthTrackerSeconds)) + { + if (!entry.IsGhost) + { + entry.IsGhost = true; + (newlyGhosted ??= []).Add(target.ObjectId); + } + } + } + + foreach (uint objectId in _entries.Keys.ToArray()) + { + Entry entry = _entries[objectId]; + if (!live.Contains(objectId) + && now - entry.LastSeenAt > Math.Max( + 300d, + settings.BlacklistMonsterTimeoutSeconds)) + { + _entries.Remove(objectId); + } + } + return newlyGhosted ?? (IReadOnlyList)Array.Empty(); + } + + public void BeginEngagement(uint objectId, double now) + { + if (objectId == 0u) + return; + Entry entry = Get(objectId); + entry.EngagedAt ??= now; + } + + public bool RecordSpellDidNotStart( + uint objectId, + CombatSettings settings) + { + if (objectId == 0u || !settings.DeleteGhostMonsters) + return false; + Entry entry = Get(objectId); + entry.SpellStartFailures++; + if (entry.SpellStartFailures + >= Math.Max(1, settings.GhostMonsterSpellAttemptCount)) + { + if (!entry.IsGhost) + { + entry.IsGhost = true; + return true; + } + } + return false; + } + + public void RecordSuccessfulAttack( + uint objectId, + double now, + CombatSettings settings) + { + if (objectId == 0u) + return; + Entry entry = Get(objectId); + if (entry.HealthRevision > entry.AttackHealthRevision) + { + entry.SuccessfulMisses = 0; + return; + } + entry.SuccessfulMisses++; + if (entry.SuccessfulMisses + >= Math.Max(1, settings.BlacklistMonsterAttemptCount)) + { + entry.BlacklistedUntil = now + Math.Max( + 0d, + settings.BlacklistMonsterTimeoutSeconds); + entry.SuccessfulMisses = 0; + } + } + + public void BeginAttack(uint objectId, long healthRevision) + { + if (objectId == 0u) + return; + Entry entry = Get(objectId); + entry.AttackHealthRevision = healthRevision; + } + + public CombatSuppressionReason Reason(uint objectId, double now) + { + if (!_entries.TryGetValue(objectId, out Entry? entry)) + return CombatSuppressionReason.None; + if (entry.IsGhost) + return CombatSuppressionReason.Ghost; + return entry.BlacklistedUntil > now + ? CombatSuppressionReason.Blacklisted + : CombatSuppressionReason.None; + } + + public void Reset() => _entries.Clear(); + + private Entry Get(uint objectId) + { + if (!_entries.TryGetValue(objectId, out Entry? entry)) + _entries[objectId] = entry = new Entry(); + return entry; + } + + private sealed class Entry + { + public ushort Incarnation; + public double LastSeenAt; + public long HealthRevision; + public int SuccessfulMisses; + public int SpellStartFailures; + public long AttackHealthRevision; + public double? EngagedAt; + public double BlacklistedUntil; + public bool IsGhost; + } +} diff --git a/src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs b/src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs new file mode 100644 index 00000000..b7fa3eb9 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs @@ -0,0 +1,224 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum CombatDebuffSourceKind +{ + LearnedSpell, + CasterItem, + ProcWeapon, + Grenade, +} + +internal readonly record struct CombatDebuffSource( + DebuffIdentity Identity, + PluginSpellInfo Spell, + CombatDebuffSourceKind Kind, + uint ItemObjectId, + int SourceSkill, + int ActionOrder) +{ + public bool UsesItem => Kind != CombatDebuffSourceKind.LearnedSpell; +} + +/// +/// Port of official VTank dz.b.CompareTo plus dz.a(MySpell,f7) +/// source discovery. It considers only Items/Consumables profile members, +/// matches by real debuff identity, and gives a direct learned spell the exact +/// final tie-break preference VTank does. +/// +internal static class CombatItemDebuffPlanner +{ + private const uint MeleeWeapon = 0x00000001u; + private const uint MissileWeapon = 0x00000100u; + private const uint Caster = 0x00008000u; + private const uint WarMagicSkill = 34u; + private const uint VoidMagicSkill = 43u; + private const uint AlchemySkill = 38u; + + public static IReadOnlyList Candidates( + MonsterRuleActions actions, + CombatSettings settings, + ICharacterInfo character, + ISpellCatalog spells, + IReadOnlyList items, + Func isDue) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(spells); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(isDue); + + HashSet required = DebuffSpellCatalog.Required(actions); + if (required.Count == 0) + return Array.Empty(); + + var result = new List(); + foreach (PluginSpellInfo spell in spells.KnownCombatSpells) + { + AddIfRequired( + result, + required, + spell, + CombatDebuffSourceKind.LearnedSpell, + 0u, + CurrentSkill(character, spell.School), + isDue); + } + + foreach (PluginInventoryItem item in items) + { + if (settings.CombatItemObjectIds.Contains(item.ObjectId) + || settings.CombatItemNames.Contains(item.Name)) + AddProfileItem(result, required, item, spells, isDue); + if (settings.ConsumableNames.Contains(item.Name)) + AddGrenade(result, required, item, character, spells, isDue); + } + + result.Sort((left, right) => Compare( + left, + right, + settings.DebuffSelectionMethod)); + return result; + } + + private static void AddProfileItem( + ICollection result, + IReadOnlySet required, + PluginInventoryItem item, + ISpellCatalog spells, + Func isDue) + { + if ((item.ItemType & Caster) != 0u + && item.SpellId != 0u + && spells.TryGet(item.SpellId, out PluginSpellInfo casterSpell)) + { + AddIfRequired( + result, + required, + casterSpell, + CombatDebuffSourceKind.CasterItem, + item.ObjectId, + item.ItemSpellcraft, + isDue); + return; + } + + if ((item.ItemType & (MeleeWeapon | MissileWeapon)) == 0u) + return; + foreach (uint spellId in item.AppraisedSpellIds) + { + if (!spells.TryGet(spellId, out PluginSpellInfo proc) + || !proc.IsOffensive + || proc.IsUntargeted + || proc.School is WarMagicSkill or VoidMagicSkill) + { + continue; + } + AddIfRequired( + result, + required, + proc, + CombatDebuffSourceKind.ProcWeapon, + item.ObjectId, + item.ItemSpellcraft, + isDue); + // ga.a uses the first qualifying item spell. + return; + } + } + + private static void AddGrenade( + ICollection result, + IReadOnlySet required, + PluginInventoryItem item, + ICharacterInfo character, + ISpellCatalog spells, + Func isDue) + { + if ((item.ItemType & MissileWeapon) == 0u + || item.CombatUse != 0 + || !GrenadeCatalog.TryGet(item.Name, out GrenadeDefinition grenade) + || CurrentSkill(character, AlchemySkill) < grenade.RequiredAlchemy + || !spells.TryGet(grenade.SpellId, out PluginSpellInfo spell)) + { + return; + } + AddIfRequired( + result, + required, + spell, + CombatDebuffSourceKind.Grenade, + item.ObjectId, + grenade.Spellcraft, + isDue); + } + + private static void AddIfRequired( + ICollection result, + IReadOnlySet required, + PluginSpellInfo spell, + CombatDebuffSourceKind kind, + uint itemObjectId, + int sourceSkill, + Func isDue) + { + if (!DebuffSpellCatalog.TryClassify( + spell, + out DebuffIdentity identity, + out int actionOrder) + || !required.Contains(identity) + || !isDue(identity, spell)) + { + return; + } + result.Add(new CombatDebuffSource( + identity, + spell, + kind, + itemObjectId, + sourceSkill, + actionOrder)); + } + + private static int Compare( + CombatDebuffSource left, + CombatDebuffSource right, + DebuffSelectionMethod selection) + { + if (selection == DebuffSelectionMethod.Skill) + { + int skill = right.SourceSkill.CompareTo(left.SourceSkill); + if (skill != 0) + return skill; + int quality = right.Spell.Quality.CompareTo(left.Spell.Quality); + if (quality != 0) + return quality; + } + else + { + int quality = right.Spell.Quality.CompareTo(left.Spell.Quality); + if (quality != 0) + return quality; + int skill = right.SourceSkill.CompareTo(left.SourceSkill); + if (skill != 0) + return skill; + } + + bool leftDirect = left.Kind == CombatDebuffSourceKind.LearnedSpell; + bool rightDirect = right.Kind == CombatDebuffSourceKind.LearnedSpell; + if (leftDirect != rightDirect) + return leftDirect ? -1 : 1; + int action = left.ActionOrder.CompareTo(right.ActionOrder); + return action != 0 + ? action + : left.ItemObjectId.CompareTo(right.ItemObjectId); + } + + private static int CurrentSkill(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + ? checked((int)skill.Current) + : 0; +} diff --git a/src/AcDream.Plugins.MossTank/CombatSettings.cs b/src/AcDream.Plugins.MossTank/CombatSettings.cs new file mode 100644 index 00000000..e7ca4d09 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatSettings.cs @@ -0,0 +1,151 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum TargetSelectionMethod +{ + Range, + Angle, + Both, +} + +internal enum DebuffEachFirst +{ + One = 1, + Priority = 2, + All = 3, +} + +internal enum DebuffSelectionMethod +{ + SpellLevel = 1, + Skill = 2, +} + +internal enum PetRangeMode +{ + AttackDistance = 0, + Custom = 1, +} + +internal enum ConsumableCategory +{ + Other, + HealthKit, + HealthFood, + StaminaKit, + StaminaFood, + ManaKit, + ManaFood, + Pea, + AllPeas, + Lockpick, +} + +internal sealed class CombatSettings +{ + /// + /// VTank's EnableCombat profile option. This is deliberately separate + /// from the panel's Run Macro state: a running macro may navigate, loot, + /// buff, or execute Meta rules while combat itself is disabled. + /// + public bool Enabled { get; set; } = true; + /// VTank's hunt-cast skill margin. + public int HuntSkillExcessOverDifficulty { get; set; } = 25; + public float MaximumRange { get; set; } = 5f; + /// + /// Monsters nearer than this are not valid attack targets. VTank applies + /// this before priority and angle/range ranking. + /// + public float MinimumRange { get; set; } + /// + /// VTank's Approach Distance. Zero disables monster approach; otherwise + /// navigation may close a selected target from this range down to + /// . + /// + public float ApproachDistance { get; set; } + public bool IdlePeaceMode { get; set; } + public bool StopMacroOnDeath { get; set; } = true; + public bool JumpOutWandCasting { get; set; } + public bool DoJiggle { get; set; } + public TargetSelectionMethod SelectionMethod { get; set; } = + TargetSelectionMethod.Both; + public float TargetSelectAngleRange { get; set; } = 5f; + public bool TargetLock { get; set; } + public PluginAttackHeight AttackHeight { get; set; } = + PluginAttackHeight.Medium; + public float AttackPower { get; set; } = 0.5f; + public bool AutoAttackPower { get; set; } = true; + public bool UseRecklessness { get; set; } = true; + public double ScanIntervalSeconds { get; set; } = 0.25; + public DebuffEachFirst DebuffEachFirst { get; set; } = DebuffEachFirst.One; + public DebuffSelectionMethod DebuffSelectionMethod { get; set; } = + DebuffSelectionMethod.Skill; + public double DebuffPrecastSeconds { get; set; } = 5d; + public bool SwitchWandsToDebuff { get; set; } + public bool UseArcs { get; set; } = true; + public float SpellRangeFudge { get; set; } = 1f; + public bool UseBreakableTurnTo { get; set; } = true; + public bool UseProjectileAwareness { get; set; } = true; + public float CollisionProjectileRadius { get; set; } = 0.4f; + public float CollisionStepDistance { get; set; } = 0.7f; + public bool ShowCollisionDebug { get; set; } + public int MaximumCollisionChecksPerTick { get; set; } = 500; + public float ArcRange { get; set; } = 5f; + public float RingDistance { get; set; } = 5f; + public int MinimumRingTargets { get; set; } = 4; + public bool DeleteGhostMonsters { get; set; } = true; + public int GhostMonsterSpellAttemptCount { get; set; } = 200; + public int BlacklistMonsterAttemptCount { get; set; } = 4; + public double BlacklistMonsterTimeoutSeconds { get; set; } = 120d; + public bool DeleteGhostMonstersByHealthTracker { get; set; } = true; + public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d; + public bool SummonPets { get; set; } = true; + public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance; + public float PetCustomRange { get; set; } = 5f; + public int PetMonsterDensity { get; set; } = 1; + public int PetRefillCountIdle { get; set; } = 3; + public int PetRefillCountNormal { get; set; } = 1; + public bool AllowDebuffFallback { get; set; } + public int UseSpecialAmmo { get; set; } + public bool WhoYouGonnaCall { get; set; } = true; + public bool AutoFellowManagement { get; set; } = true; + public string BlacklistedSpellComponents { get; set; } = string.Empty; + /// + /// Runtime object ids resolved from VTank's Items profile. Debuff lenses + /// and cast-on-strike weapons are never taken from arbitrary inventory. + /// + public ISet CombatItemObjectIds { get; } = new HashSet(); + public ISet CombatItemNames { get; } = + new HashSet(StringComparer.Ordinal); + /// Exact names enabled in VTank's Consumables profile. + public ISet ConsumableNames { get; } = + new HashSet(StringComparer.Ordinal); + public IDictionary ConsumableCategories { get; } = + new Dictionary(StringComparer.Ordinal); + public IList Rules { get; } = + new List { new("DEFAULT", 0) }; + + public ResolvedMonsterRule ResolveRule(PluginCombatTarget target) + { + var context = new MonsterExpressionContext( + target.Name, + target.WeenieClassId, + target.SpeciesName, + target.MaximumHealth, + target.Distance, + target.HasShield, + MetaState, + ResolveSetting); + return MonsterRuleResolver.Resolve(Rules, context); + } + + public string MetaState { get; set; } = "Default"; + public IDictionary DynamicSettings { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private MonsterValue? ResolveSetting(string name) => + DynamicSettings.TryGetValue(name, out MonsterValue value) + ? value + : null; +} diff --git a/src/AcDream.Plugins.MossTank/Crafting.cs b/src/AcDream.Plugins.MossTank/Crafting.cs new file mode 100644 index 00000000..65abec4f --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Crafting.cs @@ -0,0 +1,649 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct CraftingPlan( + VtankCraftRecipe Recipe, + uint FirstObjectId, + uint SecondObjectId, + string DesiredResult) +{ + public bool RequiresSplitFirstStack { get; init; } + public uint SplitContainerObjectId { get; init; } +} + +internal static class ConsumableClassifier +{ + private const uint HealingKitPublicFlag = 0x00010000u; + private const uint LockpickPublicFlag = 0x00020000u; + + public static ConsumableCategory Classify(in PluginInventoryItem item) + { + if (item.Name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal)) + return ConsumableCategory.AllPeas; + if (item.Name.EndsWith(" Pea", StringComparison.Ordinal)) + return ConsumableCategory.Pea; + if ((item.PublicFlags & LockpickPublicFlag) != 0u) + return ConsumableCategory.Lockpick; + if ((item.PublicFlags & HealingKitPublicFlag) != 0u) + return KitCategory(item.Name); + return item.BoosterVital switch + { + 2 => ConsumableCategory.HealthFood, + 4 => ConsumableCategory.StaminaFood, + 6 => ConsumableCategory.ManaFood, + _ => ClassifyName(item.Name), + }; + } + + public static ConsumableCategory ClassifyName(string name) + { + if (name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal)) + return ConsumableCategory.AllPeas; + if (name.EndsWith(" Pea", StringComparison.Ordinal)) + return ConsumableCategory.Pea; + return name.EndsWith(" Kit", StringComparison.Ordinal) + ? KitCategory(name) + : ConsumableCategory.Other; + } + + private static ConsumableCategory KitCategory(string name) => name switch + { + "Medicated Stamina Kit" or "Eternal Stamina Kit" + or "Greater Stamina Kit" or "Lesser Stamina Kit" => + ConsumableCategory.StaminaKit, + "Medicated Mana Kit" or "Eternal Mana Kit" + or "Greater Mana Kit" or "Lesser Mana Kit" => + ConsumableCategory.ManaKit, + _ => ConsumableCategory.HealthKit, + }; +} + +internal static class CraftingPlanner +{ + public const string AllPeas = "[All Peas]"; + + public static CraftingPlan? Plan( + IReadOnlyList inventory, + IEnumerable desiredResults, + ICharacterInfo character, + int desiredCount = 1, + int arrowheadFletchDifficultyExcess = 10) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(desiredResults); + ArgumentNullException.ThrowIfNull(character); + var counts = inventory + .GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Sum(item => Math.Max(1, item.StackSize)), + StringComparer.OrdinalIgnoreCase); + foreach (string desired in desiredResults + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)) + { + if (counts.GetValueOrDefault(desired) >= Math.Max(1, desiredCount)) + continue; + CraftingPlan? plan = FindStep( + desired, + desired, + inventory, + character, + counts, + new HashSet(StringComparer.OrdinalIgnoreCase), + arrowheadFletchDifficultyExcess); + if (plan is not null) + return plan; + } + return null; + } + + public static CraftingPlan? PlanPeaSplit( + IReadOnlyList inventory, + ISet consumableProfile, + int minimumComponentCount) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(consumableProfile); + int minimum = Math.Max(0, minimumComponentCount); + if (minimum == 0) + return null; + PluginInventoryItem tool = Find(inventory, "Splitting Tool"); + if (tool.ObjectId == 0u) + return null; + bool allPeas = consumableProfile.Contains(AllPeas); + var counts = inventory + .GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Sum(item => Math.Max(1, item.StackSize)), + StringComparer.OrdinalIgnoreCase); + foreach (VtankCraftRecipe recipe in VtankCraftDatabase.Recipes) + { + if (!recipe.FirstItem.Equals("Splitting Tool", StringComparison.Ordinal) + || !recipe.SecondItem.EndsWith(" Pea", StringComparison.Ordinal) + || (!allPeas && !consumableProfile.Contains(recipe.SecondItem)) + || counts.GetValueOrDefault(recipe.ResultItem) >= minimum) + { + continue; + } + PluginInventoryItem pea = Find(inventory, recipe.SecondItem); + if (pea.ObjectId == 0u) + continue; + return new CraftingPlan( + recipe, + tool.ObjectId, + pea.ObjectId, + recipe.ResultItem); + } + return null; + } + + private static CraftingPlan? FindStep( + string result, + string desiredResult, + IReadOnlyList inventory, + ICharacterInfo character, + IReadOnlyDictionary counts, + HashSet visiting, + int arrowheadFletchDifficultyExcess) + { + if (!visiting.Add(result)) + return null; + try + { + foreach (VtankCraftRecipe recipe in VtankCraftDatabase.ForResult(result)) + { + if (!HasRequiredSkill( + character, + recipe.RequiredSkill, + recipe.Difficulty, + arrowheadFletchDifficultyExcess)) + continue; + + PluginInventoryItem first = Find(inventory, recipe.FirstItem); + if (first.ObjectId == 0u) + { + CraftingPlan? prerequisite = FindStep( + recipe.FirstItem, + desiredResult, + inventory, + character, + counts, + visiting, + arrowheadFletchDifficultyExcess); + if (prerequisite is not null) + return prerequisite; + continue; + } + + PluginInventoryItem second = Find( + inventory, + recipe.SecondItem, + excludedObjectId: recipe.FirstItem.Equals( + recipe.SecondItem, + StringComparison.OrdinalIgnoreCase) + ? first.ObjectId + : 0u); + if (second.ObjectId == 0u) + { + if (recipe.FirstItem.Equals( + recipe.SecondItem, + StringComparison.OrdinalIgnoreCase) + && first.StackSize >= 2) + { + return new CraftingPlan( + recipe, + first.ObjectId, + 0u, + desiredResult) + { + RequiresSplitFirstStack = true, + SplitContainerObjectId = first.ContainerObjectId, + }; + } + CraftingPlan? prerequisite = FindStep( + recipe.SecondItem, + desiredResult, + inventory, + character, + counts, + visiting, + arrowheadFletchDifficultyExcess); + if (prerequisite is not null) + return prerequisite; + continue; + } + + return new CraftingPlan( + recipe, + first.ObjectId, + second.ObjectId, + desiredResult); + } + return null; + } + finally + { + visiting.Remove(result); + } + } + + private static PluginInventoryItem Find( + IReadOnlyList inventory, + string name, + uint excludedObjectId = 0u) + { + foreach (PluginInventoryItem item in inventory) + { + if (item.ObjectId != excludedObjectId + && item.StackSize > 0 + && item.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return item; + } + } + return default; + } + + private static bool HasRequiredSkill( + ICharacterInfo character, + uint requiredSkill, + int difficulty, + int arrowheadFletchDifficultyExcess) + { + if (requiredSkill == 0u) + return true; + if (!character.TryGetSkill(requiredSkill, out PluginSkillInfo skill) + || skill.Training is not (PluginSkillTraining.Trained + or PluginSkillTraining.Specialized)) + { + return false; + } + return requiredSkill != 37u + || skill.Current >= Math.Max(0, difficulty) + + arrowheadFletchDifficultyExcess; + } +} + +internal sealed class CraftingController +{ + private const double SplitTimeoutSeconds = 10d; + + private readonly IPluginHost _host; + private readonly InventorySettings _settings; + private readonly CombatSettings _profiles; + private CraftingPlan? _pending; + private CraftingPlan? _pendingSplit; + private long _observedCompletion; + private long _observedInventoryCompletion; + private double _untilScan; + private double _untilCriticalScan; + private double _untilIdleScan; + private double _splitElapsed; + private bool _splitAcknowledged; + + public CraftingController( + IPluginHost host, + InventorySettings settings, + CombatSettings profiles) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _profiles = profiles ?? throw new ArgumentNullException(nameof(profiles)); + } + + public string Status { get; private set; } = "AutoCraft idle"; + + /// + /// Immediate VTank subsystem request, used by ammunition selection. This + /// bypasses the general AutoCraftItems toggle just as bv.cs does, while + /// still using the one canonical crafting transaction state machine. + /// + public bool Request(string resultName, int desiredCount = 1) + { + if (string.IsNullOrWhiteSpace(resultName) + || _pending is not null + || _pendingSplit is not null + || !_host.Automation.IsAvailable) + { + return false; + } + IItemAutomation items = _host.Automation.Items; + if (!items.IsAvailable || items.IsBusy) + return false; + CraftingPlan? plan = CraftingPlanner.Plan( + items.CaptureOwnedItems(), + [resultName], + _host.Automation.Character, + desiredCount, + _settings.ArrowheadFletchDifficultyExcess); + return plan is { } next && Start(items, next); + } + + public bool CanRequest(string resultName, int desiredCount = 1) + { + if (string.IsNullOrWhiteSpace(resultName) + || !_host.Automation.IsAvailable + || !_host.Automation.Items.IsAvailable) + { + return false; + } + return CraftingPlanner.Plan( + _host.Automation.Items.CaptureOwnedItems(), + [resultName], + _host.Automation.Character, + desiredCount, + _settings.ArrowheadFletchDifficultyExcess) + is not null; + } + + public bool TickCritical(double elapsedSeconds, bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (ObserveSplitCompletion(items, elapsedSeconds)) + return true; + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.AutoCraftItems + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + _untilCriticalScan -= Math.Max(0d, elapsedSeconds); + if (_untilCriticalScan > 0d) + return false; + _untilCriticalScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + IReadOnlyList inventory = items.CaptureOwnedItems(); + CraftingPlan? plan = _settings.SplitPeas + ? CraftingPlanner.PlanPeaSplit( + inventory, + _profiles.ConsumableNames, + _settings.CriticalComponentMinimum) + : null; + plan ??= PlanCategoryCraft( + inventory, + idleCounts: false); + return plan is { } next && Start(items, next); + } + + public bool Tick(double elapsedSeconds, bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (ObserveSplitCompletion(items, elapsedSeconds)) + return true; + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.AutoCraftItems + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + + _untilScan -= Math.Max(0d, elapsedSeconds); + if (_untilScan > 0d) + return false; + _untilScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + IReadOnlyList inventory = items.CaptureOwnedItems(); + CraftingPlan? plan = _settings.SplitPeas + ? CraftingPlanner.PlanPeaSplit( + inventory, + _profiles.ConsumableNames, + _settings.NormalComponentMinimum) + : null; + plan ??= CraftingPlanner.Plan( + inventory, + _profiles.ConsumableNames + .Concat(_profiles.CombatItemNames) + .Where(static name => + !name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal) + && !name.EndsWith(" Pea", StringComparison.Ordinal)), + _host.Automation.Character, + arrowheadFletchDifficultyExcess: + _settings.ArrowheadFletchDifficultyExcess); + if (plan is not { } next) + { + Status = "AutoCraft idle"; + return false; + } + + return Start(items, next); + } + + public bool TickIdle(double elapsedSeconds, bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (ObserveSplitCompletion(items, elapsedSeconds)) + return true; + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.AutoCraftItems + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + _untilIdleScan -= Math.Max(0d, elapsedSeconds); + if (_untilIdleScan > 0d) + return false; + _untilIdleScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + IReadOnlyList inventory = items.CaptureOwnedItems(); + CraftingPlan? plan = _settings.SplitPeas + ? CraftingPlanner.PlanPeaSplit( + inventory, + _profiles.ConsumableNames, + _settings.IdleComponentMinimum) + : null; + plan ??= PlanCategoryCraft(inventory, idleCounts: true); + return plan is { } next && Start(items, next); + } + + private CraftingPlan? PlanCategoryCraft( + IReadOnlyList inventory, + bool idleCounts) + { + foreach (string name in _profiles.ConsumableNames + .OrderBy(static name => name, StringComparer.Ordinal)) + { + ConsumableCategory category = _profiles.ConsumableCategories + .TryGetValue(name, out ConsumableCategory stored) + ? stored + : ConsumableClassifier.ClassifyName(name); + int desired = idleCounts ? IdleCount(category) : category switch + { + ConsumableCategory.HealthKit + or ConsumableCategory.HealthFood + or ConsumableCategory.StaminaKit + or ConsumableCategory.StaminaFood + or ConsumableCategory.ManaKit + or ConsumableCategory.ManaFood => 1, + _ => 0, + }; + if (desired <= 0) + continue; + CraftingPlan? plan = CraftingPlanner.Plan( + inventory, + [name], + _host.Automation.Character, + desired, + _settings.ArrowheadFletchDifficultyExcess); + if (plan is not null) + return plan; + } + return null; + } + + private int IdleCount(ConsumableCategory category) => category switch + { + ConsumableCategory.HealthKit => _settings.IdleHealthKitCount, + ConsumableCategory.StaminaKit => _settings.IdleStaminaKitCount, + ConsumableCategory.ManaKit => _settings.IdleManaKitCount, + ConsumableCategory.HealthFood => _settings.IdleHealthFoodCount, + ConsumableCategory.StaminaFood => _settings.IdleStaminaFoodCount, + ConsumableCategory.ManaFood => _settings.IdleManaFoodCount, + _ => 0, + }; + + private bool Start(IItemAutomation items, CraftingPlan next) + { + if (next.RequiresSplitFirstStack) + { + long completionBefore = items.LastInventoryCompletion.Revision; + PluginItemCommandResult split = items.MoveToContainer( + next.FirstObjectId, + next.SplitContainerObjectId, + amount: 1u); + if (!split.Accepted) + { + Status = $"AutoCraft split waiting: {split.Status}"; + return split.Status == PluginItemCommandStatus.Busy; + } + _pendingSplit = next; + _observedInventoryCompletion = completionBefore; + _splitElapsed = 0d; + _splitAcknowledged = false; + Status = $"Splitting {next.Recipe.FirstItem} for crafting"; + return true; + } + PluginItemCommandResult result = items.Apply( + next.FirstObjectId, + next.SecondObjectId); + if (!result.Accepted) + { + Status = $"AutoCraft waiting: {result.Status}"; + return result.Status == PluginItemCommandStatus.Busy; + } + _pending = next; + Status = $"Crafting {next.Recipe.ResultItem}"; + return true; + } + + public void Reset() + { + _pending = null; + _pendingSplit = null; + _untilScan = 0d; + _untilCriticalScan = 0d; + _untilIdleScan = 0d; + _splitElapsed = 0d; + _splitAcknowledged = false; + Status = "AutoCraft idle"; + } + + private void ObserveCompletion(IItemAutomation items) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision == 0 || completion.Revision == _observedCompletion) + return; + _observedCompletion = completion.Revision; + if (_pending is not { } pending + || completion.SourceObjectId != pending.FirstObjectId) + { + return; + } + Status = completion.IsSuccess + ? $"Crafted {pending.Recipe.ResultItem}" + : $"Craft failed (0x{completion.WeenieError:X})"; + _pending = null; + _untilScan = 0d; + _untilCriticalScan = 0d; + _untilIdleScan = 0d; + } + + private bool ObserveSplitCompletion( + IItemAutomation items, + double elapsedSeconds) + { + if (_pendingSplit is not { } splitPlan) + return false; + + _splitElapsed += Math.Max(0d, elapsedSeconds); + PluginInventoryCompletion completion = items.LastInventoryCompletion; + if (completion.Revision != 0 + && completion.Revision != _observedInventoryCompletion) + { + _observedInventoryCompletion = completion.Revision; + if (completion.SourceObjectId == splitPlan.FirstObjectId) + { + if (!completion.IsSuccess) + { + Status = $"AutoCraft split failed (0x{completion.WeenieError:X})"; + ClearPendingSplit(); + return true; + } + _splitAcknowledged = true; + } + } + + if (_splitAcknowledged && TryStartAfterSplit(items, splitPlan)) + return true; + if (_splitElapsed < SplitTimeoutSeconds) + { + Status = _splitAcknowledged + ? "AutoCraft waiting for split inventory" + : $"Splitting {splitPlan.Recipe.FirstItem} for crafting"; + return true; + } + + Status = "AutoCraft split timed out"; + ClearPendingSplit(); + return true; + } + + private bool TryStartAfterSplit( + IItemAutomation items, + CraftingPlan splitPlan) + { + PluginInventoryItem[] inputs = items.CaptureOwnedItems() + .Where(item => item.Name.Equals( + splitPlan.Recipe.FirstItem, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(static item => item.ObjectId) + .ToArray(); + if (inputs.Length < 2) + return false; + CraftingPlan ready = splitPlan with + { + FirstObjectId = inputs[0].ObjectId, + SecondObjectId = inputs[1].ObjectId, + RequiresSplitFirstStack = false, + }; + ClearPendingSplit(); + return Start(items, ready); + } + + private void ClearPendingSplit() + { + _pendingSplit = null; + _splitElapsed = 0d; + _splitAcknowledged = false; + _untilScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + _untilCriticalScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + _untilIdleScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + } +} diff --git a/src/AcDream.Plugins.MossTank/DebuffScheduler.cs b/src/AcDream.Plugins.MossTank/DebuffScheduler.cs new file mode 100644 index 00000000..4a30bb52 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/DebuffScheduler.cs @@ -0,0 +1,400 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct DebuffIdentity( + MonsterActionFlags Flag, + MonsterDamageType DamageType); + +internal readonly record struct DebuffChoice( + DebuffIdentity Identity, + PluginSpellInfo Spell, + int ActionOrder); + +/// +/// Converts retail spell-table data into VTank's Monsters-column vocabulary. +/// Names are the stable retail identities VTank exposed to users; no host-side +/// combat policy leaks into the plugin API. +/// +internal sealed class DebuffSpellCatalog +{ + private static readonly (MonsterActionFlags Flag, int Order)[] OrderedFlags = + [ + (MonsterActionFlags.Fester, 0), + (MonsterActionFlags.Broadside, 1), + (MonsterActionFlags.GravityWell, 2), + (MonsterActionFlags.Imperil, 3), + (MonsterActionFlags.Yield, 4), + (MonsterActionFlags.Vulnerability, 5), + (MonsterActionFlags.WeakeningCurse, 6), + (MonsterActionFlags.FesteringCurse, 7), + (MonsterActionFlags.Corruption, 8), + (MonsterActionFlags.DestructiveCurse, 9), + (MonsterActionFlags.Corrosion, 10), + ]; + + private readonly DebuffChoice[] _choices; + + private DebuffSpellCatalog(DebuffChoice[] choices) => _choices = choices; + + public static DebuffSpellCatalog Build(IReadOnlyList spells) + { + ArgumentNullException.ThrowIfNull(spells); + var choices = new List(); + foreach (PluginSpellInfo spell in spells) + { + if (!TryClassify(spell, out DebuffIdentity identity, out int order)) + continue; + choices.Add(new DebuffChoice(identity, spell, order)); + } + return new DebuffSpellCatalog([.. choices]); + } + + public IReadOnlyList Candidates( + MonsterRuleActions actions, + DebuffSelectionMethod selection, + ICharacterInfo character, + Func isDue) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(isDue); + + HashSet required = Required(actions); + if (required.Count == 0 || _choices.Length == 0) + return Array.Empty(); + + var candidates = new List(); + foreach (DebuffChoice choice in _choices) + { + if (required.Contains(choice.Identity) + && isDue(choice.Identity, choice.Spell)) + { + candidates.Add(choice); + } + } + + candidates.Sort((left, right) => Compare( + left, right, selection, character)); + return candidates; + } + + public bool HasKnownRequirement(MonsterRuleActions actions) + { + HashSet required = Required(actions); + foreach (DebuffChoice choice in _choices) + { + if (required.Contains(choice.Identity)) + return true; + } + return false; + } + + private static int Compare( + DebuffChoice left, + DebuffChoice right, + DebuffSelectionMethod selection, + ICharacterInfo character) + { + if (selection == DebuffSelectionMethod.Skill) + { + uint leftSkill = Skill(character, left.Spell.School); + uint rightSkill = Skill(character, right.Spell.School); + int skill = rightSkill.CompareTo(leftSkill); + if (skill != 0) + return skill; + } + + int tier = right.Spell.Tier.CompareTo(left.Spell.Tier); + if (tier != 0) + return tier; + int difficulty = right.Spell.Difficulty.CompareTo(left.Spell.Difficulty); + if (difficulty != 0) + return difficulty; + int action = left.ActionOrder.CompareTo(right.ActionOrder); + return action != 0 + ? action + : left.Spell.SpellId.CompareTo(right.Spell.SpellId); + } + + private static uint Skill(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + ? skill.Current + : 0u; + + internal static HashSet Required(MonsterRuleActions actions) + { + var required = new HashSet(); + foreach ((MonsterActionFlags flag, _) in OrderedFlags) + { + if ((actions.Flags & flag) == 0) + continue; + MonsterDamageType damage = flag == MonsterActionFlags.Vulnerability + ? actions.DamageType + : MonsterDamageType.Auto; + required.Add(new DebuffIdentity(flag, damage)); + } + + if ((actions.Flags & MonsterActionFlags.Vulnerability) != 0 + && actions.ExtraVulnerability != MonsterDamageType.Auto) + { + required.Add(new DebuffIdentity( + MonsterActionFlags.Vulnerability, + actions.ExtraVulnerability)); + } + return required; + } + + internal static bool TryClassify( + PluginSpellInfo spell, + out DebuffIdentity identity, + out int order) + { + string name = Normalize(spell.Name); + MonsterActionFlags flag; + MonsterDamageType damage = MonsterDamageType.Auto; + + if (name.StartsWith("Fester Other", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Fester; + else if (name.StartsWith("Broadside of a Barn", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Broadside; + else if (name.StartsWith("Gravity Well", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.GravityWell; + else if (name.StartsWith("Imperil Other", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Imperil; + else if (name.StartsWith("Magic Yield Other", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Yield; + else if (name.Contains(" Vulnerability Other", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Vulnerability Other", StringComparison.OrdinalIgnoreCase) + || IsClassicLure(name)) + { + flag = MonsterActionFlags.Vulnerability; + damage = DamageFromName(name); + } + else if (name.StartsWith("Weakening Curse", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.WeakeningCurse; + else if (name.StartsWith("Festering Curse", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.FesteringCurse; + else if (name.StartsWith("Corruption", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Corruption; + else if (name.StartsWith("Destructive Curse", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.DestructiveCurse; + else if (name.StartsWith("Corrosion", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Corrosion; + else + { + identity = default; + order = int.MaxValue; + return false; + } + + order = Array.FindIndex( + OrderedFlags, + entry => entry.Flag == flag); + if (order < 0) + order = int.MaxValue; + identity = new DebuffIdentity(flag, damage); + return true; + } + + private static string Normalize(string name) + { + const string incantation = "Incantation of "; + return name.StartsWith(incantation, StringComparison.OrdinalIgnoreCase) + ? name[incantation.Length..] + : name; + } + + /// + /// Retail's levels I-VII vulnerability line uses the older * Lure names. + /// Do not confuse it with the distinct Lure Blade item-enchantment line. + /// + private static bool IsClassicLure(string name) => + name.StartsWith("Acid Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Blade Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Bludgeon Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Flame Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Frost Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Lightning Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Piercing Lure", StringComparison.OrdinalIgnoreCase); + + internal static MonsterDamageType DamageFromName(string name) + { + if (name.Contains("Blade", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Slash; + if (name.Contains("Piercing", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Pierce; + if (name.Contains("Bludgeon", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Bludgeon; + if (name.Contains("Cold", StringComparison.OrdinalIgnoreCase) + || name.Contains("Frost", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Cold; + if (name.Contains("Fire", StringComparison.OrdinalIgnoreCase) + || name.Contains("Flame", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Fire; + if (name.Contains("Acid", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Acid; + if (name.Contains("Lightning", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Electric; + if (name.Contains("Nether", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Nether; + return MonsterDamageType.Auto; + } +} + +/// +/// Session-local VTank spell tracker. A debuff becomes active only after the +/// host publishes its matching server UseDone receipt. +/// +internal sealed class DebuffTracker +{ + private readonly Dictionary<(uint Target, DebuffIdentity Identity), Applied> _applied = []; + private Pending? _pending; + private long _observedCompletionRevision; + + public bool HasPending => _pending is not null; + public string PendingName => _pending?.Spell.Name ?? string.Empty; + public uint PendingTarget => _pending?.TargetObjectId ?? 0u; + + public bool IsDue( + uint targetObjectId, + DebuffIdentity identity, + PluginSpellInfo spell, + double now, + double precastSeconds) + { + if (!_applied.TryGetValue((targetObjectId, identity), out Applied applied)) + return true; + if (applied.SpellId != spell.SpellId && spell.Tier > applied.Tier) + return true; + double lead = spell.IsDamageOverTime ? 0d : Math.Max(0d, precastSeconds); + return now >= applied.ExpiresAt - lead; + } + + public void Begin( + uint targetObjectId, + DebuffIdentity identity, + PluginSpellInfo spell, + double now, + long completionRevision) + { + _observedCompletionRevision = Math.Max( + _observedCompletionRevision, + completionRevision); + _pending = new Pending(targetObjectId, identity, spell, now); + } + + public DebuffCompletion Observe( + PluginCastCompletion completion, + double now) + { + if (completion.Revision <= _observedCompletionRevision) + return default; + _observedCompletionRevision = completion.Revision; + if (_pending is not { } pending + || pending.Spell.SpellId != completion.SpellId + || pending.TargetObjectId != completion.TargetObjectId) + { + return default; + } + + _pending = null; + if (!completion.IsSuccess) + { + return new DebuffCompletion( + Completed: true, + Succeeded: false, + pending.Spell.Name, + completion.WeenieError); + } + + double duration = Math.Max(0d, pending.Spell.DurationSeconds); + _applied[(pending.TargetObjectId, pending.Identity)] = new Applied( + pending.Spell.SpellId, + pending.Spell.Tier, + now + duration); + return new DebuffCompletion( + Completed: true, + Succeeded: true, + pending.Spell.Name, + 0u); + } + + public bool ExpirePending(double now, double timeoutSeconds = 15d) + { + if (_pending is not { } pending + || now - pending.DispatchedAt < timeoutSeconds) + { + return false; + } + _pending = null; + return true; + } + + public void RecordApplied( + uint targetObjectId, + DebuffIdentity identity, + PluginSpellInfo spell, + double now) + { + double duration = Math.Max(0d, spell.DurationSeconds); + _applied[(targetObjectId, identity)] = new Applied( + spell.SpellId, + spell.Tier, + now + duration); + } + + /// + /// VTank's /vt fakeimp records Gossamer Flesh locally for 3,000 + /// seconds. It is deliberately stronger than every learnable Imperil tier + /// so the debug marker remains authoritative for its requested duration. + /// + public void RecordFakeImperil(uint targetObjectId, double now) + { + const uint gossamerFlesh = 0x081Au; + const double durationSeconds = 3000d; + _applied[(targetObjectId, new DebuffIdentity( + MonsterActionFlags.Imperil, + MonsterDamageType.Auto))] = new Applied( + gossamerFlesh, + int.MaxValue, + now + durationSeconds); + } + + public void ClearPending() => _pending = null; + + public void RetainTargets(IReadOnlySet liveTargets) + { + if (_applied.Count == 0) + return; + foreach ((uint Target, DebuffIdentity Identity) key in _applied.Keys.ToArray()) + { + if (!liveTargets.Contains(key.Target)) + _applied.Remove(key); + } + } + + public void Reset() + { + _applied.Clear(); + _pending = null; + _observedCompletionRevision = 0; + } + + private readonly record struct Pending( + uint TargetObjectId, + DebuffIdentity Identity, + PluginSpellInfo Spell, + double DispatchedAt); + + private readonly record struct Applied( + uint SpellId, + int Tier, + double ExpiresAt); +} + +internal readonly record struct DebuffCompletion( + bool Completed, + bool Succeeded, + string SpellName, + uint WeenieError); diff --git a/src/AcDream.Plugins.MossTank/DispelController.cs b/src/AcDream.Plugins.MossTank/DispelController.cs new file mode 100644 index 00000000..c81631c2 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/DispelController.cs @@ -0,0 +1,420 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank's post-buff dispel rules from c8.cs, cx.cs and af.cs. Policy lives in +/// the plugin; the host contributes only canonical spell, item, mode and +/// completion operations. +/// +internal sealed class DispelController +{ + private const uint EradicateLifeMagicSelf = + (uint)SpellId.EradicateLifeMagicSelf; + private const double ActionTimeoutSeconds = 15d; + private const float AllyDispelRangeMeters = 5f; + private const uint CreatureEnchantmentSkill = 31u; + private const uint ArcaneLoreSkill = 14u; + private const uint DispelProtectionSpell = 3179u; + + private static readonly string[] HighDifficultyItems = + [ + "Rune of Dispel", + "Society Gem of Dispelling", + "Black Market Gem of Dispelling", + ]; + + private static readonly string[] NormalDifficultyItems = + [ + "Rune of Dispel", + "Chocolate Gromnie", + "Condensed Dispel Potion", + "Gem of Stillness", + ]; + + private readonly IPluginHost _host; + private readonly VitalSettings _settings; + private Pending? _pending; + private double _pendingSeconds; + private double _retryDelay; + + public DispelController(IPluginHost host, VitalSettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status { get; private set; } = "Dispel idle"; + + public bool Tick(double elapsedSeconds, bool canAct) + { + double elapsed = Math.Max(0d, elapsedSeconds); + _retryDelay = Math.Max(0d, _retryDelay - elapsed); + if (ObservePending(elapsed)) + return true; + + IAutomationSurface automation = _host.Automation; + if (!canAct + || _retryDelay > 0d + || !_host.Automation.IsAvailable + || (!_settings.CastDispelSelf + && !_settings.UseDispelItems + && !_settings.UseDispelDrum) + || automation.Magic.IsCasting + || automation.Items.IsBusy) + { + return false; + } + + if (_settings.CastDispelSelf + && TryStartSelfDispel(automation)) + { + return true; + } + if (_settings.UseDispelItems + && TrySelectDispelItem(automation, out PluginInventoryItem item)) + { + long revision = automation.Items.LastCompletion.Revision; + PluginItemCommandResult result = automation.Items.Use(item.ObjectId); + if (result.Accepted) + { + _pending = new Pending( + DispelSource.Item, + item.ObjectId, + item.Name, + revision); + _pendingSeconds = 0d; + Status = $"Using {item.Name}"; + return true; + } + Status = $"Waiting to use {item.Name}"; + return result.Status == PluginItemCommandStatus.Busy; + } + if (_settings.UseDispelDrum && TryStartAllyDispel(automation)) + return true; + + Status = "Dispel idle"; + return false; + } + + public void Reset() + { + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0d; + Status = "Dispel idle"; + } + + private bool TryStartSelfDispel(IAutomationSurface automation) + { + if (!automation.Spells.TryGet( + EradicateLifeMagicSelf, + out PluginSpellInfo spell) + || !automation.Spells.IsKnown(EradicateLifeMagicSelf) + || !HasVulnerabilityAtOrBelow(automation, spell.Difficulty) + || !automation.Items.CaptureOwnedItems().Any(static item => + item.StackSize > 0 + && item.Name.Equals("Chorizite", StringComparison.Ordinal))) + { + return false; + } + + if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic) + { + PluginCombatCommandResult mode = automation.Combat.EnterMode( + PluginCombatMode.Magic); + Status = mode.Accepted + ? "Switching to Magic for self dispel" + : "Waiting for Magic mode to self dispel"; + return true; + } + + uint target = automation.Character.ObjectId; + PluginCastGate gate = automation.Magic.EvaluateGate( + EradicateLifeMagicSelf, + target); + if (gate != PluginCastGate.Ready) + { + Status = "Waiting to cast Eradicate Life Magic Self"; + return true; + } + + long revision = automation.Magic.LastCompletion.Revision; + if (!automation.Magic.Cast(EradicateLifeMagicSelf, target)) + { + Status = "Self dispel was refused"; + _retryDelay = 0.25d; + return true; + } + _pending = new Pending( + DispelSource.Spell, + EradicateLifeMagicSelf, + spell.Name, + revision); + _pendingSeconds = 0d; + Status = $"Casting {spell.Name}"; + return true; + } + + private bool TrySelectDispelItem( + IAutomationSurface automation, + out PluginInventoryItem selected) + { + selected = default; + IReadOnlyList inventory = + automation.Items.CaptureOwnedItems(); + if (HasVulnerabilityAtOrBelow(automation, 400) + && TryFind(inventory, HighDifficultyItems, out selected)) + { + return true; + } + return HasVulnerabilityAtOrBelow(automation, 350) + && TryFind(inventory, NormalDifficultyItems, out selected); + } + + private static bool TryFind( + IReadOnlyList inventory, + IEnumerable names, + out PluginInventoryItem selected) + { + foreach (string name in names) + { + foreach (PluginInventoryItem item in inventory) + { + if (item.StackSize > 0 + && item.Name.Equals(name, StringComparison.Ordinal)) + { + selected = item; + return true; + } + } + } + selected = default; + return false; + } + + private bool TryStartAllyDispel(IAutomationSurface automation) + { + if (!automation.Fellowship.IsInFellowship + || !TrySelectAwakener(automation, out PluginInventoryItem drum) + || !TrySelectAlly(automation, out PluginFellowMember target)) + { + return false; + } + + if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic) + { + PluginCombatCommandResult mode = automation.Combat.EnterMode( + PluginCombatMode.Magic); + Status = mode.Accepted + ? "Switching to Magic for ally dispel" + : "Waiting for Magic mode to dispel ally"; + return true; + } + + long revision = automation.Items.LastCompletion.Revision; + PluginItemCommandResult result = automation.Items.Apply( + drum.ObjectId, + target.ObjectId); + if (!result.Accepted) + { + Status = $"Waiting to use {drum.Name} on {target.Name}"; + return result.Status == PluginItemCommandStatus.Busy; + } + _pending = new Pending( + DispelSource.AllyItem, + drum.ObjectId, + $"{drum.Name} on {target.Name}", + revision); + _pendingSeconds = 0d; + Status = $"Using {drum.Name} on {target.Name}"; + return true; + } + + private static bool TrySelectAwakener( + IAutomationSurface automation, + out PluginInventoryItem selected) + { + selected = default; + if (!automation.Character.TryGetSkill( + CreatureEnchantmentSkill, + out PluginSkillInfo creature) + || !automation.Character.TryGetSkill( + ArcaneLoreSkill, + out PluginSkillInfo arcane) + || arcane.Current < 110u) + { + return false; + } + + foreach (PluginInventoryItem item in automation.Items.CaptureOwnedItems()) + { + if (!item.IsEquipped) + continue; + bool valid = item.Name switch + { + "Awakener" => creature.Training == PluginSkillTraining.Specialized, + "Attenuated Awakener" => creature.Training + is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized, + _ => false, + }; + if (!valid) + continue; + selected = item; + return true; + } + return false; + } + + private static bool TrySelectAlly( + IAutomationSurface automation, + out PluginFellowMember selected) + { + selected = default; + int highestScore = 0; + foreach (PluginFellowMember member in automation.Fellowship.CaptureMembers()) + { + if (member.ObjectId == automation.Character.ObjectId + || member.Distance > AllyDispelRangeMeters) + { + continue; + } + IReadOnlyList tracked = + automation.Enchantments.Capture(member.ObjectId); + if (tracked.Any(static enchantment => + enchantment.SpellId == DispelProtectionSpell + && enchantment.SecondsRemaining > 0d)) + { + continue; + } + + var qualities = new Dictionary(); + foreach (PluginTrackedEnchantment enchantment in tracked) + { + if (enchantment.SecondsRemaining <= 0d + || enchantment.IsUntargeted + || !automation.Spells.TryGet( + enchantment.SpellId, + out PluginSpellInfo spell) + || spell.Difficulty > 350 + || !DebuffSpellCatalog.TryClassify( + spell, + out DebuffIdentity identity, + out _) + || identity.Flag != MonsterActionFlags.Vulnerability + || identity.DamageType == MonsterDamageType.Auto) + { + continue; + } + int quality = enchantment.Quality; + if (!qualities.TryGetValue(identity.DamageType, out int old) + || quality > old) + { + qualities[identity.DamageType] = quality; + } + } + + int score = qualities.Values.Where(static quality => quality > 250).Sum(); + if (score <= highestScore) + continue; + highestScore = score; + selected = member; + } + return selected.ObjectId != 0u; + } + + private static bool HasVulnerabilityAtOrBelow( + IAutomationSurface automation, + int maximumDifficulty) + { + foreach (PluginActiveEnchantment active + in automation.Character.ActiveEnchantments) + { + if (active.SecondsRemaining < 0d + || !automation.Spells.TryGet(active.SpellId, out PluginSpellInfo spell) + || spell.Difficulty > maximumDifficulty + || spell.IsUntargeted + || !DebuffSpellCatalog.TryClassify( + spell, + out DebuffIdentity identity, + out _) + || identity.Flag != MonsterActionFlags.Vulnerability) + { + continue; + } + return true; + } + return false; + } + + private bool ObservePending(double elapsedSeconds) + { + if (_pending is not { } pending) + return false; + _pendingSeconds += elapsedSeconds; + + if (pending.Source == DispelSource.Spell) + { + PluginCastCompletion completion = _host.Automation.Magic.LastCompletion; + if (completion.Revision > pending.Revision) + { + pending.Revision = completion.Revision; + if (completion.SpellId == pending.ObjectId) + return Finish(completion.IsSuccess, completion.WeenieError); + } + } + else + { + PluginItemUseCompletion completion = _host.Automation.Items.LastCompletion; + if (completion.Revision > pending.Revision) + { + pending.Revision = completion.Revision; + if (completion.SourceObjectId == pending.ObjectId) + return Finish(completion.IsSuccess, completion.WeenieError); + } + } + + if (_pendingSeconds < ActionTimeoutSeconds) + return true; + Status = $"Dispel timed out: {pending.Name}"; + ClearPending(); + return true; + } + + private bool Finish(bool succeeded, uint weenieError) + { + string name = _pending?.Name ?? "dispel"; + Status = succeeded + ? $"Dispel completed: {name}" + : $"Dispel failed (0x{weenieError:X}): {name}"; + ClearPending(); + return true; + } + + private void ClearPending() + { + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0.25d; + } + + private enum DispelSource + { + Spell, + Item, + AllyItem, + } + + private sealed class Pending( + DispelSource source, + uint objectId, + string name, + long revision) + { + public DispelSource Source { get; } = source; + public uint ObjectId { get; } = objectId; + public string Name { get; } = name; + public long Revision { get; set; } = revision; + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs b/src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs new file mode 100644 index 00000000..17c2c239 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs @@ -0,0 +1,653 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// Presentation- and game-independent UtilityBelt expression functions. +/// World queries and actions are registered by a separate capability adapter; +/// keeping this library pure makes Meta evaluation deterministic in tests and +/// prevents expression code from reaching around the plugin API. +/// +internal static class CoreExpressionFunctions +{ + private static readonly Regex CoordinatePattern = new( + @"^\s*(?[-+]?\d+(?:\.\d+)?)\s*(?[NS])\s*,\s*" + + @"(?[-+]?\d+(?:\.\d+)?)\s*(?[EW])" + + @"(?:\s*,\s*(?[-+]?\d+(?:\.\d+)?))?\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + public static ExpressionFunctionRegistry CreateDefault(Random? random = null) + { + var registry = new ExpressionFunctionRegistry(); + Register(registry, random); + return registry; + } + + public static void Register( + ExpressionFunctionRegistry registry, + Random? random = null) + { + ArgumentNullException.ThrowIfNull(registry); + RegisterVariables(registry, ExpressionVariableScope.Session, string.Empty); + RegisterVariables(registry, ExpressionVariableScope.Persistent, "p"); + RegisterVariables(registry, ExpressionVariableScope.Global, "g"); + RegisterConversionsAndMath(registry, random ?? Random.Shared); + RegisterLists(registry); + RegisterDictionaries(registry); + RegisterCoordinates(registry); + RegisterTime(registry); + } + + private static void RegisterVariables( + ExpressionFunctionRegistry registry, + ExpressionVariableScope scope, + string infix) + { + string get = "get" + infix + "var"; + string set = "set" + infix + "var"; + string test = "test" + infix + "var"; + string touch = "touch" + infix + "var"; + string clear = "clear" + infix + "var"; + string clearAll = "clearall" + infix + "vars"; + + registry.Register(get, 1, 1, (context, args) => + context.State.Get(scope, args[0].AsString(get)), $"{get}[name]"); + registry.Register(set, 2, 2, (context, args) => + context.State.Set(scope, args[0].AsString(set), args[1]), + $"{set}[name,value]"); + registry.Register(test, 1, 1, (context, args) => + ExpressionValue.Boolean(context.State.Contains( + scope, + args[0].AsString(test))), $"{test}[name]"); + registry.Register(touch, 1, 1, (context, args) => + { + string name = args[0].AsString(touch); + bool existed = context.State.Contains(scope, name); + if (!existed) + context.State.Set(scope, name, ExpressionValue.Zero); + return ExpressionValue.Boolean(existed); + }, $"{touch}[name]"); + registry.Register(clear, 1, 1, (context, args) => + ExpressionValue.Boolean(context.State.Clear( + scope, + args[0].AsString(clear))), $"{clear}[name]"); + registry.Register(clearAll, 0, 0, (context, _) => + { + context.State.Clear(scope); + return ExpressionValue.One; + }, $"{clearAll}[]"); + } + + private static void RegisterConversionsAndMath( + ExpressionFunctionRegistry registry, + Random random) + { + RegisterUnaryMath(registry, "abs", Math.Abs); + RegisterUnaryMath(registry, "acos", Math.Acos); + RegisterUnaryMath(registry, "asin", Math.Asin); + RegisterUnaryMath(registry, "atan", Math.Atan); + RegisterUnaryMath(registry, "ceiling", Math.Ceiling); + RegisterUnaryMath(registry, "cos", Math.Cos); + RegisterUnaryMath(registry, "cosh", Math.Cosh); + RegisterUnaryMath(registry, "floor", Math.Floor); + RegisterUnaryMath(registry, "round", Math.Round); + RegisterUnaryMath(registry, "sin", Math.Sin); + RegisterUnaryMath(registry, "sinh", Math.Sinh); + RegisterUnaryMath(registry, "sqrt", Math.Sqrt); + RegisterUnaryMath(registry, "tan", Math.Tan); + RegisterUnaryMath(registry, "tanh", Math.Tanh); + registry.Register("atan2", 2, 2, (_, args) => ExpressionValue.Number( + Math.Atan2(args[0].AsNumber("atan2"), args[1].AsNumber("atan2"))), + "atan2[y,x]"); + registry.Register("chr", 1, 1, (_, args) => ExpressionValue.String( + char.ConvertFromUtf32(checked((int)args[0].AsNumber("chr")))), + "chr[codepoint]"); + registry.Register("ord", 1, 1, (_, args) => + { + string value = args[0].AsString("ord"); + if (value.Length == 0) + throw new ExpressionEvaluationException("ord expects a non-empty string"); + return ExpressionValue.Number(char.ConvertToUtf32(value, 0)); + }, "ord[text]"); + registry.Register("cnumber", 1, 1, (_, args) => + double.TryParse( + args[0].AsString("cnumber"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double result) + ? ExpressionValue.Number(result) + : ExpressionValue.Zero, + "cnumber[text]"); + registry.Register("cstr", 1, 1, (_, args) => ExpressionValue.String( + args[0].AsNumber("cstr").ToString("G15", CultureInfo.InvariantCulture)), + "cstr[number]"); + registry.Register("cstrf", 2, 2, (_, args) => + { + double number = args[0].AsNumber("cstrf"); + string format = args[1].AsString("cstrf"); + return ExpressionValue.String( + format.Contains('X', StringComparison.OrdinalIgnoreCase) + ? checked((uint)number).ToString(format, CultureInfo.InvariantCulture) + : number.ToString(format, CultureInfo.InvariantCulture)); + }, "cstrf[number,format]"); + registry.Register("hexstr", 1, 1, (_, args) => ExpressionValue.String( + $"0x{checked((int)args[0].AsNumber("hexstr")):X}"), + "hexstr[number]"); + registry.Register("strlen", 1, 1, (_, args) => ExpressionValue.Number( + args[0].AsString("strlen").Length), "strlen[text]"); + registry.Register("tostring", 1, 1, (_, args) => + ExpressionValue.String(args[0].ToDisplayString()), "tostring[value]"); + registry.Register("istrue", 1, 1, (_, args) => + ExpressionValue.Boolean(args[0].IsTruthy), "istrue[value]"); + registry.Register("isfalse", 1, 1, (_, args) => + ExpressionValue.Boolean(!args[0].IsTruthy), "isfalse[value]"); + registry.Register("iif", 3, 3, (_, args) => + args[0].IsTruthy ? args[1] : args[2], "iif[test,trueValue,falseValue]"); + registry.Register("ifthen", 2, 3, (context, args) => + { + string? source = args[0].IsTruthy + ? args[1].AsString("ifthen") + : args.Count == 3 + ? args[2].AsString("ifthen") + : null; + return source is null + ? ExpressionValue.Zero + : ExpressionProgram.Compile(source).Evaluate(context); + }, "ifthen[test,trueExpression,falseExpression?]"); + registry.Register("randint", 2, 2, (_, args) => + { + int minimum = checked((int)args[0].AsNumber("randint")); + int maximum = checked((int)args[1].AsNumber("randint")); + return ExpressionValue.Number(random.Next(minimum, maximum)); + }, "randint[min,maxExclusive]"); + registry.Register("getregexmatch", 2, 2, (_, args) => + { + var regex = new Regex( + args[1].AsString("getregexmatch"), + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + Match match = regex.Match(args[0].AsString("getregexmatch")); + return match.Success + ? ExpressionValue.String(match.Value) + : ExpressionValue.Zero; + }, "getregexmatch[text,pattern]"); + } + + private static void RegisterUnaryMath( + ExpressionFunctionRegistry registry, + string name, + Func operation) => + registry.Register(name, 1, 1, (_, args) => ExpressionValue.Number( + operation(args[0].AsNumber(name))), $"{name}[number]"); + + private static void RegisterLists(ExpressionFunctionRegistry registry) + { + registry.Register("listcreate", 0, int.MaxValue, (_, args) => + ExpressionValue.List(new ExpressionList(args)), "listcreate[items...]"); + registry.Register("listadd", 2, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listadd"); + GuardNoCycle(list, args[1], "listadd"); + list.Items.Add(args[1]); + return args[0]; + }, "listadd[list,item]"); + registry.Register("listinsert", 3, 3, (_, args) => + { + ExpressionList list = args[0].AsList("listinsert"); + GuardNoCycle(list, args[1], "listinsert"); + int index = ToTruncatedInt(args[2], "listinsert"); + if ((uint)index > (uint)list.Items.Count) + throw BadIndex("insert", index, list.Items.Count, allowEnd: true); + list.Items.Insert(index, args[1]); + return args[0]; + }, "listinsert[list,item,index]"); + registry.Register("listremove", 2, 2, (_, args) => + { + args[0].AsList("listremove").Items.Remove(args[1]); + return args[0]; + }, "listremove[list,item]"); + registry.Register("listremoveat", 2, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listremoveat"); + int index = RequireListIndex(list, args[1], "listremoveat"); + list.Items.RemoveAt(index); + return args[0]; + }, "listremoveat[list,index]"); + registry.Register("listgetitem", 2, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listgetitem"); + return list.Items[RequireListIndex(list, args[1], "listgetitem")]; + }, "listgetitem[list,index]"); + registry.Register("listcontains", 2, 2, (_, args) => + ExpressionValue.Boolean(args[0].AsList("listcontains").Items.Contains(args[1])), + "listcontains[list,item]"); + registry.Register("listindexof", 2, 2, (_, args) => ExpressionValue.Number( + args[0].AsList("listindexof").Items.IndexOf(args[1])), + "listindexof[list,item]"); + registry.Register("listlastindexof", 2, 2, (_, args) => + ExpressionValue.Number(args[0].AsList("listlastindexof") + .Items.LastIndexOf(args[1])), "listlastindexof[list,item]"); + registry.Register("listcopy", 1, 1, (_, args) => ExpressionValue.List( + new ExpressionList(args[0].AsList("listcopy").Items)), "listcopy[list]"); + registry.Register("listreverse", 1, 1, (_, args) => + { + var values = args[0].AsList("listreverse").Items.ToArray(); + Array.Reverse(values); + return ExpressionValue.List(new ExpressionList(values)); + }, "listreverse[list]"); + registry.Register("listpop", 1, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listpop"); + int index = args.Count == 1 || args[1].AsNumber("listpop") == -1d + ? list.Items.Count - 1 + : RequireListIndex(list, args[1], "listpop"); + if (index < 0) + throw BadIndex("pop", index, list.Items.Count, allowEnd: false); + ExpressionValue result = list.Items[index]; + list.Items.RemoveAt(index); + return result; + }, "listpop[list,index?]"); + registry.Register("listcount", 1, 1, (_, args) => ExpressionValue.Number( + args[0].AsList("listcount").Items.Count), "listcount[list]"); + registry.Register("listclear", 1, 1, (_, args) => + { + args[0].AsList("listclear").Items.Clear(); + return args[0]; + }, "listclear[list]"); + registry.Register("listfilter", 2, 2, (context, args) => + { + ExpressionList source = args[0].AsList("listfilter"); + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listfilter")); + var result = new ExpressionList(); + WithIterationVariables(context.State, () => + { + for (int index = 0; index < source.Items.Count; index++) + { + SetIteration(context.State, index, source.Items[index]); + if (program.Evaluate(context).IsTruthy) + result.Items.Add(source.Items[index]); + } + }); + return ExpressionValue.List(result); + }, "listfilter[list,expression]"); + registry.Register("listmap", 2, 2, (context, args) => + { + ExpressionList source = args[0].AsList("listmap"); + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listmap")); + var result = new ExpressionList(); + WithIterationVariables(context.State, () => + { + for (int index = 0; index < source.Items.Count; index++) + { + SetIteration(context.State, index, source.Items[index]); + result.Items.Add(program.Evaluate(context)); + } + }); + return ExpressionValue.List(result); + }, "listmap[list,expression]"); + registry.Register("listreduce", 2, 2, (context, args) => + { + ExpressionList source = args[0].AsList("listreduce"); + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listreduce")); + ExpressionValue result = ExpressionValue.Zero; + WithIterationVariables(context.State, () => + { + for (int index = 0; index < source.Items.Count; index++) + { + SetIteration(context.State, index, source.Items[index], result); + result = program.Evaluate(context); + } + }); + return result; + }, "listreduce[list,expression]"); + registry.Register("listsort", 1, 2, (context, args) => + { + var result = new ExpressionList(args[0].AsList("listsort").Items); + if (args.Count == 1 || args[1].AsString("listsort").Length == 0) + { + result.Items.Sort(DefaultValueComparer.Instance); + return ExpressionValue.List(result); + } + + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listsort")); + WithIterationVariables(context.State, () => + { + // A stable insertion sort avoids the exception wrapping used by + // List.Sort and lets cancellation/budget errors escape intact. + for (int index = 1; index < result.Items.Count; index++) + { + ExpressionValue value = result.Items[index]; + int cursor = index - 1; + while (cursor >= 0) + { + context.State.Set(ExpressionVariableScope.Session, "1", result.Items[cursor]); + context.State.Set(ExpressionVariableScope.Session, "2", value); + if (program.Evaluate(context).AsNumber("listsort comparator") <= 0d) + break; + result.Items[cursor + 1] = result.Items[cursor]; + cursor--; + } + result.Items[cursor + 1] = value; + } + }); + return ExpressionValue.List(result); + }, "listsort[list,expression?]"); + registry.Register("listfromrange", 2, 2, (_, args) => + { + int start = ToTruncatedInt(args[0], "listfromrange"); + int end = ToTruncatedInt(args[1], "listfromrange"); + int count = checked(Math.Abs(end - start) + 1); + if (count > 100_000) + { + throw new ExpressionEvaluationException( + "listfromrange is limited to 100000 entries"); + } + var result = new ExpressionList(); + int step = start <= end ? 1 : -1; + for (int value = start;; value += step) + { + result.Items.Add(ExpressionValue.Number(value)); + if (value == end) + break; + } + return ExpressionValue.List(result); + }, "listfromrange[start,end]"); + } + + private static void RegisterDictionaries(ExpressionFunctionRegistry registry) + { + registry.Register("dictcreate", 0, int.MaxValue, (_, args) => + { + if ((args.Count & 1) != 0) + throw new ExpressionEvaluationException( + "dictcreate expects key/value pairs"); + var dictionary = new ExpressionDictionary(); + for (int index = 0; index < args.Count; index += 2) + { + string key = args[index].AsString("dictcreate key"); + if (!dictionary.Items.TryAdd(key, args[index + 1])) + { + throw new ExpressionEvaluationException( + $"dictcreate received duplicate key '{key}'"); + } + } + return ExpressionValue.Dictionary(dictionary); + }, "dictcreate[key,value,...]"); + registry.Register("dictgetitem", 2, 2, (_, args) => + { + ExpressionDictionary dictionary = args[0].AsDictionary("dictgetitem"); + string key = args[1].AsString("dictgetitem key"); + if (!dictionary.Items.TryGetValue(key, out ExpressionValue value)) + throw new ExpressionEvaluationException($"Dictionary key '{key}' was not found"); + return value; + }, "dictgetitem[dictionary,key]"); + registry.Register("dictadditem", 3, 3, (_, args) => + { + ExpressionDictionary dictionary = args[0].AsDictionary("dictadditem"); + string key = args[1].AsString("dictadditem key"); + GuardNoCycle(dictionary, args[2], "dictadditem"); + bool replaced = dictionary.Items.ContainsKey(key); + dictionary.Items[key] = args[2]; + return ExpressionValue.Boolean(replaced); + }, "dictadditem[dictionary,key,value]"); + registry.Register("dicthaskey", 2, 2, (_, args) => ExpressionValue.Boolean( + args[0].AsDictionary("dicthaskey").Items.ContainsKey( + args[1].AsString("dicthaskey key"))), "dicthaskey[dictionary,key]"); + registry.Register("dictremovekey", 2, 2, (_, args) => ExpressionValue.Boolean( + args[0].AsDictionary("dictremovekey").Items.Remove( + args[1].AsString("dictremovekey key"))), "dictremovekey[dictionary,key]"); + registry.Register("dictkeys", 1, 1, (_, args) => ExpressionValue.List( + new ExpressionList(args[0].AsDictionary("dictkeys").Items.Keys.Select( + ExpressionValue.String))), "dictkeys[dictionary]"); + registry.Register("dictvalues", 1, 1, (_, args) => ExpressionValue.List( + new ExpressionList(args[0].AsDictionary("dictvalues").Items.Values)), + "dictvalues[dictionary]"); + registry.Register("dictsize", 1, 1, (_, args) => ExpressionValue.Number( + args[0].AsDictionary("dictsize").Items.Count), "dictsize[dictionary]"); + registry.Register("dictclear", 1, 1, (_, args) => + { + args[0].AsDictionary("dictclear").Items.Clear(); + return args[0]; + }, "dictclear[dictionary]"); + registry.Register("dictcopy", 1, 1, (_, args) => + { + var result = new ExpressionDictionary(); + foreach ((string key, ExpressionValue value) in + args[0].AsDictionary("dictcopy").Items) + { + result.Items[key] = value; + } + return ExpressionValue.Dictionary(result); + }, "dictcopy[dictionary]"); + } + + private static void RegisterCoordinates(ExpressionFunctionRegistry registry) + { + registry.Register("coordinateparse", 1, 1, (_, args) => + { + string source = args[0].AsString("coordinateparse"); + Match match = CoordinatePattern.Match(source); + if (!match.Success) + { + throw new ExpressionEvaluationException( + $"Unable to parse coordinate '{source}'"); + } + double northSouth = double.Parse( + match.Groups["ns"].Value, + CultureInfo.InvariantCulture); + double eastWest = double.Parse( + match.Groups["ew"].Value, + CultureInfo.InvariantCulture); + if (match.Groups["nsdir"].Value.Equals("S", StringComparison.OrdinalIgnoreCase)) + northSouth = -Math.Abs(northSouth); + else + northSouth = Math.Abs(northSouth); + if (match.Groups["ewdir"].Value.Equals("W", StringComparison.OrdinalIgnoreCase)) + eastWest = -Math.Abs(eastWest); + else + eastWest = Math.Abs(eastWest); + double elevation = match.Groups["z"].Success + ? double.Parse(match.Groups["z"].Value, CultureInfo.InvariantCulture) + : 0d; + return ExpressionValue.Coordinates(new ExpressionCoordinates( + eastWest, + northSouth, + elevation)); + }, "coordinateparse[text]"); + registry.Register("coordinategetns", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsCoordinates("coordinategetns").NorthSouth), + "coordinategetns[coordinates]"); + registry.Register("coordinategetwe", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsCoordinates("coordinategetwe").EastWest), + "coordinategetwe[coordinates]"); + registry.Register("coordinategetz", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsCoordinates("coordinategetz").Elevation), + "coordinategetz[coordinates]"); + registry.Register("coordinatetostring", 1, 1, (_, args) => + ExpressionValue.String(args[0].AsCoordinates("coordinatetostring").ToString()), + "coordinatetostring[coordinates]"); + registry.Register("coordinatedistanceflat", 2, 2, (_, args) => + ExpressionValue.Number(CoordinateDistance(args[0], args[1], includeElevation: false)), + "coordinatedistanceflat[first,second]"); + registry.Register("coordinatedistancewithz", 2, 2, (_, args) => + ExpressionValue.Number(CoordinateDistance(args[0], args[1], includeElevation: true)), + "coordinatedistancewithz[first,second]"); + } + + private static void RegisterTime(ExpressionFunctionRegistry registry) + { + registry.Register("getdatetimelocal", 0, 1, (_, args) => ExpressionValue.String( + DateTime.Now.ToString( + args.Count == 0 ? "hh:mm:ss tt" : args[0].AsString("getdatetimelocal"), + CultureInfo.InvariantCulture)), "getdatetimelocal[format?]"); + registry.Register("getdatetimeutc", 0, 1, (_, args) => ExpressionValue.String( + DateTime.UtcNow.ToString( + args.Count == 0 ? "hh:mm:ss tt" : args[0].AsString("getdatetimeutc"), + CultureInfo.InvariantCulture)), "getdatetimeutc[format?]"); + registry.Register("getunixtime", 0, 0, (_, _) => ExpressionValue.Number( + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000d), "getunixtime[]"); + registry.Register("stopwatchcreate", 0, 0, (_, _) => + ExpressionValue.Stopwatch(new ExpressionStopwatch()), "stopwatchcreate[]"); + registry.Register("stopwatchstart", 1, 1, (_, args) => + { + args[0].AsStopwatch("stopwatchstart").Start(); + return args[0]; + }, "stopwatchstart[stopwatch]"); + registry.Register("stopwatchstop", 1, 1, (_, args) => + { + args[0].AsStopwatch("stopwatchstop").Stop(); + return args[0]; + }, "stopwatchstop[stopwatch]"); + registry.Register("stopwatchelapsedseconds", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsStopwatch( + "stopwatchelapsedseconds").ElapsedSeconds), + "stopwatchelapsedseconds[stopwatch]"); + } + + private static double CoordinateDistance( + in ExpressionValue first, + in ExpressionValue second, + bool includeElevation) + { + ExpressionCoordinates left = first.AsCoordinates("coordinate distance"); + ExpressionCoordinates right = second.AsCoordinates("coordinate distance"); + double eastWest = (left.EastWest - right.EastWest) * 240d; + double northSouth = (left.NorthSouth - right.NorthSouth) * 240d; + double elevation = includeElevation + ? (left.Elevation - right.Elevation) * 240d + : 0d; + return Math.Sqrt( + eastWest * eastWest + + northSouth * northSouth + + elevation * elevation); + } + + private static int RequireListIndex( + ExpressionList list, + in ExpressionValue value, + string operation) + { + int index = ToTruncatedInt(value, operation); + if ((uint)index >= (uint)list.Items.Count) + throw BadIndex(operation, index, list.Items.Count, allowEnd: false); + return index; + } + + private static int ToTruncatedInt(in ExpressionValue value, string operation) => + checked((int)value.AsNumber(operation)); + + private static ExpressionEvaluationException BadIndex( + string operation, + int index, + int count, + bool allowEnd) => new( + $"Unable to {operation} index {index}; valid range is 0.." + + (allowEnd ? count : count - 1)); + + private static void SetIteration( + ExpressionState state, + int index, + in ExpressionValue item, + ExpressionValue? accumulator = null) + { + state.Set(ExpressionVariableScope.Session, "0", ExpressionValue.Number(index)); + state.Set(ExpressionVariableScope.Session, "1", item); + if (accumulator is { } value) + state.Set(ExpressionVariableScope.Session, "2", value); + } + + private static void WithIterationVariables(ExpressionState state, Action action) + { + var saved = new (string Name, bool Exists, ExpressionValue Value)[3]; + for (int index = 0; index < saved.Length; index++) + { + string name = index.ToString(CultureInfo.InvariantCulture); + saved[index] = ( + name, + state.Contains(ExpressionVariableScope.Session, name), + state.Get(ExpressionVariableScope.Session, name)); + } + try + { + action(); + } + finally + { + foreach ((string name, bool exists, ExpressionValue value) in saved) + { + if (exists) + state.Set(ExpressionVariableScope.Session, name, value); + else + state.Clear(ExpressionVariableScope.Session, name); + } + } + } + + private static void GuardNoCycle(object destination, in ExpressionValue value, string operation) + { + if (ContainsReference(value, destination, new HashSet( + ReferenceEqualityComparer.Instance))) + { + throw new ExpressionEvaluationException( + $"{operation} cannot create a cyclic collection"); + } + } + + private static bool ContainsReference( + in ExpressionValue value, + object destination, + HashSet visited) + { + if (value.Kind == ExpressionValueKind.List) + { + ExpressionList list = value.AsList(); + if (ReferenceEquals(list, destination)) + return true; + return visited.Add(list) + && list.Items.Any(item => ContainsReference(item, destination, visited)); + } + if (value.Kind == ExpressionValueKind.Dictionary) + { + ExpressionDictionary dictionary = value.AsDictionary(); + if (ReferenceEquals(dictionary, destination)) + return true; + return visited.Add(dictionary) + && dictionary.Items.Values.Any(item => + ContainsReference(item, destination, visited)); + } + return false; + } + + private sealed class DefaultValueComparer : IComparer + { + public static DefaultValueComparer Instance { get; } = new(); + + public int Compare(ExpressionValue left, ExpressionValue right) + { + if (left.Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean + && right.Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean) + { + return left.AsNumber().CompareTo(right.AsNumber()); + } + if (left.Kind == ExpressionValueKind.String + && right.Kind == ExpressionValueKind.String) + { + return StringComparer.OrdinalIgnoreCase.Compare( + left.AsString(), + right.AsString()); + } + int kind = left.Kind.CompareTo(right.Kind); + return kind != 0 + ? kind + : StringComparer.Ordinal.Compare( + left.ToDisplayString(), + right.ToDisplayString()); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs b/src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs new file mode 100644 index 00000000..e6537cb2 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// UtilityBelt-compatible session XP/luminance accumulator. +internal sealed class ExperienceMeter(IPluginHost host) +{ + private long _lastExperience; + private long _lastLuminance; + private bool _hasBaseline; + + public double DurationSeconds { get; private set; } + public long Experience { get; private set; } + public long Luminance { get; private set; } + public double ExperiencePerHour => DurationSeconds > 0d + ? Experience / DurationSeconds * 3600d + : 0d; + public double LuminancePerHour => DurationSeconds > 0d + ? Luminance / DurationSeconds * 3600d + : 0d; + + public void OnTick(double elapsedSeconds) + { + if (!host.Automation.Character.IsInWorld + || !host.Automation.Objects.TryCaptureProperties( + host.Automation.Character.ObjectId, + out PluginItemProperties properties)) + { + _hasBaseline = false; + return; + } + long experience = properties.Int64s.TryGetValue(1u, out long xp) ? xp : 0L; + long luminance = properties.Int64s.TryGetValue(6u, out long lum) ? lum : 0L; + if (!_hasBaseline) + { + _lastExperience = experience; + _lastLuminance = luminance; + _hasBaseline = true; + } + else + { + if (experience >= _lastExperience) + Experience = checked(Experience + experience - _lastExperience); + if (luminance >= _lastLuminance) + Luminance = checked(Luminance + luminance - _lastLuminance); + _lastExperience = experience; + _lastLuminance = luminance; + } + DurationSeconds += elapsedSeconds; + } + + public void Reset() + { + DurationSeconds = 0d; + Experience = 0L; + Luminance = 0L; + _hasBaseline = false; + } + + public string Format() + { + string result = Experience.ToString("N0", CultureInfo.InvariantCulture) + + " XP"; + if (Luminance != 0) + { + result += " and " + + Luminance.ToString("N0", CultureInfo.InvariantCulture) + + " LUM"; + } + result += ", " + + DurationSeconds.ToString("N0", CultureInfo.InvariantCulture) + + "s, " + + ExperiencePerHour.ToString("N0", CultureInfo.InvariantCulture) + + " XP/hr"; + if (Luminance != 0) + { + result += " and " + + LuminancePerHour.ToString("N0", CultureInfo.InvariantCulture) + + " LUM/hr"; + } + return result; + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs b/src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs new file mode 100644 index 00000000..11e001ec --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs @@ -0,0 +1,789 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace AcDream.Plugins.MossTank.Expressions; + +internal sealed class ExpressionProgram +{ + private readonly Node[] _statements; + + private ExpressionProgram(Node[] statements) => _statements = statements; + + public static ExpressionProgram Compile(string source) + { + if (string.IsNullOrWhiteSpace(source)) + throw new ExpressionParseException("Expression is empty", 0); + return new ExpressionProgram(new Parser(source).ParseProgram()); + } + + public ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + ArgumentNullException.ThrowIfNull(context); + ExpressionValue result = ExpressionValue.Zero; + foreach (Node statement in _statements) + result = statement.Evaluate(context); + return result; + } + + private abstract class Node(int offset) + { + protected int Offset { get; } = offset; + internal abstract ExpressionValue Evaluate(ExpressionEvaluationContext context); + } + + private sealed class LiteralNode(ExpressionValue value, int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + return value; + } + } + + private sealed class VariableNode( + ExpressionVariableScope scope, + Node name, + int offset) : Node(offset) + { + public ExpressionVariableScope Scope { get; } = scope; + + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + return context.State.Get(Scope, ResolveName(context)); + } + + public ExpressionValue Set( + ExpressionEvaluationContext context, + ExpressionValue value) => + context.State.Set(Scope, ResolveName(context), value); + + private string ResolveName(ExpressionEvaluationContext context) => + name.Evaluate(context).ToDisplayString(); + } + + private sealed class AssignmentNode( + VariableNode variable, + Node value, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue result = value.Evaluate(context); + return variable.Set(context, result); + } + } + + private sealed class FunctionNode( + string name, + Node[] arguments, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + var values = new ExpressionValue[arguments.Length]; + for (int index = 0; index < arguments.Length; index++) + values[index] = arguments[index].Evaluate(context); + return context.Invoke(name, values, Offset); + } + } + + private sealed class UnaryNode(TokenKind operation, Node operand, int offset) + : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue value = operand.Evaluate(context); + return operation switch + { + TokenKind.Minus => ExpressionValue.Number( + -value.AsNumber("unary '-'")), + TokenKind.Tilde => ExpressionValue.Number( + ~value.AsInt32("bitwise complement")), + _ => throw new ExpressionEvaluationException( + $"Unsupported unary operator {operation}", Offset), + }; + } + } + + private sealed class BinaryNode( + TokenKind operation, + Node left, + Node right, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue lhs = left.Evaluate(context); + if (operation == TokenKind.AndAnd) + return lhs.IsTruthy ? right.Evaluate(context) : ExpressionValue.Zero; + if (operation == TokenKind.OrOr) + return lhs.IsTruthy ? lhs : right.Evaluate(context); + + ExpressionValue rhs = right.Evaluate(context); + return operation switch + { + TokenKind.Plus => Add(lhs, rhs), + TokenKind.Minus => Subtract(lhs, rhs), + TokenKind.Star => ExpressionValue.Number( + lhs.AsNumber("multiplication") * rhs.AsNumber("multiplication")), + TokenKind.Slash => ExpressionValue.Number( + lhs.AsNumber("division") / rhs.AsNumber("division")), + TokenKind.Percent => ExpressionValue.Number( + lhs.AsNumber("modulo") % rhs.AsNumber("modulo")), + TokenKind.Caret => ExpressionValue.Number(Math.Pow( + lhs.AsNumber("power"), rhs.AsNumber("power"))), + TokenKind.ShiftLeft => ExpressionValue.Number( + lhs.AsInt32("left shift") << rhs.AsInt32("left shift")), + TokenKind.ShiftRight => ExpressionValue.Number( + lhs.AsInt32("right shift") >> rhs.AsInt32("right shift")), + TokenKind.Ampersand => ExpressionValue.Number( + lhs.AsInt32("bitwise and") & rhs.AsInt32("bitwise and")), + TokenKind.Pipe => ExpressionValue.Number( + lhs.AsInt32("bitwise or") | rhs.AsInt32("bitwise or")), + TokenKind.Hash => RegexMatch(context, lhs, rhs), + TokenKind.EqualEqual => ExpressionValue.Boolean(lhs.Equals(rhs)), + TokenKind.BangEqual => ExpressionValue.Boolean(!lhs.Equals(rhs)), + TokenKind.Less => Compare(lhs, rhs, static comparison => comparison < 0), + TokenKind.LessEqual => Compare(lhs, rhs, static comparison => comparison <= 0), + TokenKind.Greater => Compare(lhs, rhs, static comparison => comparison > 0), + TokenKind.GreaterEqual => Compare(lhs, rhs, static comparison => comparison >= 0), + _ => throw new ExpressionEvaluationException( + $"Unsupported binary operator {operation}", Offset), + }; + } + + private static ExpressionValue Add( + in ExpressionValue left, + in ExpressionValue right) + { + if (left.Kind == ExpressionValueKind.Number + || left.Kind == ExpressionValueKind.Boolean) + { + return ExpressionValue.Number( + left.AsNumber("addition") + right.AsNumber("addition")); + } + if (left.Kind == ExpressionValueKind.String) + { + return ExpressionValue.String( + left.AsString("concatenation") + right.ToDisplayString()); + } + throw new ExpressionEvaluationException( + $"Unable to add {left.Kind} to {right.Kind}."); + } + + private static ExpressionValue Subtract( + in ExpressionValue left, + in ExpressionValue right) + { + if (left.Kind is ExpressionValueKind.Number + or ExpressionValueKind.Boolean + && right.Kind is ExpressionValueKind.Number + or ExpressionValueKind.Boolean) + { + return ExpressionValue.Number( + left.AsNumber("subtraction") - right.AsNumber("subtraction")); + } + if (left.Kind == ExpressionValueKind.String + && right.Kind == ExpressionValueKind.String) + { + return ExpressionValue.String( + left.AsString() + "-" + right.AsString()); + } + throw new ExpressionEvaluationException( + $"Unable to subtract {right.Kind} from {left.Kind}."); + } + + private static ExpressionValue Compare( + in ExpressionValue left, + in ExpressionValue right, + Func predicate) + { + double lhs = left.AsNumber("comparison"); + double rhs = right.AsNumber("comparison"); + return ExpressionValue.Boolean(predicate(lhs.CompareTo(rhs))); + } + + private static ExpressionValue RegexMatch( + ExpressionEvaluationContext context, + in ExpressionValue left, + in ExpressionValue right) + { + var regex = new Regex( + right.ToDisplayString(), + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + Match match = regex.Match(left.ToDisplayString()); + foreach (string groupName in regex.GetGroupNames()) + { + string variableName = "capturegroup_" + groupName; + Group group = match.Groups[groupName]; + if (group.Success) + { + context.State.Set( + ExpressionVariableScope.Session, + variableName, + ExpressionValue.String(group.Value)); + } + else + { + context.State.Clear( + ExpressionVariableScope.Session, + variableName); + } + } + return ExpressionValue.Boolean(match.Success); + } + } + + private sealed class IndexNode( + Node source, + Node? start, + Node? end, + bool isSlice, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue value = source.Evaluate(context); + if (value.Kind == ExpressionValueKind.Dictionary) + { + if (isSlice) + { + throw new ExpressionEvaluationException( + "Range indices are not supported with dictionaries", + Offset); + } + string key = (start?.Evaluate(context) ?? ExpressionValue.Zero) + .AsString("dictionary index"); + return value.AsDictionary().Items.TryGetValue( + key, + out ExpressionValue found) + ? found + : ExpressionValue.Zero; + } + + int length = value.Kind switch + { + ExpressionValueKind.List => value.AsList().Items.Count, + ExpressionValueKind.String => value.AsString().Length, + _ => throw new ExpressionEvaluationException( + $"{value.Kind} does not support index access", + Offset), + }; + int first = ResolveIndex(context, start, length, 0, allowEnd: isSlice); + if (!isSlice) + { + return value.Kind == ExpressionValueKind.List + ? value.AsList().Items[first] + : ExpressionValue.String(value.AsString().Substring(first, 1)); + } + int last = ResolveIndex(context, end, length, length, allowEnd: true); + int count = Math.Max(0, last - first); + return value.Kind == ExpressionValueKind.List + ? ExpressionValue.List(new ExpressionList( + value.AsList().Items.Skip(first).Take(count))) + : ExpressionValue.String(value.AsString().Substring(first, count)); + } + + private int ResolveIndex( + ExpressionEvaluationContext context, + Node? expression, + int length, + int defaultValue, + bool allowEnd) + { + int index = expression is null + ? defaultValue + : expression.Evaluate(context).AsInt32("index"); + if (index < 0) + index += length; + int maximum = allowEnd ? length : length - 1; + if (index < 0 || index > maximum) + { + throw new ExpressionEvaluationException( + $"Index {index} is outside 0..{maximum}", + Offset); + } + return index; + } + } + + private enum TokenKind + { + End, + Number, + HexNumber, + String, + True, + False, + LeftParen, + RightParen, + LeftBracket, + RightBracket, + LeftBrace, + RightBrace, + Comma, + Semicolon, + Colon, + Dollar, + At, + Ampersand, + Pipe, + Tilde, + Plus, + Minus, + Star, + Slash, + Percent, + Caret, + Hash, + Equal, + EqualEqual, + BangEqual, + Less, + LessEqual, + Greater, + GreaterEqual, + ShiftLeft, + ShiftRight, + AndAnd, + OrOr, + } + + private readonly record struct Token(TokenKind Kind, string Text, int Offset); + + private sealed class Lexer(string source) + { + private int _offset; + + public Token Next() + { + while (_offset < source.Length && char.IsWhiteSpace(source[_offset])) + _offset++; + if (_offset >= source.Length) + return new Token(TokenKind.End, string.Empty, _offset); + + int start = _offset; + char current = source[_offset]; + if (current is '`' or '\'' or '"') + return ReadQuoted(current, start); + if (char.IsDigit(current) + || (current == '.' + && _offset + 1 < source.Length + && char.IsDigit(source[_offset + 1]))) + { + return ReadNumber(start); + } + if (TryOperator(out Token operation)) + return operation; + + while (_offset < source.Length && !IsDelimiter(source[_offset])) + _offset++; + string text = source[start.._offset].Trim(); + if (text.Length == 0) + { + throw new ExpressionParseException( + $"Unexpected character '{source[start]}'", + start); + } + return text.Equals("true", StringComparison.OrdinalIgnoreCase) + ? new Token(TokenKind.True, text, start) + : text.Equals("false", StringComparison.OrdinalIgnoreCase) + ? new Token(TokenKind.False, text, start) + : new Token(TokenKind.String, Unescape(text), start); + } + + private Token ReadQuoted(char delimiter, int start) + { + _offset++; + var built = new StringBuilder(); + while (_offset < source.Length) + { + char value = source[_offset++]; + if (value == delimiter) + return new Token(TokenKind.String, built.ToString(), start); + if (value == '\\' && _offset < source.Length) + value = source[_offset++]; + built.Append(value); + } + throw new ExpressionParseException("Unterminated string", start); + } + + private Token ReadNumber(int start) + { + if (_offset + 1 < source.Length + && source[_offset] == '0' + && source[_offset + 1] is 'x' or 'X') + { + _offset += 2; + int digits = _offset; + while (_offset < source.Length && Uri.IsHexDigit(source[_offset])) + _offset++; + if (_offset == digits) + throw new ExpressionParseException("Hexadecimal digits expected", start); + return new Token(TokenKind.HexNumber, source[digits.._offset], start); + } + + bool dot = false; + while (_offset < source.Length) + { + char value = source[_offset]; + if (char.IsDigit(value)) + { + _offset++; + continue; + } + if (value == '.' && !dot) + { + dot = true; + _offset++; + continue; + } + break; + } + return new Token(TokenKind.Number, source[start.._offset], start); + } + + private bool TryOperator(out Token token) + { + int start = _offset; + if (_offset + 1 < source.Length) + { + string pair = source.Substring(_offset, 2); + TokenKind pairKind = pair switch + { + "==" => TokenKind.EqualEqual, + "!=" => TokenKind.BangEqual, + "<=" => TokenKind.LessEqual, + ">=" => TokenKind.GreaterEqual, + "<<" => TokenKind.ShiftLeft, + ">>" => TokenKind.ShiftRight, + "&&" => TokenKind.AndAnd, + "||" => TokenKind.OrOr, + _ => TokenKind.End, + }; + if (pairKind != TokenKind.End) + { + _offset += 2; + token = new Token(pairKind, pair, start); + return true; + } + } + + TokenKind kind = source[_offset] switch + { + '(' => TokenKind.LeftParen, + ')' => TokenKind.RightParen, + '[' => TokenKind.LeftBracket, + ']' => TokenKind.RightBracket, + '{' => TokenKind.LeftBrace, + '}' => TokenKind.RightBrace, + ',' => TokenKind.Comma, + ';' => TokenKind.Semicolon, + ':' => TokenKind.Colon, + '$' => TokenKind.Dollar, + '@' => TokenKind.At, + '&' => TokenKind.Ampersand, + '|' => TokenKind.Pipe, + '~' => TokenKind.Tilde, + '+' => TokenKind.Plus, + '-' => TokenKind.Minus, + '*' => TokenKind.Star, + '/' => TokenKind.Slash, + '%' => TokenKind.Percent, + '^' => TokenKind.Caret, + '#' => TokenKind.Hash, + '=' => TokenKind.Equal, + '<' => TokenKind.Less, + '>' => TokenKind.Greater, + _ => TokenKind.End, + }; + if (kind == TokenKind.End) + { + token = default; + return false; + } + _offset++; + token = new Token(kind, source[start].ToString(), start); + return true; + } + + private static bool IsDelimiter(char value) => + char.IsWhiteSpace(value) + ? false + : value is '(' or ')' or '[' or ']' or '{' or '}' + or ',' or ';' or ':' or '$' or '@' or '&' or '|' + or '~' or '+' or '-' or '*' or '/' or '%' or '^' + or '#' or '=' or '!' or '<' or '>' or '`' or '\'' or '"'; + + private static string Unescape(string value) + { + if (!value.Contains('\\', StringComparison.Ordinal)) + return value; + var built = new StringBuilder(value.Length); + for (int index = 0; index < value.Length; index++) + { + char current = value[index]; + if (current == '\\' && index + 1 < value.Length) + current = value[++index]; + built.Append(current); + } + return built.ToString(); + } + } + + private sealed class Parser + { + private readonly Lexer _lexer; + private Token _current; + + public Parser(string source) + { + _lexer = new Lexer(source); + _current = _lexer.Next(); + } + + public Node[] ParseProgram() + { + var statements = new List(); + while (_current.Kind != TokenKind.End) + { + statements.Add(ParseAssignment()); + if (_current.Kind == TokenKind.Semicolon) + { + Advance(); + continue; + } + if (_current.Kind != TokenKind.End) + { + throw new ExpressionParseException( + $"Unexpected token '{_current.Text}'", + _current.Offset); + } + } + return statements.ToArray(); + } + + private Node ParseAssignment() + { + Node left = ParseOr(); + if (_current.Kind != TokenKind.Equal) + return left; + Token operation = _current; + Advance(); + if (left is not VariableNode variable) + { + throw new ExpressionParseException( + "Only variables may appear on the left of '='", + operation.Offset); + } + return new AssignmentNode(variable, ParseAssignment(), operation.Offset); + } + + private Node ParseOr() => ParseLeft(ParseAnd, TokenKind.OrOr); + private Node ParseAnd() => ParseLeft(ParseComparison, TokenKind.AndAnd); + private Node ParseComparison() => ParseLeft( + ParseRegex, + TokenKind.EqualEqual, + TokenKind.BangEqual, + TokenKind.Less, + TokenKind.LessEqual, + TokenKind.Greater, + TokenKind.GreaterEqual); + private Node ParseRegex() => ParseLeft(ParseBitwiseOr, TokenKind.Hash); + private Node ParseBitwiseOr() => ParseLeft(ParseBitwiseAnd, TokenKind.Pipe); + private Node ParseBitwiseAnd() => ParseLeft(ParseShift, TokenKind.Ampersand); + private Node ParseShift() => ParseLeft( + ParseAdditive, + TokenKind.ShiftLeft, + TokenKind.ShiftRight); + private Node ParseAdditive() => ParseLeft( + ParseMultiplicative, + TokenKind.Plus, + TokenKind.Minus); + private Node ParseMultiplicative() => ParseLeft( + ParsePower, + TokenKind.Star, + TokenKind.Slash, + TokenKind.Percent); + + private Node ParsePower() + { + Node left = ParseUnary(); + if (_current.Kind != TokenKind.Caret) + return left; + Token operation = _current; + Advance(); + return new BinaryNode( + operation.Kind, + left, + ParsePower(), + operation.Offset); + } + + private Node ParseUnary() + { + if (_current.Kind is not (TokenKind.Minus or TokenKind.Tilde)) + return ParsePostfix(); + Token operation = _current; + Advance(); + return new UnaryNode(operation.Kind, ParseUnary(), operation.Offset); + } + + private Node ParsePostfix() + { + Node source = ParsePrimary(); + while (_current.Kind == TokenKind.LeftBrace) + { + Token opening = _current; + Advance(); + Node? start = null; + Node? end = null; + bool slice = false; + if (_current.Kind != TokenKind.Colon + && _current.Kind != TokenKind.RightBrace) + { + start = ParseAssignment(); + } + if (_current.Kind == TokenKind.Colon) + { + slice = true; + Advance(); + if (_current.Kind != TokenKind.RightBrace) + end = ParseAssignment(); + } + Require(TokenKind.RightBrace, "Closing '}' expected"); + source = new IndexNode(source, start, end, slice, opening.Offset); + } + return source; + } + + private Node ParsePrimary() + { + Token token = _current; + switch (token.Kind) + { + case TokenKind.Number: + Advance(); + return new LiteralNode( + ExpressionValue.Number(double.Parse( + token.Text, + NumberStyles.Float, + CultureInfo.InvariantCulture)), + token.Offset); + case TokenKind.HexNumber: + Advance(); + return new LiteralNode( + ExpressionValue.Number(Convert.ToUInt32( + token.Text, + 16)), + token.Offset); + case TokenKind.True: + case TokenKind.False: + Advance(); + return new LiteralNode( + ExpressionValue.Boolean(token.Kind == TokenKind.True), + token.Offset); + case TokenKind.String: + Advance(); + if (_current.Kind != TokenKind.LeftBracket) + { + return new LiteralNode( + ExpressionValue.String(token.Text), + token.Offset); + } + return ParseFunction(token); + case TokenKind.Dollar: + case TokenKind.At: + case TokenKind.Ampersand: + return ParseVariable(); + case TokenKind.LeftParen: + Advance(); + Node nested = ParseAssignment(); + Require(TokenKind.RightParen, "Closing ')' expected"); + return nested; + default: + throw new ExpressionParseException( + $"Expression expected; found '{token.Text}'", + token.Offset); + } + } + + private Node ParseFunction(Token name) + { + Require(TokenKind.LeftBracket, "Opening '[' expected"); + var arguments = new List(); + if (_current.Kind != TokenKind.RightBracket) + { + while (true) + { + arguments.Add(ParseAssignment()); + if (_current.Kind != TokenKind.Comma) + break; + Advance(); + } + } + Require(TokenKind.RightBracket, "Closing ']' expected"); + return new FunctionNode(name.Text, arguments.ToArray(), name.Offset); + } + + private Node ParseVariable() + { + Token prefix = _current; + Advance(); + if (_current.Kind is TokenKind.End + or TokenKind.Comma + or TokenKind.Semicolon + or TokenKind.RightBracket + or TokenKind.RightBrace + or TokenKind.RightParen) + { + throw new ExpressionParseException( + "Variable name expected", + _current.Offset); + } + Node name = ParsePrimary(); + ExpressionVariableScope scope = prefix.Kind switch + { + TokenKind.Dollar => ExpressionVariableScope.Session, + TokenKind.At => ExpressionVariableScope.Persistent, + TokenKind.Ampersand => ExpressionVariableScope.Global, + _ => throw new InvalidOperationException(), + }; + return new VariableNode(scope, name, prefix.Offset); + } + + private Node ParseLeft( + Func operand, + params TokenKind[] operations) + { + Node left = operand(); + while (operations.Contains(_current.Kind)) + { + Token operation = _current; + Advance(); + left = new BinaryNode( + operation.Kind, + left, + operand(), + operation.Offset); + } + return left; + } + + private void Require(TokenKind expected, string message) + { + if (_current.Kind != expected) + throw new ExpressionParseException(message, _current.Offset); + Advance(); + } + + private void Advance() => _current = _lexer.Next(); + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs b/src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs new file mode 100644 index 00000000..31c345b1 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs @@ -0,0 +1,204 @@ +namespace AcDream.Plugins.MossTank.Expressions; + +internal enum ExpressionVariableScope +{ + Session, + Persistent, + Global, +} + +internal sealed class ExpressionState +{ + private readonly Dictionary _session = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _persistent = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _global = + new(StringComparer.OrdinalIgnoreCase); + + public ExpressionValue Get(ExpressionVariableScope scope, string name) => + Table(scope).TryGetValue(name, out ExpressionValue value) + ? value + : ExpressionValue.Zero; + + public bool Contains(ExpressionVariableScope scope, string name) => + Table(scope).ContainsKey(name); + + public ExpressionValue Set( + ExpressionVariableScope scope, + string name, + ExpressionValue value) + { + Table(scope)[name] = value; + return value; + } + + public bool Clear(ExpressionVariableScope scope, string name) => + Table(scope).Remove(name); + + public void Clear(ExpressionVariableScope scope) => Table(scope).Clear(); + + public IReadOnlyDictionary Capture( + ExpressionVariableScope scope) => + new Dictionary(Table(scope), + StringComparer.OrdinalIgnoreCase); + + public void Replace( + ExpressionVariableScope scope, + IEnumerable> values) + { + Dictionary target = Table(scope); + target.Clear(); + foreach ((string name, ExpressionValue value) in values) + target[name] = value; + } + + private Dictionary Table( + ExpressionVariableScope scope) => scope switch + { + ExpressionVariableScope.Session => _session, + ExpressionVariableScope.Persistent => _persistent, + ExpressionVariableScope.Global => _global, + _ => throw new ArgumentOutOfRangeException(nameof(scope)), + }; +} + +internal delegate ExpressionValue ExpressionFunctionHandler( + ExpressionEvaluationContext context, + IReadOnlyList arguments); + +internal sealed record ExpressionFunction( + string Name, + int MinimumArguments, + int MaximumArguments, + ExpressionFunctionHandler Handler, + string Signature, + string Description); + +internal sealed class ExpressionFunctionRegistry +{ + private readonly Dictionary _functions = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyCollection Functions => + _functions.Values; + + public void Register( + string name, + int minimumArguments, + int maximumArguments, + ExpressionFunctionHandler handler, + string? signature = null, + string description = "") + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(handler); + if (minimumArguments < 0 || maximumArguments < minimumArguments) + throw new ArgumentOutOfRangeException(nameof(minimumArguments)); + var function = new ExpressionFunction( + name, + minimumArguments, + maximumArguments, + handler, + signature ?? name + "[...]", + description); + if (!_functions.TryAdd(name, function)) + { + throw new InvalidOperationException( + $"Expression function '{name}' is already registered."); + } + } + + public void Alias(string alias, string existing) + { + if (!_functions.TryGetValue(existing, out ExpressionFunction? function)) + throw new InvalidOperationException( + $"Expression function '{existing}' is not registered."); + Register( + alias, + function.MinimumArguments, + function.MaximumArguments, + function.Handler, + function.Signature.Replace(existing, alias, StringComparison.Ordinal), + function.Description); + } + + public ExpressionFunction Resolve(string name, int offset) + { + if (_functions.TryGetValue(name, out ExpressionFunction? function)) + return function; + throw new ExpressionEvaluationException( + $"Unknown expression method: {name}", + offset); + } +} + +internal sealed class ExpressionEvaluationContext +{ + private int _remainingInstructions; + + public ExpressionEvaluationContext( + ExpressionState state, + ExpressionFunctionRegistry functions, + int instructionBudget = 10_000, + CancellationToken cancellationToken = default) + { + State = state ?? throw new ArgumentNullException(nameof(state)); + Functions = functions ?? throw new ArgumentNullException(nameof(functions)); + if (instructionBudget <= 0) + throw new ArgumentOutOfRangeException(nameof(instructionBudget)); + _remainingInstructions = instructionBudget; + CancellationToken = cancellationToken; + } + + public ExpressionState State { get; } + public ExpressionFunctionRegistry Functions { get; } + public CancellationToken CancellationToken { get; } + public int RemainingInstructions => _remainingInstructions; + + public void Step(int offset) + { + CancellationToken.ThrowIfCancellationRequested(); + if (--_remainingInstructions < 0) + { + throw new ExpressionEvaluationException( + "Expression instruction budget exceeded", + offset); + } + } + + public ExpressionValue Invoke( + string name, + IReadOnlyList arguments, + int offset) + { + Step(offset); + ExpressionFunction function = Functions.Resolve(name, offset); + if (arguments.Count < function.MinimumArguments + || arguments.Count > function.MaximumArguments) + { + string expected = function.MinimumArguments == function.MaximumArguments + ? function.MinimumArguments.ToString( + System.Globalization.CultureInfo.InvariantCulture) + : $"{function.MinimumArguments}..{function.MaximumArguments}"; + throw new ExpressionEvaluationException( + $"{function.Signature} expects {expected} arguments; " + + $"{arguments.Count} were passed", + offset); + } + try + { + return function.Handler(this, arguments); + } + catch (ExpressionEvaluationException) + { + throw; + } + catch (Exception error) + { + throw new ExpressionEvaluationException( + $"{function.Signature} failed: {error.Message}", + offset); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs b/src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs new file mode 100644 index 00000000..9362da61 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs @@ -0,0 +1,240 @@ +using System.Globalization; + +namespace AcDream.Plugins.MossTank.Expressions; + +internal enum ExpressionValueKind +{ + Number, + String, + Boolean, + List, + Dictionary, + Coordinates, + WorldObject, + Stopwatch, + UiControl, +} + +internal readonly record struct ExpressionCoordinates( + double EastWest, + double NorthSouth, + double Elevation = 0d) +{ + public override string ToString() + { + string ns = NorthSouth < 0d ? "S" : "N"; + string ew = EastWest < 0d ? "W" : "E"; + return string.Create( + CultureInfo.InvariantCulture, + $"{Math.Abs(NorthSouth):0.0}{ns}, {Math.Abs(EastWest):0.0}{ew}"); + } +} + +internal sealed class ExpressionList +{ + public List Items { get; } = []; + + public ExpressionList() + { + } + + public ExpressionList(IEnumerable values) => + Items.AddRange(values); + + public override string ToString() => + $"[{string.Join(",", Items.Select(static item => item.ToDisplayString()))}]"; +} + +internal sealed class ExpressionDictionary +{ + public Dictionary Items { get; } = + new(StringComparer.Ordinal); + + public override string ToString() => + $"[{string.Join(",", Items.Select(static pair => + pair.Key + "=>" + pair.Value.ToDisplayString()))}]"; +} + +internal sealed class ExpressionStopwatch +{ + private readonly System.Diagnostics.Stopwatch _clock = new(); + + public bool IsRunning => _clock.IsRunning; + public double ElapsedSeconds => _clock.Elapsed.TotalSeconds; + public void Start() => _clock.Start(); + public void Stop() => _clock.Stop(); + public void Reset() => _clock.Reset(); + public void Restart() => _clock.Restart(); + public override string ToString() => + ElapsedSeconds.ToString("0.###", CultureInfo.InvariantCulture); +} + +internal readonly record struct ExpressionWorldObject(uint ObjectId); +internal readonly record struct ExpressionUiControl(string View, string Control); + +internal readonly struct ExpressionValue : IEquatable +{ + private readonly double _number; + private readonly object? _reference; + + private ExpressionValue( + ExpressionValueKind kind, + double number, + object? reference) + { + Kind = kind; + _number = number; + _reference = reference; + } + + public ExpressionValueKind Kind { get; } + + public static ExpressionValue Zero => Number(0d); + public static ExpressionValue One => Number(1d); + public static ExpressionValue Number(double value) => + new(ExpressionValueKind.Number, value, null); + public static ExpressionValue String(string? value) => + new(ExpressionValueKind.String, 0d, value ?? string.Empty); + public static ExpressionValue Boolean(bool value) => + new(ExpressionValueKind.Boolean, value ? 1d : 0d, null); + public static ExpressionValue List(ExpressionList value) => + new(ExpressionValueKind.List, 0d, value); + public static ExpressionValue Dictionary(ExpressionDictionary value) => + new(ExpressionValueKind.Dictionary, 0d, value); + public static ExpressionValue Coordinates(ExpressionCoordinates value) => + new(ExpressionValueKind.Coordinates, 0d, value); + public static ExpressionValue WorldObject(uint objectId) => + new(ExpressionValueKind.WorldObject, objectId, null); + public static ExpressionValue Stopwatch(ExpressionStopwatch value) => + new(ExpressionValueKind.Stopwatch, 0d, value); + public static ExpressionValue UiControl(ExpressionUiControl value) => + new(ExpressionValueKind.UiControl, 0d, value); + + public double AsNumber(string? operation = null) + { + if (Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean) + return _number; + throw TypeError(operation ?? "operation", "number"); + } + + public int AsInt32(string? operation = null) => + Convert.ToInt32(AsNumber(operation), CultureInfo.InvariantCulture); + + public string AsString(string? operation = null) + { + if (Kind == ExpressionValueKind.String) + return (string)_reference!; + throw TypeError(operation ?? "operation", "string"); + } + + public ExpressionList AsList(string? operation = null) => + Kind == ExpressionValueKind.List + ? (ExpressionList)_reference! + : throw TypeError(operation ?? "operation", "list"); + + public ExpressionDictionary AsDictionary(string? operation = null) => + Kind == ExpressionValueKind.Dictionary + ? (ExpressionDictionary)_reference! + : throw TypeError(operation ?? "operation", "dictionary"); + + public ExpressionCoordinates AsCoordinates(string? operation = null) => + Kind == ExpressionValueKind.Coordinates + ? (ExpressionCoordinates)_reference! + : throw TypeError(operation ?? "operation", "coordinates"); + + public ExpressionStopwatch AsStopwatch(string? operation = null) => + Kind == ExpressionValueKind.Stopwatch + ? (ExpressionStopwatch)_reference! + : throw TypeError(operation ?? "operation", "stopwatch"); + + public ExpressionUiControl AsUiControl(string? operation = null) => + Kind == ExpressionValueKind.UiControl + ? (ExpressionUiControl)_reference! + : throw TypeError(operation ?? "operation", "UI control"); + + public uint AsObjectId(string? operation = null) => Kind switch + { + ExpressionValueKind.WorldObject => checked((uint)_number), + ExpressionValueKind.Number => checked((uint)_number), + _ => throw TypeError(operation ?? "operation", "world object"), + }; + + public bool IsTruthy => Kind switch + { + ExpressionValueKind.Number or ExpressionValueKind.Boolean => + _number != 0d, + ExpressionValueKind.String => ((string)_reference!).Length != 0, + _ => true, + }; + + public string ToDisplayString() => Kind switch + { + ExpressionValueKind.Number => + _number.ToString("G15", CultureInfo.InvariantCulture), + ExpressionValueKind.Boolean => _number != 0d ? "True" : "False", + ExpressionValueKind.String => (string)_reference!, + ExpressionValueKind.List => _reference!.ToString()!, + ExpressionValueKind.Dictionary => _reference!.ToString()!, + ExpressionValueKind.Coordinates => _reference!.ToString()!, + ExpressionValueKind.WorldObject => + checked((uint)_number).ToString(CultureInfo.InvariantCulture), + ExpressionValueKind.Stopwatch => _reference!.ToString()!, + ExpressionValueKind.UiControl => _reference!.ToString()!, + _ => string.Empty, + }; + + public bool Equals(ExpressionValue other) + { + if (Kind == ExpressionValueKind.String) + { + return other.Kind == ExpressionValueKind.String + && string.Equals( + (string)_reference!, + (string)other._reference!, + StringComparison.OrdinalIgnoreCase); + } + if (Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean + && other.Kind is ExpressionValueKind.Number + or ExpressionValueKind.Boolean) + { + return _number.Equals(other._number); + } + return Kind == other.Kind && ReferenceEquals(_reference, other._reference); + } + + public override bool Equals(object? obj) => + obj is ExpressionValue other && Equals(other); + + public override int GetHashCode() => Kind switch + { + ExpressionValueKind.String => StringComparer.OrdinalIgnoreCase.GetHashCode( + (string)_reference!), + ExpressionValueKind.Number or ExpressionValueKind.Boolean => + _number.GetHashCode(), + _ => HashCode.Combine(Kind, _reference), + }; + + public override string ToString() => ToDisplayString(); + + private ExpressionEvaluationException TypeError( + string operation, + string expected) => new( + $"{operation} expects {expected}, but received {Kind}."); +} + +internal sealed class ExpressionParseException : Exception +{ + public ExpressionParseException(string message, int offset) + : base($"{message} at offset {offset}.") => Offset = offset; + + public int Offset { get; } +} + +internal sealed class ExpressionEvaluationException : Exception +{ + public ExpressionEvaluationException(string message, int offset = -1) + : base(offset < 0 ? message : $"{message} at offset {offset}.") => + Offset = offset; + + public int Offset { get; } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs b/src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs new file mode 100644 index 00000000..d95841ce --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs @@ -0,0 +1,1276 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// UtilityBelt-compatible expression methods backed by acdream's canonical +/// plugin automation surface. This class contains translation only; macro +/// policy remains in MossTank and authoritative game state remains in Runtime. +/// +internal static class HostExpressionFunctions +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); + private static readonly string[] GameMonthNames = + [ + "Morningthaw", "Solclaim", "Seedsow", "Leafdawning", "Verdantine", + "Thistledown", "HarvestGain", "Leafcull", "Frostfell", "Snowreap", + "Coldeve", "Wintersebb", + ]; + private static readonly string[] GameHourNames = + [ + "Darktide", "Darktide-and-Half", "Foredawn", "Foredawn-and-Half", + "Dawnsong", "Dawnsong-and-Half", "Morntide", "Morntide-and-Half", + "Midsong", "Midsong-and-Half", "Warmtide", "Warmtide-and-Half", + "Evensong", "Evensong-and-Half", "Gloaming", "Gloaming-and-Half", + ]; + + public static void Register(ExpressionFunctionRegistry registry, IPluginHost host) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(host); + RegisterCharacter(registry, host); + RegisterSpells(registry, host); + RegisterObjects(registry, host); + RegisterLoot(registry, host); + RegisterFellowship(registry, host); + RegisterWorldTime(registry, host); + RegisterUi(registry, host); + RegisterActions(registry, host); + RegisterCombatAndMovement(registry, host); + RegisterLogin(registry, host); + RegisterNetwork(registry, host); + } + + private static void RegisterUi( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("uigetcontrol", 2, 2, (_, args) => + { + string view = args[0].AsString("uigetcontrol"); + string control = args[1].AsString("uigetcontrol"); + return host.Ui.ControlExists(view, control) + ? ExpressionValue.UiControl(new ExpressionUiControl(view, control)) + : ExpressionValue.Zero; + }, "uigetcontrol[windowName,controlName]"); + registry.Register("uisetlabel", 2, 2, (_, args) => + { + ExpressionUiControl control = args[0].AsUiControl("uisetlabel"); + return ExpressionValue.Boolean(host.Ui.SetControlLabel( + control.View, + control.Control, + args[1].AsString("uisetlabel"))); + }, "uisetlabel[control,label]"); + registry.Register("uisetvisible", 2, 2, (_, args) => + { + ExpressionUiControl control = args[0].AsUiControl("uisetvisible"); + return ExpressionValue.Boolean(host.Ui.SetControlVisible( + control.View, + control.Control, + args[1].AsNumber("uisetvisible") >= 1d)); + }, "uisetvisible[control,visible]"); + registry.Register("uiviewexists", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Ui.ViewExists( + args[0].AsString("uiviewexists"))), "uiviewexists[windowName]"); + registry.Register("uiviewvisible", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Ui.IsViewVisible( + args[0].AsString("uiviewvisible"))), "uiviewvisible[windowName]"); + } + + private static void RegisterLoot( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("hascorpsebeenopenedbyme", 1, 1, (_, args) => + { + uint objectId = args[0].AsObjectId("hascorpsebeenopenedbyme"); + return ExpressionValue.Boolean(host.Automation.Loot + .CaptureCorpses(float.MaxValue) + .Any(corpse => corpse.ObjectId == objectId && corpse.HasBeenOpened)); + }, "hascorpsebeenopenedbyme[corpse]"); + registry.Register("getcorpsesunopenedbyme", 0, 0, (_, _) => + NumberList(host.Automation.Loot.CaptureCorpses(float.MaxValue) + .Where(static corpse => !corpse.HasBeenOpened) + .Select(static corpse => corpse.ObjectId)), + "getcorpsesunopenedbyme[]"); + } + + private static void RegisterCharacter( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + ICharacterInfo character = host.Automation.Character; + registry.Register("getworldname", 0, 0, (_, _) => + ExpressionValue.String(character.WorldName), "getworldname[]"); + registry.Register("getaccounthash", 0, 0, (_, _) => + ExpressionValue.String(LegacyStringHash(character.AccountName) + .ToString("X", CultureInfo.InvariantCulture)), "getaccounthash[]"); + registry.Register("getcharacterindex", 0, 1, (_, args) => + { + string name = args.Count == 0 + ? character.Name + : args[0].AsString("getcharacterindex"); + IReadOnlyList roster = + host.Automation.Login.CaptureRoster(); + if (roster.Count == 0) + return ExpressionValue.Number(character.CharacterIndex); + for (int index = 0; index < roster.Count; index++) + { + if (roster[index].Name.Contains( + name, + StringComparison.OrdinalIgnoreCase)) + { + return ExpressionValue.Number(index); + } + } + return ExpressionValue.Number(-1d); + }, "getcharacterindex[name?]"); + registry.Register("getplayercoordinates", 0, 0, (_, _) => + { + PluginNavigationSnapshot snapshot = host.Automation.Navigation.Snapshot; + return snapshot.IsAvailable + ? Coordinates(snapshot.Position) + : ExpressionValue.Zero; + }, "getplayercoordinates[]"); + registry.Register("getplayerlandblock", 0, 0, (_, _) => + ExpressionValue.Number( + host.Automation.Navigation.Snapshot.Position.CellId >> 16), + "getplayerlandblock[]"); + registry.Register("getplayerlandcell", 0, 0, (_, _) => + ExpressionValue.Number( + host.Automation.Navigation.Snapshot.Position.CellId), + "getplayerlandcell[]"); + registry.Register("isportaling", 0, 0, (_, _) => + ExpressionValue.Boolean( + !character.IsInWorld + || host.Automation.Navigation.Snapshot.IsPortalSpace), + "isportaling[]"); + registry.Register("getcharburden", 0, 0, (_, _) => + { + // Decal/UB reports the friendly percentage from the same two + // retail properties: EncumbranceVal (5) and EncumbranceCapacity (96). + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return ExpressionValue.Zero; + double burden = Get(properties.Ints, 5u); + double capacity = Get(properties.Ints, 96u); + return ExpressionValue.Number(capacity > 0d + ? Math.Round(burden * 100d / capacity) + : 0d); + }, "getcharburden[]"); + registry.Register("getcharintprop", 1, 1, (_, args) => + ExpressionValue.Number(PlayerProperty(host, args[0], PropertyKind.Int)), + "getcharintprop[property]"); + registry.Register("getchardoubleprop", 1, 1, (_, args) => + ExpressionValue.Number(PlayerProperty(host, args[0], PropertyKind.Double)), + "getchardoubleprop[property]"); + registry.Register("getcharquadprop", 1, 1, (_, args) => + ExpressionValue.Number(PlayerProperty(host, args[0], PropertyKind.Int64)), + "getcharquadprop[property]"); + registry.Register("getcharboolprop", 1, 1, (_, args) => + ExpressionValue.Boolean(PlayerProperty(host, args[0], PropertyKind.Bool) != 0d), + "getcharboolprop[property]"); + registry.Register("getcharstringprop", 1, 1, (_, args) => + { + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return ExpressionValue.Zero; + uint key = ToUInt(args[0], "getcharstringprop"); + return properties.Strings.TryGetValue(key, out string? value) + ? ExpressionValue.String(value) + : ExpressionValue.Zero; + }, "getcharstringprop[property]"); + + registry.Register("getcharattribute_base", 1, 1, (_, args) => + ExpressionValue.Number(Attribute(character, args[0], buffed: false)), + "getcharattribute_base[attributeId]"); + registry.Register("getcharattribute_buffed", 1, 1, (_, args) => + ExpressionValue.Number(Attribute(character, args[0], buffed: true)), + "getcharattribute_buffed[attributeId]"); + registry.Register("getcharskill_base", 1, 1, (_, args) => + ExpressionValue.Number(Skill(character, args[0], SkillRead.Base)), + "getcharskill_base[skillId]"); + registry.Register("getcharskill_buffed", 1, 1, (_, args) => + ExpressionValue.Number(Skill(character, args[0], SkillRead.Buffed)), + "getcharskill_buffed[skillId]"); + registry.Register("getcharskill_traininglevel", 1, 1, (_, args) => + ExpressionValue.Number(Skill(character, args[0], SkillRead.Training)), + "getcharskill_traininglevel[skillId]"); + registry.Register("getcharvital_base", 1, 1, (_, args) => + ExpressionValue.Number(Vital(character, args[0], VitalRead.Maximum)), + "getcharvital_base[vitalId]"); + registry.Register("getcharvital_buffedmax", 1, 1, (_, args) => + ExpressionValue.Number(Vital(character, args[0], VitalRead.Maximum)), + "getcharvital_buffedmax[vitalId]"); + registry.Register("getcharvital_current", 1, 1, (_, args) => + ExpressionValue.Number(Vital(character, args[0], VitalRead.Current)), + "getcharvital_current[vitalId]"); + registry.Register("vitae", 0, 0, (_, _) => + { + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return ExpressionValue.Number(100d); + // PropertyFloat.Vitae (129) is the multiplier 0..1. + double value = Get(properties.Floats, 129u, 1d); + return ExpressionValue.Number(value * 100d); + }, "vitae[]"); + } + + private static void RegisterLogin( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("setnextlogin", 1, 1, (_, args) => + { + ILoginAutomation login = host.Automation.Login; + IReadOnlyList roster = login.CaptureRoster(); + if (!login.IsAvailable || roster.Count == 0) + return ExpressionValue.Zero; + + string selector = args[0].ToDisplayString(); + PluginLoginCharacter selected = default; + if (int.TryParse( + selector, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int relative)) + { + int current = -1; + for (int index = 0; index < roster.Count; index++) + { + if (roster[index].Name.Equals( + host.Automation.Character.Name, + StringComparison.OrdinalIgnoreCase)) + { + current = index; + break; + } + } + if (current < 0) + return ExpressionValue.Zero; + int target = ((current + relative) % roster.Count + roster.Count) + % roster.Count; + selected = roster[target]; + } + else + { + foreach (PluginLoginCharacter candidate in roster) + { + if (candidate.Name.Contains( + selector, + StringComparison.OrdinalIgnoreCase)) + { + selected = candidate; + break; + } + } + } + return ExpressionValue.Boolean( + selected.ObjectId != 0u + && !selected.IsPendingDelete + && login.SetNextLogin(selected.ObjectId)); + }, "setnextlogin[nameOrRelativeIndex]"); + registry.Register("clearnextlogin", 0, 0, (_, _) => + ExpressionValue.Boolean(host.Automation.Login.ClearNextLogin()), + "clearnextlogin[]"); + } + + private static void RegisterNetwork( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("netclients", 0, 1, (_, args) => + { + string? tag = args.Count == 0 + ? null + : args[0].AsString("netclients"); + var clients = new ExpressionList(); + foreach (PluginNetworkClient client in + host.Automation.Network.CaptureClients()) + { + if (!string.IsNullOrEmpty(tag) + && !client.Tags.Any(candidate => candidate.Equals( + tag, + StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var tags = new ExpressionList(); + foreach (string candidate in client.Tags) + tags.Items.Add(ExpressionValue.String(candidate)); + var data = new ExpressionDictionary(); + data.Items["ClientId"] = ExpressionValue.Number(client.ClientId); + data.Items["PlayerId"] = ExpressionValue.Number(client.PlayerId); + data.Items["Position"] = Coordinates(client.Position); + data.Items["Name"] = ExpressionValue.String(client.Name); + data.Items["Tags"] = ExpressionValue.List(tags); + data.Items["WorldName"] = ExpressionValue.String(client.WorldName); + data.Items["CurrentHealth"] = + ExpressionValue.Number(client.CurrentHealth); + data.Items["CurrentMana"] = + ExpressionValue.Number(client.CurrentMana); + data.Items["CurrentStamina"] = + ExpressionValue.Number(client.CurrentStamina); + data.Items["MaxHealth"] = ExpressionValue.Number(client.MaxHealth); + data.Items["MaxMana"] = ExpressionValue.Number(client.MaxMana); + data.Items["MaxStamina"] = + ExpressionValue.Number(client.MaxStamina); + data.Items["Heading"] = ExpressionValue.Number(client.Heading); + clients.Items.Add(ExpressionValue.Dictionary(data)); + } + return ExpressionValue.List(clients); + }, "netclients[tag?]"); + } + + private static void RegisterSpells( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + ISpellCatalog spells = host.Automation.Spells; + ICharacterInfo character = host.Automation.Character; + registry.Register("componentname", 1, 1, (_, args) => + spells.TryGetComponent( + ToUInt(args[0], "componentname"), + out PluginSpellComponentInfo component) + ? ExpressionValue.String(component.Name) + : ExpressionValue.Zero, + "componentname[componentid]"); + registry.Register("componentdata", 1, 1, (_, args) => + { + if (!spells.TryGetComponent( + ToUInt(args[0], "componentdata"), + out PluginSpellComponentInfo component)) + { + return ExpressionValue.Dictionary(new ExpressionDictionary()); + } + var data = new ExpressionDictionary(); + data.Items["BurnRate"] = ExpressionValue.Number(component.BurnRate); + data.Items["GestureId"] = ExpressionValue.Number(component.GestureId); + data.Items["GestureSpeed"] = ExpressionValue.Number(component.GestureSpeed); + data.Items["IconId"] = ExpressionValue.Number(component.IconId); + data.Items["Id"] = ExpressionValue.Number(component.ComponentId); + data.Items["Name"] = ExpressionValue.String(component.Name); + data.Items["SortKey"] = ExpressionValue.Number(component.SortKey); + data.Items["Type"] = ExpressionValue.String(component.Type); + data.Items["Word"] = ExpressionValue.String(component.Word); + return ExpressionValue.Dictionary(data); + }, "componentdata[componentid]"); + registry.Register("getisspellknown", 1, 1, (_, args) => + ExpressionValue.Boolean(spells.IsKnown(ToUInt(args[0], "getisspellknown"))), + "getisspellknown[spellId]"); + registry.Register("getknownspells", 0, 0, (_, _) => + { + IEnumerable ids = spells.KnownSelfBuffs + .Concat(spells.KnownCombatSpells) + .Select(static spell => spell.SpellId) + .Distinct() + .Order(); + return NumberList(ids); + }, "getknownspells[]"); + registry.Register("spellname", 1, 1, (_, args) => + spells.TryGet(ToUInt(args[0], "spellname"), out PluginSpellInfo spell) + ? ExpressionValue.String(spell.Name) + : ExpressionValue.Zero, "spellname[spellId]"); + registry.Register("spelldata", 2, 2, (_, args) => + { + if (!spells.TryGet(ToUInt(args[0], "spelldata"), out PluginSpellInfo spell)) + return ExpressionValue.Zero; + return SpellProperty(spell, args[1].ToDisplayString()); + }, "spelldata[spellId,property]"); + registry.Register("getspellexpiration", 1, 1, (_, args) => + ExpressionValue.Number(SpellExpiration( + character.ActiveEnchantments, + ToUInt(args[0], "getspellexpiration"))), + "getspellexpiration[spellId]"); + registry.Register("getspellexpirationbyname", 1, 1, (_, args) => + { + string name = args[0].AsString("getspellexpirationbyname"); + PluginSpellInfo? match = spells.KnownSelfBuffs + .Concat(spells.KnownCombatSpells) + .FirstOrDefault(spell => spell.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + return ExpressionValue.Number(match is { SpellId: > 0u } spell + ? SpellExpiration(character.ActiveEnchantments, spell.SpellId) + : 0d); + }, "getspellexpirationbyname[name]"); + registry.Register("getcooldownexpiration", 1, 1, (_, args) => + ExpressionValue.Number(spells.GetCooldownRemaining( + ToUInt(args[0], "getcooldownexpiration"))), + "getcooldownexpiration[cooldownId]"); + registry.Register("getcancastspell_buff", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Automation.Magic.EvaluateGate( + ToUInt(args[0], "getcancastspell_buff")) == PluginCastGate.Ready), + "getcancastspell_buff[spellId]"); + registry.Register("getcancastspell_hunt", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Automation.Magic.EvaluateGate( + ToUInt(args[0], "getcancastspell_hunt")) == PluginCastGate.Ready), + "getcancastspell_hunt[spellId]"); + } + + private static void RegisterObjects( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + IWorldObjectAutomation objects = host.Automation.Objects; + registry.Register("wobjectfindbyid", 1, 1, (context, args) => + { + uint id = ToUInt(args[0], "wobjectfindbyid"); + return objects.TryGet(id, out _) + ? ExpressionValue.WorldObject(id) + : ExpressionValue.Zero; + }, "wobjectfindbyid[id]"); + registry.Register("wobjectgetplayer", 0, 0, (_, _) => + ExpressionValue.WorldObject(host.Automation.Character.ObjectId), + "wobjectgetplayer[]"); + registry.Register("wobjectgetselection", 0, 0, (_, _) => + host.Selection.SelectedObjectId is uint id + ? ExpressionValue.WorldObject(id) + : ExpressionValue.Zero, "wobjectgetselection[]"); + registry.Register("wobjectgetopencontainer", 0, 0, (_, _) => + objects.OpenContainerObjectId != 0u + ? ExpressionValue.WorldObject(objects.OpenContainerObjectId) + : ExpressionValue.Zero, "wobjectgetopencontainer[]"); + registry.Register("wobjectgetid", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsObjectId("wobjectgetid")), + "wobjectgetid[object]"); + registry.Register("wobjectgetname", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetname", out PluginWorldObject obj) + ? ExpressionValue.String(obj.Name) + : ExpressionValue.Zero, "wobjectgetname[object]"); + registry.Register("wobjectgetobjectclass", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetobjectclass", out PluginWorldObject obj) + ? ExpressionValue.Number((int)obj.ObjectClass) + : ExpressionValue.Zero, "wobjectgetobjectclass[object]"); + registry.Register("wobjectgettemplatetype", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgettemplatetype", out PluginWorldObject obj) + ? ExpressionValue.Number(obj.WeenieClassId) + : ExpressionValue.Zero, "wobjectgettemplatetype[object]"); + registry.Register("wobjectgetinternaltype", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetinternaltype", out PluginWorldObject obj) + ? ExpressionValue.Number(obj.ItemType) + : ExpressionValue.Zero, "wobjectgetinternaltype[object]"); + registry.Alias("getobjectinternaltype", "wobjectgetinternaltype"); + registry.Register("wobjecthasdata", 1, 1, (_, args) => + ExpressionValue.Boolean( + TryObject(objects, args[0], "wobjecthasdata", out PluginWorldObject obj) + && obj.HasAppraisalData), "wobjecthasdata[object]"); + registry.Register("wobjectlastidtime", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectlastidtime", out PluginWorldObject obj) + ? ExpressionValue.Number(obj.LastIdTime) + : ExpressionValue.Zero, + "wobjectlastidtime[object]"); + registry.Register("wobjectisvalid", 1, 1, (_, args) => + ExpressionValue.Boolean(TryObject( + objects, + args[0], + "wobjectisvalid", + out PluginWorldObject obj) && obj.HasPosition), + "wobjectisvalid[object]"); + registry.Register("wobjectrequestdata", 1, 1, (_, args) => + ExpressionValue.Boolean(objects.Identify( + args[0].AsObjectId("wobjectrequestdata")).Accepted), + "wobjectrequestdata[object]"); + registry.Register("wobjectgetisdooropen", 1, 1, (_, args) => + ExpressionValue.Boolean( + TryObject(objects, args[0], "wobjectgetisdooropen", out PluginWorldObject obj) + && obj.IsDoorOpen), "wobjectgetisdooropen[object]"); + registry.Register("wobjectgetphysicscoordinates", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetphysicscoordinates", out PluginWorldObject obj) + && obj.HasPosition + ? Coordinates(obj.Position) + : ExpressionValue.Zero, + "wobjectgetphysicscoordinates[object]"); + registry.Register("getheading", 1, 1, (_, args) => + TryObject(objects, args[0], "getheading", out PluginWorldObject obj) + && obj.HasPosition + ? ExpressionValue.Number(NormalizeHeading(obj.Position.HeadingDegrees)) + : ExpressionValue.Zero, "getheading[object]"); + registry.Register("getheadingto", 1, 1, (_, args) => + { + PluginNavigationSnapshot player = host.Automation.Navigation.Snapshot; + return player.IsAvailable + && TryObject(objects, args[0], "getheadingto", out PluginWorldObject obj) + && obj.HasPosition + ? ExpressionValue.Number(HeadingTo(player.Position, obj.Position)) + : ExpressionValue.Zero; + }, "getheadingto[object]"); + + RegisterObjectProperty(registry, host, "wobjectgetintprop", PropertyKind.Int); + RegisterObjectProperty(registry, host, "wobjectgetdoubleprop", PropertyKind.Double); + RegisterObjectProperty(registry, host, "wobjectgetboolprop", PropertyKind.Bool); + RegisterObjectProperty(registry, host, "wobjectgetstringprop", PropertyKind.String); + registry.Register("wobjectgetspellids", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetspellids", out PluginWorldObject obj) + ? NumberList(obj.SpellIds) + : ExpressionValue.List(new ExpressionList()), + "wobjectgetspellids[object]"); + registry.Register("wobjectgetactivespellids", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetactivespellids", out PluginWorldObject obj) + ? NumberList(obj.ActiveSpellIds) + : ExpressionValue.List(new ExpressionList()), + "wobjectgetactivespellids[object]"); + + RegisterObjectFinders(registry, host); + RegisterInventoryCounts(registry, host); + RegisterObjectVitals(registry, host); + } + + private static void RegisterObjectFinders( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("wobjectfindall", 0, 0, (_, _) => + ObjectList(host.Automation.Objects.CaptureObjects()), "wobjectfindall[]"); + RegisterFinder(registry, host, "wobjectfindallbyobjectclass", ObjectSet.All, + (obj, arg) => (int)obj.ObjectClass == arg.AsInt32()); + RegisterFinder(registry, host, "wobjectfindallbytemplatetype", ObjectSet.All, + (obj, arg) => obj.WeenieClassId == ToUInt(arg, "template type")); + RegisterRegexFinder(registry, host, "wobjectfindallbynamerx", ObjectSet.All); + RegisterFinder(registry, host, "wobjectfindallinventorybyobjectclass", ObjectSet.Inventory, + (obj, arg) => (int)obj.ObjectClass == arg.AsInt32()); + RegisterFinder(registry, host, "wobjectfindallinventorybytemplatetype", ObjectSet.Inventory, + (obj, arg) => obj.WeenieClassId == ToUInt(arg, "template type")); + RegisterRegexFinder(registry, host, "wobjectfindallinventorybynamerx", ObjectSet.Inventory); + RegisterFinder(registry, host, "wobjectfindalllandscapebyobjectclass", ObjectSet.Landscape, + (obj, arg) => (int)obj.ObjectClass == arg.AsInt32()); + RegisterFinder(registry, host, "wobjectfindalllandscapebytemplatetype", ObjectSet.Landscape, + (obj, arg) => obj.WeenieClassId == ToUInt(arg, "template type")); + RegisterRegexFinder(registry, host, "wobjectfindalllandscapebynamerx", ObjectSet.Landscape); + registry.Register("wobjectfindallinventory", 0, 0, (_, _) => ObjectList( + FilterSet(host.Automation.Objects.CaptureObjects(), ObjectSet.Inventory)), + "wobjectfindallinventory[]"); + registry.Register("wobjectfindalllandscape", 0, 0, (_, _) => ObjectList( + FilterSet(host.Automation.Objects.CaptureObjects(), ObjectSet.Landscape)), + "wobjectfindalllandscape[]"); + registry.Register("wobjectfindallbycontainer", 1, 1, (_, args) => + { + uint container = args[0].AsObjectId("wobjectfindallbycontainer"); + return ObjectList(host.Automation.Objects.CaptureObjects().Where( + obj => obj.ContainerObjectId == container)); + }, "wobjectfindallbycontainer[container]"); + registry.Register("wobjectfindininventorybyname", 1, 1, (_, args) => + FirstObject(host, ObjectSet.Inventory, obj => obj.Name.Equals( + args[0].AsString("wobjectfindininventorybyname"), + StringComparison.OrdinalIgnoreCase)), + "wobjectfindininventorybyname[name]"); + registry.Register("wobjectfindininventorybynamerx", 1, 1, (_, args) => + { + Regex regex = CreateRegex(args[0].AsString("wobjectfindininventorybynamerx")); + return FirstObject(host, ObjectSet.Inventory, obj => regex.IsMatch(obj.Name)); + }, "wobjectfindininventorybynamerx[pattern]"); + registry.Register("wobjectfindininventorybytemplatetype", 1, 1, (_, args) => + FirstObject(host, ObjectSet.Inventory, obj => + obj.WeenieClassId == ToUInt(args[0], "template type")), + "wobjectfindininventorybytemplatetype[templateType]"); + + RegisterNearest(registry, host, "wobjectfindnearestbyobjectclass", + (obj, args) => (int)obj.ObjectClass == args[0].AsInt32()); + RegisterNearest(registry, host, "wobjectfindnearestbytemplatetype", + (obj, args) => obj.WeenieClassId == ToUInt(args[0], "template type")); + RegisterNearest(registry, host, "wobjectfindnearestbynameandobjectclass", + (obj, args) => obj.Name.Equals(args[0].AsString(), StringComparison.OrdinalIgnoreCase) + && (int)obj.ObjectClass == args[1].AsInt32(), argumentCount: 2); + RegisterNearest(registry, host, "wobjectfindnearestdoor", + (obj, _) => obj.ObjectClass == PluginObjectClass.Door, argumentCount: 0); + RegisterNearest(registry, host, "wobjectfindnearestmonster", + (obj, _) => obj.ObjectClass == PluginObjectClass.Monster, argumentCount: 0); + } + + private static void RegisterInventoryCounts( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("getitemcountininventorybyname", 1, 1, (_, args) => + { + string name = args[0].AsString("getitemcountininventorybyname"); + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsOwned && obj.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)) + .Sum(static obj => Math.Max(1, obj.StackSize))); + }, "getitemcountininventorybyname[name]"); + registry.Register("getitemcountininventorybynamerx", 1, 1, (_, args) => + { + Regex regex = CreateRegex(args[0].AsString("getitemcountininventorybynamerx")); + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsOwned && regex.IsMatch(obj.Name)) + .Sum(static obj => Math.Max(1, obj.StackSize))); + }, "getitemcountininventorybynamerx[pattern]"); + registry.Register("getinventorycountbytemplatetype", 1, 1, (_, args) => + { + uint template = ToUInt(args[0], "getinventorycountbytemplatetype"); + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsOwned && obj.WeenieClassId == template) + .Sum(static obj => Math.Max(1, obj.StackSize))); + }, "getinventorycountbytemplatetype[templateType]"); + registry.Register("getcontaineritemcount", 0, 1, (_, args) => + { + uint container = args.Count == 0 + ? host.Automation.Character.ObjectId + : args[0].AsObjectId("getcontaineritemcount"); + if (!host.Automation.Objects.TryGet(container, out PluginWorldObject obj) + || obj.ObjectClass is not (PluginObjectClass.Container or PluginObjectClass.Player)) + { + return ExpressionValue.Number(-1d); + } + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects().Count( + item => item.ContainerObjectId == container)); + }, "getcontaineritemcount[container?]"); + registry.Register("getfreeitemslots", 0, 1, (_, args) => + ExpressionValue.Number(FreeSlots(host, args, containers: false)), + "getfreeitemslots[container?]"); + registry.Register("getfreecontainerslots", 0, 1, (_, args) => + ExpressionValue.Number(FreeSlots(host, args, containers: true)), + "getfreecontainerslots[container?]"); + } + + private static void RegisterObjectVitals( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("wobjectgethealth", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Fraction)), + "wobjectgethealth[object]"); + registry.Register("wobjectgethealthvalue", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Health)), + "wobjectgethealthvalue[object]"); + registry.Register("wobjectgetstaminavalue", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Stamina)), + "wobjectgetstaminavalue[object]"); + registry.Register("wobjectgetmanavalue", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Mana)), + "wobjectgetmanavalue[object]"); + } + + private static void RegisterActions( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("echo", 1, 1, (_, args) => + { + host.Automation.Chat.PostSystemMessage(args[0].ToDisplayString()); + return args[0]; + }, "echo[text]"); + registry.Register("chatbox", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Automation.Chat.Submit(args[0].ToDisplayString())), "chatbox[text]"); + registry.Register("chatboxpaste", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Automation.Chat.Submit(args[0].ToDisplayString())), "chatboxpaste[text]"); + registry.Register("actiontryselect", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Selection.Select(args[0].AsObjectId("actiontryselect"))), + "actiontryselect[object]"); + registry.Register("actiontryuseitem", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Use(args[0].AsObjectId("actiontryuseitem")).Accepted), + "actiontryuseitem[object]"); + registry.Register("actiontryapplyitem", 2, 2, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Apply( + args[0].AsObjectId("actiontryapplyitem"), + args[1].AsObjectId("actiontryapplyitem")).Accepted), + "actiontryapplyitem[source,target]"); + registry.Register("actiontrygiveitem", 2, 3, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Give( + args[0].AsObjectId("actiontrygiveitem"), + args[1].AsObjectId("actiontrygiveitem"), + args.Count == 3 ? ToUInt(args[2], "actiontrygiveitem") : 0u).Accepted), + "actiontrygiveitem[item,target,amount?]"); + registry.Register("actiontrydrop", 1, 2, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Drop( + args[0].AsObjectId("actiontrydrop"), + args.Count == 2 ? ToUInt(args[1], "actiontrydrop") : 0u).Accepted), + "actiontrydrop[item,amount?]"); + registry.Register("actiontrymove", 2, 4, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.MoveToContainer( + args[0].AsObjectId("actiontrymove"), + args[1].AsObjectId("actiontrymove"), + 0u, + args.Count >= 3 ? args[2].AsInt32("actiontrymove") : 0).Accepted), + "actiontrymove[item,destination,slot?,addToStack?]"); + registry.Register("actiontrysplit", 2, 3, (_, args) => + { + uint destination = args.Count == 3 + ? args[2].AsObjectId("actiontrysplit") + : host.Automation.Character.ObjectId; + return ExpressionValue.Boolean(host.Automation.Items.MoveToContainer( + args[0].AsObjectId("actiontrysplit"), + destination, + ToUInt(args[1], "actiontrysplit")).Accepted); + }, "actiontrysplit[item,newStackSize,destination?]"); + registry.Register("actiontrycastbyid", 1, 1, (_, args) => CastResult( + host.Automation.Magic, + ToUInt(args[0], "actiontrycastbyid"), + target: null), "actiontrycastbyid[spellId]"); + registry.Register("actiontrycastbyidontarget", 2, 2, (_, args) => CastResult( + host.Automation.Magic, + ToUInt(args[0], "actiontrycastbyidontarget"), + args[1].AsObjectId("actiontrycastbyidontarget")), + "actiontrycastbyidontarget[spellId,target]"); + registry.Register("actiontryequipanywand", 0, 0, (_, _) => + { + PluginEquipmentItem? wand = host.Automation.Equipment + .CaptureOwnedEquipment() + .FirstOrDefault(static item => + (item.ValidLocations & 0x01000000u) != 0u); + return wand is { ObjectId: > 0u } item + ? ExpressionValue.Boolean(item.IsEquipped + || host.Automation.Equipment.Equip(item.ObjectId).Accepted) + : ExpressionValue.Zero; + }, "actiontryequipanywand[]"); + } + + private static void RegisterFellowship( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + IFellowshipAutomation fellowship = host.Automation.Fellowship; + registry.Register("getfellowshipstatus", 0, 0, (_, _) => + ExpressionValue.Boolean(fellowship.IsInFellowship), + "getfellowshipstatus[]"); + registry.Register("getfellowshipname", 0, 0, (_, _) => + ExpressionValue.String(fellowship.Name), "getfellowshipname[]"); + registry.Register("getfellowshipcount", 0, 0, (_, _) => + ExpressionValue.Number(fellowship.MemberCount), "getfellowshipcount[]"); + registry.Register("getfellowshipleaderid", 0, 0, (_, _) => + ExpressionValue.Number(fellowship.LeaderObjectId), + "getfellowshipleaderid[]"); + registry.Register("getfellowid", 1, 1, (_, args) => + { + IReadOnlyList roster = fellowship.CaptureRoster(); + int index = args[0].AsInt32("getfellowid"); + return (uint)index < (uint)roster.Count + ? ExpressionValue.Number(roster[index].ObjectId) + : ExpressionValue.Zero; + }, "getfellowid[index]"); + registry.Register("getfellowname", 1, 1, (_, args) => + { + IReadOnlyList roster = fellowship.CaptureRoster(); + int index = args[0].AsInt32("getfellowname"); + return (uint)index < (uint)roster.Count + ? ExpressionValue.String(roster[index].Name) + : ExpressionValue.String(string.Empty); + }, "getfellowname[index]"); + registry.Register("getfellowshiplocked", 0, 0, (_, _) => + ExpressionValue.Boolean(fellowship.IsLocked), "getfellowshiplocked[]"); + registry.Register("getfellowshipisleader", 0, 0, (_, _) => + ExpressionValue.Boolean( + fellowship.IsInFellowship + && fellowship.LeaderObjectId == host.Automation.Character.ObjectId), + "getfellowshipisleader[]"); + registry.Register("getfellowshipisopen", 0, 0, (_, _) => + ExpressionValue.Boolean(fellowship.IsOpen), "getfellowshipisopen[]"); + registry.Register("getfellowshipisfull", 0, 0, (_, _) => + ExpressionValue.Boolean( + fellowship.IsInFellowship && fellowship.MemberCount == 9), + "getfellowshipisfull[]"); + registry.Register("getfellowshipcanrecruit", 0, 0, (_, _) => + ExpressionValue.Boolean( + fellowship.IsInFellowship + && (fellowship.IsOpen + || fellowship.LeaderObjectId == host.Automation.Character.ObjectId) + && fellowship.MemberCount < 9), + "getfellowshipcanrecruit[]"); + registry.Register("getfellownames", 0, 0, (_, _) => ExpressionValue.List( + new ExpressionList(fellowship.CaptureRoster().Select( + static member => ExpressionValue.String(member.Name)))), + "getfellownames[]"); + registry.Register("getfellowids", 0, 0, (_, _) => ExpressionValue.List( + new ExpressionList(fellowship.CaptureRoster().Select( + static member => ExpressionValue.Number(member.ObjectId)))), + "getfellowids[]"); + } + + private static void RegisterWorldTime( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + PluginWorldTimeSnapshot Snapshot() => host.Automation.WorldTime.Snapshot; + registry.Register("getgameyear", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Year), "getgameyear[]"); + registry.Register("getgamemonth", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Month), "getgamemonth[]"); + registry.Register("getgamemonthname", 1, 1, (_, args) => + { + int index = args[0].AsInt32("getgamemonthname"); + return (uint)index < (uint)GameMonthNames.Length + ? ExpressionValue.String(GameMonthNames[index]) + : ExpressionValue.String(string.Empty); + }, "getgamemonthname[monthIndex]"); + registry.Register("getgameday", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Day), "getgameday[]"); + registry.Register("getgamehour", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Hour), "getgamehour[]"); + registry.Register("getgamehourname", 1, 1, (_, args) => + { + int index = args[0].AsInt32("getgamehourname"); + return (uint)index < (uint)GameHourNames.Length + ? ExpressionValue.String(GameHourNames[index]) + : ExpressionValue.String(string.Empty); + }, "getgamehourname[hourIndex]"); + registry.Register("getminutesuntilday", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().MinutesUntilDay), + "getminutesuntilday[]"); + registry.Register("getminutesuntilnight", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().MinutesUntilNight), + "getminutesuntilnight[]"); + registry.Register("getgameticks", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().GameTicks), "getgameticks[]"); + registry.Register("getisday", 0, 0, (_, _) => + ExpressionValue.Boolean(Snapshot().IsDay), "getisday[]"); + registry.Register("getisnight", 0, 0, (_, _) => + ExpressionValue.Boolean(!Snapshot().IsDay), "getisnight[]"); + } + + private static void RegisterCombatAndMovement( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("getcombatstate", 0, 0, (_, _) => ExpressionValue.String( + host.Automation.Combat.Snapshot.Mode.ToString()), "getcombatstate[]"); + registry.Register("setcombatstate", 1, 1, (_, args) => + { + if (!Enum.TryParse( + args[0].AsString("setcombatstate"), + ignoreCase: true, + out PluginCombatMode mode) + || mode == PluginCombatMode.Unknown) + { + return ExpressionValue.Zero; + } + return ExpressionValue.Boolean( + host.Automation.Combat.EnterMode(mode).Accepted); + }, "setcombatstate[state]"); + registry.Register("getbusystate", 0, 0, (_, _) => ExpressionValue.Number( + host.Automation.Items.IsBusy + || host.Automation.Equipment.IsBusy + || host.Automation.Magic.IsCasting ? 1d : 0d), "getbusystate[]"); + registry.Register("getequippedweapontype", 0, 0, (_, _) => + { + foreach (PluginEquipmentItem item in host.Automation.Equipment + .CaptureOwnedEquipment().Where(static item => item.IsEquipped)) + { + if ((item.EquippedLocation & 0x00400000u) != 0u) + return ExpressionValue.String("Missile"); + if ((item.EquippedLocation & 0x01000000u) != 0u) + return ExpressionValue.String("Wand"); + if ((item.EquippedLocation & 0x00100000u) != 0u) + return ExpressionValue.String("Melee"); + } + return ExpressionValue.String("None"); + }, "getequippedweapontype[]"); + registry.Register("setmotion", 2, 2, (_, args) => + { + string motion = args[0].AsString("setmotion"); + bool enabled = args[1].AsNumber("setmotion") != 0d; + PluginNavigationSnapshot snapshot = host.Automation.Navigation.Snapshot; + PluginMovementIntent intent = enabled + ? MotionIntent(motion) + : default; + PluginNavigationCommandStatus result = enabled + ? host.Automation.Navigation.SetMovementIntent(intent) + : host.Automation.Navigation.ClearMovementIntent(); + return ExpressionValue.Boolean(result == PluginNavigationCommandStatus.Accepted); + }, "setmotion[motion,state]"); + registry.Register("getmotion", 1, 1, (_, args) => + { + string motion = args[0].AsString("getmotion"); + PluginNavigationSnapshot snapshot = host.Automation.Navigation.Snapshot; + return ExpressionValue.Number(snapshot.IsMoving + && motion is not null ? 2d : 0d); + }, "getmotion[motion]"); + registry.Register("clearmotion", 0, 0, (_, _) => ExpressionValue.Boolean( + host.Automation.Navigation.ClearMovementIntent() + == PluginNavigationCommandStatus.Accepted), "clearmotion[]"); + } + + private static void RegisterObjectProperty( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + PropertyKind kind) + { + registry.Register(name, 2, 2, (_, args) => + { + uint objectId = args[0].AsObjectId(name); + uint key = ToUInt(args[1], name); + if (!host.Automation.Objects.TryCaptureProperties( + objectId, + out PluginItemProperties properties)) + { + return ExpressionValue.Zero; + } + return kind switch + { + PropertyKind.Int => ExpressionValue.Number(Get(properties.Ints, key)), + PropertyKind.Double => ExpressionValue.Number(Get(properties.Floats, key)), + PropertyKind.Bool => ExpressionValue.Boolean(Get(properties.Bools, key)), + PropertyKind.String => properties.Strings.TryGetValue(key, out string? value) + ? ExpressionValue.String(value) + : ExpressionValue.Zero, + _ => ExpressionValue.Zero, + }; + }, $"{name}[object,property]"); + } + + private static void RegisterFinder( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + ObjectSet set, + Func predicate) + { + registry.Register(name, 1, 1, (_, args) => ObjectList(FilterSet( + host.Automation.Objects.CaptureObjects(), + set).Where(obj => predicate(obj, args[0]))), $"{name}[value]"); + } + + private static void RegisterRegexFinder( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + ObjectSet set) + { + registry.Register(name, 1, 1, (_, args) => + { + Regex regex = CreateRegex(args[0].AsString(name)); + return ObjectList(FilterSet( + host.Automation.Objects.CaptureObjects(), + set).Where(obj => regex.IsMatch(obj.Name))); + }, $"{name}[pattern]"); + } + + private static void RegisterNearest( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + Func, bool> predicate, + int argumentCount = 1) + { + registry.Register(name, argumentCount, argumentCount, (_, args) => + { + PluginNavigationSnapshot player = host.Automation.Navigation.Snapshot; + if (!player.IsAvailable) + return ExpressionValue.Zero; + PluginWorldObject? nearest = host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsLandscape && obj.HasPosition && predicate(obj, args)) + .OrderBy(obj => player.Position.HorizontalDistanceMeters(obj.Position)) + .ThenBy(static obj => obj.ObjectId) + .Cast() + .FirstOrDefault(); + return nearest is { } found + ? ExpressionValue.WorldObject(found.ObjectId) + : ExpressionValue.Zero; + }, $"{name}[...]" ); + } + + private static ExpressionValue FirstObject( + IPluginHost host, + ObjectSet set, + Func predicate) + { + PluginWorldObject? found = FilterSet( + host.Automation.Objects.CaptureObjects(), set) + .Where(predicate) + .OrderBy(static obj => obj.ObjectId) + .Cast() + .FirstOrDefault(); + return found is { } value + ? ExpressionValue.WorldObject(value.ObjectId) + : ExpressionValue.Zero; + } + + private static IEnumerable FilterSet( + IEnumerable objects, + ObjectSet set) => set switch + { + ObjectSet.Inventory => objects.Where(static obj => obj.IsOwned), + ObjectSet.Landscape => objects.Where(static obj => obj.IsLandscape), + _ => objects, + }; + + private static ExpressionValue ObjectList(IEnumerable objects) => + ExpressionValue.List(new ExpressionList(objects + .OrderBy(static obj => obj.ObjectId) + .Select(static obj => ExpressionValue.WorldObject(obj.ObjectId)))); + + private static ExpressionValue NumberList(IEnumerable values) => + ExpressionValue.List(new ExpressionList(values.Select( + static value => ExpressionValue.Number(value)))); + + private static bool TryObject( + IWorldObjectAutomation objects, + in ExpressionValue value, + string operation, + out PluginWorldObject obj) => objects.TryGet( + value.AsObjectId(operation), + out obj); + + private static bool TryPlayerProperties( + IPluginHost host, + out PluginItemProperties properties) => + host.Automation.Objects.TryCaptureProperties( + host.Automation.Character.ObjectId, + out properties); + + private static double PlayerProperty( + IPluginHost host, + in ExpressionValue keyValue, + PropertyKind kind) + { + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return 0d; + uint key = ToUInt(keyValue, "character property"); + return kind switch + { + PropertyKind.Int => Get(properties.Ints, key), + PropertyKind.Int64 => Get(properties.Int64s, key), + PropertyKind.Double => Get(properties.Floats, key), + PropertyKind.Bool => Get(properties.Bools, key) ? 1d : 0d, + _ => 0d, + }; + } + + private static double Attribute( + ICharacterInfo character, + in ExpressionValue id, + bool buffed) + { + int kind = id.AsInt32("character attribute") - 1; + foreach (PluginAttributeInfo attribute in character.Attributes) + { + if (attribute.Kind == kind) + return buffed ? attribute.Current : attribute.Base; + } + return 0d; + } + + private static double Skill( + ICharacterInfo character, + in ExpressionValue id, + SkillRead read) + { + if (!character.TryGetSkill(ToUInt(id, "character skill"), out PluginSkillInfo skill)) + return 0d; + return read switch + { + SkillRead.Base => skill.Base, + SkillRead.Buffed => skill.Current, + SkillRead.Training => skill.Training switch + { + PluginSkillTraining.Untrained => 1d, + PluginSkillTraining.Trained => 2d, + PluginSkillTraining.Specialized => 3d, + _ => 0d, + }, + _ => 0d, + }; + } + + private static double Vital( + ICharacterInfo character, + in ExpressionValue id, + VitalRead read) + { + (uint current, uint maximum) = id.AsInt32("character vital") switch + { + 1 => (character.CurrentHealth, character.MaxHealth), + 2 => (character.CurrentStamina, character.MaxStamina), + 3 => (character.CurrentMana, character.MaxMana), + _ => (0u, 0u), + }; + return read == VitalRead.Current ? current : maximum; + } + + private static double ObjectVital( + IPluginHost host, + in ExpressionValue objectValue, + VitalObjectRead read) + { + uint id = objectValue.AsObjectId("object vital"); + ICharacterInfo character = host.Automation.Character; + if (id == character.ObjectId) + { + return read switch + { + VitalObjectRead.Fraction => character.MaxHealth == 0u + ? -1d : character.CurrentHealth / (double)character.MaxHealth, + VitalObjectRead.Health => character.CurrentHealth, + VitalObjectRead.Stamina => character.CurrentStamina, + VitalObjectRead.Mana => character.CurrentMana, + _ => -1d, + }; + } + if (read is VitalObjectRead.Fraction or VitalObjectRead.Health) + { + foreach (PluginCombatTarget target in host.Automation.Combat + .CaptureHostileTargets(float.MaxValue)) + { + if (target.ObjectId != id || !target.IsHealthKnown) + continue; + return read == VitalObjectRead.Fraction + ? target.HealthFraction + : target.MaximumHealth > 0 + ? target.HealthFraction * target.MaximumHealth + : -1d; + } + } + return -1d; + } + + private static double FreeSlots( + IPluginHost host, + IReadOnlyList args, + bool containers) + { + uint containerId = args.Count == 0 + ? host.Automation.Character.ObjectId + : args[0].AsObjectId("free slots"); + if (!host.Automation.Objects.TryGet(containerId, out PluginWorldObject container) + || container.ObjectClass is not (PluginObjectClass.Container or PluginObjectClass.Player)) + { + return -1d; + } + IReadOnlyList all = host.Automation.Objects.CaptureObjects(); + int used = all.Count(item => item.ContainerObjectId == containerId + && (item.ObjectClass == PluginObjectClass.Container) == containers); + int capacity = containers + ? container.ContainersCapacity + : container.ItemsCapacity; + return Math.Max(0, capacity - used); + } + + private static ExpressionValue CastResult( + IMagicCommands magic, + uint spellId, + uint? target) + { + PluginCastGate gate = target is uint objectId + ? magic.EvaluateGate(spellId, objectId) + : magic.EvaluateGate(spellId); + if (gate == PluginCastGate.Ready) + { + bool started = target is uint id + ? magic.Cast(spellId, id) + : magic.Cast(spellId); + return ExpressionValue.Number(started ? 1d : 0d); + } + return ExpressionValue.Number(gate is PluginCastGate.NotKnown + or PluginCastGate.Unavailable + or PluginCastGate.Refused ? 2d : 0d); + } + + private static ExpressionValue SpellProperty( + in PluginSpellInfo spell, + string property) => property.Trim().ToLowerInvariant() switch + { + "id" or "spellid" => ExpressionValue.Number(spell.SpellId), + "name" => ExpressionValue.String(spell.Name), + "family" => ExpressionValue.Number(spell.Family), + "generation" or "tier" => ExpressionValue.Number(spell.Tier), + "difficulty" => ExpressionValue.Number(spell.Difficulty), + "quality" => ExpressionValue.Number(spell.Quality), + "manacost" => ExpressionValue.Number(spell.ManaCost), + "duration" => ExpressionValue.Number(spell.DurationSeconds), + "school" => ExpressionValue.Number(spell.School), + "description" => ExpressionValue.String(spell.Description), + "isbeneficial" => ExpressionValue.Boolean(spell.IsBeneficial), + "isoffensive" => ExpressionValue.Boolean(spell.IsOffensive), + "isdebuff" => ExpressionValue.Boolean(spell.IsDebuff), + "spelltype" => ExpressionValue.Number(spell.SpellType), + "flags" => ExpressionValue.Number(spell.RawFlags), + "targetmask" => ExpressionValue.Number(spell.TargetMask), + _ => ExpressionValue.Zero, + }; + + private static double SpellExpiration( + IReadOnlyList enchantments, + uint spellId) + { + foreach (PluginActiveEnchantment enchantment in enchantments) + { + if (enchantment.SpellId == spellId) + return enchantment.SecondsRemaining; + } + return 0d; + } + + private static PluginMovementIntent MotionIntent(string motion) => + motion.Trim().ToLowerInvariant() switch + { + "forward" => new PluginMovementIntent(Forward: true), + "backward" or "backup" => new PluginMovementIntent(Backward: true), + "turnright" => new PluginMovementIntent(TurnRight: true), + "turnleft" => new PluginMovementIntent(TurnLeft: true), + "straferight" => new PluginMovementIntent(StrafeRight: true), + "strafeleft" => new PluginMovementIntent(StrafeLeft: true), + "walk" => new PluginMovementIntent(Forward: true, Run: false), + _ => throw new ExpressionEvaluationException( + $"Invalid motion '{motion}'."), + }; + + private static ExpressionValue Coordinates(in PluginNavigationPosition position) => + ExpressionValue.Coordinates(new ExpressionCoordinates( + position.EastWest, + position.NorthSouth, + position.Elevation / 240d)); + + private static double HeadingTo( + in PluginNavigationPosition from, + in PluginNavigationPosition to) + { + double east = to.EastWest - from.EastWest; + double north = to.NorthSouth - from.NorthSouth; + return NormalizeHeading(Math.Atan2(east, north) * 180d / Math.PI); + } + + private static double NormalizeHeading(double heading) + { + double result = heading % 360d; + return result < 0d ? result + 360d : result; + } + + private static Regex CreateRegex(string pattern) => new( + pattern, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RegexTimeout); + + private static uint ToUInt(in ExpressionValue value, string operation) => + checked((uint)value.AsNumber(operation)); + + private static double Get(IReadOnlyDictionary values, uint key) => + values.TryGetValue(key, out int value) ? value : 0d; + + private static double Get(IReadOnlyDictionary values, uint key) => + values.TryGetValue(key, out long value) ? value : 0d; + + private static double Get( + IReadOnlyDictionary values, + uint key, + double fallback = 0d) => + values.TryGetValue(key, out double value) ? value : fallback; + + private static bool Get(IReadOnlyDictionary values, uint key) => + values.TryGetValue(key, out bool value) && value; + + /// Legacy .NET Framework ordinal string hash used by UtilityBelt. + private static int LegacyStringHash(string value) + { + unchecked + { + int hash1 = 5381; + int hash2 = hash1; + for (int index = 0; index < value.Length; index += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ value[index]; + if (index == value.Length - 1) + break; + hash2 = ((hash2 << 5) + hash2) ^ value[index + 1]; + } + return hash1 + hash2 * 1566083941; + } + } + + private enum PropertyKind { Int, Int64, Double, Bool, String } + private enum SkillRead { Base, Buffed, Training } + private enum VitalRead { Current, Maximum } + private enum VitalObjectRead { Fraction, Health, Stamina, Mana } + private enum ObjectSet { All, Inventory, Landscape } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs b/src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs new file mode 100644 index 00000000..0c28a348 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs @@ -0,0 +1,429 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// One shared expression lifetime for MossTank commands and Meta. Session, +/// persistent, and world-global variables therefore mean the same thing from +/// every entry point, just as they do in UtilityBelt. +/// +internal sealed class MossTankExpressionRuntime : IDisposable +{ + private const int DefaultInstructionBudget = 10_000; + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private readonly ExpressionState _state = new(); + private readonly ExpressionFunctionRegistry _functions; + private readonly ExperienceMeter _experience; + private readonly QuestTracker _quests; + private readonly SalvageStagingManager _salvage; + private readonly StatusHudManager _statusHud; + private readonly List _delayed = []; + private int _nextDelayId = 1; + private string _identity = string.Empty; + private string? _persistentJson; + private string? _globalJson; + private bool _disposed; + + public MossTankExpressionRuntime(IPluginHost host, Random? random = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _experience = new ExperienceMeter(host); + _quests = new QuestTracker(host); + _salvage = new SalvageStagingManager(host); + _statusHud = new StatusHudManager(host); + _functions = CoreExpressionFunctions.CreateDefault(random); + HostExpressionFunctions.Register(_functions, host); + RegisterExperienceFunctions(); + RegisterQuestFunctions(); + RegisterSalvageFunctions(); + RegisterStatusHudFunctions(); + RegisterExecutionFunctions(); + BindIdentity(force: true); + } + + public ExpressionState State => _state; + internal ExpressionFunctionRegistry Registry => _functions; + public IReadOnlyCollection Functions => _functions.Functions; + public int PendingExecutionCount => _delayed.Count; + + public ExpressionValue Evaluate( + string source, + int instructionBudget = DefaultInstructionBudget, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + BindIdentity(force: false); + ExpressionProgram program = ExpressionProgram.Compile(source); + var context = new ExpressionEvaluationContext( + _state, + _functions, + instructionBudget, + cancellationToken); + ExpressionValue result = program.Evaluate(context); + FlushVariables(); + return result; + } + + public void OnTick(double elapsedSeconds) + { + ObjectDisposedException.ThrowIf(_disposed, this); + BindIdentity(force: false); + if (elapsedSeconds < 0d || !double.IsFinite(elapsedSeconds)) + throw new ArgumentOutOfRangeException(nameof(elapsedSeconds)); + _experience.OnTick(elapsedSeconds); + _quests.OnTick(elapsedSeconds); + if (_delayed.Count == 0) + return; + + double elapsedMilliseconds = elapsedSeconds * 1000d; + for (int index = 0; index < _delayed.Count; index++) + _delayed[index] = _delayed[index] with + { + RemainingMilliseconds = + _delayed[index].RemainingMilliseconds - elapsedMilliseconds, + }; + + DelayedExpression[] ready = _delayed + .Where(static delayed => delayed.RemainingMilliseconds <= 0d) + .OrderBy(static delayed => delayed.Id) + .ToArray(); + if (ready.Length == 0) + return; + _delayed.RemoveAll(static delayed => delayed.RemainingMilliseconds <= 0d); + foreach (DelayedExpression delayed in ready) + { + try + { + Evaluate(delayed.Source); + } + catch (Exception error) + { + _host.Log.Error( + $"Delayed expression {delayed.Id} failed: {error.Message}"); + } + } + } + + public void ClearSession() + { + _state.Clear(ExpressionVariableScope.Session); + _delayed.Clear(); + } + + public void DestroyAuxiliaryViews() => _statusHud.Destroy(); + + public void Dispose() + { + if (_disposed) + return; + FlushVariables(); + _delayed.Clear(); + _statusHud.Destroy(); + _disposed = true; + } + + private void RegisterExecutionFunctions() + { + _functions.Register("exec", 1, 1, (context, args) => + ExpressionProgram.Compile(args[0].AsString("exec")).Evaluate(context), + "exec[expression]"); + _functions.Register("delayexec", 2, 2, (_, args) => + { + double delay = Math.Max(0d, args[0].AsNumber("delayexec")); + string source = args[1].AsString("delayexec"); + int id = NextDelayId(); + _delayed.Add(new DelayedExpression(id, delay, source)); + return ExpressionValue.Number(id); + }, "delayexec[milliseconds,expression]"); + _functions.Register("clearexec", 1, 1, (_, args) => + { + int id = args[0].AsInt32("clearexec"); + return ExpressionValue.Boolean( + _delayed.RemoveAll(delayed => delayed.Id == id) != 0); + }, "clearexec[id]"); + } + + private void RegisterExperienceFunctions() + { + _functions.Register("xpreset", 0, 0, (_, _) => + { + _experience.Reset(); + return ExpressionValue.One; + }, "xpreset[]"); + _functions.Register("xpmeter", 0, 0, (_, _) => + ExpressionValue.String(_experience.Format()), "xpmeter[]"); + _functions.Register("xpduration", 0, 0, (_, _) => + ExpressionValue.Number(_experience.DurationSeconds), "xpduration[]"); + _functions.Register("xptotal", 0, 0, (_, _) => + ExpressionValue.Number(_experience.Experience), "xptotal[]"); + _functions.Register("lumtotal", 0, 0, (_, _) => + ExpressionValue.Number(_experience.Luminance), "lumtotal[]"); + _functions.Register("xpavg", 0, 0, (_, _) => + ExpressionValue.Number(_experience.ExperiencePerHour), "xpavg[]"); + _functions.Register("lumavg", 0, 0, (_, _) => + ExpressionValue.Number(_experience.LuminancePerHour), "lumavg[]"); + } + + private void RegisterQuestFunctions() + { + _functions.Register("testquestflag", 1, 1, (_, args) => + ExpressionValue.Boolean(_quests.HasCompleted( + args[0].AsString("testquestflag"))), "testquestflag[questflag]"); + _functions.Register("getqueststatus", 1, 1, (_, args) => + ExpressionValue.Boolean(_quests.IsReady( + args[0].AsString("getqueststatus"))), "getqueststatus[questflag]"); + _functions.Register("getquestktprogress", 1, 1, (_, args) => + ExpressionValue.Number(_quests.Progress( + args[0].AsString("getquestktprogress"))), + "getquestktprogress[questflag]"); + _functions.Register("getquestktrequired", 1, 1, (_, args) => + ExpressionValue.Number(_quests.Required( + args[0].AsString("getquestktrequired"))), + "getquestktrequired[questflag]"); + _functions.Register("isrefreshingquests", 0, 0, (_, _) => + ExpressionValue.Boolean(_quests.IsRefreshing), "isrefreshingquests[]"); + } + + private void RegisterSalvageFunctions() + { + _functions.Register("ustadd", 1, 1, (_, args) => + ExpressionValue.Boolean(_salvage.Add( + args[0].AsObjectId("ustadd"))), "ustadd[object]"); + _functions.Register("ustopen", 0, 0, (_, _) => + ExpressionValue.Boolean(_salvage.Open()), "ustopen[]"); + _functions.Register("ustsalvage", 0, 0, (_, _) => + ExpressionValue.Boolean(_salvage.Salvage()), "ustsalvage[]"); + } + + private void RegisterStatusHudFunctions() + { + _functions.Register("statushud", 2, 2, (_, args) => + ExpressionValue.Boolean(_statusHud.Update( + args[0].AsString("statushud"), + args[1].ToDisplayString())), + "statushud[key,value]"); + _functions.Register("statushudcolored", 3, 3, (_, args) => + ExpressionValue.Boolean(_statusHud.Update( + args[0].AsString("statushudcolored"), + args[1].ToDisplayString(), + checked((uint)args[2].AsNumber("statushudcolored")))), + "statushudcolored[key,value,rgb]"); + } + + private int NextDelayId() + { + int initial = _nextDelayId; + do + { + int candidate = _nextDelayId++; + if (_nextDelayId <= 0) + _nextDelayId = 1; + if (_delayed.All(delayed => delayed.Id != candidate)) + return candidate; + } + while (_nextDelayId != initial); + throw new ExpressionEvaluationException("No delayed-expression ids remain"); + } + + private void BindIdentity(bool force) + { + ICharacterInfo character = _host.Automation.Character; + string identity = string.Join( + '\n', + character.WorldName, + character.AccountName, + character.Name); + if (!force && identity.Equals(_identity, StringComparison.Ordinal)) + return; + if (_identity.Length != 0) + FlushVariables(); + _identity = identity; + _quests.BindIdentity(identity); + _salvage.Clear(); + _state.Clear(ExpressionVariableScope.Session); + _delayed.Clear(); + _experience.Reset(); + _persistentJson = LoadScope(ExpressionVariableScope.Persistent); + _globalJson = LoadScope(ExpressionVariableScope.Global); + } + + private string? LoadScope(ExpressionVariableScope scope) + { + _state.Clear(scope); + if (!_host.Storage.IsAvailable || _identity.Length == 0) + return null; + try + { + string? json = _host.Storage.ReadText(StorageKey(scope)); + if (string.IsNullOrWhiteSpace(json)) + return null; + Dictionary? document = JsonSerializer.Deserialize< + Dictionary>(json, JsonOptions); + if (document is not null) + { + _state.Replace(scope, document.Select(static pair => + new KeyValuePair( + pair.Key, + Restore(pair.Value)))); + } + return json; + } + catch (Exception error) + { + _host.Log.Error($"Unable to load {scope} expression variables: {error.Message}"); + return null; + } + } + + private void FlushVariables() + { + if (!_host.Storage.IsAvailable || _identity.Length == 0) + return; + _persistentJson = FlushScope( + ExpressionVariableScope.Persistent, + _persistentJson); + _globalJson = FlushScope(ExpressionVariableScope.Global, _globalJson); + } + + private string? FlushScope(ExpressionVariableScope scope, string? previous) + { + try + { + Dictionary document = _state.Capture(scope) + .ToDictionary( + static pair => pair.Key, + static pair => Store(pair.Value), + StringComparer.OrdinalIgnoreCase); + string json = JsonSerializer.Serialize(document, JsonOptions); + if (!json.Equals(previous, StringComparison.Ordinal)) + _host.Storage.WriteText(StorageKey(scope), json); + return json; + } + catch (Exception error) + { + _host.Log.Error($"Unable to save {scope} expression variables: {error.Message}"); + return previous; + } + } + + private string StorageKey(ExpressionVariableScope scope) + { + ICharacterInfo character = _host.Automation.Character; + string owner = scope == ExpressionVariableScope.Persistent + ? string.Join('\n', character.WorldName, character.AccountName, character.Name) + : string.Join('\n', character.WorldName, character.AccountName); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(owner)); + return $"expressions/{scope.ToString().ToLowerInvariant()}/" + + $"{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json"; + } + + private static StoredValue Store(in ExpressionValue value) => value.Kind switch + { + ExpressionValueKind.Number => new StoredValue + { + Kind = "number", + Number = value.AsNumber(), + }, + ExpressionValueKind.Boolean => new StoredValue + { + Kind = "boolean", + Number = value.AsNumber(), + }, + ExpressionValueKind.String => new StoredValue + { + Kind = "string", + Text = value.AsString(), + }, + ExpressionValueKind.List => new StoredValue + { + Kind = "list", + List = value.AsList().Items.Select(static item => Store(item)).ToList(), + }, + ExpressionValueKind.Dictionary => new StoredValue + { + Kind = "dictionary", + Dictionary = value.AsDictionary().Items.ToDictionary( + static pair => pair.Key, + static pair => Store(pair.Value), + StringComparer.Ordinal), + }, + ExpressionValueKind.Coordinates => StoreCoordinates(value.AsCoordinates()), + ExpressionValueKind.WorldObject => new StoredValue + { + Kind = "worldobject", + Number = value.AsObjectId(), + }, + _ => throw new ExpressionEvaluationException( + $"{value.Kind} values cannot be persisted"), + }; + + private static StoredValue StoreCoordinates(in ExpressionCoordinates value) => new() + { + Kind = "coordinates", + Coordinates = + [ + value.EastWest, + value.NorthSouth, + value.Elevation, + ], + }; + + private static ExpressionValue Restore(StoredValue value) => + value.Kind.ToLowerInvariant() switch + { + "number" => ExpressionValue.Number(value.Number), + "boolean" => ExpressionValue.Boolean(value.Number != 0d), + "string" => ExpressionValue.String(value.Text), + "list" => ExpressionValue.List(new ExpressionList( + (value.List ?? []).Select(Restore))), + "dictionary" => RestoreDictionary(value.Dictionary), + "coordinates" => RestoreCoordinates(value.Coordinates), + "worldobject" => ExpressionValue.WorldObject(checked((uint)value.Number)), + _ => ExpressionValue.Zero, + }; + + private static ExpressionValue RestoreDictionary( + Dictionary? values) + { + var result = new ExpressionDictionary(); + if (values is not null) + { + foreach ((string key, StoredValue value) in values) + result.Items[key] = Restore(value); + } + return ExpressionValue.Dictionary(result); + } + + private static ExpressionValue RestoreCoordinates(double[]? values) => + values is { Length: >= 2 } + ? ExpressionValue.Coordinates(new ExpressionCoordinates( + values[0], + values[1], + values.Length >= 3 ? values[2] : 0d)) + : ExpressionValue.Zero; + + private sealed class StoredValue + { + public string Kind { get; set; } = "number"; + public double Number { get; set; } + public string Text { get; set; } = string.Empty; + public List? List { get; set; } + public Dictionary? Dictionary { get; set; } + public double[]? Coordinates { get; set; } + } + + private readonly record struct DelayedExpression( + int Id, + double RemainingMilliseconds, + string Source); +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs b/src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs new file mode 100644 index 00000000..b2e47cd7 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs @@ -0,0 +1,152 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// UtilityBelt-compatible /myquests cache. The server remains authoritative; +/// this owner only parses the same lines UB consumes and never invents flags. +/// +internal sealed partial class QuestTracker(IPluginHost host) +{ + private const double CompletionSilenceSeconds = 1d; + private const double RetrySeconds = 15d; + private const int MaximumAttempts = 3; + + private readonly Dictionary _flags = + new(StringComparer.OrdinalIgnoreCase); + private ulong _chatSequence; + private string _identity = string.Empty; + private double _silenceSeconds; + private int _attemptsRemaining; + private bool _receivedFlag; + + public bool IsRefreshing { get; private set; } + public int Count => _flags.Count; + + public void BindIdentity(string identity) + { + if (identity.Equals(_identity, StringComparison.Ordinal)) + return; + _identity = identity; + _flags.Clear(); + IsRefreshing = false; + _receivedFlag = false; + _silenceSeconds = 0d; + if (!string.IsNullOrWhiteSpace(identity)) + Refresh(); + } + + public void Refresh() + { + if (IsRefreshing) + return; + _flags.Clear(); + _attemptsRemaining = MaximumAttempts; + _receivedFlag = false; + _silenceSeconds = 0d; + IsRefreshing = true; + SubmitRequest(); + } + + public void OnTick(double elapsedSeconds) + { + CaptureChat(); + if (!IsRefreshing) + return; + _silenceSeconds += elapsedSeconds; + if (_receivedFlag && _silenceSeconds > CompletionSilenceSeconds) + { + IsRefreshing = false; + return; + } + if (!_receivedFlag && _silenceSeconds > RetrySeconds) + SubmitRequest(); + } + + public bool HasCompleted(string key) => + _flags.ContainsKey(Normalize(key)); + + public bool IsReady(string key) + { + if (!_flags.TryGetValue(Normalize(key), out QuestFlag flag)) + return true; + DateTimeOffset next = flag.CompletedOn.AddSeconds(flag.RepeatSeconds); + if (next > DateTimeOffset.UtcNow) + return false; + return !(flag.MaxSolves == 1 && flag.Solves <= 1); + } + + public int Progress(string key) => + _flags.TryGetValue(Normalize(key), out QuestFlag flag) ? flag.Solves : 0; + + public int Required(string key) => + _flags.TryGetValue(Normalize(key), out QuestFlag flag) ? flag.MaxSolves : 0; + + private void CaptureChat() + { + foreach (PluginChatMessage message in host.Automation.Chat + .CaptureMessages(_chatSequence).OrderBy(static message => message.Sequence)) + { + _chatSequence = Math.Max(_chatSequence, message.Sequence); + string text = message.Text.Trim(); + if (text.Equals("Quest list is empty.", StringComparison.Ordinal) + || text.Equals( + "The command \"myquests\" is not currently enabled on this server.", + StringComparison.Ordinal)) + { + IsRefreshing = false; + continue; + } + Match match = MyQuestLine().Match(text); + if (!match.Success) + continue; + if (!int.TryParse(match.Groups["solves"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out int solves) + || !long.TryParse(match.Groups["completedOn"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out long completed)) + { + continue; + } + _ = int.TryParse(match.Groups["maxSolves"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out int maximum); + _ = long.TryParse(match.Groups["repeatTime"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out long repeat); + string key = Normalize(match.Groups["key"].Value); + _flags[key] = new QuestFlag( + solves, + maximum, + DateTimeOffset.FromUnixTimeSeconds(Math.Max(0L, completed)), + Math.Max(0L, repeat)); + _receivedFlag = true; + _silenceSeconds = 0d; + } + } + + private void SubmitRequest() + { + if (_attemptsRemaining <= 0) + { + IsRefreshing = false; + return; + } + _attemptsRemaining--; + _silenceSeconds = 0d; + host.Automation.Chat.Submit("/myquests"); + } + + private static string Normalize(string key) => key.Trim().ToLowerInvariant(); + + [GeneratedRegex( + "(?\\S+) \\- (?\\d+) solves \\((?\\d{0,11})\\)\"?((?.*)\" (?.*) (?\\d{0,11}))?.*$", + RegexOptions.CultureInvariant, + 100)] + private static partial Regex MyQuestLine(); + + private readonly record struct QuestFlag( + int Solves, + int MaxSolves, + DateTimeOffset CompletedOn, + long RepeatSeconds); +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs b/src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs new file mode 100644 index 00000000..a1fcbed8 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs @@ -0,0 +1,59 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// UtilityBelt UST expression staging over the canonical salvage command. +internal sealed class SalvageStagingManager(IPluginHost host) +{ + private readonly HashSet _staged = []; + + public int Count => _staged.Count; + + public bool Add(uint objectId) + { + if (!host.Automation.Items.CaptureOwnedItems() + .Any(item => item.ObjectId == objectId && !item.IsEquipped)) + { + return false; + } + _staged.Add(objectId); + return true; + } + + public bool Open() + { + PluginInventoryItem? ust = host.Automation.Items.CaptureOwnedItems() + .Where(static item => item.Name.Equals("Ust", StringComparison.Ordinal)) + .OrderBy(static item => item.ObjectId) + .Cast() + .FirstOrDefault(); + return ust is { } found + && host.Automation.Items.Use(found.ObjectId).Accepted; + } + + public bool Salvage() + { + IReadOnlyList inventory = + host.Automation.Items.CaptureOwnedItems(); + PluginInventoryItem? ust = inventory + .Where(static item => item.Name.Equals("Ust", StringComparison.Ordinal)) + .OrderBy(static item => item.ObjectId) + .Cast() + .FirstOrDefault(); + if (ust is not { } tool) + return false; + uint[] items = inventory + .Where(item => item.ObjectId != tool.ObjectId && _staged.Contains(item.ObjectId)) + .Select(static item => item.ObjectId) + .ToArray(); + if (items.Length == 0 + || !host.Automation.Items.Salvage(tool.ObjectId, items).Accepted) + { + return false; + } + _staged.Clear(); + return true; + } + + public void Clear() => _staged.Clear(); +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs b/src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs new file mode 100644 index 00000000..0fba5a42 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs @@ -0,0 +1,68 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// VTank Meta status HUD backed by one shelf-managed plugin window. +internal sealed class StatusHudManager(IPluginHost host) +{ + private const uint DefaultColor = 0xE8DEC3u; + private const string Markup = """ + + + """; + + private readonly Dictionary _entries = + new(StringComparer.Ordinal); + private readonly StatusBinding _binding = new(); + private IDisposable? _registration; + + public int Count => _entries.Count; + internal IReadOnlyList Rows => _binding.Rows; + internal IReadOnlyList RowColors => _binding.RowColors; + + public bool Update(string key, string value, uint? color = null) + { + if (string.IsNullOrEmpty(key)) + return false; + _entries[key] = new StatusEntry(value ?? string.Empty, color ?? DefaultColor); + _binding.Rows = _entries.Select(static pair => + $"{pair.Key}: {pair.Value.Value}").ToArray(); + _binding.RowColors = _entries.Select(static pair => pair.Value.Color).ToArray(); + if (_registration is null && host.HasUi) + { + _registration = host.Ui.RegisterPanelContent( + new PluginPanelDescriptor("vtank-meta-status", "VTank Meta Status") + { + IconText = "S", + StartVisible = true, + ShowInSidePanel = true, + }, + Markup, + _binding); + } + return true; + } + + public void Destroy() + { + _registration?.Dispose(); + _registration = null; + _entries.Clear(); + _binding.Rows = []; + _binding.RowColors = []; + } + + private readonly record struct StatusEntry(string Value, uint Color); + + private sealed class StatusBinding + { + public bool WindowAvailable => true; + public IReadOnlyList Rows { get; internal set; } = []; + public IReadOnlyList RowColors { get; internal set; } = []; + public int SelectedRow => -1; + } +} diff --git a/src/AcDream.Plugins.MossTank/FellowshipManager.cs b/src/AcDream.Plugins.MossTank/FellowshipManager.cs new file mode 100644 index 00000000..8ec1c4cd --- /dev/null +++ b/src/AcDream.Plugins.MossTank/FellowshipManager.cs @@ -0,0 +1,523 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank's tell-driven fellowship manager: waiting-list recruitment, status +/// commands, and two-minute member votes. The host owns only the retail wire +/// commands; every queue and vote remains plugin policy. +/// +internal sealed class FellowshipManager +{ + private const int MaximumOtherMembers = 8; + private const double RequestLifetimeSeconds = 300d; + private const double VoteLifetimeSeconds = 120d; + private const double VoteCallerCooldownSeconds = 240d; + private const double RecruitRangeMeters = 10d; + + private readonly IPluginHost _host; + private readonly List _waiting = []; + private readonly HashSet _banned = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _voteCooldowns = + new(StringComparer.OrdinalIgnoreCase); + private readonly List _votes = []; + private readonly Dictionary> _tellRate = + new(StringComparer.OrdinalIgnoreCase); + private ulong _chatSequence; + private double _now; + private double _nextRecruitAt; + private int _nextVoteId = 1; + private bool _wasLeader; + private bool _desiredOpen = true; + + public FellowshipManager(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + public string Status { get; private set; } = "Fellow manager idle"; + public IReadOnlyList WaitingNames => + _waiting.Select(static value => value.Name).ToArray(); + + public void Tick(double elapsedSeconds, bool enabled) + { + _now += Math.Max(0d, elapsedSeconds); + IReadOnlyList messages = + _host.Automation.Chat.CaptureMessages(_chatSequence); + foreach (PluginChatMessage message in messages) + { + _chatSequence = Math.Max(_chatSequence, message.Sequence); + if (enabled && IsIncomingTell(message)) + HandleTell(message); + } + + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + if (!enabled || !fellowship.IsInFellowship) + { + Status = enabled ? "Not in a fellowship" : "Fellow manager disabled"; + if (!fellowship.IsInFellowship) + ResetSocialState(); + return; + } + + bool isLeader = fellowship.LeaderObjectId == _host.Automation.Character.ObjectId; + if (_wasLeader && !isLeader) + { + if (_votes.Count != 0) + Fellow("[VT Fellow Manager] I am no longer the fellowship leader. All votes have been canceled. -v-"); + _votes.Clear(); + _waiting.Clear(); + _banned.Clear(); + } + _wasLeader = isLeader; + + RemoveJoinedPlayers(fellowship.CaptureRoster()); + ExpireVotes(isLeader); + ExpireWaitingPlayers(); + if (isLeader) + RecruitNext(fellowship); + Status = isLeader + ? $"Fellow leader — {_waiting.Count} waiting, {_votes.Count} vote(s)" + : $"Fellow member — leader {LeaderName(fellowship)}"; + } + + public void Reset() + { + _chatSequence = 0u; + _now = 0d; + _nextRecruitAt = 0d; + _nextVoteId = 1; + _wasLeader = false; + ResetSocialState(); + _tellRate.Clear(); + Status = "Fellow manager idle"; + } + + private void HandleTell(PluginChatMessage message) + { + string sender = message.Sender.Trim(); + string command = message.Text.Trim(); + if (sender.Length == 0 || command.Length == 0 || IsSpam(sender)) + return; + + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + IReadOnlyList roster = fellowship.CaptureRoster(); + bool isMember = roster.Any(member => member.Name.Equals( + sender, StringComparison.OrdinalIgnoreCase)); + bool isLeader = fellowship.LeaderObjectId == _host.Automation.Character.ObjectId; + + if (command.Equals("xp", StringComparison.OrdinalIgnoreCase)) + { + RequestRecruit(sender, message.SenderObjectId, roster, isLeader); + return; + } + if (command.Equals("line", StringComparison.OrdinalIgnoreCase) + || command.Equals("list", StringComparison.OrdinalIgnoreCase) + || command.Equals("status", StringComparison.OrdinalIgnoreCase)) + { + SendLineStatus(sender, fellowship, isLeader); + return; + } + if (command.Equals("remove", StringComparison.OrdinalIgnoreCase)) + { + RemoveWaiting(sender); + Tell(sender, "[VT Fellow Manager] You have been removed from the list. -v-"); + return; + } + if (command.Equals("leader", StringComparison.OrdinalIgnoreCase)) + { + string openness = fellowship.IsOpen ? "open" : "closed"; + Tell(sender, isLeader + ? $"[VT Fellow Manager] I am the fellowship leader. The fellowship is {openness}. -v-" + : $"[VT Fellow Manager] The leader is currently: {LeaderName(fellowship)}. The fellowship is {openness}. -v-"); + return; + } + if (command.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, "[VT Fellow Manager] Available commands: xp, line, remove, leader, startvote, vote, location, help -v-"); + return; + } + if (command.Equals("help startvote", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, "[VT Fellow Manager] Usage: startvote [votetype] [parameter]. Possible vote types: kick, ban, giveleader, setopen. -v-"); + return; + } + if (command.Equals("help vote", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, "[VT Fellow Manager] Usage: vote [vote id] [yes/no] -v-"); + return; + } + if (command.StartsWith("startvote ", StringComparison.OrdinalIgnoreCase)) + { + StartVote(sender, command, roster, isMember, isLeader); + return; + } + if (command.StartsWith("vote ", StringComparison.OrdinalIgnoreCase)) + { + CastVote(sender, command, isMember); + return; + } + if (command.Equals("location", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, isMember + ? $"[VT Fellow Manager] I am currently located in landcell: {_host.Automation.Navigation.Snapshot.Position.CellId:X8} -v-" + : "[VT Fellow Manager] Sorry, I can only send my location to members of the fellowship. -v-"); + } + } + + private void RequestRecruit( + string sender, + uint senderObjectId, + IReadOnlyList roster, + bool isLeader) + { + if (roster.Any(member => member.Name.Equals( + sender, StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, "[VT Fellow Manager] You are already in the fellowship. -v-"); + return; + } + if (_banned.Contains(sender)) + { + Tell(sender, "[VT Fellow Manager] Sorry, but you have been banned from this fellowship. -v-"); + return; + } + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + if (!isLeader && !fellowship.IsOpen) + { + Tell(sender, $"[VT Fellow Manager] I'm sorry, but the fellowship is closed and I am not the leader. The leader is currently: {LeaderName(fellowship)} -v-"); + return; + } + WaitingPlayer? existing = _waiting.FirstOrDefault(value => + value.Name.Equals(sender, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + existing.ObjectId = senderObjectId != 0u ? senderObjectId : existing.ObjectId; + existing.ExpiresAt = _now + RequestLifetimeSeconds; + int position = _waiting.IndexOf(existing) + 1; + Tell(sender, $"[VT Fellow Manager] You are already number {position} of {_waiting.Count} on the waiting list. -v-"); + return; + } + + _waiting.Add(new WaitingPlayer( + sender, + senderObjectId, + _now + RequestLifetimeSeconds)); + if (isLeader && roster.Count >= MaximumOtherMembers + 1) + { + _desiredOpen = fellowship.IsOpen; + fellowship.SetOpen(false); + Tell(sender, $"[VT Fellow Manager] The fellow is full, and I am the leader. I am adding you to the waiting list at position {_waiting.Count} -v-"); + } + else + { + Tell(sender, "[VT Fellow Manager] I will recruit you in a moment. Please stand close to me. -v-"); + } + } + + private void RecruitNext(IFellowshipAutomation fellowship) + { + if (_waiting.Count == 0) + { + if (fellowship.IsOpen != _desiredOpen) + fellowship.SetOpen(_desiredOpen); + return; + } + if (fellowship.CaptureRoster().Count >= MaximumOtherMembers + 1) + { + if (fellowship.IsOpen) + fellowship.SetOpen(false); + return; + } + if (_now < _nextRecruitAt) + return; + WaitingPlayer player = _waiting[0]; + if (player.ObjectId == 0u || !IsNear(player.ObjectId)) + { + player.Attempts++; + _nextRecruitAt = _now + 1d; + if (player.Attempts == 16) + Tell(player.Name, "[VT Fellow Manager] You are too far away. I will wait 20 seconds and give you one more chance. -v-"); + if (player.Attempts > 30) + { + Tell(player.Name, "[VT Fellow Manager] I'm sorry, but I couldn't recruit you. Please try again. -v-"); + _waiting.RemoveAt(0); + } + return; + } + PluginFellowshipCommandResult result = fellowship.Recruit(player.ObjectId); + _nextRecruitAt = _now + 1d; + if (!result.Accepted) + player.Attempts++; + } + + private void StartVote( + string sender, + string command, + IReadOnlyList roster, + bool isMember, + bool isLeader) + { + if (!isMember || _banned.Contains(sender)) + return; + if (!isLeader) + { + Tell(sender, "[VT Fellow Manager] I am not the fellowship leader and cannot manage votes. -v-"); + return; + } + if (_voteCooldowns.TryGetValue(sender, out double readyAt) && readyAt > _now) + { + Tell(sender, "[VT Fellow Manager] You have initiated a vote too recently. -v-"); + return; + } + string[] parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3) + { + Tell(sender, "[VT Fellow Manager] Not enough parameters to startvote command. Tell me 'help startvote' for more information. -v-"); + return; + } + string kindText = parts[1].ToLowerInvariant(); + string parameter = parts[2].Trim(); + FellowVoteKind kind; + if (kindText is "kick" or "ban" or "giveleader") + { + if (!roster.Any(member => member.Name.Equals( + parameter, StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, $"[VT Fellow Manager] Cannot vote to {kindText} {parameter}, that player is not in the fellow. -v-"); + return; + } + kind = kindText switch + { + "kick" => FellowVoteKind.Kick, + "ban" => FellowVoteKind.Ban, + _ => FellowVoteKind.GiveLeader, + }; + } + else if (kindText == "setopen" + && bool.TryParse(parameter, out _)) + { + kind = FellowVoteKind.SetOpen; + parameter = parameter.ToLowerInvariant(); + } + else + { + Tell(sender, "[VT Fellow Manager] Unknown vote type. Tell me 'help startvote' for more information. -v-"); + return; + } + if (_votes.Any(value => value.Kind == kind + && value.Parameter.Equals(parameter, StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, "[VT Fellow Manager] An identical vote is already in progress! -v-"); + return; + } + var vote = new FellowVote( + _nextVoteId++, kind, parameter, _now + VoteLifetimeSeconds); + vote.Ballots[sender] = true; + _votes.Add(vote); + _voteCooldowns[sender] = _now + VoteCallerCooldownSeconds; + Fellow($"[VT Fellow Manager] {sender} has called a new vote: {kindText} {parameter}! To vote, tell me 'vote {vote.Id} yes' or 'vote {vote.Id} no'. You have 2 minutes. -v-"); + AnnounceVote(vote); + } + + private void CastVote(string sender, string command, bool isMember) + { + if (!isMember || _banned.Contains(sender)) + return; + string[] parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3 + || !int.TryParse(parts[1], out int id) + || !(parts[2].Equals("yes", StringComparison.OrdinalIgnoreCase) + || parts[2].Equals("no", StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, "[VT Fellow Manager] Invalid vote command. Votes should look like: vote idnumber yes, or: vote idnumber no -v-"); + return; + } + FellowVote? vote = _votes.FirstOrDefault(value => value.Id == id); + if (vote is null) + { + Tell(sender, "[VT Fellow Manager] Invalid vote ID number. Votes should look like: vote idnumber yes, or: vote idnumber no -v-"); + return; + } + vote.Ballots[sender] = parts[2].Equals("yes", StringComparison.OrdinalIgnoreCase); + AnnounceVote(vote); + } + + private void ExpireVotes(bool isLeader) + { + foreach (FellowVote vote in _votes.Where(value => value.ExpiresAt <= _now).ToArray()) + { + _votes.Remove(vote); + int yes = vote.Ballots.Values.Count(static value => value); + int no = vote.Ballots.Count - yes; + bool passed = yes > (yes + no) / 2; + Fellow($"[VT Fellow Manager] Vote {vote.Description} {(passed ? "passed" : "failed")} ({yes}/{no}). -v-"); + if (passed && isLeader) + ExecuteVote(vote); + } + } + + private void ExecuteVote(FellowVote vote) + { + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + PluginFellowMember target = fellowship.CaptureRoster().FirstOrDefault(member => + member.Name.Equals(vote.Parameter, StringComparison.OrdinalIgnoreCase)); + switch (vote.Kind) + { + case FellowVoteKind.Kick when target.ObjectId != 0u: + fellowship.Dismiss(target.ObjectId); + break; + case FellowVoteKind.Ban when target.ObjectId != 0u: + _banned.Add(target.Name); + fellowship.Dismiss(target.ObjectId); + break; + case FellowVoteKind.GiveLeader when target.ObjectId != 0u: + fellowship.AssignLeader(target.ObjectId); + break; + case FellowVoteKind.SetOpen: + _desiredOpen = bool.Parse(vote.Parameter); + fellowship.SetOpen(_desiredOpen); + break; + } + } + + private void SendLineStatus( + string sender, + IFellowshipAutomation fellowship, + bool isLeader) + { + if (!isLeader) + { + Tell(sender, $"[VT Fellow Manager] The leader is currently: {LeaderName(fellowship)}. The fellowship is {(fellowship.IsOpen ? "open" : "closed")}. -v-"); + return; + } + WaitingPlayer? waiting = _waiting.FirstOrDefault(value => + value.Name.Equals(sender, StringComparison.OrdinalIgnoreCase)); + if (waiting is null) + { + Tell(sender, _waiting.Count == 0 + ? $"[VT Fellow Manager] There is no waiting list. The fellowship has {fellowship.CaptureRoster().Count} members. -v-" + : $"[VT Fellow Manager] The waiting list contains {_waiting.Count} players. You are not on it. -v-"); + return; + } + Tell(sender, $"[VT Fellow Manager] You are number {_waiting.IndexOf(waiting) + 1} of {_waiting.Count} on the waiting list. -v-"); + } + + private void RemoveJoinedPlayers(IReadOnlyList roster) + { + _waiting.RemoveAll(waiting => roster.Any(member => member.Name.Equals( + waiting.Name, StringComparison.OrdinalIgnoreCase))); + foreach (FellowVote vote in _votes) + { + foreach (string voter in vote.Ballots.Keys + .Where(name => !roster.Any(member => member.Name.Equals( + name, StringComparison.OrdinalIgnoreCase))) + .ToArray()) + { + vote.Ballots.Remove(voter); + } + } + } + + private void ExpireWaitingPlayers() + { + foreach (WaitingPlayer player in _waiting + .Where(value => value.ExpiresAt <= _now).ToArray()) + { + _waiting.Remove(player); + Tell(player.Name, "[VT Fellow Manager] Your spot in the fellowship has expired. You have been removed from the list. -v-"); + } + } + + private bool IsNear(uint objectId) + { + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot self = navigation.Snapshot; + return self.IsAvailable + && navigation.TryGetObject(objectId, out PluginNavigationObject target) + && self.Position.HorizontalDistanceMeters(target.Position) + <= RecruitRangeMeters; + } + + private bool IsSpam(string sender) + { + if (!_tellRate.TryGetValue(sender, out Queue? times)) + { + times = new Queue(); + _tellRate[sender] = times; + } + while (times.Count != 0 && times.Peek() <= _now - 180d) + times.Dequeue(); + times.Enqueue(_now); + return times.Count > 8; + } + + private static bool IsIncomingTell(in PluginChatMessage message) => + message.Kind == 3 && message.SenderObjectId != 0u; + + private string LeaderName(IFellowshipAutomation fellowship) => + fellowship.CaptureRoster().FirstOrDefault(member => + member.ObjectId == fellowship.LeaderObjectId).Name is { Length: > 0 } name + ? name + : "????"; + + private void RemoveWaiting(string name) => _waiting.RemoveAll(value => + value.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + private void AnnounceVote(FellowVote vote) + { + int yes = vote.Ballots.Values.Count(static value => value); + int no = vote.Ballots.Count - yes; + Fellow($"[VT Fellow Manager] Vote total for {vote.Description}: {yes}/{no} -v-"); + } + + private void Tell(string player, string text) => + _host.Automation.Chat.Submit($"/t {player}, {text}"); + + private void Fellow(string text) => + _host.Automation.Chat.Submit("/f " + text); + + private void ResetSocialState() + { + _waiting.Clear(); + _banned.Clear(); + _voteCooldowns.Clear(); + _votes.Clear(); + _wasLeader = false; + } + + private sealed class WaitingPlayer( + string name, + uint objectId, + double expiresAt) + { + public string Name { get; } = name; + public uint ObjectId { get; set; } = objectId; + public double ExpiresAt { get; set; } = expiresAt; + public int Attempts { get; set; } + } + + private enum FellowVoteKind + { + Kick, + Ban, + GiveLeader, + SetOpen, + } + + private sealed class FellowVote( + int id, + FellowVoteKind kind, + string parameter, + double expiresAt) + { + public int Id { get; } = id; + public FellowVoteKind Kind { get; } = kind; + public string Parameter { get; } = parameter; + public double ExpiresAt { get; } = expiresAt; + public Dictionary Ballots { get; } = + new(StringComparer.OrdinalIgnoreCase); + public string Description => $"'{Kind} {Parameter}' (ID {Id})"; + } +} diff --git a/src/AcDream.Plugins.MossTank/GrenadeCatalog.cs b/src/AcDream.Plugins.MossTank/GrenadeCatalog.cs new file mode 100644 index 00000000..6ab7c68e --- /dev/null +++ b/src/AcDream.Plugins.MossTank/GrenadeCatalog.cs @@ -0,0 +1,79 @@ +namespace AcDream.Plugins.MossTank; + +internal readonly record struct GrenadeDefinition( + string Name, + uint SpellId, + int Spellcraft, + int RequiredAlchemy); + +/// +/// VTank's exact 72-entry GameInfoDB GrenadeOptions table. The source is the +/// official Virindi update feed (DB version 9), not an inferred name pattern. +/// +internal static class GrenadeCatalog +{ + private readonly record struct Tier( + string Name, + int RequiredAlchemy, + int Spellcraft, + uint Imperil, + uint Blade, + uint Acid, + uint Cold, + uint Bludgeon, + uint Fire, + uint Piercing, + uint Lightning, + uint Fester); + + private static readonly Tier[] Tiers = + [ + new("Iron", 75, 100, 1323, 1128, 522, 1061, 1049, 1104, 1152, 1085, 172), + new("Copper", 125, 160, 1324, 1129, 523, 1062, 1050, 1105, 1153, 1086, 173), + new("Silver", 175, 220, 1325, 1130, 524, 1063, 1051, 1106, 1154, 1087, 174), + new("Gold", 225, 270, 1326, 1131, 525, 1064, 1052, 1107, 1155, 1088, 175), + new("Pyreal", 275, 340, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176), + new("Platinum", 325, 400, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176), + new("Empowered Platinum", 375, 460, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176), + new("Mana", 400, 520, 2074, 2164, 2162, 2168, 2166, 2170, 2174, 2172, 2178), + ]; + + private static readonly IReadOnlyList Entries = Build(); + private static readonly IReadOnlyDictionary ByName = + Entries.ToDictionary(entry => entry.Name, StringComparer.Ordinal); + + public static IReadOnlyList All => Entries; + + public static bool TryGet(string exactName, out GrenadeDefinition definition) => + ByName.TryGetValue(exactName, out definition); + + private static IReadOnlyList Build() + { + var result = new List(72); + foreach (Tier tier in Tiers) + { + Add(result, tier, "Imperil", tier.Imperil); + Add(result, tier, "Blade Vulnerability", tier.Blade); + Add(result, tier, "Acid Vulnerability", tier.Acid); + Add(result, tier, "Cold Vulnerability", tier.Cold); + Add(result, tier, "Bludgeon Vulnerability", tier.Bludgeon); + Add(result, tier, "Fire Vulnerability", tier.Fire); + Add(result, tier, "Piercing Vulnerability", tier.Piercing); + Add(result, tier, "Lightning Vulnerability", tier.Lightning); + } + foreach (Tier tier in Tiers) + Add(result, tier, "Fester", tier.Fester); + return result; + } + + private static void Add( + ICollection result, + Tier tier, + string effect, + uint spellId) => + result.Add(new GrenadeDefinition( + $"{tier.Name} Phial of {effect}", + spellId, + tier.Spellcraft, + tier.RequiredAlchemy)); +} diff --git a/src/AcDream.Plugins.MossTank/InventoryMaintenance.cs b/src/AcDream.Plugins.MossTank/InventoryMaintenance.cs new file mode 100644 index 00000000..249fe49c --- /dev/null +++ b/src/AcDream.Plugins.MossTank/InventoryMaintenance.cs @@ -0,0 +1,299 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal sealed class InventorySettings +{ + public bool ManaChargesWhenOff { get; set; } = true; + // Official VTank defaults from uTank2.Resources.defaultsettings.usd. + public bool AutoStack { get; set; } = true; + public bool AutoCram { get; set; } + public bool AutoCraftItems { get; set; } = true; + public int ArrowheadFletchDifficultyExcess { get; set; } = 10; + public bool SplitPeas { get; set; } = true; + public int CriticalComponentMinimum { get; set; } = 4; + public int NormalComponentMinimum { get; set; } = 20; + public int IdleComponentMinimum { get; set; } = 20; + public int IdleHealthKitCount { get; set; } = 2; + public int IdleStaminaKitCount { get; set; } = 2; + public int IdleManaKitCount { get; set; } = 2; + public int IdleHealthFoodCount { get; set; } = 15; + public int IdleStaminaFoodCount { get; set; } = 15; + public int IdleManaFoodCount { get; set; } = 15; + public bool RefillWornMana { get; set; } = true; + public int RefillWornManaPercent { get; set; } = 33; + public double ScanIntervalSeconds { get; set; } = 0.25d; + public LootSettings Loot { get; } = new(); +} + +internal enum InventoryMaintenanceKind +{ + Merge, + Cram, +} + +internal readonly record struct InventoryMaintenancePlan( + InventoryMaintenanceKind Kind, + uint SourceObjectId, + uint TargetObjectId, + uint Amount); + +/// +/// Pure VTank StackCram planner. AutoStack always wins over AutoCram; it groups +/// by WCID, picks the lowest-burden source and a non-full target, then performs +/// exactly one retail move. AutoCram moves one direct-main-pack non-container +/// into the first side pack with room. +/// +internal static class InventoryMaintenancePlanner +{ + private const uint PublicWeenieFoci = 0x00800000u; + private static readonly ISet EmptyIgnored = new HashSet(); + + public static InventoryMaintenancePlan? Plan( + IReadOnlyList items, + uint playerObjectId, + InventorySettings settings, + ISet? ignored = null) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(settings); + ignored ??= EmptyIgnored; + + if (settings.AutoStack) + { + InventoryMaintenancePlan? stack = PlanStack(items, ignored); + if (stack is not null) + return stack; + } + + return settings.AutoCram + ? PlanCram(items, playerObjectId, ignored) + : null; + } + + private static InventoryMaintenancePlan? PlanStack( + IReadOnlyList items, + ISet ignored) + { + Dictionary byId = items.ToDictionary( + static item => item.ObjectId); + foreach (IGrouping group in items + .Where(item => item.ObjectId != 0u + && item.WeenieClassId != 0u + && item.MaximumStackSize > 1 + && item.StackSize > 0 + && !item.IsEquipped + && !ignored.Contains(item.ObjectId)) + .GroupBy(static item => item.WeenieClassId) + .OrderBy(static group => group.Key)) + { + PluginInventoryItem[] ordered = group + .OrderBy(item => BurdenRank(item, byId)) + .ThenBy(static item => item.ContainerSlot) + .ThenBy(static item => item.ObjectId) + .ToArray(); + if (ordered.Length < 2) + continue; + + PluginInventoryItem source = ordered[0]; + for (int i = ordered.Length - 1; i >= 1; i--) + { + PluginInventoryItem target = ordered[i]; + int free = target.MaximumStackSize - Math.Max(1, target.StackSize); + if (free <= 0) + continue; + uint amount = (uint)Math.Min(Math.Max(1, source.StackSize), free); + return new InventoryMaintenancePlan( + InventoryMaintenanceKind.Merge, + source.ObjectId, + target.ObjectId, + amount); + } + } + return null; + } + + private static InventoryMaintenancePlan? PlanCram( + IReadOnlyList items, + uint playerObjectId, + ISet ignored) + { + if (playerObjectId == 0u) + return null; + + PluginInventoryItem source = items + .Where(item => item.ContainerObjectId == playerObjectId + && item.WielderObjectId == 0u + && item.ItemsCapacity <= 0 + && item.ContainersCapacity <= 0 + && (item.PublicFlags & PublicWeenieFoci) == 0u + && !ignored.Contains(item.ObjectId)) + .OrderBy(static item => item.ContainerSlot) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + if (source.ObjectId == 0u) + return null; + + Dictionary containedCounts = items + .Where(static item => item.ContainerObjectId != 0u) + .GroupBy(static item => item.ContainerObjectId) + .ToDictionary(static group => group.Key, static group => group.Count()); + PluginInventoryItem destination = items + .Where(item => item.ContainerObjectId == playerObjectId + && item.ItemsCapacity > 0 + && !ignored.Contains(item.ObjectId) + && containedCounts.GetValueOrDefault(item.ObjectId) + < item.ItemsCapacity) + .OrderBy(static item => item.ContainerSlot) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + return destination.ObjectId != 0u + ? new InventoryMaintenancePlan( + InventoryMaintenanceKind.Cram, + source.ObjectId, + destination.ObjectId, + (uint)Math.Max(1, source.StackSize)) + : null; + } + + private static long BurdenRank( + PluginInventoryItem item, + IReadOnlyDictionary byId) + { + long parent = item.ContainerObjectId != 0u + && byId.TryGetValue(item.ContainerObjectId, out PluginInventoryItem container) + ? Math.Max(0, container.Burden) + 1L + : 0L; + return Math.Max(0, item.Burden) + (10_000L * parent); + } + +} + +/// +/// Executes one StackCram operation at a time and waits for the host's +/// authoritative inventory receipt before planning the next one. +/// +internal sealed class InventoryMaintenanceController +{ + private const int RetailAbandonAttempts = 80; + private readonly IPluginHost _host; + private readonly InventorySettings _settings; + private readonly Dictionary<(uint Source, uint Target), int> _attempts = []; + private readonly HashSet _ignored = []; + private InventoryMaintenancePlan? _pending; + private long _observedRevision; + private double _untilScan; + + public InventoryMaintenanceController( + IPluginHost host, + InventorySettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status { get; private set; } = "Stack/Cram idle"; + + /// Returns true only when StackCram owns this scheduler tick. + public bool Tick(double elapsedSeconds, bool canAct) + { + IItemAutomation commands = _host.Automation.Items; + ObserveCompletion(commands); + if (_pending is not null) + { + if (commands.IsBusy) + return true; + // Older hosts may implement the command but not receipts. The + // canonical host always publishes one before clearing Busy. + _pending = null; + } + if (!canAct || !_host.Automation.IsAvailable || !commands.IsAvailable) + return false; + if (!_settings.AutoStack && !_settings.AutoCram) + { + Status = "Stack/Cram disabled"; + return false; + } + if (commands.IsBusy) + return false; + + _untilScan -= Math.Max(0d, elapsedSeconds); + if (_untilScan > 0d) + return false; + _untilScan = Math.Max(0.05d, _settings.ScanIntervalSeconds); + + IReadOnlyList inventory = commands.CaptureOwnedItems(); + _ignored.RemoveWhere(id => !inventory.Any(item => item.ObjectId == id)); + InventoryMaintenancePlan? plan = InventoryMaintenancePlanner.Plan( + inventory, + _host.Automation.Character.ObjectId, + _settings, + _ignored); + if (plan is not { } next) + { + Status = "Stack/Cram idle"; + return false; + } + + PluginItemCommandResult result = next.Kind == InventoryMaintenanceKind.Merge + ? commands.Merge(next.SourceObjectId, next.TargetObjectId, next.Amount) + : commands.MoveToContainer( + next.SourceObjectId, + next.TargetObjectId, + next.Amount); + if (!result.Accepted) + { + Status = $"Stack/Cram waiting: {result.Status}"; + return result.Status == PluginItemCommandStatus.Busy; + } + + _pending = next; + Status = next.Kind == InventoryMaintenanceKind.Merge + ? "Stacking items" + : "Moving an item to a side pack"; + return true; + } + + public void Reset() + { + _pending = null; + _attempts.Clear(); + _ignored.Clear(); + _untilScan = 0d; + Status = "Stack/Cram idle"; + } + + private void ObserveCompletion(IItemAutomation commands) + { + PluginInventoryCompletion completion = commands.LastInventoryCompletion; + if (completion.Revision == 0 || completion.Revision == _observedRevision) + return; + _observedRevision = completion.Revision; + if (_pending is not { } pending + || completion.SourceObjectId != pending.SourceObjectId) + { + return; + } + + if (!completion.IsSuccess) + { + var key = (pending.SourceObjectId, pending.TargetObjectId); + int attempts = _attempts.GetValueOrDefault(key) + 1; + _attempts[key] = attempts; + Status = $"Stack/Cram failed (0x{completion.WeenieError:X})"; + if (attempts > RetailAbandonAttempts) + { + _ignored.Add(pending.SourceObjectId); + _ignored.Add(pending.TargetObjectId); + _host.Automation.Chat.PostSystemMessage( + "[MossTank] Abandoned trying to stack/cram two bugged items."); + } + } + else + { + _attempts.Remove((pending.SourceObjectId, pending.TargetObjectId)); + } + _pending = null; + _untilScan = 0d; + } +} diff --git a/src/AcDream.Plugins.MossTank/ItemManaRecharge.cs b/src/AcDream.Plugins.MossTank/ItemManaRecharge.cs new file mode 100644 index 00000000..af51003f --- /dev/null +++ b/src/AcDream.Plugins.MossTank/ItemManaRecharge.cs @@ -0,0 +1,140 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct ItemManaRechargePlan( + uint ChargeObjectId, + uint TargetObjectId, + string ChargeName, + string TargetName, + int CurrentMana, + int MaximumMana); + +internal static class ItemManaRechargePlanner +{ + private const uint ManaStoneItemType = 0x00080000u; + + public static ItemManaRechargePlan? Plan( + IReadOnlyList inventory, + ISet consumableNames, + int thresholdPercent) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(consumableNames); + int threshold = Math.Clamp(thresholdPercent, 0, 99); + PluginInventoryItem charge = inventory + .Where(item => (item.ItemType & ManaStoneItemType) != 0u + && consumableNames.Contains(item.Name) + && !item.IsEquipped) + .Where(static item => item.ItemCurrentMana > 0) + .OrderBy(static item => item.Name, StringComparer.Ordinal) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + if (charge.ObjectId == 0u) + return null; + + PluginInventoryItem target = inventory + .Where(item => item.IsEquipped + && item.ItemMaximumMana > 0 + && 100L * Math.Max(0, item.ItemCurrentMana) + / item.ItemMaximumMana < threshold) + .OrderBy(item => 100d * Math.Max(0, item.ItemCurrentMana) + / item.ItemMaximumMana) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + return target.ObjectId == 0u + ? null + : new ItemManaRechargePlan( + charge.ObjectId, + target.ObjectId, + charge.Name, + target.Name, + target.ItemCurrentMana, + target.ItemMaximumMana); + } +} + +internal sealed class ItemManaRechargeController +{ + private readonly IPluginHost _host; + private readonly InventorySettings _settings; + private readonly CombatSettings _profiles; + private ItemManaRechargePlan? _pending; + private long _observedCompletion; + + public ItemManaRechargeController( + IPluginHost host, + InventorySettings settings, + CombatSettings profiles) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _profiles = profiles ?? throw new ArgumentNullException(nameof(profiles)); + } + + public string Status { get; private set; } = "Worn mana ready"; + + public bool Tick(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.RefillWornMana + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + + ItemManaRechargePlan? plan = ItemManaRechargePlanner.Plan( + items.CaptureOwnedItems(), + _profiles.ConsumableNames, + _settings.RefillWornManaPercent); + if (plan is not { } next) + { + Status = "Worn mana ready"; + return false; + } + PluginItemCommandResult result = items.Apply( + next.ChargeObjectId, + next.TargetObjectId); + if (!result.Accepted) + { + Status = $"Mana refill waiting: {result.Status}"; + return result.Status == PluginItemCommandStatus.Busy; + } + _pending = next; + Status = $"Refilling {next.TargetName} ({next.CurrentMana}/{next.MaximumMana})"; + return true; + } + + public void Reset() + { + _pending = null; + Status = "Worn mana ready"; + } + + private void ObserveCompletion(IItemAutomation items) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision == 0 || completion.Revision == _observedCompletion) + return; + _observedCompletion = completion.Revision; + if (_pending is not { } pending + || completion.SourceObjectId != pending.ChargeObjectId) + { + return; + } + Status = completion.IsSuccess + ? $"Refilled {pending.TargetName}" + : $"Mana refill failed (0x{completion.WeenieError:X})"; + _pending = null; + } +} diff --git a/src/AcDream.Plugins.MossTank/Looting.cs b/src/AcDream.Plugins.MossTank/Looting.cs new file mode 100644 index 00000000..5474be54 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Looting.cs @@ -0,0 +1,1766 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum LootAction +{ + NoLoot, + Keep, + Salvage, + Sell, + Read, + User1, + User2, + User3, + User4, + User5, + KeepUpTo, + ManaStone, + ManaTank, +} + +internal sealed class LootRule +{ + private string _expression = "*"; + private string? _compiledSource; + private LootRuleExpression? _compiled; + + public string Name { get; set; } = "Rule"; + public string Expression + { + get => _expression; + set => _expression = string.IsNullOrWhiteSpace(value) ? "*" : value.Trim(); + } + public LootAction Action { get; set; } = LootAction.Keep; + public int KeepCount { get; set; } = 1; + public int Priority { get; set; } + public string CustomExpression { get; set; } = string.Empty; + public List VtankRequirements { get; set; } = []; + + public bool IsMatch( + in PluginInventoryItem item, + in PluginItemProperties properties, + IPluginHost? host, + out string? error) + { + if (VtankRequirements.Count > 0) + { + return VtankLootRequirementEvaluator.IsMatch( + VtankRequirements, + item, + properties, + host, + out error); + } + try + { + if (_compiled is null + || !string.Equals( + _compiledSource, + Expression, + StringComparison.Ordinal)) + { + _compiled = LootRuleExpression.Compile(Expression); + _compiledSource = Expression; + } + error = null; + return _compiled.IsMatch(item, properties); + } + catch (FormatException failure) + { + error = failure.Message; + return false; + } + } +} + +internal sealed class LootSettings +{ + // Official VTank defaults from uTank2.Resources.defaultsettings.usd. + public bool Enabled { get; set; } + public string ExternalClassifierId { get; set; } = string.Empty; + public bool PriorityBoost { get; set; } + public bool LootAllCorpses { get; set; } + public bool LootFellowCorpses { get; set; } + public bool LootOnlyRareCorpses { get; set; } + public bool ReadUnknownScrolls { get; set; } = true; + public bool CombineSalvage { get; set; } = true; + public int ManaStoneLootCount { get; set; } = 4; + public int ManaTankMinimumMana { get; set; } = 1000; + public float CorpseApproachRange { get; set; } = 40f; + public float CorpseMinimumApproachRange { get; set; } = 3.36f; + public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d; + public double CorpseItemAppearanceTimeoutSeconds { get; set; } = 6d; + public double CorpseItemIdentifyTimeoutSeconds { get; set; } = 60d; + public int BlacklistCorpseOpenAttemptCount { get; set; } = 30; + public double BlacklistCorpseOpenTimeoutSeconds { get; set; } = 200d; + public double CorpseCacheTimeoutMinutes { get; set; } = 60d; + public int CorpseLootItemMaxAttempts { get; set; } = 20; + public double ScanIntervalSeconds { get; set; } = 0.25d; + public List Rules { get; } = []; + public VtankSalvageCombineSettings SalvageCombine { get; set; } = new(); +} + +internal readonly record struct LootDecision( + LootAction Action, + int Priority, + int RuleIndex, + string RuleName, + string ClassifierId = ""); + +internal readonly record struct ManaStoneTransferPlan( + uint StoneObjectId, + uint TankObjectId, + string StoneName, + string TankName); + +internal static class ManaStoneTransferPlanner +{ + private const uint ManaStoneItemType = 0x00080000u; + private const uint RetainedFlag = 0x01000000u; + + public static ManaStoneTransferPlan? Plan( + IReadOnlyList owned, + IReadOnlyDictionary classified, + int minimumTankMana) + { + PluginInventoryItem stone = owned + .Where(item => classified.TryGetValue( + item.ObjectId, + out LootAction action) + && action == LootAction.ManaStone + && (item.ItemType & ManaStoneItemType) != 0u) + .OrderBy(static item => item.ObjectId) + .FirstOrDefault(); + if (stone.ObjectId == 0u) + return null; + int minimum = Math.Clamp(minimumTankMana, 1, int.MaxValue); + PluginInventoryItem tank = owned + .Where(item => classified.TryGetValue( + item.ObjectId, + out LootAction action) + && action == LootAction.ManaTank + && item.ItemCurrentMana >= minimum + && item.Value != 0 + && (item.PublicFlags & RetainedFlag) == 0u) + .OrderByDescending(static item => item.ItemCurrentMana) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + return tank.ObjectId == 0u + ? null + : new ManaStoneTransferPlan( + stone.ObjectId, + tank.ObjectId, + stone.Name, + tank.Name); + } +} + +internal sealed record SalvageBagCombinePlan( + IReadOnlyList ObjectIds, + uint MaterialType, + IReadOnlyList Names) +{ + public uint FirstObjectId => ObjectIds.Count > 0 ? ObjectIds[0] : 0u; + public uint SecondObjectId => ObjectIds.Count > 1 ? ObjectIds[1] : 0u; + public string FirstName => Names.Count > 0 ? Names[0] : string.Empty; + public string SecondName => Names.Count > 1 ? Names[1] : string.Empty; +} + +internal static partial class SalvageBagCombinePlanner +{ + public static SalvageBagCombinePlan? Plan( + IReadOnlyList owned, + ISet? abandoned = null, + VtankSalvageCombineSettings? settings = null) + { + settings ??= new VtankSalvageCombineSettings(); + PluginInventoryItem[] bags = owned + .Where(item => item.MaterialType != 0u + && SalvageBagName().IsMatch(item.Name) + && (abandoned is null || !abandoned.Contains(item.ObjectId))) + .OrderBy(static item => item.MaterialType) + .ThenBy(static item => item.Workmanship) + .ThenBy(static item => item.ObjectId) + .ToArray(); + foreach (IGrouping materialGroup in + bags.GroupBy(static item => item.MaterialType)) + { + string combine = settings.MaterialCombineStrings.TryGetValue( + checked((int)materialGroup.Key), + out string? materialCombine) + ? materialCombine + : settings.DefaultCombineString; + IReadOnlyList<(double Minimum, double Maximum)> ranges = + ParseCombineString(combine); + foreach (IGrouping bin in materialGroup + .GroupBy(item => RangeIndex(ranges, item.Workmanship)) + .OrderBy(static group => group.Key)) + { + PluginInventoryItem[] candidates = bin.ToArray(); + if (candidates.Length < 2) + continue; + IReadOnlyList selected; + if (settings.MaterialValueModeValues.TryGetValue( + checked((int)materialGroup.Key), + out int targetValue)) + { + if (candidates.Sum(static item => item.Value) >= targetValue) + { + selected = candidates; + } + else + { + selected = FindSubHundredPair(candidates); + if (selected.Count == 0) + continue; + } + } + else + { + var maximumBags = new List(); + int units = 0; + foreach (PluginInventoryItem candidate in candidates) + { + maximumBags.Add(candidate); + units += Math.Max(0, candidate.Structure); + if (units >= 100) + break; + } + selected = maximumBags; + } + return new SalvageBagCombinePlan( + selected.Select(static item => item.ObjectId).ToArray(), + materialGroup.Key, + selected.Select(static item => item.Name).ToArray()); + } + } + return null; + } + + internal static bool SameVtankWorkmanshipBand(float left, float right) => + (left < 7f && right < 7f) + || (left >= 7f && left < 9f && right >= 7f && right < 9f) + || (left >= 9f && left < 10f && right >= 9f && right < 10f) + || (left == 10f && right == 10f); + + internal static bool SameCombineBand( + float left, + float right, + string combineString) + { + IReadOnlyList<(double Minimum, double Maximum)> ranges = + ParseCombineString(combineString); + return RangeIndex(ranges, left) == RangeIndex(ranges, right); + } + + private static IReadOnlyList FindSubHundredPair( + IReadOnlyList candidates) + { + for (int left = 0; left < candidates.Count - 1; left++) + { + for (int right = left + 1; right < candidates.Count; right++) + { + if (Math.Max(0, candidates[left].Structure) + + Math.Max(0, candidates[right].Structure) < 100) + { + return [candidates[left], candidates[right]]; + } + } + } + return []; + } + + private static IReadOnlyList<(double Minimum, double Maximum)> + ParseCombineString(string? source) + { + var result = new List<(double, double)>(); + foreach (string token in (source ?? string.Empty).Split( + [',', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + string[] bounds = token.Split( + '-', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (bounds.Length == 0 + || !double.TryParse(bounds[0], NumberStyles.Float, + CultureInfo.InvariantCulture, out double minimum)) + { + continue; + } + double maximum = minimum; + if (bounds.Length > 1) + { + _ = double.TryParse(bounds[1], NumberStyles.Float, + CultureInfo.InvariantCulture, out maximum); + } + result.Add((minimum, maximum)); + } + return result; + } + + private static int RangeIndex( + IReadOnlyList<(double Minimum, double Maximum)> ranges, + double value) + { + for (int index = 0; index < ranges.Count; index++) + { + if (ranges[index].Minimum > value) + return index - 1; + if (ranges[index].Minimum <= value + && ranges[index].Maximum >= value) + { + return index; + } + } + return ranges.Count; + } + + [GeneratedRegex(@"^Salvage(?:d)?.* \([0-9]{1,2}\)$", RegexOptions.IgnoreCase)] + private static partial Regex SalvageBagName(); +} + +internal static class LootRuleEngine +{ + public static LootDecision? Decide( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList rules, + IReadOnlyList ownedItems, + IReadOnlyDictionary? pendingByName = null, + IPluginHost? host = null) + { + ArgumentNullException.ThrowIfNull(rules); + ArgumentNullException.ThrowIfNull(ownedItems); + + for (int index = 0; index < rules.Count; index++) + { + LootRule rule = rules[index]; + if (!rule.IsMatch(item, properties, host, out _)) + continue; + if (rule.Action == LootAction.NoLoot) + return null; + if (rule.Action == LootAction.KeepUpTo) + { + int limit = Math.Max(0, rule.KeepCount); + string itemName = item.Name; + int held = ownedItems + .Where(owned => string.Equals( + owned.Name, + itemName, + StringComparison.OrdinalIgnoreCase)) + .Sum(static owned => Math.Max(1, owned.StackSize)); + if (pendingByName is not null + && pendingByName.TryGetValue(item.Name, out int pending)) + { + held += pending; + } + if (held >= limit) + return null; + } + return new LootDecision( + rule.Action, + rule.Priority, + index, + string.IsNullOrWhiteSpace(rule.Name) + ? $"Rule {index + 1}" + : rule.Name); + } + return null; + } +} + +/// +/// VTank corpse-open / classify / pickup state machine. It owns policy only; +/// every action travels through the host's canonical retail item transaction. +/// +internal sealed class LootController +{ + private const double PickupTimeoutSeconds = 4d; + + private readonly IPluginHost _host; + private readonly LootSettings _settings; + private readonly Dictionary _completedCorpses = []; + private readonly Dictionary _corpseOpenAttempts = []; + private readonly Dictionary _corpseBlacklistedAt = []; + private readonly Dictionary _itemAttempts = []; + private readonly Dictionary _pendingByName = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _classifiedOwnedItems = []; + private readonly Dictionary _externalClassifierByItem = []; + private readonly Dictionary _decisions = []; + private readonly Dictionary _corpseFirstSeen = []; + private double _scanRemaining; + private double _stateAge; + private uint _activeCorpse; + private bool _activeCorpseSawContents; + private uint _waitingItem; + private string _waitingName = string.Empty; + private LootAction _waitingAction; + private int _waitingQuantity; + private PluginInventoryItem _waitingItemSnapshot; + private string _waitingClassifierId = string.Empty; + private long _waitingInventoryRevision; + private uint _awaitingAppraisal; + private uint _awaitingCorpseAppraisal; + private double _lifetime; + private uint _postUseItem; + private string _postUseName = string.Empty; + private bool _postUseStarted; + private long _postUseRevision; + private uint _salvagePendingItem; + private string _salvagePendingName = string.Empty; + private int _salvageAttempts; + private uint _sellPendingItem; + private string _sellPendingName = string.Empty; + private ManaStoneTransferPlan? _manaTransfer; + private long _manaTransferRevision; + private SalvageBagCombinePlan? _combinePending; + private readonly Dictionary _combineAttempts = []; + private readonly HashSet _abandonedCombineBags = []; + + public LootController( + IPluginHost host, + LootSettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status { get; private set; } = "Looting disabled."; + public IReadOnlyDictionary ClassifiedOwnedItems => + _classifiedOwnedItems; + + public bool Tick(double elapsedSeconds, bool canAct) + { + ILootAutomation loot = _host.Automation.Loot; + if (!_settings.Enabled) + { + ResetTransient(); + Status = "Looting disabled."; + return false; + } + if (!_host.Automation.IsAvailable || !loot.IsAvailable) + { + Reset(); + Status = "Looting unavailable."; + return false; + } + if (_settings.Rules.Count == 0 + && string.IsNullOrWhiteSpace(_settings.ExternalClassifierId)) + { + ResetTransient(); + Status = "Loot profile has no rules."; + return false; + } + + if (_activeCorpse == 0u && _waitingItem == 0u) + PruneRemovedExternalItems(); + + _stateAge += Math.Max(0d, elapsedSeconds); + _lifetime += Math.Max(0d, elapsedSeconds); + if (_waitingItem != 0u) + return ContinuePickup(loot); + if (_postUseItem != 0u) + return ContinuePostUse(canAct); + if (_activeCorpse == 0u + && (_manaTransfer is not null + || HasManaStoneTransfer()) + && ContinueManaStoneTransfer(canAct)) + { + return true; + } + if (_activeCorpse == 0u + && (_salvagePendingItem != 0u + || _classifiedOwnedItems.Values.Contains(LootAction.Salvage)) + && ContinueSalvage(canAct)) + { + return true; + } + if (_activeCorpse == 0u + && _settings.CombineSalvage + && (_combinePending is not null || HasSalvageBagCombine()) + && ContinueSalvageBagCombine(canAct)) + { + return true; + } + if (_activeCorpse == 0u + && (_sellPendingItem != 0u + || _classifiedOwnedItems.Values.Contains(LootAction.Sell)) + && ContinueSell(canAct)) + { + return true; + } + + uint current = loot.CurrentContainerId; + if (_activeCorpse != 0u && current == _activeCorpse) + return ContinueCurrentCorpse(loot, canAct); + + if (_activeCorpse != 0u + && loot.RequestedContainerId == _activeCorpse) + { + if (_stateAge <= Math.Max(0.25d, _settings.CorpseOpenTimeoutSeconds)) + { + Status = "Waiting for corpse contents…"; + return true; + } + uint failedCorpse = _activeCorpse; + BlacklistFailedCorpse(failedCorpse); + _activeCorpse = 0u; + _activeCorpseSawContents = false; + _stateAge = 0d; + if (IsCorpseBlacklisted(failedCorpse)) + return false; + } + + if (!canAct || loot.IsBusy) + return false; + + _scanRemaining -= Math.Max(0d, elapsedSeconds); + if (_scanRemaining > 0d) + return false; + _scanRemaining = Math.Clamp(_settings.ScanIntervalSeconds, 0.05d, 5d); + + IReadOnlyList corpses = loot.CaptureCorpses( + Math.Clamp(_settings.CorpseApproachRange, 2f, 100f)); + PruneCorpseCache(); + foreach (PluginLootContainer seen in corpses) + _corpseFirstSeen.TryAdd(seen.ObjectId, _lifetime); + + if (_awaitingCorpseAppraisal != 0u) + { + PluginAppraisalState appraisal = loot.Appraisal; + if (appraisal.CurrentObjectId == _awaitingCorpseAppraisal + && appraisal.AwaitingObjectId != _awaitingCorpseAppraisal) + { + _awaitingCorpseAppraisal = 0u; + _stateAge = 0d; + } + else if (_stateAge < Math.Max( + 1d, + _settings.CorpseOpenTimeoutSeconds * 2d)) + { + Status = "Identifying corpse…"; + return true; + } + else + { + MarkCorpseComplete(_awaitingCorpseAppraisal); + _awaitingCorpseAppraisal = 0u; + _stateAge = 0d; + } + } + + PluginLootContainer? next = null; + foreach (PluginLootContainer candidateCorpse in corpses + .Where(corpse => !_completedCorpses.ContainsKey(corpse.ObjectId)) + .Where(corpse => !IsCorpseBlacklisted(corpse.ObjectId)) + .OrderBy(static corpse => corpse.Distance) + .ThenBy(static corpse => corpse.ObjectId)) + { + if (!candidateCorpse.IsIdentified) + { + PluginItemCommandResult identify = loot.Identify( + candidateCorpse.ObjectId); + if (identify.Accepted) + { + _awaitingCorpseAppraisal = candidateCorpse.ObjectId; + _stateAge = 0d; + Status = $"Identifying {candidateCorpse.Name}…"; + return true; + } + if (identify.Status == PluginItemCommandStatus.Busy) + return true; + continue; + } + if (!CanLoot(candidateCorpse)) + continue; + next = candidateCorpse; + break; + } + if (next is not { } corpse) + { + Status = "No nearby corpses."; + return false; + } + + PluginItemCommandResult opened = loot.Open(corpse.ObjectId); + if (!opened.Accepted) + { + Status = opened.Status == PluginItemCommandStatus.Busy + ? "Waiting to open corpse…" + : $"Could not open {corpse.Name}."; + return opened.Status == PluginItemCommandStatus.Busy; + } + _activeCorpse = corpse.ObjectId; + _activeCorpseSawContents = false; + _stateAge = 0d; + Status = $"Opening {corpse.Name}…"; + return true; + } + + public void Reset() + { + foreach ((uint objectId, string classifierId) in + _externalClassifierByItem.ToArray()) + { + _host.LootClassifiers.TryNotifyItemRemoved(classifierId, objectId); + } + ResetTransient(); + _completedCorpses.Clear(); + _corpseOpenAttempts.Clear(); + _corpseBlacklistedAt.Clear(); + _itemAttempts.Clear(); + _pendingByName.Clear(); + _classifiedOwnedItems.Clear(); + _externalClassifierByItem.Clear(); + _decisions.Clear(); + _corpseFirstSeen.Clear(); + _scanRemaining = 0d; + _lifetime = 0d; + _postUseItem = 0u; + _postUseName = string.Empty; + _postUseStarted = false; + _postUseRevision = 0L; + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _salvageAttempts = 0; + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _manaTransfer = null; + _manaTransferRevision = 0L; + _combinePending = null; + _combineAttempts.Clear(); + _abandonedCombineBags.Clear(); + Status = _settings.Enabled ? "Idle." : "Looting disabled."; + } + + private bool ContinueCurrentCorpse(ILootAutomation loot, bool canAct) + { + if (_stateAge < 0.10d) + { + Status = "Reading corpse contents…"; + return true; + } + + IReadOnlyList contents = + loot.CaptureCurrentContents(); + if (contents.Count > 0) + _activeCorpseSawContents = true; + if (contents.Count == 0 + && !_activeCorpseSawContents + && _stateAge < Math.Clamp( + _settings.CorpseItemAppearanceTimeoutSeconds, + 0d, + 300d)) + { + Status = "Waiting for corpse items to appear…"; + return true; + } + IReadOnlyList owned = + _host.Automation.Items.CaptureOwnedItems(); + if (_awaitingAppraisal != 0u) + { + PluginAppraisalState appraisal = loot.Appraisal; + if (appraisal.CurrentObjectId == _awaitingAppraisal + && appraisal.AwaitingObjectId != _awaitingAppraisal) + { + if (contents.FirstOrDefault( + item => item.ObjectId == _awaitingAppraisal) is { } item + && item.ObjectId != 0u) + { + PluginItemProperties identified = default; + _ = loot.TryCaptureProperties(item.ObjectId, out identified); + _decisions[item.ObjectId] = DecideItem( + item, + identified, + owned, + _pendingByName); + } + _awaitingAppraisal = 0u; + _stateAge = 0d; + } + else if (_stateAge < Math.Clamp( + _settings.CorpseItemIdentifyTimeoutSeconds, + 1d, + 600d)) + { + Status = "Identifying corpse item…"; + return true; + } + else + { + IncrementAttempt(_awaitingAppraisal); + _awaitingAppraisal = 0u; + _stateAge = 0d; + } + } + + foreach (PluginInventoryItem item in contents) + { + if (_decisions.ContainsKey(item.ObjectId)) + continue; + if (!canAct || loot.IsBusy) + return true; + + PluginAppraisalState appraisal = loot.Appraisal; + if (appraisal.CurrentObjectId != item.ObjectId) + { + PluginItemCommandResult identify = loot.Identify(item.ObjectId); + if (identify.Accepted) + { + _awaitingAppraisal = item.ObjectId; + _stateAge = 0d; + Status = $"Identifying {item.Name}…"; + return true; + } + if (identify.Status == PluginItemCommandStatus.Busy) + return true; + } + + PluginItemProperties properties = default; + _ = loot.TryCaptureProperties(item.ObjectId, out properties); + _decisions[item.ObjectId] = DecideItem( + item, + properties, + owned, + _pendingByName); + } + + var candidates = new List<(PluginInventoryItem Item, LootDecision Decision)>(); + foreach (PluginInventoryItem item in contents) + { + if (_itemAttempts.TryGetValue(item.ObjectId, out int attempts) + && attempts >= Math.Clamp( + _settings.CorpseLootItemMaxAttempts, + 1, + 100)) + { + continue; + } + if (_decisions.TryGetValue(item.ObjectId, out LootDecision? cached) + && cached is { } decision) + { + candidates.Add((item, decision)); + } + } + + if (candidates.Count == 0) + { + foreach (PluginInventoryItem item in contents) + _decisions.Remove(item.ObjectId); + MarkCorpseComplete(_activeCorpse); + _activeCorpse = 0u; + _activeCorpseSawContents = false; + _stateAge = 0d; + Status = "Corpse complete."; + return false; + } + if (!canAct || loot.IsBusy) + return true; + + (PluginInventoryItem Item, LootDecision Decision) chosen = candidates + .OrderByDescending(static candidate => candidate.Decision.Priority) + .ThenBy(static candidate => candidate.Decision.RuleIndex) + .ThenBy(static candidate => candidate.Item.ContainerSlot) + .ThenBy(static candidate => candidate.Item.ObjectId) + .First(); + PluginItemCommandResult pickup = loot.Pickup(chosen.Item.ObjectId); + if (!pickup.Accepted) + { + IncrementAttempt(chosen.Item.ObjectId); + Status = $"Pickup refused: {chosen.Item.Name}."; + return pickup.Status == PluginItemCommandStatus.Busy; + } + + _waitingItem = chosen.Item.ObjectId; + _waitingName = chosen.Item.Name; + _waitingAction = chosen.Decision.Action; + _waitingQuantity = Math.Max(1, chosen.Item.StackSize); + _waitingItemSnapshot = chosen.Item; + _waitingClassifierId = chosen.Decision.ClassifierId; + _waitingInventoryRevision = loot.LastInventoryCompletion.Revision; + _stateAge = 0d; + if (chosen.Decision.Action == LootAction.KeepUpTo) + { + _pendingByName.TryGetValue(chosen.Item.Name, out int pending); + _pendingByName[chosen.Item.Name] = + pending + _waitingQuantity; + } + Status = $"Looting {chosen.Item.Name} ({chosen.Decision.RuleName})…"; + return true; + } + + private bool ContinuePickup(ILootAutomation loot) + { + PluginInventoryCompletion completion = loot.LastInventoryCompletion; + bool advanced = completion.Revision > _waitingInventoryRevision + && completion.SourceObjectId == _waitingItem; + bool stillInCorpse = loot.CaptureCurrentContents().Any( + item => item.ObjectId == _waitingItem); + if (!advanced && stillInCorpse && _stateAge < PickupTimeoutSeconds) + { + Status = $"Waiting for {_waitingName}…"; + return true; + } + + bool success = !stillInCorpse || (advanced && completion.IsSuccess); + if (success) + { + _classifiedOwnedItems[_waitingItem] = _waitingAction; + if (_waitingClassifierId.Length != 0) + { + _externalClassifierByItem[_waitingItem] = _waitingClassifierId; + _host.LootClassifiers.TryNotifyLooted( + _waitingClassifierId, + new PluginLootedItem( + _waitingItemSnapshot, + (PluginLootAction)(int)_waitingAction)); + } + _decisions.Remove(_waitingItem); + Status = $"Looted {_waitingName}."; + _itemAttempts.Remove(_waitingItem); + if (_waitingAction == LootAction.Read) + { + _postUseItem = _waitingItem; + _postUseName = _waitingName; + _postUseStarted = false; + _postUseRevision = 0L; + } + } + else + { + IncrementAttempt(_waitingItem); + Status = $"Retrying {_waitingName}."; + } + if (_waitingAction == LootAction.KeepUpTo + && _pendingByName.TryGetValue(_waitingName, out int pending)) + { + if (pending <= _waitingQuantity) + _pendingByName.Remove(_waitingName); + else + _pendingByName[_waitingName] = pending - _waitingQuantity; + } + _waitingItem = 0u; + _waitingName = string.Empty; + _waitingAction = LootAction.NoLoot; + _waitingQuantity = 0; + _waitingItemSnapshot = default; + _waitingClassifierId = string.Empty; + _waitingInventoryRevision = 0L; + _stateAge = 0d; + return true; + } + + private bool ContinuePostUse(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + if (_postUseStarted) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision <= _postUseRevision + || completion.SourceObjectId != _postUseItem) + { + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Reading {_postUseName}…"; + return true; + } + Status = $"Read timed out: {_postUseName}."; + } + else + { + Status = completion.IsSuccess + ? $"Read {_postUseName}." + : $"Could not read {_postUseName}."; + } + _postUseItem = 0u; + _postUseName = string.Empty; + _postUseStarted = false; + _postUseRevision = 0L; + _stateAge = 0d; + return true; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + PluginItemCommandResult use = items.Use(_postUseItem); + if (!use.Accepted) + { + if (use.Status == PluginItemCommandStatus.Busy) + return true; + Status = $"Could not read {_postUseName}."; + _postUseItem = 0u; + _postUseName = string.Empty; + return false; + } + _postUseRevision = items.LastCompletion.Revision; + _postUseStarted = true; + _stateAge = 0d; + Status = $"Reading {_postUseName}…"; + return true; + } + + private bool ContinueSalvage(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + IReadOnlyList owned = items.CaptureOwnedItems(); + if (_salvagePendingItem != 0u) + { + if (!owned.Any(item => item.ObjectId == _salvagePendingItem)) + { + RemoveClassifiedOwned(_salvagePendingItem); + Status = $"Salvaged {_salvagePendingName}."; + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _salvageAttempts = 0; + _stateAge = 0d; + return true; + } + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Salvaging {_salvagePendingName}…"; + return true; + } + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _stateAge = 0d; + _salvageAttempts++; + } + + var ownedById = owned.ToDictionary(static item => item.ObjectId); + foreach (uint stale in _classifiedOwnedItems + .Where(entry => entry.Value == LootAction.Salvage + && !ownedById.ContainsKey(entry.Key)) + .Select(static entry => entry.Key) + .ToArray()) + { + RemoveClassifiedOwned(stale); + } + uint sourceId = _classifiedOwnedItems + .Where(static entry => entry.Value == LootAction.Salvage) + .Select(static entry => entry.Key) + .FirstOrDefault(ownedById.ContainsKey); + if (sourceId == 0u) + return false; + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + const uint tinkeringTool = 0x20000000u; + PluginInventoryItem tool = owned.FirstOrDefault( + item => (item.ItemType & tinkeringTool) != 0u); + if (tool.ObjectId == 0u) + { + Status = "Salvage action is waiting for a salvage tool."; + return false; + } + + PluginInventoryItem source = ownedById[sourceId]; + PluginItemCommandResult result = items.Salvage(tool.ObjectId, [sourceId]); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to salvage…" + : $"Could not salvage {source.Name}."; + return result.Status == PluginItemCommandStatus.Busy; + } + _salvagePendingItem = sourceId; + _salvagePendingName = source.Name; + _stateAge = 0d; + Status = $"Salvaging {source.Name}…"; + return true; + } + + private bool ContinueSell(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + IReadOnlyList owned = items.CaptureOwnedItems(); + if (_sellPendingItem != 0u) + { + if (!owned.Any(item => item.ObjectId == _sellPendingItem)) + { + RemoveClassifiedOwned(_sellPendingItem); + Status = $"Sold {_sellPendingName}."; + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _stateAge = 0d; + return true; + } + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Selling {_sellPendingName}…"; + return true; + } + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _stateAge = 0d; + } + + var ownedById = owned.ToDictionary(static item => item.ObjectId); + foreach (uint stale in _classifiedOwnedItems + .Where(entry => entry.Value == LootAction.Sell + && !ownedById.ContainsKey(entry.Key)) + .Select(static entry => entry.Key) + .ToArray()) + { + RemoveClassifiedOwned(stale); + } + uint sourceId = _classifiedOwnedItems + .Where(static entry => entry.Value == LootAction.Sell) + .Select(static entry => entry.Key) + .FirstOrDefault(ownedById.ContainsKey); + if (sourceId == 0u) + return false; + if (items.ActiveVendorObjectId == 0u) + { + Status = "Sell loot is queued until a vendor is open."; + return false; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + PluginInventoryItem source = ownedById[sourceId]; + PluginItemCommandResult result = items.Sell(sourceId); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to sell…" + : result.Notice ?? $"Could not sell {source.Name}."; + return result.Status == PluginItemCommandStatus.Busy; + } + _sellPendingItem = sourceId; + _sellPendingName = source.Name; + _stateAge = 0d; + Status = $"Selling {source.Name}…"; + return true; + } + + private bool HasManaStoneTransfer() + { + if (!_classifiedOwnedItems.Values.Contains(LootAction.ManaStone) + || !_classifiedOwnedItems.Values.Contains(LootAction.ManaTank)) + { + return false; + } + return ManaStoneTransferPlanner.Plan( + _host.Automation.Items.CaptureOwnedItems(), + _classifiedOwnedItems, + _settings.ManaTankMinimumMana) is not null; + } + + private bool ContinueManaStoneTransfer(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + if (_manaTransfer is { } pending) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision <= _manaTransferRevision + || completion.SourceObjectId != pending.StoneObjectId) + { + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Filling {pending.StoneName}…"; + return true; + } + Status = $"Mana stone fill timed out: {pending.StoneName}."; + } + else + { + Status = completion.IsSuccess + ? $"Filled {pending.StoneName}." + : $"Could not fill {pending.StoneName}."; + } + RemoveClassifiedOwned(pending.StoneObjectId); + RemoveClassifiedOwned(pending.TankObjectId); + _manaTransfer = null; + _manaTransferRevision = 0L; + _stateAge = 0d; + return true; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + ManaStoneTransferPlan? plan = ManaStoneTransferPlanner.Plan( + items.CaptureOwnedItems(), + _classifiedOwnedItems, + _settings.ManaTankMinimumMana); + if (plan is not { } next) + return false; + PluginItemCommandResult result = items.Apply( + next.StoneObjectId, + next.TankObjectId); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to fill mana stone…" + : $"Could not use {next.StoneName} on {next.TankName}."; + return result.Status == PluginItemCommandStatus.Busy; + } + _manaTransfer = next; + _manaTransferRevision = items.LastCompletion.Revision; + _stateAge = 0d; + Status = $"Filling {next.StoneName} from {next.TankName}…"; + return true; + } + + private bool HasSalvageBagCombine() => SalvageBagCombinePlanner.Plan( + _host.Automation.Items.CaptureOwnedItems(), + _abandonedCombineBags, + _settings.SalvageCombine) is not null; + + private bool ContinueSalvageBagCombine(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + IReadOnlyList owned = items.CaptureOwnedItems(); + if (_combinePending is { } pending) + { + bool allExist = pending.ObjectIds.All( + id => owned.Any(item => item.ObjectId == id)); + if (!allExist) + { + foreach (uint id in pending.ObjectIds) + _combineAttempts.Remove(id); + _combinePending = null; + _stateAge = 0d; + Status = "Combined salvage bags."; + return true; + } + if (_stateAge < PickupTimeoutSeconds) + { + Status = "Combining salvage bags…"; + return true; + } + + _combineAttempts.TryGetValue(pending.FirstObjectId, out int attempts); + attempts++; + _combineAttempts[pending.FirstObjectId] = attempts; + if (attempts > 40) + { + _abandonedCombineBags.Add(pending.FirstObjectId); + _combineAttempts.Remove(pending.FirstObjectId); + Status = $"Abandoned bugged salvage bag {pending.FirstName}."; + } + else + { + Status = $"Retrying salvage combine ({attempts}/40)…"; + } + _combinePending = null; + _stateAge = 0d; + return attempts <= 40; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + SalvageBagCombinePlan? plan = SalvageBagCombinePlanner.Plan( + owned, + _abandonedCombineBags, + _settings.SalvageCombine); + if (plan is not { } next) + return false; + const uint tinkeringTool = 0x20000000u; + PluginInventoryItem tool = owned.FirstOrDefault( + item => (item.ItemType & tinkeringTool) != 0u); + if (tool.ObjectId == 0u) + { + Status = "Salvage combine is waiting for a salvage tool."; + return false; + } + PluginItemCommandResult result = items.Salvage( + tool.ObjectId, + next.ObjectIds); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to combine salvage…" + : "Could not combine salvage bags."; + return result.Status == PluginItemCommandStatus.Busy; + } + _combinePending = next; + _stateAge = 0d; + Status = "Combining salvage bags…"; + return true; + } + + private void IncrementAttempt(uint objectId) + { + _itemAttempts.TryGetValue(objectId, out int attempts); + _itemAttempts[objectId] = attempts + 1; + } + + private bool CanLoot(in PluginLootContainer corpse) + { + if (_settings.LootOnlyRareCorpses && !corpse.IsGeneratedRare) + return false; + string killer = KillerName(corpse.LongDescription); + string character = _host.Automation.Character.Name; + if (killer.Length != 0 + && character.Length != 0 + && string.Equals(killer, character, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // VTank never crosses ownership on a rare-generating corpse. Its + // fo.cs prioritizes the player's own rare corpse, but rejects a rare + // corpse whose killer is anyone else even after the public timer. + if (corpse.IsGeneratedRare) + return false; + + double firstSeen = _corpseFirstSeen.TryGetValue( + corpse.ObjectId, + out double value) ? value : _lifetime; + double age = _lifetime - firstSeen; + PluginFellowMember? fellow = _host.Automation.Fellowship + .CaptureMembers() + .FirstOrDefault(member => string.Equals( + member.Name, + killer, + StringComparison.OrdinalIgnoreCase)); + if (fellow is { ObjectId: not 0u } member) + { + if (!_settings.LootFellowCorpses) + return false; + return member.ShareLoot || age >= 100d; + } + + // VTank's fo.cs waits 100 seconds before treating an unrelated corpse + // as public, even when LootAllCorpses is enabled. + return _settings.LootAllCorpses && age >= 100d; + } + + private LootDecision? DecideItem( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList owned, + IReadOnlyDictionary pending) + { + LootDecision? decision = string.IsNullOrWhiteSpace( + _settings.ExternalClassifierId) + ? LootRuleEngine.Decide( + item, + properties, + _settings.Rules, + owned, + pending, + _host) + : DecideWithExternalClassifier(item, properties, owned, pending); + if (decision is not null || !IsReadableUnknownScroll(item)) + { + if (decision is not null) + return decision; + } + else + { + return new LootDecision( + LootAction.Read, + Priority: 0, + RuleIndex: int.MaxValue, + RuleName: "Unknown Scroll"); + } + + LootAction? manaAction = AutomaticManaAction(item, owned); + return manaAction is { } action + ? new LootDecision( + action, + Priority: 0, + RuleIndex: int.MaxValue, + RuleName: action.ToString()) + : null; + } + + private LootDecision? DecideWithExternalClassifier( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList owned, + IReadOnlyDictionary pending) + { + var context = new PluginLootClassificationContext( + item, + properties, + owned); + if (!_host.LootClassifiers.TryClassify( + _settings.ExternalClassifierId, + context, + out PluginLootClassification classification) + || !classification.Matched + || !Enum.IsDefined(classification.Action)) + { + return null; + } + + LootAction action = (LootAction)(int)classification.Action; + if (action == LootAction.NoLoot) + return null; + if (action == LootAction.KeepUpTo) + { + int limit = Math.Max(0, classification.KeepCount); + string itemName = item.Name; + int held = owned + .Where(ownedItem => string.Equals( + ownedItem.Name, + itemName, + StringComparison.OrdinalIgnoreCase)) + .Sum(static ownedItem => Math.Max(1, ownedItem.StackSize)); + if (pending.TryGetValue(itemName, out int pendingCount)) + held += pendingCount; + if (held >= limit) + return null; + } + + return new LootDecision( + action, + classification.Priority, + RuleIndex: -1, + RuleName: string.IsNullOrWhiteSpace(classification.RuleName) + ? _settings.ExternalClassifierId + : classification.RuleName.Trim(), + ClassifierId: _settings.ExternalClassifierId); + } + + private void RemoveClassifiedOwned(uint objectId) + { + _classifiedOwnedItems.Remove(objectId); + if (!_externalClassifierByItem.Remove(objectId, out string? classifierId)) + return; + _host.LootClassifiers.TryNotifyItemRemoved(classifierId, objectId); + } + + private void PruneRemovedExternalItems() + { + if (_externalClassifierByItem.Count == 0) + return; + HashSet owned = _host.Automation.Items.CaptureOwnedItems() + .Select(static item => item.ObjectId) + .ToHashSet(); + foreach (uint removed in _externalClassifierByItem.Keys + .Where(objectId => !owned.Contains(objectId)) + .ToArray()) + { + RemoveClassifiedOwned(removed); + } + } + + private LootAction? AutomaticManaAction( + in PluginInventoryItem item, + IReadOnlyList owned) + { + const uint manaStoneType = 0x00080000u; + const uint retainedFlag = 0x01000000u; + int desired = Math.Clamp(_settings.ManaStoneLootCount, 0, 100); + int stones = owned.Count(ownedItem => + (ownedItem.ItemType & manaStoneType) != 0u); + stones += _classifiedOwnedItems.Values.Count( + static action => action == LootAction.ManaStone); + if ((item.ItemType & manaStoneType) != 0u && stones < desired) + return LootAction.ManaStone; + if (stones < desired + && item.ItemCurrentMana >= Math.Clamp( + _settings.ManaTankMinimumMana, + 1, + int.MaxValue) + && item.Value != 0 + && (item.PublicFlags & retainedFlag) == 0u) + { + return LootAction.ManaTank; + } + return null; + } + + private bool IsReadableUnknownScroll(in PluginInventoryItem item) + { + if (!_settings.ReadUnknownScrolls + || item.SpellId == 0u + || _host.Automation.Spells.IsKnown(item.SpellId)) + { + return false; + } + + // Decal's ObjectClass.Scroll (42) is a derived client classification, + // not a field on retail PublicWeenieDesc. On the wire a scroll is the + // Misc item carrying one Spell DID; the name guard excludes casters + // and spell-bearing quest items that share those two qualities. + const uint miscItemType = 0x00000080u; + bool scrollShape = (item.ItemType & miscItemType) != 0u + && item.Name.EndsWith(" Scroll", StringComparison.OrdinalIgnoreCase); + if (!scrollShape + || !_host.Automation.Spells.TryGet(item.SpellId, out PluginSpellInfo spell)) + { + return false; + } + return _host.Automation.Character.TryGetSkill( + spell.School, + out PluginSkillInfo skill) + && spell.Difficulty - 15 <= skill.Current; + } + + private void BlacklistFailedCorpse(uint corpseId) + { + if (corpseId == 0u) + return; + _corpseOpenAttempts.TryGetValue(corpseId, out int attempts); + attempts++; + int threshold = Math.Clamp( + _settings.BlacklistCorpseOpenAttemptCount, + 1, + 1000); + if (attempts < threshold) + { + _corpseOpenAttempts[corpseId] = attempts; + Status = $"Retrying corpse ({attempts}/{threshold})…"; + return; + } + _corpseOpenAttempts.Remove(corpseId); + _corpseBlacklistedAt[corpseId] = _lifetime; + Status = $"Blacklisted unopenable corpse for " + + $"{Math.Clamp(_settings.BlacklistCorpseOpenTimeoutSeconds, 1d, 3600d):0} seconds."; + } + + private bool IsCorpseBlacklisted(uint corpseId) + { + if (!_corpseBlacklistedAt.TryGetValue(corpseId, out double since)) + return false; + double timeout = Math.Clamp( + _settings.BlacklistCorpseOpenTimeoutSeconds, + 1d, + 3600d); + if (_lifetime - since < timeout) + return true; + _corpseBlacklistedAt.Remove(corpseId); + return false; + } + + private void MarkCorpseComplete(uint corpseId) + { + if (corpseId == 0u) + return; + _completedCorpses[corpseId] = _lifetime; + _corpseOpenAttempts.Remove(corpseId); + _corpseBlacklistedAt.Remove(corpseId); + } + + private void PruneCorpseCache() + { + double expiry = Math.Clamp( + _settings.CorpseCacheTimeoutMinutes, + 1d, + 1440d) * 60d; + foreach (uint id in _completedCorpses + .Where(entry => _lifetime - entry.Value >= expiry) + .Select(static entry => entry.Key) + .ToArray()) + { + _completedCorpses.Remove(id); + _corpseFirstSeen.Remove(id); + } + } + + internal static string KillerName(string description) + { + const string prefix = "Killed by "; + if (string.IsNullOrWhiteSpace(description) + || !description.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + string remainder = description[prefix.Length..]; + int period = remainder.IndexOf('.'); + if (period >= 0) + remainder = remainder[..period]; + return remainder.Trim(); + } + + private void ResetTransient() + { + _activeCorpse = 0u; + _activeCorpseSawContents = false; + _waitingItem = 0u; + _waitingName = string.Empty; + _waitingAction = LootAction.NoLoot; + _waitingQuantity = 0; + _waitingItemSnapshot = default; + _waitingClassifierId = string.Empty; + _waitingInventoryRevision = 0L; + _awaitingAppraisal = 0u; + _awaitingCorpseAppraisal = 0u; + _postUseItem = 0u; + _postUseName = string.Empty; + _postUseStarted = false; + _postUseRevision = 0L; + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _salvageAttempts = 0; + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _manaTransfer = null; + _manaTransferRevision = 0L; + _combinePending = null; + _stateAge = 0d; + } +} + +/// +/// Compact ordered loot-rule expression. OR groups contain AND clauses; each +/// clause compares one named item field or raw property table entry. +/// +internal sealed class LootRuleExpression +{ + private readonly Clause[][] _groups; + + private LootRuleExpression(Clause[][] groups) => _groups = groups; + + public static LootRuleExpression Compile(string source) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + string normalized = source.Trim(); + if (normalized is "*" || normalized.Equals( + "DEFAULT", + StringComparison.OrdinalIgnoreCase)) + { + return new LootRuleExpression([[]]); + } + + Clause[][] groups = Split(normalized, "||") + .Select(group => Split(group, "&&") + .Select(ParseClause) + .ToArray()) + .ToArray(); + if (groups.Length == 0 || groups.Any(static group => group.Length == 0)) + throw new FormatException("Loot expression contains an empty condition."); + return new LootRuleExpression(groups); + } + + public bool IsMatch( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + if (_groups.Length == 1 && _groups[0].Length == 0) + return true; + foreach (Clause[] group in _groups) + { + bool all = true; + foreach (Clause clause in group) + { + if (!clause.IsMatch(item, properties)) + { + all = false; + break; + } + } + if (all) + return true; + } + return false; + } + + private static Clause ParseClause(string text) + { + foreach (string operation in new[] { ">=", "<=", "!=", "==", "~=", ">", "<" }) + { + int offset = FindOutsideQuotes(text, operation); + if (offset < 0) + continue; + string field = text[..offset].Trim(); + string expected = Unquote(text[(offset + operation.Length)..].Trim()); + if (field.Length == 0 || expected.Length == 0) + throw new FormatException($"Invalid loot condition '{text.Trim()}'."); + return new Clause(field, operation, expected); + } + throw new FormatException( + $"Loot condition '{text.Trim()}' needs a comparison operator."); + } + + private static string[] Split(string source, string delimiter) + { + var result = new List(); + int start = 0; + char quote = '\0'; + for (int index = 0; index <= source.Length - delimiter.Length; index++) + { + char current = source[index]; + if (current is '\'' or '"') + quote = quote == '\0' ? current : quote == current ? '\0' : quote; + if (quote != '\0' + || !source.AsSpan(index).StartsWith( + delimiter, + StringComparison.Ordinal)) + { + continue; + } + result.Add(source[start..index].Trim()); + start = index + delimiter.Length; + index += delimiter.Length - 1; + } + result.Add(source[start..].Trim()); + return result.ToArray(); + } + + private static int FindOutsideQuotes(string source, string operation) + { + char quote = '\0'; + for (int index = 0; index <= source.Length - operation.Length; index++) + { + char current = source[index]; + if (current is '\'' or '"') + quote = quote == '\0' ? current : quote == current ? '\0' : quote; + if (quote == '\0' + && source.AsSpan(index).StartsWith( + operation, + StringComparison.Ordinal)) + { + return index; + } + } + return -1; + } + + private static string Unquote(string value) => value.Length >= 2 + && ((value[0] == '"' && value[^1] == '"') + || (value[0] == '\'' && value[^1] == '\'')) + ? value[1..^1] + : value; + + private readonly record struct Clause( + string Field, + string Operation, + string Expected) + { + public bool IsMatch( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + Value actual = Resolve(item, properties, Field); + if (Operation == "~=") + { + if (actual.Kind != ValueKind.Text) + throw new FormatException("~= is only valid for text fields."); + return actual.Text.Contains(Expected, StringComparison.OrdinalIgnoreCase); + } + int comparison = actual.Kind switch + { + ValueKind.Number => actual.Number.CompareTo(ParseNumber(Expected)), + ValueKind.Boolean => actual.Boolean.CompareTo(ParseBoolean(Expected)), + _ => string.Compare( + actual.Text, + Expected, + StringComparison.OrdinalIgnoreCase), + }; + return Operation switch + { + "==" => comparison == 0, + "!=" => comparison != 0, + ">" => comparison > 0, + "<" => comparison < 0, + ">=" => comparison >= 0, + "<=" => comparison <= 0, + _ => false, + }; + } + + private static Value Resolve( + in PluginInventoryItem item, + in PluginItemProperties properties, + string field) + { + string key = field.Trim().ToLowerInvariant(); + return key switch + { + "name" => Value.FromText(item.Name), + "wcid" or "typeid" => Value.FromNumber(item.WeenieClassId), + "itemtype" or "type" => Value.FromNumber(item.ItemType), + "stack" or "stacksize" => Value.FromNumber(item.StackSize), + "maxstack" => Value.FromNumber(item.MaximumStackSize), + "value" => Value.FromNumber(item.Value), + "burden" => Value.FromNumber(item.Burden), + "workmanship" => Value.FromNumber(item.Workmanship), + "material" => Value.FromNumber(item.MaterialType), + _ => ResolveRaw(key, properties), + }; + } + + private static Value ResolveRaw( + string field, + in PluginItemProperties properties) + { + if (!TryRawKey(field, out string table, out uint key)) + throw new FormatException($"Unknown loot field '{field}'."); + return table switch + { + "int" => Value.FromNumber( + properties.Ints?.TryGetValue(key, out int value) == true + ? value : 0), + "int64" => Value.FromNumber( + properties.Int64s?.TryGetValue(key, out long value) == true + ? value : 0), + "bool" => Value.FromBoolean( + properties.Bools?.TryGetValue(key, out bool value) == true + && value), + "float" => Value.FromNumber( + properties.Floats?.TryGetValue(key, out double value) == true + ? value : 0d), + "string" => Value.FromText( + properties.Strings?.TryGetValue(key, out string? value) == true + ? value : string.Empty), + "did" => Value.FromNumber( + properties.DataIds?.TryGetValue(key, out uint value) == true + ? value : 0u), + "iid" => Value.FromNumber( + properties.InstanceIds?.TryGetValue(key, out uint value) == true + ? value : 0u), + _ => throw new FormatException($"Unknown raw table '{table}'."), + }; + } + + private static bool TryRawKey( + string field, + out string table, + out uint key) + { + int open = field.IndexOf('[', StringComparison.Ordinal); + int close = field.LastIndexOf(']'); + table = open > 0 ? field[..open] : string.Empty; + key = 0u; + return open > 0 && close == field.Length - 1 + && uint.TryParse( + field.AsSpan(open + 1, close - open - 1), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out key); + } + + private static double ParseNumber(string value) => + double.TryParse( + value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double parsed) + ? parsed + : throw new FormatException($"'{value}' is not a number."); + + private static bool ParseBoolean(string value) => + bool.TryParse(value, out bool parsed) + ? parsed + : throw new FormatException($"'{value}' is not true or false."); + } + + private enum ValueKind + { + Number, + Text, + Boolean, + } + + private readonly record struct Value( + ValueKind Kind, + double Number, + string Text, + bool Boolean) + { + public static Value FromNumber(double value) => + new(ValueKind.Number, value, string.Empty, false); + public static Value FromText(string value) => + new(ValueKind.Text, 0d, value ?? string.Empty, false); + public static Value FromBoolean(bool value) => + new(ValueKind.Boolean, 0d, string.Empty, value); + } +} diff --git a/src/AcDream.Plugins.MossTank/Meta.cs b/src/AcDream.Plugins.MossTank/Meta.cs new file mode 100644 index 00000000..e361d4b6 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Meta.cs @@ -0,0 +1,656 @@ +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; +using AcDream.Plugins.MossTank.Expressions; + +namespace AcDream.Plugins.MossTank; + +internal enum MetaConditionKind +{ + Never, + Always, + All, + Any, + ChatMessage, + PackSlotsLessThanOrEqual, + SecondsInStateGreaterThanOrEqual, + NavigationRouteEmpty, + CharacterDeath, + AnyVendorOpen, + VendorClosed, + InventoryItemCountLessThanOrEqual, + InventoryItemCountGreaterThanOrEqual, + MonsterNameCountWithinDistance, + MonsterPriorityCountWithinDistance, + NeedToBuff, + NoMonstersWithinDistance, + LandblockEquals, + LandcellEquals, + PortalspaceEntered, + PortalspaceExited, + Not, + PersistentSecondsInStateGreaterThanOrEqual, + TimeLeftOnSpellGreaterThanOrEqual, + BurdenPercentGreaterThanOrEqual, + DistanceFromAnyRoutePointGreaterThanOrEqual, + Expression, + ChatMessageCapture, +} + +internal enum MetaActionKind +{ + None, + SetMetaState, + ChatCommand, + All, + LoadEmbeddedNavigationRoute, + CallMetaState, + ReturnFromCall, + ExpressionAction, + ChatExpression, + SetWatchdog, + ClearWatchdog, + GetVtankOption, + SetVtankOption, + CreateView, + DestroyView, + DestroyAllViews, +} + +internal sealed class MetaCondition +{ + public MetaConditionKind Kind { get; set; } = MetaConditionKind.Always; + public string Text { get; set; } = string.Empty; + public string SecondaryText { get; set; } = string.Empty; + public double Number { get; set; } + public double SecondaryNumber { get; set; } + public double TertiaryNumber { get; set; } + public List Children { get; set; } = []; + + public static MetaCondition Always() => new() { Kind = MetaConditionKind.Always }; +} + +internal sealed class MetaAction +{ + public MetaActionKind Kind { get; set; } = MetaActionKind.None; + public string Text { get; set; } = string.Empty; + public string SecondaryText { get; set; } = string.Empty; + public double Number { get; set; } + public double SecondaryNumber { get; set; } + public List Children { get; set; } = []; +} + +internal sealed class MetaRule +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string State { get; set; } = MetaEngine.DefaultState; + public MetaCondition Condition { get; set; } = MetaCondition.Always(); + public MetaAction Action { get; set; } = new(); + public bool Enabled { get; set; } = true; +} + +internal sealed class MetaProfile +{ + public List Rules { get; set; } = []; +} + +/// Bridges engine behavior to the already-owned MossTank controllers. +internal sealed class MetaServices +{ + public Func IsNavigationRouteEmpty { get; init; } = static () => true; + public Func NeedsBuff { get; init; } = static () => false; + public Func DistanceFromAnyRoutePoint { get; init; } = + static () => double.PositiveInfinity; + public Func CountMonstersByPriority { get; init; } = + static (_, _) => 0; + public Action LoadEmbeddedNavigationRoute { get; init; } = static _ => { }; + public Func GetOption { get; init; } = + static _ => ExpressionValue.Zero; + public Func SetOption { get; init; } = + static (_, _) => false; + public Func CreateView { get; init; } = + static (_, _) => false; + public Func DestroyView { get; init; } = static _ => false; + public Action DestroyAllViews { get; init; } = static () => { }; +} + +/// +/// VTank's ordered Meta engine: state-local rules fire once per state entry, +/// actions may continue the same pass, and transitions/calls stop the pass. +/// +internal sealed class MetaEngine +{ + public const string DefaultState = "Default"; + public const double DecisionIntervalSeconds = 0.293d; + public const int MaximumCallDepth = 10_000; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); + + private readonly IPluginHost _host; + private readonly MossTankExpressionRuntime _expressions; + private readonly MetaServices _services; + private readonly HashSet _fired = []; + private readonly Stack _callStack = []; + private readonly List _chatBatch = []; + private MetaProfile _profile; + private double _decisionAccumulator; + private double _stateSeconds; + private double _persistentStateSeconds; + private ulong _chatSequence; + private bool _wasPortalSpace; + private bool _wasDead; + private bool _portalEntered; + private bool _portalExited; + private bool _deathEdge; + private Watchdog? _watchdog; + private string _status = "Meta disabled."; + + public MetaEngine( + IPluginHost host, + MossTankExpressionRuntime expressions, + MetaProfile profile, + MetaServices? services = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _expressions = expressions ?? throw new ArgumentNullException(nameof(expressions)); + _profile = profile ?? throw new ArgumentNullException(nameof(profile)); + _services = services ?? new MetaServices(); + _wasPortalSpace = host.Automation.Navigation.Snapshot.IsPortalSpace; + _wasDead = IsDead(); + } + + public bool Enabled { get; private set; } + public string CurrentState { get; private set; } = DefaultState; + public string Status => _status; + public int CallDepth => _callStack.Count; + public int FiredRuleCount => _fired.Count; + public IReadOnlyCollection States => _profile.Rules + .Select(static rule => NormalizeState(rule.State)) + .Append(DefaultState) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public void SetEnabled(bool enabled) + { + if (Enabled == enabled) + return; + Enabled = enabled; + if (enabled) + { + _stateSeconds = 0d; + _decisionAccumulator = DecisionIntervalSeconds; + _status = $"Meta running: {CurrentState}."; + } + else + { + _status = "Meta disabled."; + _watchdog = null; + } + } + + /// + /// Ends the complete VTank meta-session lifetime. A graphical plugin may + /// survive logout and reconnect, but call stacks, once-per-entry receipts, + /// chat cursors, portal/death edges and state timers must not cross that + /// boundary into the next character session. + /// + public void ResetSession() + { + Enabled = false; + CurrentState = DefaultState; + _fired.Clear(); + _callStack.Clear(); + _chatBatch.Clear(); + _decisionAccumulator = 0d; + _stateSeconds = 0d; + _persistentStateSeconds = 0d; + _chatSequence = 0u; + _wasPortalSpace = _host.Automation.Navigation.Snapshot.IsPortalSpace; + _wasDead = IsDead(); + _portalEntered = false; + _portalExited = false; + _deathEdge = false; + _watchdog = null; + _status = "Meta disabled."; + } + + public void ReplaceProfile(MetaProfile profile) + { + _profile = profile ?? throw new ArgumentNullException(nameof(profile)); + Transition(DefaultState); + } + + public void Transition(string state) + { + CurrentState = NormalizeState(state); + _fired.Clear(); + _stateSeconds = 0d; + _persistentStateSeconds = 0d; + _watchdog = null; + _status = $"Meta transitioned to {CurrentState}."; + } + + public void OnTick(double elapsedSeconds) + { + if (elapsedSeconds < 0d || !double.IsFinite(elapsedSeconds)) + throw new ArgumentOutOfRangeException(nameof(elapsedSeconds)); + _expressions.OnTick(elapsedSeconds); + CaptureEdgesAndChat(); + if (!Enabled) + return; + + _stateSeconds += elapsedSeconds; + _persistentStateSeconds += elapsedSeconds; + _decisionAccumulator += elapsedSeconds; + UpdateWatchdog(elapsedSeconds); + if (_decisionAccumulator < DecisionIntervalSeconds) + return; + _decisionAccumulator %= DecisionIntervalSeconds; + EvaluatePass(); + _portalEntered = false; + _portalExited = false; + _deathEdge = false; + _chatBatch.Clear(); + } + + public void EvaluatePass() + { + if (!Enabled) + return; + if (WatchdogExpired()) + { + if (_callStack.Count >= MaximumCallDepth) + { + DisableWithError("Meta Error: Call stack overflow (watchdog loop?)."); + return; + } + string target = _watchdog!.Value.State; + _callStack.Push(CurrentState); + Transition(target); + _status = $"Meta watchdog expired; calling {target}."; + return; + } + + MetaRule[] rules = _profile.Rules.Where(rule => + rule.Enabled + && NormalizeState(rule.State).Equals( + CurrentState, + StringComparison.OrdinalIgnoreCase)).ToArray(); + foreach (MetaRule rule in rules) + { + if (_fired.Contains(rule.Id) || !EvaluateCondition(rule.Condition)) + continue; + _fired.Add(rule.Id); + _status = $"Meta executing {Describe(rule.Action)}."; + bool continuePass; + try + { + continuePass = ExecuteAction(rule.Action); + } + catch (Exception error) + { + _status = $"Meta action failed: {error.Message}"; + _host.Log.Error(_status, error); + continuePass = false; + } + if (!continuePass) + break; + } + } + + /// Command-only test hook matching VTank's /vt fakedeath. + internal void TriggerFakeDeath() + { + _deathEdge = true; + if (Enabled) + EvaluatePass(); + _deathEdge = false; + } + + private bool EvaluateCondition(MetaCondition condition) => condition.Kind switch + { + MetaConditionKind.Never => false, + MetaConditionKind.Always => true, + MetaConditionKind.All => condition.Children.All(EvaluateCondition), + MetaConditionKind.Any => condition.Children.Any(EvaluateCondition), + MetaConditionKind.Not => condition.Children.Count != 0 + && !EvaluateCondition(condition.Children[0]), + MetaConditionKind.ChatMessage => ChatMatch(condition, capture: false), + MetaConditionKind.ChatMessageCapture => ChatMatch(condition, capture: true), + MetaConditionKind.PackSlotsLessThanOrEqual => + EvaluateNumber("getfreeitemslots[]") <= condition.Number, + MetaConditionKind.SecondsInStateGreaterThanOrEqual => + _stateSeconds >= condition.Number, + MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => + _persistentStateSeconds >= condition.Number, + MetaConditionKind.NavigationRouteEmpty => _services.IsNavigationRouteEmpty(), + MetaConditionKind.CharacterDeath => _deathEdge, + MetaConditionKind.AnyVendorOpen => hostItems().ActiveVendorObjectId != 0u, + MetaConditionKind.VendorClosed => hostItems().ActiveVendorObjectId == 0u, + MetaConditionKind.InventoryItemCountLessThanOrEqual => + InventoryCount(condition.Text) <= condition.Number, + MetaConditionKind.InventoryItemCountGreaterThanOrEqual => + InventoryCount(condition.Text) >= condition.Number, + MetaConditionKind.MonsterNameCountWithinDistance => + MonsterCount(condition.Text, condition.SecondaryNumber) >= condition.Number, + MetaConditionKind.MonsterPriorityCountWithinDistance => + _services.CountMonstersByPriority( + checked((int)condition.TertiaryNumber), + condition.SecondaryNumber) >= condition.Number, + MetaConditionKind.NeedToBuff => _services.NeedsBuff(), + MetaConditionKind.NoMonstersWithinDistance => + _host.Automation.Combat.CaptureHostileTargets( + checked((float)condition.Number)).Count == 0, + MetaConditionKind.LandblockEquals => + (_host.Automation.Navigation.Snapshot.Position.CellId & 0xFFFF0000u) + == unchecked((uint)checked((int)condition.Number)), + MetaConditionKind.LandcellEquals => + _host.Automation.Navigation.Snapshot.Position.CellId + == unchecked((uint)checked((int)condition.Number)), + MetaConditionKind.PortalspaceEntered => _portalEntered, + MetaConditionKind.PortalspaceExited => _portalExited, + MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => + SpellTimeLeft(condition) >= condition.SecondaryNumber, + MetaConditionKind.BurdenPercentGreaterThanOrEqual => + EvaluateNumber("getcharburden[]") >= condition.Number, + MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => + _services.DistanceFromAnyRoutePoint() >= condition.Number, + MetaConditionKind.Expression => + _expressions.Evaluate(condition.Text).IsTruthy, + _ => false, + }; + + private bool ExecuteAction(MetaAction action) + { + switch (action.Kind) + { + case MetaActionKind.None: + return true; + case MetaActionKind.SetMetaState: + Transition(action.Text); + return false; + case MetaActionKind.ChatCommand: + _host.Automation.Chat.Submit(action.Text); + return true; + case MetaActionKind.All: + foreach (MetaAction child in action.Children) + { + if (!ExecuteAction(child)) + return false; + } + return true; + case MetaActionKind.LoadEmbeddedNavigationRoute: + _services.LoadEmbeddedNavigationRoute(action.Text); + return true; + case MetaActionKind.CallMetaState: + if (_callStack.Count >= MaximumCallDepth) + { + DisableWithError("Meta Error: Call stack overflow (recursive call loop?)."); + return false; + } + _callStack.Push(string.IsNullOrWhiteSpace(action.SecondaryText) + ? CurrentState + : NormalizeState(action.SecondaryText)); + Transition(action.Text); + return false; + case MetaActionKind.ReturnFromCall: + if (_callStack.Count == 0) + { + DisableWithError("Meta Error: Call stack underflow, cannot return."); + return false; + } + Transition(_callStack.Pop()); + return false; + case MetaActionKind.ExpressionAction: + _expressions.Evaluate(action.Text); + return true; + case MetaActionKind.ChatExpression: + ExpressionValue result = _expressions.Evaluate(action.Text); + if (result.ToDisplayString().Length != 0) + _host.Automation.Chat.Submit(result.ToDisplayString()); + return true; + case MetaActionKind.SetWatchdog: + SetWatchdog( + action.Text, + action.Number <= 0d ? 5d : action.Number, + action.SecondaryNumber <= 0d ? 10d : action.SecondaryNumber); + return true; + case MetaActionKind.ClearWatchdog: + _watchdog = null; + return true; + case MetaActionKind.GetVtankOption: + _expressions.State.Set( + ExpressionVariableScope.Session, + string.IsNullOrWhiteSpace(action.SecondaryText) + ? "option" + : action.SecondaryText, + _services.GetOption(action.Text)); + return true; + case MetaActionKind.SetVtankOption: + return _services.SetOption( + action.Text, + _expressions.Evaluate(action.SecondaryText)); + case MetaActionKind.CreateView: + return _services.CreateView(action.Text, action.SecondaryText); + case MetaActionKind.DestroyView: + return _services.DestroyView(action.Text); + case MetaActionKind.DestroyAllViews: + _services.DestroyAllViews(); + return true; + default: + return false; + } + } + + private void CaptureEdgesAndChat() + { + bool portal = _host.Automation.Navigation.Snapshot.IsPortalSpace; + _portalEntered |= !_wasPortalSpace && portal; + _portalExited |= _wasPortalSpace && !portal; + _wasPortalSpace = portal; + bool dead = IsDead(); + _deathEdge |= !_wasDead && dead; + _wasDead = dead; + + IReadOnlyList messages = + _host.Automation.Chat.CaptureMessages(_chatSequence); + foreach (PluginChatMessage message in messages) + { + _chatBatch.Add(message); + _chatSequence = Math.Max(_chatSequence, message.Sequence); + } + } + + private bool ChatMatch(MetaCondition condition, bool capture) + { + Regex regex; + try + { + regex = new Regex( + condition.Text, + RegexOptions.CultureInvariant, + RegexTimeout); + } + catch (ArgumentException) + { + return false; + } + HashSet? acceptedKinds = ParseKinds(condition.SecondaryText); + foreach (PluginChatMessage message in _chatBatch) + { + if (acceptedKinds is not null && !acceptedKinds.Contains(message.Kind)) + continue; + Match match = regex.Match(message.Text); + if (!match.Success) + continue; + if (capture) + { + foreach (string name in regex.GetGroupNames()) + { + Group group = match.Groups[name]; + string variable = "capturegroup_" + name; + if (group.Success) + { + _expressions.State.Set( + ExpressionVariableScope.Session, + variable, + ExpressionValue.String(group.Value)); + } + else + { + _expressions.State.Clear( + ExpressionVariableScope.Session, + variable); + } + } + _expressions.State.Set( + ExpressionVariableScope.Session, + "capturecolor", + ExpressionValue.Number(message.Kind)); + } + return true; + } + return false; + } + + private static HashSet? ParseKinds(string source) + { + if (string.IsNullOrWhiteSpace(source)) + return null; + var result = new HashSet(); + foreach (string part in source.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + if (!int.TryParse(part.Trim(), out int kind)) + return []; + result.Add(kind); + } + return result; + } + + private double InventoryCount(string name) + { + string escaped = name.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("'", "\\'", StringComparison.Ordinal); + return EvaluateNumber($"getitemcountininventorybyname['{escaped}']"); + } + + private int MonsterCount(string pattern, double distance) + { + Regex regex; + try + { + regex = new Regex( + pattern, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RegexTimeout); + } + catch (ArgumentException) + { + return 0; + } + return _host.Automation.Combat.CaptureHostileTargets( + checked((float)distance)).Count(target => regex.IsMatch(target.Name)); + } + + private double SpellTimeLeft(MetaCondition condition) + { + uint spellId = condition.Number > 0d + ? checked((uint)condition.Number) + : _host.Automation.Spells.KnownSelfBuffs + .Concat(_host.Automation.Spells.KnownCombatSpells) + .FirstOrDefault(spell => spell.Name.Equals( + condition.Text, + StringComparison.OrdinalIgnoreCase)).SpellId; + foreach (PluginActiveEnchantment enchantment in + _host.Automation.Character.ActiveEnchantments) + { + if (enchantment.SpellId == spellId) + return enchantment.SecondsRemaining; + } + return 0d; + } + + private double EvaluateNumber(string source) => + _expressions.Evaluate(source).AsNumber(source); + + private IItemAutomation hostItems() => _host.Automation.Items; + + private bool IsDead() + { + ICharacterInfo character = _host.Automation.Character; + return character.IsInWorld + && character.MaxHealth > 0u + && character.CurrentHealth == 0u; + } + + private void SetWatchdog(string state, double rangeMeters, double seconds) + { + PluginNavigationPosition position = + _host.Automation.Navigation.Snapshot.Position; + _watchdog = new Watchdog( + NormalizeState(state), + Math.Max(0d, rangeMeters), + Math.Max(0.001d, seconds), + 0d, + 0d, + Enumerable.Repeat(position, 10).ToArray()); + } + + private void UpdateWatchdog(double elapsedSeconds) + { + if (_watchdog is not Watchdog watchdog) + return; + watchdog = watchdog with + { + TotalSeconds = watchdog.TotalSeconds + elapsedSeconds, + SampleSeconds = watchdog.SampleSeconds + elapsedSeconds, + }; + double interval = watchdog.TimeSpanSeconds / 10d; + if (watchdog.SampleSeconds >= interval) + { + int index = ((int)Math.Floor(watchdog.TotalSeconds / interval)) % 10; + watchdog.Samples[index] = _host.Automation.Navigation.Snapshot.Position; + watchdog = watchdog with { SampleSeconds = watchdog.SampleSeconds % interval }; + } + _watchdog = watchdog; + } + + private bool WatchdogExpired() + { + if (_watchdog is not Watchdog watchdog + || watchdog.TotalSeconds < watchdog.TimeSpanSeconds) + { + return false; + } + PluginNavigationPosition current = + _host.Automation.Navigation.Snapshot.Position; + return watchdog.Samples.All(sample => + sample.HorizontalDistanceMeters(current) <= watchdog.RangeMeters); + } + + private void DisableWithError(string message) + { + Enabled = false; + _status = message + " Meta disabled."; + _host.Automation.Chat.PostSystemMessage(_status); + _host.Log.Error(_status); + } + + private static string NormalizeState(string? state) => + string.IsNullOrWhiteSpace(state) ? DefaultState : state.Trim(); + + private static string Describe(MetaAction action) => action.Kind switch + { + MetaActionKind.SetMetaState => $"Set Meta State {action.Text}", + MetaActionKind.CallMetaState => $"Call Meta State {action.Text}", + MetaActionKind.ChatCommand => $"Chat {action.Text}", + _ => action.Kind.ToString(), + }; + + private readonly record struct Watchdog( + string State, + double RangeMeters, + double TimeSpanSeconds, + double TotalSeconds, + double SampleSeconds, + PluginNavigationPosition[] Samples); +} diff --git a/src/AcDream.Plugins.MossTank/MetaViewManager.cs b/src/AcDream.Plugins.MossTank/MetaViewManager.cs new file mode 100644 index 00000000..54ad920a --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MetaViewManager.cs @@ -0,0 +1,97 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Owns VTank Meta-created views independently from the macro window. The +/// official implementation replaces duplicate names and permits five normal +/// entries (including its historical sixth-entry boundary quirk). +/// +internal sealed class MetaViewManager +{ + private const int OfficialViewLimit = 5; + private readonly IPluginHost _host; + private readonly Dictionary _views = + new(StringComparer.Ordinal); + + public MetaViewManager(IPluginHost host) => + _host = host ?? throw new ArgumentNullException(nameof(host)); + + public int Count => _views.Count; + + public bool Create(string name, string markup) + { + if (!_host.HasUi || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(markup)) + return false; + + // bw.a(string,string) checks Count > 5 before duplicate replacement. + // Preserve that observable VTank quirk for imported Meta profiles. + if (_views.Count > OfficialViewLimit) + return false; + + try + { + XElement root = XDocument.Parse(markup).Root + ?? throw new InvalidDataException("View markup has no root element."); + if (!root.Name.LocalName.Equals("panel", StringComparison.OrdinalIgnoreCase)) + return false; + } + catch (Exception error) when (error is InvalidDataException or System.Xml.XmlException) + { + _host.Log.Warn($"MossTank Meta view '{name}' is invalid: {error.Message}"); + return false; + } + + Destroy(name); + IDisposable token = _host.Ui.RegisterPanelContent( + new PluginPanelDescriptor(WindowId(name), name) + { + IconText = Initials(name), + StartVisible = true, + ShowInSidePanel = true, + }, + markup, + MetaViewBinding.Instance); + _views.Add(name, token); + return true; + } + + public bool Destroy(string name) + { + if (!_views.Remove(name, out IDisposable? registration)) + return false; + registration.Dispose(); + return true; + } + + public void DestroyAll() + { + IDisposable[] registrations = _views.Values.ToArray(); + _views.Clear(); + foreach (IDisposable registration in registrations) + registration.Dispose(); + } + + private static string WindowId(string name) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(name)); + return "meta-" + Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant(); + } + + private static string Initials(string name) + { + string[] words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + return "M"; + return string.Concat(words.Take(2).Select(static word => word[0])).ToUpperInvariant(); + } + + private sealed class MetaViewBinding + { + internal static MetaViewBinding Instance { get; } = new(); + public bool WindowAvailable => true; + } +} diff --git a/src/AcDream.Plugins.MossTank/MonsterExpression.cs b/src/AcDream.Plugins.MossTank/MonsterExpression.cs new file mode 100644 index 00000000..98c9c8e8 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MonsterExpression.cs @@ -0,0 +1,608 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace AcDream.Plugins.MossTank; + +internal enum MonsterValueKind +{ + Number, + Text, + Boolean, +} + +/// +/// One value in VTank's monster-list expression language. Unlike meta +/// expressions, monster expressions have a real boolean type and require both +/// operands of a comparison to have the same type. +/// +internal readonly record struct MonsterValue +{ + private MonsterValue( + MonsterValueKind kind, + double number, + string? text, + bool boolean) + { + Kind = kind; + Number = number; + Text = text ?? string.Empty; + Boolean = boolean; + } + + public MonsterValueKind Kind { get; } + public double Number { get; } + public string Text { get; } + public bool Boolean { get; } + + public static MonsterValue FromNumber(double value) => + new(MonsterValueKind.Number, value, null, false); + + public static MonsterValue FromText(string value) => + new(MonsterValueKind.Text, 0d, value, false); + + public static MonsterValue FromBoolean(bool value) => + new(MonsterValueKind.Boolean, 0d, null, value); + + public override string ToString() => Kind switch + { + MonsterValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), + MonsterValueKind.Text => Text, + MonsterValueKind.Boolean => Boolean ? "true" : "false", + _ => string.Empty, + }; +} + +/// Live values exposed by VTank's /vt listmonstervariables. +internal readonly record struct MonsterExpressionContext( + string Name, + uint TypeId, + string Species, + int MaximumHealth, + float Range, + bool HasShield, + string MetaState, + Func? Setting = null) +{ + internal bool TryResolve(string token, out MonsterValue value) + { + if (token.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromBoolean(true); + return true; + } + if (token.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromBoolean(false); + return true; + } + if (token.Equals("name", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromText(Name); + return true; + } + if (token.Equals("typeid", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromNumber(TypeId); + return true; + } + if (token.Equals("species", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromText(Species); + return true; + } + if (token.Equals("maxhp", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromNumber(MaximumHealth); + return true; + } + if (token.Equals("range", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromNumber(Range); + return true; + } + if (token.Equals("hasshield", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromBoolean(HasShield); + return true; + } + if (token.Equals("metastate", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromText(MetaState); + return true; + } + + // VTank documents setting names as case-sensitive even though the + // built-in monster variables and string comparisons are not. + const string settingPrefix = "setting_"; + if (token.StartsWith(settingPrefix, StringComparison.Ordinal) + && Setting?.Invoke(token[settingPrefix.Length..]) is { } setting) + { + value = setting; + return true; + } + + value = default; + return false; + } +} + +internal sealed class MonsterExpressionException(string message) + : FormatException(message); + +/// +/// Immutable compiled VTank monster-list expression. The lexer deliberately +/// has no quoted strings: VTank strings are runs of letters/spaces and use a +/// backslash to escape every operator, digit, or punctuation character. +/// +internal sealed class MonsterExpression +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(25); + + private readonly Node _root; + + private MonsterExpression(string source, Node root) + { + Source = source; + _root = root; + } + + public string Source { get; } + public bool IsDynamic => _root.IsDynamic; + + public static MonsterExpression Compile(string source) + { + ArgumentNullException.ThrowIfNull(source); + string normalized = source.Trim(); + if (normalized.Length == 0) + throw new MonsterExpressionException("Monster expression is empty."); + var parser = new Parser(normalized); + Node root = parser.Parse(); + return new MonsterExpression(normalized, root); + } + + public bool TryEvaluate( + in MonsterExpressionContext context, + out MonsterValue value, + out string? error) + { + try + { + value = _root.Evaluate(context); + error = null; + return true; + } + catch (Exception ex) when (ex is MonsterExpressionException + or RegexMatchTimeoutException + or ArgumentException + or OverflowException + or DivideByZeroException) + { + value = default; + error = ex.Message; + return false; + } + } + + public bool IsMatch(in MonsterExpressionContext context, out string? error) + { + if (!TryEvaluate(context, out MonsterValue result, out error)) + return false; + return result.Kind switch + { + MonsterValueKind.Boolean => result.Boolean, + MonsterValueKind.Text => string.Equals( + result.Text.Trim(), + context.Name.Trim(), + StringComparison.OrdinalIgnoreCase), + _ => false, + }; + } + + private abstract class Node(bool isDynamic) + { + internal bool IsDynamic { get; } = isDynamic; + internal abstract MonsterValue Evaluate(in MonsterExpressionContext context); + } + + private sealed class NumberNode(double value) : Node(false) + { + internal override MonsterValue Evaluate(in MonsterExpressionContext context) => + MonsterValue.FromNumber(value); + } + + private sealed class AtomNode(string token) : Node(IsDynamicToken(token)) + { + internal override MonsterValue Evaluate(in MonsterExpressionContext context) => + context.TryResolve(token, out MonsterValue value) + ? value + : MonsterValue.FromText(token.Trim()); + + private static bool IsDynamicToken(string value) => + value.Equals("range", StringComparison.OrdinalIgnoreCase) + || value.Equals("hasshield", StringComparison.OrdinalIgnoreCase) + || value.Equals("metastate", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("setting_", StringComparison.Ordinal); + } + + private sealed class BinaryNode(TokenKind operation, Node left, Node right) + : Node(left.IsDynamic || right.IsDynamic) + { + internal override MonsterValue Evaluate(in MonsterExpressionContext context) + { + // VTank's boolean operators are short-circuiting in practice; this + // also keeps an invalid right branch from poisoning a decided rule. + MonsterValue lhs = left.Evaluate(context); + if (operation == TokenKind.And) + { + bool l = RequireBoolean(lhs, "&&"); + return !l + ? MonsterValue.FromBoolean(false) + : MonsterValue.FromBoolean( + RequireBoolean(right.Evaluate(context), "&&")); + } + if (operation == TokenKind.Or) + { + bool l = RequireBoolean(lhs, "||"); + return l + ? MonsterValue.FromBoolean(true) + : MonsterValue.FromBoolean( + RequireBoolean(right.Evaluate(context), "||")); + } + + MonsterValue rhs = right.Evaluate(context); + return operation switch + { + TokenKind.Modulo => MonsterValue.FromNumber( + (long)RequireNumber(lhs, "%") % (long)RequireNonZero(rhs, "%")), + TokenKind.Divide => MonsterValue.FromNumber( + RequireNumber(lhs, "/") / RequireNonZero(rhs, "/")), + TokenKind.Multiply => MonsterValue.FromNumber( + RequireNumber(lhs, "*") * RequireNumber(rhs, "*")), + TokenKind.Add => Add(lhs, rhs), + TokenKind.Subtract => MonsterValue.FromNumber( + RequireNumber(lhs, "-") - RequireNumber(rhs, "-")), + TokenKind.Regex => RegexMatch(lhs, rhs), + TokenKind.Equal => Compare(lhs, rhs, comparison => comparison == 0), + TokenKind.NotEqual => Compare(lhs, rhs, comparison => comparison != 0), + TokenKind.Greater => Compare(lhs, rhs, comparison => comparison > 0), + TokenKind.Less => Compare(lhs, rhs, comparison => comparison < 0), + TokenKind.GreaterOrEqual => Compare(lhs, rhs, comparison => comparison >= 0), + TokenKind.LessOrEqual => Compare(lhs, rhs, comparison => comparison <= 0), + _ => throw new MonsterExpressionException( + $"Unsupported monster-expression operator {operation}."), + }; + } + + private static MonsterValue Add(MonsterValue left, MonsterValue right) + { + RequireSameType(left, right, "+"); + return left.Kind switch + { + MonsterValueKind.Number => MonsterValue.FromNumber( + left.Number + right.Number), + MonsterValueKind.Text => MonsterValue.FromText( + left.Text + right.Text), + _ => throw TypeError("+", left.Kind), + }; + } + + private static MonsterValue RegexMatch( + MonsterValue left, + MonsterValue right) + { + RequireSameType(left, right, "#"); + if (left.Kind != MonsterValueKind.Text) + throw TypeError("#", left.Kind); + return MonsterValue.FromBoolean(Regex.IsMatch( + left.Text, + right.Text, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RegexTimeout)); + } + + private static MonsterValue Compare( + MonsterValue left, + MonsterValue right, + Func predicate) + { + RequireSameType(left, right, "comparison"); + int comparison = left.Kind switch + { + MonsterValueKind.Number => left.Number.CompareTo(right.Number), + MonsterValueKind.Text => string.Compare( + left.Text, + right.Text, + StringComparison.OrdinalIgnoreCase), + MonsterValueKind.Boolean => left.Boolean.CompareTo(right.Boolean), + _ => throw TypeError("comparison", left.Kind), + }; + return MonsterValue.FromBoolean(predicate(comparison)); + } + + private static void RequireSameType( + MonsterValue left, + MonsterValue right, + string operation) + { + if (left.Kind != right.Kind) + { + throw new MonsterExpressionException( + $"Operator {operation} requires matching operand types; " + + $"received {left.Kind} and {right.Kind}."); + } + } + + private static double RequireNumber(MonsterValue value, string operation) + { + if (value.Kind != MonsterValueKind.Number) + throw TypeError(operation, value.Kind); + return value.Number; + } + + private static double RequireNonZero(MonsterValue value, string operation) + { + double number = RequireNumber(value, operation); + if (number == 0d) + throw new DivideByZeroException($"Operator {operation} divided by zero."); + return number; + } + + private static bool RequireBoolean(MonsterValue value, string operation) + { + if (value.Kind != MonsterValueKind.Boolean) + throw TypeError(operation, value.Kind); + return value.Boolean; + } + + private static MonsterExpressionException TypeError( + string operation, + MonsterValueKind actual) => new( + $"Operator {operation} cannot be applied to {actual}."); + } + + private enum TokenKind + { + End, + Atom, + Number, + LeftParen, + RightParen, + Modulo, + Divide, + Multiply, + Add, + Subtract, + Regex, + NotEqual, + Equal, + Greater, + Less, + GreaterOrEqual, + LessOrEqual, + And, + Or, + } + + private readonly record struct Token(TokenKind Kind, string Text, int Offset); + + private sealed class Lexer(string source) + { + private int _offset; + + internal Token Next() + { + while (_offset < source.Length && char.IsWhiteSpace(source[_offset])) + _offset++; + if (_offset >= source.Length) + return new Token(TokenKind.End, string.Empty, _offset); + + int start = _offset; + char current = source[_offset]; + if (TryOperator(out Token operation)) + return operation; + + if (char.IsDigit(current) + || (current == '.' + && _offset + 1 < source.Length + && char.IsDigit(source[_offset + 1]))) + { + _offset++; + while (_offset < source.Length + && (char.IsDigit(source[_offset]) || source[_offset] == '.')) + { + _offset++; + } + string number = source[start.._offset]; + if (!double.TryParse( + number, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out _)) + { + throw new MonsterExpressionException( + $"Invalid number '{number}' at offset {start}."); + } + return new Token(TokenKind.Number, number, start); + } + + var text = new StringBuilder(); + while (_offset < source.Length) + { + current = source[_offset]; + if (current == '\\') + { + if (_offset + 1 >= source.Length) + { + throw new MonsterExpressionException( + $"Trailing escape at offset {_offset}."); + } + text.Append(source[_offset + 1]); + _offset += 2; + continue; + } + if (IsOperatorStart(current) || char.IsDigit(current)) + break; + text.Append(current); + _offset++; + } + + string atom = text.ToString().Trim(); + if (atom.Length == 0) + { + throw new MonsterExpressionException( + $"Unexpected character '{source[_offset]}' at offset {_offset}; " + + "digits and punctuation in VTank strings must be escaped."); + } + return new Token(TokenKind.Atom, atom, start); + } + + private bool TryOperator(out Token token) + { + int start = _offset; + char c = source[_offset]; + TokenKind kind; + int length = 1; + if (_offset + 1 < source.Length) + { + string pair = source.Substring(_offset, 2); + kind = pair switch + { + "!=" => TokenKind.NotEqual, + "==" => TokenKind.Equal, + ">=" => TokenKind.GreaterOrEqual, + "<=" => TokenKind.LessOrEqual, + "&&" => TokenKind.And, + "||" => TokenKind.Or, + _ => TokenKind.End, + }; + if (kind != TokenKind.End) + { + length = 2; + _offset += length; + token = new Token(kind, pair, start); + return true; + } + } + + kind = c switch + { + '(' => TokenKind.LeftParen, + ')' => TokenKind.RightParen, + '%' => TokenKind.Modulo, + '/' => TokenKind.Divide, + '*' => TokenKind.Multiply, + '+' => TokenKind.Add, + '-' => TokenKind.Subtract, + '#' => TokenKind.Regex, + '>' => TokenKind.Greater, + '<' => TokenKind.Less, + _ => TokenKind.End, + }; + if (kind == TokenKind.End) + { + token = default; + return false; + } + _offset += length; + token = new Token(kind, c.ToString(), start); + return true; + } + + private static bool IsOperatorStart(char value) => + value is '(' or ')' or '%' or '/' or '*' or '+' or '-' or '#' + or '!' or '=' or '>' or '<' or '&' or '|'; + } + + private sealed class Parser + { + private readonly Lexer _lexer; + private Token _current; + + internal Parser(string source) + { + _lexer = new Lexer(source); + _current = _lexer.Next(); + } + + internal Node Parse() + { + Node result = ParseOr(); + if (_current.Kind != TokenKind.End) + { + throw new MonsterExpressionException( + $"Unexpected token '{_current.Text}' at offset {_current.Offset}."); + } + return result; + } + + private Node ParseOr() => ParseLeftAssociative(ParseAnd, TokenKind.Or); + private Node ParseAnd() => ParseLeftAssociative(ParseComparison, TokenKind.And); + + private Node ParseComparison() => ParseLeftAssociative( + ParseRegex, + TokenKind.NotEqual, + TokenKind.Equal, + TokenKind.Greater, + TokenKind.Less, + TokenKind.GreaterOrEqual, + TokenKind.LessOrEqual); + + private Node ParseRegex() => ParseLeftAssociative(ParseSubtract, TokenKind.Regex); + private Node ParseSubtract() => ParseLeftAssociative(ParseAdd, TokenKind.Subtract); + private Node ParseAdd() => ParseLeftAssociative(ParseMultiply, TokenKind.Add); + private Node ParseMultiply() => ParseLeftAssociative(ParseDivide, TokenKind.Multiply); + private Node ParseDivide() => ParseLeftAssociative(ParseModulo, TokenKind.Divide); + private Node ParseModulo() => ParseLeftAssociative(ParsePrimary, TokenKind.Modulo); + + private Node ParseLeftAssociative( + Func operand, + params TokenKind[] operations) + { + Node left = operand(); + while (operations.Contains(_current.Kind)) + { + TokenKind operation = _current.Kind; + Advance(); + left = new BinaryNode(operation, left, operand()); + } + return left; + } + + private Node ParsePrimary() + { + Token token = _current; + switch (token.Kind) + { + case TokenKind.Number: + Advance(); + return new NumberNode(double.Parse( + token.Text, + CultureInfo.InvariantCulture)); + case TokenKind.Atom: + Advance(); + return new AtomNode(token.Text); + case TokenKind.LeftParen: + Advance(); + Node nested = ParseOr(); + Require(TokenKind.RightParen, "Closing ')' expected"); + Advance(); + return nested; + default: + throw new MonsterExpressionException( + $"Operand expected at offset {token.Offset}; found '{token.Text}'."); + } + } + + private void Require(TokenKind kind, string message) + { + if (_current.Kind != kind) + { + throw new MonsterExpressionException( + $"{message} at offset {_current.Offset}."); + } + } + + private void Advance() => _current = _lexer.Next(); + } +} diff --git a/src/AcDream.Plugins.MossTank/MonsterRules.cs b/src/AcDream.Plugins.MossTank/MonsterRules.cs new file mode 100644 index 00000000..942d59ef --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MonsterRules.cs @@ -0,0 +1,150 @@ +namespace AcDream.Plugins.MossTank; + +[Flags] +internal enum MonsterActionFlags +{ + None = 0, + Fester = 1 << 0, + Broadside = 1 << 1, + GravityWell = 1 << 2, + Imperil = 1 << 3, + Yield = 1 << 4, + Vulnerability = 1 << 5, + Attack = 1 << 6, + Ring = 1 << 7, + Streak = 1 << 8, + WeakeningCurse = 1 << 9, + FesteringCurse = 1 << 10, + Corruption = 1 << 11, + DestructiveCurse = 1 << 12, + Corrosion = 1 << 13, +} + +internal enum MonsterDamageType +{ + Auto = 0, + Slash, + Pierce, + Bludgeon, + Cold, + Fire, + Acid, + Electric, + Nether, + VoidBasic, + DrainAuto, + Harm, + None, + PlayerAuto, + Prismatic, + Random, + Fists, + Physical, +} + +/// Every editable column in VTank's Monsters table. +internal sealed record MonsterRuleActions +{ + public MonsterActionFlags Flags { get; init; } = MonsterActionFlags.Attack; + public int Priority { get; init; } + public MonsterDamageType DamageType { get; init; } = MonsterDamageType.Auto; + public MonsterDamageType ExtraVulnerability { get; init; } = + MonsterDamageType.Auto; + public uint WeaponObjectId { get; init; } + public uint OffhandObjectId { get; init; } + /// + /// Durable profile identity. Object ids are session-local, so a loaded + /// profile resolves this exact VTank item name back to the current object. + /// + public string WeaponName { get; init; } = string.Empty; + public string OffhandName { get; init; } = string.Empty; + public MonsterDamageType PetDamageType { get; init; } = + MonsterDamageType.PlayerAuto; + + public int BoundedPriority => Math.Clamp(Priority, -1, 4); + public bool Attacks => (Flags + & (MonsterActionFlags.Attack | MonsterActionFlags.Ring)) != 0; + public bool UsesPrimaryAttack => (Flags & MonsterActionFlags.Attack) != 0; + public bool UsesRing => (Flags & MonsterActionFlags.Ring) != 0; + public bool UsesStreak => (Flags & MonsterActionFlags.Streak) != 0; +} + +/// +/// One ordered VTank Monsters row. Non-default rows compile the exact VTank +/// expression grammar; an expression yielding true, or a text value equal to +/// the monster's name, matches. DEFAULT is considered only after every row. +/// +internal sealed class MonsterRule +{ + private readonly MonsterExpression? _compiled; + + public MonsterRule(string expression, int priority) + : this(expression, new MonsterRuleActions { Priority = priority }) + { + } + + public MonsterRule(string expression, MonsterRuleActions actions) + { + Expression = string.IsNullOrWhiteSpace(expression) + ? "DEFAULT" + : expression.Trim(); + Actions = actions ?? throw new ArgumentNullException(nameof(actions)); + if (!IsDefault) + _compiled = MonsterExpression.Compile(Expression); + } + + public string Expression { get; } + public MonsterRuleActions Actions { get; } + public int Priority => Actions.BoundedPriority; + public bool IsDefault => Expression.Equals( + "DEFAULT", + StringComparison.OrdinalIgnoreCase); + public bool IsDynamic => _compiled?.IsDynamic == true; + + public bool Matches( + in MonsterExpressionContext context, + out string? error) + { + if (_compiled is null) + { + error = null; + return IsDefault; + } + return _compiled.IsMatch(context, out error); + } +} + +internal readonly record struct ResolvedMonsterRule( + MonsterRule Rule, + string? EvaluationError) +{ + public MonsterRuleActions Actions => Rule.Actions; + public int Priority => Rule.Priority; +} + +internal static class MonsterRuleResolver +{ + /// VTank: rows after DEFAULT are checked top-to-bottom; first match wins. + internal static ResolvedMonsterRule Resolve( + IEnumerable rules, + in MonsterExpressionContext context) + { + ArgumentNullException.ThrowIfNull(rules); + MonsterRule? fallback = null; + string? firstError = null; + foreach (MonsterRule rule in rules) + { + if (rule.IsDefault) + { + fallback ??= rule; + continue; + } + if (rule.Matches(context, out string? error)) + return new ResolvedMonsterRule(rule, firstError); + firstError ??= error; + } + + fallback ??= new MonsterRule("DEFAULT", 0); + return new ResolvedMonsterRule(fallback, firstError); + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankCommands.cs b/src/AcDream.Plugins.MossTank/MossTankCommands.cs new file mode 100644 index 00000000..08a47647 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankCommands.cs @@ -0,0 +1,1046 @@ +using System.Globalization; +using System.Text; +using AcDream.Plugin.Abstractions; +using AcDream.Plugins.MossTank.Expressions; + +namespace AcDream.Plugins.MossTank; + +/// VTank's documented /vt chat surface. +internal sealed partial class MossTankPanel +{ + private static readonly string[] VtankHelp = + [ + "/vt commands (profiles): settings nav loot meta opt testitem propertydump addnavpt refresh getdb addnavjump addnavcheckpoint", + "/vt commands (actions): start stop forcebuff cancelforcebuff setmetastate fakedeath deletemonster reverseroute reverseroutequery equipitemsfor mexec echo tapjump jump setattackbar", + "/vt commands (game info): dumpspells dumpspecies dumpmats dumpskills", + "/vt commands (debug): log testmonster lockdump dumptracker clearlocks clearbusy listmonstervariables dumpmetavars listmetafunctions metafunchelp fakeimp pscount testspell testpet", + ]; + + private readonly HashSet _commandLogTypes = + new(StringComparer.OrdinalIgnoreCase); + private bool _commandJumpActive; + private bool _commandJumpReleased; + private bool _commandJumpCharging; + private double _commandJumpElapsed; + private double _commandJumpTurnElapsed; + private double _commandJumpChargeSeconds; + private float _commandJumpHeading; + private PluginMovementIntent _commandJumpIntent; + private bool _commandPortalState; + private int _commandPortalCount; + + internal void ExecuteVtankCommand(PluginCommand command) + { + try + { + ExecuteVtankCommandCore(command.Arguments); + } + catch (Exception error) + { + WriteVtank($"Command failed: {error.GetBaseException().Message}"); + _host.Log.Error("MossTank /vt command failed.", error); + } + } + + private void ExecuteVtankCommandCore(string input) + { + (string verb, string arguments) = SplitHead(input); + switch (verb.ToLowerInvariant()) + { + case "": + case "help": + foreach (string line in VtankHelp) + WriteVtank(line); + return; + + case "start": + if (!_combat.Enabled) + SetMacroRunning(true); + else + WriteVtank("Macro is already running."); + return; + case "stop": + if (_combat.Enabled) + SetMacroRunning(false); + if (_running) + Stop("Force buff canceled."); + WriteVtank("Macro stopped."); + return; + case "forcebuff": + if (!_running) + StartOrStop(); + else + WriteVtank("Force buff is already enabled."); + return; + case "cancelforcebuff": + if (_running) + Stop("Force buff canceled."); + WriteVtank("Force buff canceled."); + return; + case "settings": + HandleSettingsCommand(arguments); + return; + case "nav": + HandleRouteProfileCommand(arguments); + return; + case "loot": + HandleLootProfileCommand(arguments); + return; + case "meta": + HandleMetaProfileCommand(arguments); + return; + case "opt": + HandleOptionCommand(arguments); + return; + case "setmetastate": + SetMetaStateFromCommand(arguments); + return; + case "mexec": + ExecuteExpression(arguments); + return; + case "echo": + WriteVtank(arguments); + return; + case "setattackbar": + SetAttackBar(arguments); + return; + case "tapjump": + StartCommandJump( + _host.Automation.Navigation.Snapshot.Position.HeadingDegrees, + shift: false, + milliseconds: 100, + null); + return; + case "jump": + HandleJumpCommand(arguments, addToRoute: false); + return; + case "addnavjump": + HandleJumpCommand(arguments, addToRoute: true); + return; + case "addnavpt": + AddCommandRoutePoint(arguments, checkpoint: false); + return; + case "addnavcheckpoint": + AddCommandRoutePoint(arguments, checkpoint: true); + return; + case "reverseroute": + _navigation.ToggleReverse(); + WriteVtank($"Setting nav backwards to: {_navigation.Reversing}"); + return; + case "reverseroutequery": + WriteVtank($"Nav backwards is: {_navigation.Reversing}"); + return; + case "deletemonster": + DeleteSelectedMonster(); + return; + case "equipitemsfor": + EquipItemsFor(arguments); + return; + case "testitem": + TestSelectedItem(); + return; + case "propertydump": + DumpSelectedProperties(); + return; + case "testmonster": + TestSelectedMonster(); + return; + case "testspell": + TestSpell(arguments); + return; + case "testpet": + TestPet(); + return; + case "listmonstervariables": + WriteVtank("Supported monster expression variables:"); + WriteVtank("true, false, name, typeid, species, maxhp, range, hasshield, metastate, setting_"); + return; + case "dumpmetavars": + DumpMetaVariables(); + return; + case "listmetafunctions": + ListMetaFunctions(); + return; + case "metafunchelp": + MetaFunctionHelp(arguments); + return; + case "fakedeath": + _meta.TriggerFakeDeath(); + WriteVtank("Fake character death trigger fired."); + return; + case "pscount": + WriteVtank($"Portal space toggle count: {_commandPortalCount}"); + return; + case "refresh": + RefreshMonsterEditor(); + RefreshItemEditors(); + RefreshLootEditor(); + RefreshRouteEditor(); + RefreshMetaEditor(); + WriteVtank("Refreshed settings pages."); + return; + case "getdb": + WriteVtank("Game information uses acdream's installed DAT catalog and bundled VTank tables; no remote database download is required."); + return; + case "log": + HandleLogCommand(arguments); + return; + case "lockdump": + WriteVtank($"Action busy: magic={_host.Automation.Magic.IsCasting}, items={_host.Automation.Items.IsBusy}, equipment={_host.Automation.Equipment.IsBusy}"); + return; + case "dumptracker": + DumpObjectTracker(); + return; + case "clearlocks": + ClearMossTankActionLocks(); + WriteVtank("Action locks cleared."); + return; + case "clearbusy": + PluginRecoveryResult recovery = _host.Automation.Recovery + .ClearOneBusyReference(); + WriteVtank(recovery.Accepted + ? $"Action busy: {recovery.PreviousCount} -> {recovery.CurrentCount}." + : recovery.Message); + return; + case "fakeimp": + FakeImperil(); + return; + case "dumpspells": + DumpSpells(); + return; + case "dumpspecies": + DumpSpecies(); + return; + case "dumpmats": + DumpMaterials(); + return; + case "dumpskills": + DumpSkills(); + return; + default: + WriteVtank("Unknown /vt command. Use /vt help."); + return; + } + } + + private void HandleSettingsCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 + || operation is not ("save" or "load" or "savechar" or "loadchar")) + { + WriteVtank("Usage: /vt settings [save/load/savechar/loadchar] [filename]"); + return; + } + name = StripExtension(name, ".usd", ".settings"); + if (operation is "save" or "savechar") + { + if (operation == "savechar") + _profiles.SetMineOnly(true); + if (_profiles.Create( + name, + copyCurrent: true, + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames, + out string notice)) + { + ResetProfileConsumers(); + } + WriteVtank(notice); + return; + } + if (!_profiles.Select(name)) + { + WriteVtank($"Settings profile '{name}' was not found."); + return; + } + LoadSelectedProfile(); + WriteVtank($"Loaded settings profile {_profiles.Selected}."); + } + + private void HandleRouteProfileCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 || operation is not ("save" or "load")) + { + WriteVtank("Usage: /vt nav [save/load] [filename]"); + return; + } + name = StripExtension(name, ".nav"); + if (operation == "save") + { + _routeProfiles.Create( + name, + copyCurrent: true, + _navigationSettings, + out string notice); + RefreshRouteEditor(); + WriteVtank(notice); + return; + } + if (!_routeProfiles.Select(name)) + { + if (!_routeProfiles.TryImportLegacy( + name, + _navigationSettings, + _host.Automation.Spells, + out string importNotice)) + { + WriteVtank(importNotice); + return; + } + _navigation.Reset(); + RefreshRouteEditor(); + WriteVtank(importNotice); + return; + } + LoadRouteProfile(); + WriteVtank($"Loaded navigation profile {_routeProfiles.Selected}."); + } + + private void HandleLootProfileCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 || operation is not ("new" or "save" or "load")) + { + WriteVtank("Usage: /vt loot [load/new] [filename]"); + return; + } + name = StripExtension(name, ".utl", ".json"); + if (operation is "new" or "save") + { + _lootProfiles.Create( + name, + copyCurrent: operation == "save", + _inventorySettings.Loot.Rules, + out string notice); + LoadLootProfile(); + WriteVtank(notice); + return; + } + if (!_lootProfiles.Select(name)) + { + if (!_lootProfiles.TryImportLegacy( + name, + _inventorySettings.Loot.Rules, + _inventorySettings.Loot, + out string importNotice)) + { + WriteVtank(importNotice); + return; + } + _loot.Reset(); + RefreshLootEditor(); + WriteVtank(importNotice); + return; + } + LoadLootProfile(); + WriteVtank($"Loaded loot profile {_lootProfiles.Selected}."); + } + + private void HandleMetaProfileCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 || operation is not ("save" or "load")) + { + WriteVtank("Usage: /vt meta [save/load] [filename]"); + return; + } + name = StripExtension(name, ".met", ".json"); + if (operation == "save") + { + _metaProfiles.Create( + name, + copyCurrent: true, + _metaProfile, + out string notice); + LoadMetaProfile(); + WriteVtank(notice); + return; + } + if (!_metaProfiles.Select(name)) + { + if (!_metaProfiles.TryImportLegacy( + name, + out MetaProfile imported, + out string importNotice)) + { + WriteVtank(importNotice); + return; + } + _metaProfile = imported; + _meta.ReplaceProfile(_metaProfile); + if (_initialized) + ApplyPersistedOptionOverrides(); + _selectedMetaRule = 0; + RefreshMetaEditor(); + WriteVtank(importNotice); + return; + } + LoadMetaProfile(); + WriteVtank($"Loaded Meta profile {_metaProfiles.Selected}."); + } + + private void HandleOptionCommand(string arguments) + { + (string operation, string tail) = SplitHead(arguments); + switch (operation.ToLowerInvariant()) + { + case "list": + if (tail.Length != 0) + { + WriteVtank("Usage: /vt opt list"); + return; + } + WriteVtank($"Available options: ({VtankOptionCatalog.Names.Length})"); + for (int index = 0; index < VtankOptionCatalog.Names.Length; index += 4) + WriteVtank(" " + string.Join(" ", VtankOptionCatalog.Names.Skip(index).Take(4))); + return; + case "get": + if (!VtankOptionCatalog.IsKnown(tail)) + { + WriteVtank("Option get: Invalid option specified."); + return; + } + string canonical = VtankOptionCatalog.Canonical(tail); + WriteVtank($"Option {canonical} = {GetMetaOption(canonical).ToDisplayString()}"); + return; + case "set": + case "setinall": + (string name, string rawValue) = SplitHead(tail); + if (!VtankOptionCatalog.IsKnown(name)) + { + WriteVtank("Option set: Invalid option specified."); + return; + } + if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, out ExpressionValue value)) + { + WriteVtank("Option set: Invalid value specified."); + return; + } + canonical = VtankOptionCatalog.Canonical(name); + SetMetaOption(canonical, value); + if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase)) + { + int count = _profiles.SetOptionInAll( + canonical, + ToMonsterValue(value)); + WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}"); + } + else + { + WriteVtank($"Set option {canonical} = {GetMetaOption(canonical).ToDisplayString()}"); + } + return; + default: + WriteVtank("Usage: /vt opt [list/get/set/setinall]"); + return; + } + } + + private void SetMetaStateFromCommand(string state) + { + if (state.Length == 0) + { + WriteVtank("Usage: /vt setmetastate [somestate]"); + WriteVtank("NOTE: States are case sensitive."); + return; + } + string target = _meta.States.FirstOrDefault(value => + value.Equals(state, StringComparison.Ordinal)) ?? MetaEngine.DefaultState; + if (!target.Equals(state, StringComparison.Ordinal)) + WriteVtank("Warning: Attempted to set an unused state. Setting to default instead."); + _meta.Transition(target); + _combatSettings.MetaState = _meta.CurrentState; + WriteVtank($"Meta state is now {_meta.CurrentState}."); + } + + private void ExecuteExpression(string source) + { + WriteVtank($"MExec evaluating expression: \"{source.Trim()}\""); + try + { + WriteVtank("Result: " + _expressions.Evaluate(source).ToDisplayString()); + } + catch (Exception error) + { + WriteVtank("Expression error: " + error.Message); + } + } + + private void SetAttackBar(string value) + { + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float power) + || power is < 0f or > 1f) + { + WriteVtank("Usage: /vt setattackbar [0 to 1]"); + return; + } + _combatSettings.AttackPower = power; + SaveProfile(); + WriteVtank($"Attack bar set to {power.ToString("0.###", CultureInfo.InvariantCulture)}."); + } + + private void HandleJumpCommand(string arguments, bool addToRoute) + { + string[] parts = arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length is < 3 or > 4 + || !float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float heading) + || !bool.TryParse(parts[1], out bool shift) + || !int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int milliseconds) + || milliseconds is < 0 or > 5000 + || !TryParseJumpDirection(parts.Length == 4 ? parts[3] : null, out RouteJumpDirection direction)) + { + WriteVtank(addToRoute + ? "Usage: /vt addnavjump [heading] [shift: true or false] [milliseconds]" + : "Usage: /vt jump [heading] [shift: true or false] [milliseconds]"); + return; + } + if (addToRoute) + { + PluginNavigationPosition position = _host.Automation.Navigation.Snapshot.Position; + _navigationSettings.Waypoints.Add(new RouteWaypoint + { + Type = RouteWaypointType.Jump, + Position = position, + JumpHeadingDegrees = NormalizeHeading(heading), + JumpRun = shift, + JumpChargeMilliseconds = milliseconds, + JumpDirection = direction, + }); + SaveRouteProfile(); + RefreshRouteEditor(); + WriteVtank("Added jump to the current route."); + return; + } + StartCommandJump(heading, shift, milliseconds, direction); + } + + private void StartCommandJump( + float heading, + bool shift, + int milliseconds, + RouteJumpDirection? direction) + { + PluginNavigationSnapshot snapshot = _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable || snapshot.IsPortalSpace) + { + WriteVtank("Jump unavailable outside the world."); + return; + } + RouteJumpDirection resolved = direction ?? RouteJumpDirection.Forward; + _commandJumpHeading = NormalizeHeading(heading); + _commandJumpIntent = new PluginMovementIntent( + Forward: resolved == RouteJumpDirection.Forward, + StrafeLeft: resolved == RouteJumpDirection.StrafeLeft, + StrafeRight: resolved == RouteJumpDirection.StrafeRight, + Run: shift, + Jump: true); + _commandJumpChargeSeconds = Math.Clamp(milliseconds / 1000d, 0.05d, 5d); + _commandJumpElapsed = 0d; + _commandJumpTurnElapsed = 0d; + _commandJumpReleased = false; + _commandJumpCharging = false; + _commandJumpActive = true; + WriteVtank($"Turning to heading {_commandJumpHeading:0.#} for jump."); + } + + private bool TickCommandJump(double elapsedSeconds) + { + if (!_commandJumpActive) + return false; + + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot snapshot = navigation.Snapshot; + if (!snapshot.IsAvailable || snapshot.IsPortalSpace) + { + navigation.ClearMovementIntent(); + _commandJumpActive = false; + WriteVtank("Jump canceled because the character left the world."); + return false; + } + + if (!_commandJumpCharging) + { + _commandJumpTurnElapsed += Math.Max(0d, elapsedSeconds); + float delta = NavigationController.SignedHeadingDelta( + snapshot.Position.HeadingDegrees, + _commandJumpHeading); + if (Math.Abs(delta) > 4f) + { + if (_commandJumpTurnElapsed > 10d + || navigation.SetMovementIntent(new PluginMovementIntent( + TurnLeft: delta < 0f, + TurnRight: delta > 0f, + Run: _commandJumpIntent.Run)) + != PluginNavigationCommandStatus.Accepted) + { + navigation.ClearMovementIntent(); + _commandJumpActive = false; + WriteVtank("Jump command could not align to the requested heading."); + return false; + } + return true; + } + + _commandJumpCharging = navigation.SetMovementIntent(_commandJumpIntent) + == PluginNavigationCommandStatus.Accepted; + if (!_commandJumpCharging) + { + navigation.ClearMovementIntent(); + _commandJumpActive = false; + WriteVtank("Jump command was refused by the host."); + return false; + } + _commandJumpElapsed = 0d; + WriteVtank($"Jump charging at heading {_commandJumpHeading:0.#}."); + } + + _commandJumpElapsed += Math.Max(0d, elapsedSeconds); + if (!_commandJumpReleased && _commandJumpElapsed >= _commandJumpChargeSeconds) + { + _commandJumpReleased = true; + navigation.SetMovementIntent(_commandJumpIntent with { Jump = false }); + } + if (_commandJumpElapsed < _commandJumpChargeSeconds + 0.25d) + return true; + navigation.ClearMovementIntent(); + _commandJumpActive = false; + _commandJumpCharging = false; + return false; + } + + private void AddCommandRoutePoint(string coordinates, bool checkpoint) + { + PluginNavigationPosition position; + if (coordinates.Length == 0) + { + position = _host.Automation.Navigation.Snapshot.Position; + } + else if (!TryParseCoordinates(coordinates, out position)) + { + WriteVtank(checkpoint + ? "Usage: /vt addnavcheckpoint [coords] OR /vt addnavcheckpoint" + : "Usage: /vt addnavpt [coords] OR /vt addnavpt"); + return; + } + _navigationSettings.Waypoints.Add(new RouteWaypoint + { + Type = checkpoint ? RouteWaypointType.Checkpoint : RouteWaypointType.Point, + Position = position, + }); + SaveRouteProfile(); + RefreshRouteEditor(); + WriteVtank(checkpoint ? "Added navigation checkpoint." : "Added navigation point."); + } + + private void DeleteSelectedMonster() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + PluginCombatTarget target = _host.Automation.Combat + .CaptureHostileTargets(float.MaxValue) + .FirstOrDefault(value => value.ObjectId == selected); + if (target.ObjectId == 0u) + { + WriteVtank("Select a monster, then do /vt deletemonster"); + return; + } + PluginCombatCommandResult result = + _host.Automation.Combat.DismissGhostTarget(selected); + WriteVtank(result.Accepted + ? $"Forcing the client to delete {target.Name} ({target.ObjectId})!!" + : $"Unable to delete {target.Name}: {result.Status}"); + } + + private void EquipItemsFor(string monsterName) + { + if (monsterName.Length == 0) + { + WriteVtank("Usage: /vt equipitemsfor [monster name]"); + WriteVtank("NOTE: each use of this command invokes one equipment step; multiple calls may be required."); + return; + } + bool ready = _combat.EquipOneStepForMonster(monsterName); + WriteVtank($"Changing items for monster \"{monsterName}\", ready: {ready}"); + } + + private void TestSelectedItem() + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + WriteVtank("TestItem: No item selected."); + return; + } + if (!_host.Automation.Items.TryCaptureProperties(item.ObjectId, out PluginItemProperties properties)) + { + _host.Automation.Objects.Identify(item.ObjectId); + WriteVtank("TestItem: Waiting for appraisal data."); + return; + } + LootDecision? decision = LootRuleEngine.Decide( + item, + properties, + _inventorySettings.Loot.Rules, + _host.Automation.Items.CaptureOwnedItems(), + host: _host); + WriteVtank(decision is { } match + ? $"TestItem: {item.Name} => {match.Action} ({match.RuleName}, priority {match.Priority})." + : $"TestItem: {item.Name} => NoLoot."); + } + + private void DumpSelectedProperties() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + if (selected == 0u + || !_host.Automation.Objects.TryGet(selected, out PluginWorldObject item) + || !_host.Automation.Objects.TryCaptureProperties(selected, out PluginItemProperties properties)) + { + WriteVtank("Propertydump: Either no object selected or current selection object is invalid or not appraised."); + return; + } + WriteVtank($"Object 0x{item.ObjectId:X8}: {item.Name}, class={(int)item.ObjectClass}, WCID={item.WeenieClassId}"); + DumpPropertyTable("Int", properties.Ints); + DumpPropertyTable("Int64", properties.Int64s); + DumpPropertyTable("Bool", properties.Bools); + DumpPropertyTable("Float", properties.Floats); + DumpPropertyTable("String", properties.Strings); + DumpPropertyTable("DataId", properties.DataIds); + DumpPropertyTable("InstanceId", properties.InstanceIds); + } + + private void TestSelectedMonster() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + PluginCombatTarget target = _host.Automation.Combat + .CaptureHostileTargets(float.MaxValue) + .FirstOrDefault(value => value.ObjectId == selected); + if (target.ObjectId == 0u) + { + WriteVtank("TestMonster: No monster selected."); + return; + } + ResolvedMonsterRule resolved = _combatSettings.ResolveRule(target); + WriteVtank($"TestMonster: evaluating monster rules for monster {target.Name}, type {target.WeenieClassId}"); + WriteVtank($"Matched '{resolved.Rule.Expression}', priority {resolved.Priority}, damage {resolved.Actions.DamageType}, attack={resolved.Actions.UsesPrimaryAttack}."); + if (resolved.EvaluationError is { Length: > 0 } error) + WriteVtank("Expression warning: " + error); + } + + private void TestSpell(string argument) + { + if (!uint.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint spellId)) + { + WriteVtank("Usage: /vt testspell [spellid]"); + return; + } + if (!_host.Automation.Spells.TryGet(spellId, out PluginSpellInfo spell)) + { + WriteVtank("Invalid spellid."); + return; + } + WriteVtank("---------------------------------"); + WriteVtank($"Testing ability to cast spell {spell.Name}, family {spell.Family}, quality {spell.Quality}, diff {spell.Difficulty}"); + WriteVtank($"Known: {_host.Automation.Spells.IsKnown(spellId)}, cast gate: {_host.Automation.Magic.EvaluateGate(spellId)}"); + WriteVtank("---------------------------------"); + } + + private void TestPet() + { + IReadOnlyList targets = _host.Automation.Combat + .CaptureHostileTargets(_combatSettings.PetRangeMode == PetRangeMode.Custom + ? _combatSettings.PetCustomRange + : _combatSettings.MaximumRange); + PetAutomationChoice choice = PetAutomation.Select( + _host.Automation.Items.CaptureOwnedItems(), + targets, + _host.Automation.Character, + _combatSettings, + _host.Automation.Items.ActiveOwnedPetCount, + allowRefill: true, + allowSummon: true); + WriteVtank(choice.Kind == PetAutomationActionKind.None + ? "Pet can spawn: False" + : $"Pet can spawn: True, action: {choice.Kind}, device: {choice.Device.Name}"); + } + + private void DumpMetaVariables() + { + WriteVtank("Assigned meta variables:"); + foreach (ExpressionVariableScope scope in Enum.GetValues()) + { + foreach ((string name, ExpressionValue value) in _expressions.State.Capture(scope)) + WriteVtank($"{scope}.{name} = {value.ToDisplayString()}"); + } + } + + private void ListMetaFunctions() + { + WriteVtank("Available builtin meta functions:"); + WriteChunks(_expressions.Functions + .OrderBy(static function => function.Name, StringComparer.OrdinalIgnoreCase) + .Select(static function => function.Name)); + } + + private void MetaFunctionHelp(string name) + { + ExpressionFunction? function = _expressions.Functions.FirstOrDefault(value => + value.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (function is null) + { + WriteVtank($"Function not defined \"{name}\""); + return; + } + WriteVtank("-------------------------------"); + WriteVtank("Function: " + function.Name); + WriteVtank("Signature: " + function.Signature); + WriteVtank("Description: " + function.Description); + string count = function.MinimumArguments == function.MaximumArguments + ? function.MinimumArguments.ToString(CultureInfo.InvariantCulture) + : $"{function.MinimumArguments}..{function.MaximumArguments}"; + WriteVtank("Parameter count: " + count); + WriteVtank("-------------------------------"); + } + + private void HandleLogCommand(string arguments) + { + string[] parts = arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + WriteVtank(_commandLogTypes.Count == 0 + ? "Not currently logging." + : "Log state: " + string.Join(' ', _commandLogTypes.Order(StringComparer.OrdinalIgnoreCase))); + WriteVtank("Valid logtypes: ActiveRule SalvageList SpellCast RuleInfo Timers CastInfo DebuffChoice Loot CharProps Misc BusyState"); + return; + } + if (parts.Length == 2) + parts[1] = parts[1].ToLowerInvariant(); + if (parts.Length != 2 || parts[1] is not ("on" or "off")) + { + WriteVtank("Usage: /vt log [type] [on/off]"); + return; + } + string type = parts[0]; + if (parts[1] == "on") + _commandLogTypes.Add(type); + else + _commandLogTypes.Remove(type); + WriteVtank((parts[1] == "on" ? "Set " : "Reset ") + type); + } + + private void DumpObjectTracker() + { + IReadOnlyList objects = _host.Automation.Objects.CaptureObjects(); + WriteVtank($"Object tracker count: {objects.Count}"); + foreach (PluginWorldObject item in objects.Take(100)) + WriteVtank($"0x{item.ObjectId:X8} {item.ObjectClass} {item.Name}"); + if (objects.Count > 100) + WriteVtank($"... {objects.Count - 100} more objects omitted from chat."); + } + + private void DumpSpells() + { + PluginSpellInfo[] spells = _host.Automation.Spells.KnownCombatSpells + .Concat(_host.Automation.Spells.KnownSelfBuffs) + .GroupBy(static spell => spell.SpellId) + .Select(static group => group.First()) + .OrderBy(static spell => spell.SpellId) + .ToArray(); + WriteVtank($"Known spell table ({spells.Length}):"); + foreach (PluginSpellInfo spell in spells) + WriteVtank($"{spell.SpellId}\t{spell.Name}\t{spell.Family}\t{spell.Difficulty}"); + } + + private void DumpSpecies() + { + var species = _host.Automation.Combat.CaptureHostileTargets(float.MaxValue) + .Where(static target => target.SpeciesId != 0) + .GroupBy(static target => target.SpeciesId) + .Select(static group => (Id: group.Key, Name: group.First().SpeciesName)) + .OrderBy(static value => value.Id) + .ToArray(); + WriteVtank($"Currently observed species ({species.Length}):"); + foreach (var entry in species) + WriteVtank($"{entry.Id}\t{entry.Name}"); + } + + private void DumpMaterials() + { + var materials = _host.Automation.Items.CaptureOwnedItems() + .Where(static item => item.MaterialType != 0u) + .GroupBy(static item => item.MaterialType) + .OrderBy(static group => group.Key); + WriteVtank("Materials present in owned inventory:"); + foreach (IGrouping group in materials) + WriteVtank($"{group.Key}\t{group.First().Name}"); + } + + private void DumpSkills() + { + WriteVtank($"Character skills ({_host.Automation.Character.Skills.Count}):"); + foreach (PluginSkillInfo skill in _host.Automation.Character.Skills.OrderBy(static value => value.SkillId)) + WriteVtank($"{skill.SkillId}\t{skill.Name}\t{skill.Base}\t{skill.Current}\t{skill.Training}"); + } + + private void ObserveCommandPortalState() + { + bool current = _host.Automation.Navigation.Snapshot.IsPortalSpace; + if (current != _commandPortalState) + { + _commandPortalState = current; + _commandPortalCount++; + } + } + + private void ResetCommandSession() + { + if (_commandJumpActive || _commandJumpCharging) + _host.Automation.Navigation.ClearMovementIntent(); + _commandJumpActive = false; + _commandJumpReleased = false; + _commandJumpCharging = false; + _commandJumpElapsed = 0d; + _commandJumpTurnElapsed = 0d; + _commandJumpChargeSeconds = 0d; + _commandJumpHeading = 0f; + _commandJumpIntent = default; + _commandPortalState = _host.Automation.Navigation.Snapshot.IsPortalSpace; + _commandPortalCount = 0; + } + + private void WriteVtank(string text) => + _host.Automation.Chat.PostSystemMessage(text); + + private void WriteChunks(IEnumerable values) + { + var line = new StringBuilder(); + foreach (string value in values) + { + int extra = line.Length == 0 ? value.Length : value.Length + 2; + if (line.Length != 0 && line.Length + extra > 240) + { + WriteVtank(line.ToString()); + line.Clear(); + } + if (line.Length != 0) + line.Append(", "); + line.Append(value); + } + if (line.Length != 0) + WriteVtank(line.ToString()); + } + + private void DumpPropertyTable(string kind, IReadOnlyDictionary values) + { + foreach ((uint key, T value) in values.OrderBy(static pair => pair.Key)) + WriteVtank($"{kind}[{key}] = {value}"); + } + + private static (string Head, string Tail) SplitHead(string value) + { + string trimmed = value.Trim(); + int separator = trimmed.IndexOfAny([' ', '\t']); + return separator < 0 + ? (trimmed, string.Empty) + : (trimmed[..separator], trimmed[(separator + 1)..].Trim()); + } + + private static string StripExtension(string name, params string[] extensions) + { + string result = name.Trim(); + foreach (string extension in extensions) + { + if (result.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + return result[..^extension.Length]; + } + return result; + } + + private static bool TryParseOptionValue(string source, out ExpressionValue value) + { + if (bool.TryParse(source, out bool boolean)) + { + value = ExpressionValue.Boolean(boolean); + return true; + } + if (double.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) + { + value = ExpressionValue.Number(number); + return true; + } + value = ExpressionValue.String(source); + return source.Length != 0; + } + + private bool TryParseCoordinates(string source, out PluginNavigationPosition position) + { + position = default; + string[] parts = source.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 + || !TryParseCompass(parts[0], northSouth: true, out double northSouth) + || !TryParseCompass(parts[1], northSouth: false, out double eastWest)) + { + return false; + } + double elevation = 0d; + if (parts.Length >= 3 + && !double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out elevation)) + { + return false; + } + PluginNavigationPosition current = _host.Automation.Navigation.Snapshot.Position; + position = new PluginNavigationPosition( + current.CellId, + eastWest, + northSouth, + elevation, + current.HeadingDegrees, + IsOutdoor: true); + return true; + } + + private static bool TryParseCompass(string source, bool northSouth, out double value) + { + value = 0d; + string trimmed = source.Trim(); + if (trimmed.Length < 2) + return false; + char direction = char.ToUpperInvariant(trimmed[^1]); + if (northSouth ? direction is not ('N' or 'S') : direction is not ('E' or 'W')) + return false; + if (!double.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out double magnitude)) + return false; + value = direction is 'S' or 'W' ? -Math.Abs(magnitude) : Math.Abs(magnitude); + return true; + } + + private static bool TryParseJumpDirection(string? value, out RouteJumpDirection direction) + { + direction = RouteJumpDirection.Forward; + if (string.IsNullOrWhiteSpace(value) || value.Equals("forward", StringComparison.OrdinalIgnoreCase)) + return true; + if (value.Equals("strafeleft", StringComparison.OrdinalIgnoreCase)) + { + direction = RouteJumpDirection.StrafeLeft; + return true; + } + if (value.Equals("straferight", StringComparison.OrdinalIgnoreCase)) + { + direction = RouteJumpDirection.StrafeRight; + return true; + } + return false; + } + + private static float NormalizeHeading(float heading) + { + float result = heading % 360f; + return result < 0f ? result + 360f : result; + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs new file mode 100644 index 00000000..620f3944 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs @@ -0,0 +1,477 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Independent VTank loot-profile lifecycle. Macro settings select this +/// profile by character, but its ordered rules live in their own document. +/// +internal sealed class MossTankLootProfileStore +{ + public const string ByCharacter = "By char"; + private const string IndexKey = "profiles/loot/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private IndexDocument _index; + private string _characterName = string.Empty; + private string _selected = ByCharacter; + + public MossTankLootProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new IndexDocument(); + _index.Names ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase); + } + + public string Selected => _selected; + public string? RecoveryNotice { get; private set; } + public IReadOnlyList AvailableNames => new[] { ByCharacter } + .Concat(_index.Names) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (string.Equals( + normalized, + _characterName, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + _characterName = normalized; + _selected = _index.SelectedByCharacter.TryGetValue( + SelectionKey(), + out string? selected) + && IsKnown(selected) + ? CanonicalName(selected) + : ByCharacter; + return true; + } + + public bool Select(string? name) + { + string normalized = name?.Trim() ?? string.Empty; + if (!IsKnown(normalized)) + return false; + _selected = CanonicalName(normalized); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + IReadOnlyList current, + out string notice, + LootSettings? settings = null) + { + string normalized = name?.Trim() ?? string.Empty; + if (normalized.Length is < 1 or > 64) + { + notice = "Enter a loot profile name (1-64 characters)."; + return false; + } + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in loot profile."; + return false; + } + + LootProfileDocument? currentDocument = copyCurrent + ? Read(CurrentKey()) + : null; + var document = new LootProfileDocument + { + Rules = copyCurrent + ? current.Select(LootRuleDocument.From).ToArray() + : [], + SalvageCombine = copyCurrent + ? (settings?.SalvageCombine.Clone() + ?? currentDocument?.SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings()) + : new VtankSalvageCombineSettings(), + UnknownBlocks = copyCurrent + ? currentDocument?.UnknownBlocks ?? [] + : [], + }; + Write(ProfileKey(normalized, byCharacter: false), document); + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + WriteLegacyExport(_selected, document); + notice = copyCurrent + ? $"Copied loot rules to {_selected}." + : $"Created loot profile {_selected}."; + return true; + } + + /// Returns false when no document exists (legacy migration seam). + public bool LoadCurrent(List target, LootSettings? settings = null) + { + ArgumentNullException.ThrowIfNull(target); + LootProfileDocument? document = Read(CurrentKey()); + if (document is null) + return false; + target.Clear(); + foreach (LootRuleDocument rule in document.Rules ?? []) + target.Add(rule.ToRule()); + if (settings is not null) + { + settings.SalvageCombine = + document.SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings(); + } + return true; + } + + /// + /// Loads a profile for an automation job without changing the profile + /// selected in the MossTank editor. UtilityBelt's item giver has the same + /// separation: using a give profile must not replace the active loot + /// profile. + /// + public bool TryLoadNamed(string? name, List target) + { + ArgumentNullException.ThrowIfNull(target); + string normalized = name?.Trim() ?? string.Empty; + if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^4]; + if (!IsKnown(normalized)) + return false; + + string canonical = CanonicalName(normalized); + string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(canonical, byCharacter: false); + LootProfileDocument? document = Read(key); + if (document is null) + return false; + + target.Clear(); + foreach (LootRuleDocument rule in document.Rules ?? []) + target.Add(rule.ToRule()); + return true; + } + + public void SaveCurrent( + IReadOnlyList rules, + LootSettings? settings = null) + { + LootProfileDocument? existing = Read(CurrentKey()); + var document = new LootProfileDocument + { + Rules = rules.Select(LootRuleDocument.From).ToArray(), + SalvageCombine = settings?.SalvageCombine.Clone() + ?? existing?.SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings(), + UnknownBlocks = existing?.UnknownBlocks ?? [], + }; + Write(CurrentKey(), document); + WriteLegacyExport(LegacyProfileName(), document); + } + + public void ClearCurrent(List target, LootSettings? settings = null) + { + target.Clear(); + if (settings is not null) + settings.SalvageCombine = new VtankSalvageCombineSettings(); + SaveCurrent(target, settings); + } + + public bool TryImportLegacy( + string? name, + List target, + LootSettings? settings, + out string notice) + { + ArgumentNullException.ThrowIfNull(target); + string normalized = name?.Trim() ?? string.Empty; + if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^4]; + if (!_host.Storage.IsAvailable || normalized.Length == 0) + { + notice = "Legacy loot-profile storage is unavailable."; + return false; + } + string? key = _host.Storage.List("imports") + .Concat(_host.Storage.List("exports")) + .FirstOrDefault(candidate => + candidate.EndsWith(".utl", StringComparison.OrdinalIgnoreCase) + && Path.GetFileNameWithoutExtension(candidate).Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + string? source = key is null ? null : _host.Storage.ReadText(key); + if (string.IsNullOrWhiteSpace(source)) + { + notice = $"VTClassic loot file '{normalized}.utl' was not found in imports."; + return false; + } + if (!VtankLootProfileSerializer.TryRead( + source, + out VtankLootProfile imported, + out string error)) + { + notice = $"Could not import {Path.GetFileName(key)}: {error}"; + return false; + } + + var document = new LootProfileDocument + { + Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(), + SalvageCombine = imported.SalvageCombine.Clone(), + UnknownBlocks = imported.UnknownBlocks.Select( + VtankLootExtraBlockDocument.From).ToArray(), + }; + Write(ProfileKey(normalized, byCharacter: false), document); + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + target.Clear(); + target.AddRange(imported.Rules); + if (settings is not null) + settings.SalvageCombine = imported.SalvageCombine.Clone(); + WriteLegacyExport(_selected, document); + notice = $"Imported VTClassic loot profile {_selected}."; + return true; + } + + private bool IsKnown(string? name) => name is not null + && (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + || _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase)); + + private string CanonicalName(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : _index.Names.First(entry => entry.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private string CurrentKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(_selected, byCharacter: false); + + private static string ProfileKey(string value, bool byCharacter) + { + string identity = (byCharacter ? "char:" : "named:") + + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"profiles/loot/{hash}.json"; + } + + private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName) + ? "_default" + : _characterName; + + private T? Read(string key) where T : class + { + if (!_host.Storage.IsAvailable) + return null; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "loot", + key, + json, + error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + private void Write(string key, T document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}"); + } + } + + private void SaveIndex() => Write(IndexKey, _index); + + private void WriteLegacyExport(string name, LootProfileDocument document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText( + $"exports/{LegacyFileName(name)}.utl", + VtankLootProfileSerializer.Write(document.ToVtankProfile())); + } + catch (Exception error) + { + _host.Log.Warn( + $"MossTank VTClassic loot export could not be saved: {error.Message}"); + } + } + + private string LegacyProfileName() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? string.IsNullOrWhiteSpace(_characterName) + ? ByCharacter + : _characterName + : _selected; + + private static string LegacyFileName(string name) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var result = new StringBuilder(name.Length); + foreach (char value in name.Trim()) + { + result.Append(value is '/' or '\\' || invalid.Contains(value) + ? '_' + : value); + } + return result.Length == 0 ? "Loot" : result.ToString(); + } + + private sealed class IndexDocument + { + public int Version { get; set; } = 1; + public List Names { get; set; } = []; + public Dictionary SelectedByCharacter { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class LootProfileDocument + { + public int Version { get; set; } = 2; + public LootRuleDocument[] Rules { get; set; } = []; + public VtankSalvageCombineSettings? SalvageCombine { get; set; } = new(); + public VtankLootExtraBlockDocument[] UnknownBlocks { get; set; } = []; + + public VtankLootProfile ToVtankProfile() => new() + { + Rules = (Rules ?? []).Select(static rule => rule.ToRule()).ToList(), + SalvageCombine = SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings(), + UnknownBlocks = (UnknownBlocks ?? []) + .Select(static block => block.ToBlock()) + .ToList(), + }; + } + + private sealed class LootRuleDocument + { + public string Name { get; set; } = "Rule"; + public string Expression { get; set; } = "*"; + public LootAction Action { get; set; } = LootAction.Keep; + public int KeepCount { get; set; } = 1; + public int Priority { get; set; } + public string CustomExpression { get; set; } = string.Empty; + public VtankLootRequirementDocument[] Requirements { get; set; } = []; + + public static LootRuleDocument From(LootRule rule) => new() + { + Name = rule.Name, + Expression = rule.Expression, + Action = rule.Action, + KeepCount = rule.KeepCount, + Priority = rule.Priority, + CustomExpression = rule.CustomExpression, + Requirements = rule.VtankRequirements.Select( + VtankLootRequirementDocument.From).ToArray(), + }; + + public LootRule ToRule() => new() + { + Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(), + Expression = string.IsNullOrWhiteSpace(Expression) + ? "*" + : Expression.Trim(), + Action = Action, + KeepCount = Math.Clamp(KeepCount, 0, 100000), + Priority = Math.Clamp(Priority, -1000, 1000), + CustomExpression = CustomExpression ?? string.Empty, + VtankRequirements = (Requirements ?? []) + .Select(static requirement => requirement.ToRequirement()) + .ToList(), + }; + } + + private sealed class VtankLootRequirementDocument + { + public int Type { get; set; } + public string Payload { get; set; } = string.Empty; + + public static VtankLootRequirementDocument From( + VtankLootRequirement requirement) => new() + { + Type = requirement.Type, + Payload = requirement.Payload, + }; + + public VtankLootRequirement ToRequirement() => new() + { + Type = Type, + Payload = Payload ?? string.Empty, + }; + } + + private sealed class VtankLootExtraBlockDocument + { + public string Type { get; set; } = string.Empty; + public string Payload { get; set; } = string.Empty; + + public static VtankLootExtraBlockDocument From( + VtankLootExtraBlock block) => new() + { + Type = block.Type, + Payload = block.Payload, + }; + + public VtankLootExtraBlock ToBlock() => new() + { + Type = Type ?? string.Empty, + Payload = Payload ?? string.Empty, + }; + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs new file mode 100644 index 00000000..35eecd04 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -0,0 +1,286 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// Independent VTank-style By-char/named Meta profile lifetime. +internal sealed class MossTankMetaProfileStore +{ + public const string ByCharacter = "By char"; + private const string IndexKey = "profiles/meta/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private IndexDocument _index; + private string _character = string.Empty; + private string _selected = ByCharacter; + + public MossTankMetaProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new IndexDocument(); + _index.Names ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter ?? [], + StringComparer.OrdinalIgnoreCase); + } + + private List Names => _index.Names ??= []; + + private Dictionary SelectedByCharacter => + _index.SelectedByCharacter ??= new Dictionary( + StringComparer.OrdinalIgnoreCase); + + public string Selected => _selected; + public string? RecoveryNotice { get; private set; } + public IReadOnlyList AvailableNames => new[] { ByCharacter } + .Concat(Names) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase)) + return false; + _character = normalized; + _selected = SelectedByCharacter.TryGetValue( + CharacterKey(), + out string? selected) + && IsKnown(selected) + ? Canonical(selected) + : ByCharacter; + return true; + } + + public MetaProfile LoadCurrent() => + Read(CurrentKey()) ?? new MetaProfile(); + + public void SaveCurrent(MetaProfile profile) + { + Write(CurrentKey(), profile); + WriteLegacyExport(LegacyProfileName(), profile); + } + + public bool Select(string? name) + { + string normalized = Normalize(name); + if (!IsKnown(normalized)) + return false; + _selected = Canonical(normalized); + SelectedByCharacter[CharacterKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + MetaProfile current, + out string notice) + { + string normalized = Normalize(name); + if (normalized.Length is < 1 or > 64 + || normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "Enter a unique Meta profile name (1-64 characters)."; + return false; + } + MetaProfile document = copyCurrent + ? Clone(current) + : new MetaProfile(); + Write(NamedKey(normalized), document); + if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + Names.Add(normalized); + _selected = normalized; + SelectedByCharacter[CharacterKey()] = normalized; + SaveIndex(); + WriteLegacyExport(normalized, document); + notice = copyCurrent + ? $"Copied Meta profile to {normalized}." + : $"Created Meta profile {normalized}."; + return true; + } + + public bool TryImportLegacy( + string? name, + out MetaProfile profile, + out string notice) + { + string normalized = Normalize(name); + if (!_host.Storage.IsAvailable || normalized.Length == 0) + { + profile = new MetaProfile(); + notice = "Legacy Meta storage is unavailable."; + return false; + } + string? key = _host.Storage.List("imports") + .Concat(_host.Storage.List("exports")) + .FirstOrDefault(candidate => + candidate.EndsWith(".met", StringComparison.OrdinalIgnoreCase) + && Path.GetFileNameWithoutExtension(candidate).Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + string? source = key is null ? null : _host.Storage.ReadText(key); + if (string.IsNullOrWhiteSpace(source)) + { + profile = new MetaProfile(); + notice = $"VTank Meta file '{normalized}.met' was not found in imports."; + return false; + } + if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error)) + { + notice = $"Could not import {Path.GetFileName(key)}: {error}"; + return false; + } + if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + Names.Add(normalized); + _selected = Names.First(existing => existing.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + SelectedByCharacter[CharacterKey()] = _selected; + SaveIndex(); + SaveCurrent(profile); + notice = $"Imported VTank Meta profile {_selected}."; + return true; + } + + public MetaProfile ClearCurrent() + { + var empty = new MetaProfile(); + SaveCurrent(empty); + return empty; + } + + private string CurrentKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? $"profiles/meta/by-character/{Hash(_character)}.json" + : NamedKey(_selected); + + private static string NamedKey(string name) => + $"profiles/meta/named/{Hash(name)}.json"; + + private string CharacterKey() => + string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character; + + private bool IsKnown(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + || Names.Contains(name, StringComparer.OrdinalIgnoreCase); + + private string Canonical(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : Names.First(existing => existing.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private void SaveIndex() => Write(IndexKey, _index); + + private void WriteLegacyExport(string name, MetaProfile profile) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText( + $"exports/{LegacyFileName(name)}.met", + VtankMetaProfileSerializer.Save(profile)); + } + catch (Exception error) + { + _host.Log.Warn( + $"MossTank VTank Meta export could not be saved: {error.Message}"); + } + } + + private string LegacyProfileName() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character + : _selected; + + private static string LegacyFileName(string name) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var result = new StringBuilder(name.Length); + foreach (char value in name.Trim()) + { + result.Append(value is '/' or '\\' || invalid.Contains(value) + ? '_' + : value); + } + return result.Length == 0 ? "Meta" : result.ToString(); + } + + private T? Read(string key) + { + if (!_host.Storage.IsAvailable) + return default; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? default + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "meta", + key, + json, + error); + _host.Log.Error(RecoveryNotice, error); + return default; + } + } + + private void Write(string key, T value) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options)); + } + catch (Exception error) + { + _host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error); + } + } + + private static MetaProfile Clone(MetaProfile profile) => + JsonSerializer.Deserialize( + JsonSerializer.Serialize(profile, Options), + Options) ?? new MetaProfile(); + + private static string Normalize(string? name) => name?.Trim() ?? string.Empty; + + private static string Hash(string value) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant())); + return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant(); + } + + private sealed class IndexDocument + { + public List? Names { get; set; } = []; + public Dictionary? SelectedByCharacter { get; set; } = []; + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index db09934e..ebc1ca9b 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -1,5 +1,7 @@ using System.Globalization; +using System.Text; using AcDream.Plugin.Abstractions; +using AcDream.Plugins.MossTank.Expressions; namespace AcDream.Plugins.MossTank; @@ -11,8 +13,21 @@ namespace AcDream.Plugins.MossTank; /// tick all arrive there — so no locking is used, deliberately: a lock would /// imply a second thread that does not exist. /// -internal sealed class MossTankPanel +internal sealed partial class MossTankPanel { + private enum TankTab + { + Options, + Profiles, + Vitals, + Monsters, + Items, + Consumables, + Buffs, + Route, + Meta, + } + /// /// Give up on a pass that stops making progress. Generous, because the /// pacing is now the server acknowledging each cast rather than a fixed @@ -30,6 +45,27 @@ internal sealed class MossTankPanel private readonly IPluginHost _host; private readonly BuffSettings _buffSettings = new(); private readonly VitalSettings _vitalSettings = new(); + private readonly CombatSettings _combatSettings = new(); + private readonly InventorySettings _inventorySettings = new(); + private readonly NavigationSettings _navigationSettings = new(); + private readonly MossTankProfileStore _profiles; + private readonly MossTankLootProfileStore _lootProfiles; + private readonly MossTankRouteProfileStore _routeProfiles; + private readonly MossTankMetaProfileStore _metaProfiles; + private readonly MetaViewManager _metaViews; + private readonly CombatController _combat; + private readonly VitalRechargeController _vitalRecharge; + private readonly DispelController _dispel; + private readonly InventoryMaintenanceController _inventoryMaintenance; + private readonly CraftingController _crafting; + private readonly ItemManaRechargeController _itemManaRecharge; + private readonly LootController _loot; + private readonly ProfileGiveController _profileGive; + private readonly NavigationController _navigation; + private readonly FellowshipManager _fellowshipManager; + private readonly MossTankExpressionRuntime _expressions; + private MetaProfile _metaProfile; + private readonly MetaEngine _meta; // A pass works through a queue captured at the start rather than a plan // re-derived each tick. Force Buff deliberately ignores what is already in @@ -38,6 +74,15 @@ internal sealed class MossTankPanel private List _queue = new(); private int _queueIndex; private bool _running; + private bool _forcePass; + private bool _announceBuffPass; + private double _automaticBuffScanRemaining; + private double _buffCastRecastRemaining; + private bool _fastCastMovementActive; + private long _fastCastStartCompletionRevision; + private double _fastCastMovementElapsed; + private double _randomHelperRemaining; + private int _randomHelperCursor; private double _sinceProgress; private int _castThisPass; private string _status = "Idle."; @@ -53,6 +98,77 @@ internal sealed class MossTankPanel private IReadOnlyList? _coverageSpellSnapshot; private int _coverageBuffLineCount; private double _coverageRefreshRemaining; + private TankTab _activeTab = TankTab.Options; + private readonly HashSet _noBuffItemNames = + new(StringComparer.Ordinal); + private string _profileNotice = + "Select an inventory item, then add it to this profile."; + private string _profileNameDraft = string.Empty; + private string _profileLifecycleNotice = "Macro settings are stored by character."; + private IReadOnlyList _monsterRows = Array.Empty(); + private int _selectedMonsterRule; + private string _monsterExpressionDraft = "DEFAULT"; + private string _monsterEditorNotice = "Select a rule to edit."; + // PluginCore's three columns deliberately expose different cycles. The + // damage column is eDamageElement 0..13; Ex. Vuln omits Harm/Void/etc.; + // PetDmg adds VTank's PAuto sentinel. A shared Enum.GetNames list made + // several choices visible in columns where retail could never select + // them, and also exposed our internal "Electric"/"PlayerAuto" names. + private static readonly string[] MonsterDamageNames = + [ + "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", + "Fire", "Harm", "Auto", "Void Basic", "Drain Auto", "Prismatic", + "Random", "Fists", + ]; + private static readonly string[] MonsterExtraVulnerabilityNames = + [ + "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", + "Fire", "Auto", "None", + ]; + private static readonly string[] MonsterPetDamageNames = + [ + "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", + "Fire", "PAuto", "Auto", "None", + ]; + private IReadOnlyList _itemRows = Array.Empty(); + private IReadOnlyList _consumableRows = Array.Empty(); + private int _selectedItemRow; + private int _selectedConsumableRow; + private bool _lootEditorVisible; + private bool _advancedOptionsVisible; + private int _selectedAdvancedOption; + private string _advancedOptionValueDraft = string.Empty; + private string _advancedOptionNotice = + "All VTank settings are available here."; + private IReadOnlyList _lootRuleRows = Array.Empty(); + private int _selectedLootRule; + private string _lootExpressionDraft = "*"; + private string _lootEditorNotice = "Add a rule or select one to edit."; + private string _lootProfileNameDraft = string.Empty; + private IReadOnlyList _routeRows = Array.Empty(); + private int _selectedRouteWaypoint; + private string _routeProfileNameDraft = string.Empty; + private string _routeNotice = "Add the current position or a selected object."; + private string _routeChatDraft = "/ls"; + private int _routePauseSeconds = 5; + private RouteRecallKind _routeRecallKind = RouteRecallKind.PrimaryPortal; + private bool _routeAddToEnd = true; + private IReadOnlyList _metaRows = Array.Empty(); + private int _selectedMetaRule; + private string _metaProfileNameDraft = string.Empty; + private string _metaStateDraft = MetaEngine.DefaultState; + private string _metaConditionTextDraft = string.Empty; + private string _metaActionTextDraft = string.Empty; + private string _metaSecondaryTextDraft = string.Empty; + private MetaConditionKind _metaConditionKind = MetaConditionKind.Always; + private MetaActionKind _metaActionKind = MetaActionKind.None; + private int _metaNumber; + private int _metaSecondaryNumber; + private string _metaNotice = "Add a rule or select one to edit."; + private bool _applyingProfileOptions; + private bool _initialized; + private bool _firstRunGuidancePending; + private bool _automationWasAvailable; /// /// What the player had selected before the pass, so targeting yourself for @@ -60,22 +176,769 @@ internal sealed class MossTankPanel /// private uint? _selectionBeforePass; - public MossTankPanel(IPluginHost host) => _host = host; + public MossTankPanel(IPluginHost host) + { + _host = host; + _firstRunGuidancePending = NeedsFirstRunGuidance(host); + _profiles = new MossTankProfileStore(host); + _profiles.BindCharacter(host.Automation.Character.Name); + _profiles.LoadCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + _lootProfiles = new MossTankLootProfileStore(host); + _lootProfiles.BindCharacter(host.Automation.Character.Name); + if (!_lootProfiles.LoadCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot)) + { + _lootProfiles.SaveCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + } + _routeProfiles = new MossTankRouteProfileStore(host); + _routeProfiles.BindCharacter(host.Automation.Character.Name); + if (!_routeProfiles.LoadCurrent(_navigationSettings)) + _routeProfiles.SaveCurrent(_navigationSettings); + _combat = new CombatController(host, _combatSettings, _vitalSettings); + _vitalRecharge = new VitalRechargeController( + host, + _vitalSettings, + _combatSettings); + _dispel = new DispelController(host, _vitalSettings); + _inventoryMaintenance = new InventoryMaintenanceController( + host, + _inventorySettings); + _crafting = new CraftingController( + host, + _inventorySettings, + _combatSettings); + _combat.BindAmmunitionCraftRequest( + _crafting.CanRequest, + _crafting.Request); + _itemManaRecharge = new ItemManaRechargeController( + host, + _inventorySettings, + _combatSettings); + _loot = new LootController( + host, + _inventorySettings.Loot); + _profileGive = new ProfileGiveController(host, _lootProfiles); + _navigation = new NavigationController(host, _navigationSettings); + _fellowshipManager = new FellowshipManager(host); + _metaProfiles = new MossTankMetaProfileStore(host); + _metaViews = new MetaViewManager(host); + _metaProfiles.BindCharacter(host.Automation.Character.Name); + _metaProfile = _metaProfiles.LoadCurrent(); + _expressions = new MossTankExpressionRuntime(host); + _meta = new MetaEngine( + host, + _expressions, + _metaProfile, + new MetaServices + { + IsNavigationRouteEmpty = () => _navigationSettings.Waypoints.Count == 0, + NeedsBuff = () => BuildPlan(_host.Automation, force: false).Count != 0, + DistanceFromAnyRoutePoint = DistanceFromAnyRoutePoint, + CountMonstersByPriority = CountMonstersByPriority, + LoadEmbeddedNavigationRoute = LoadEmbeddedNavigationRoute, + GetOption = GetMetaOption, + SetOption = SetMetaOption, + CreateView = _metaViews.Create, + DestroyView = _metaViews.Destroy, + DestroyAllViews = _metaViews.DestroyAll, + }); + RegisterVtankExpressionFunctions(); + _initialized = true; + ApplyPersistedOptionOverrides(); + RefreshMonsterEditor(); + RefreshItemEditors(); + RefreshLootEditor(); + RefreshRouteEditor(); + RefreshMetaEditor(); + _automationWasAvailable = host.Automation.IsAvailable; + } // ── main panel bindings ─────────────────────────────────────────────── public Action Buff => StartOrStop; - public Action OpenSettings => () => SettingsOpen = true; - public Action CloseSettings => () => SettingsOpen = false; + public Action ForceBuff => StartForceBuff; + public Action CancelForceBuff => CancelForceBuffCore; + public Action ToggleCombat => ToggleMacro; + public Action ToggleCombatEnabled => () => + { + _combatSettings.Enabled = !_combatSettings.Enabled; + SaveProfile(); + }; + public Action ToggleAutoFellowManagement => () => SetMetaOption( + "AutoFellowManagement", + ExpressionValue.Boolean(!AutoFellowManagementEnabled)); + public Action ShowOptions => () => SelectTab(TankTab.Options); + public Action ShowProfiles => () => SelectTab(TankTab.Profiles); + public Action ShowVitals => () => SelectTab(TankTab.Vitals); + public Action ShowMonsters => () => SelectTab(TankTab.Monsters); + public Action ShowItems => () => SelectTab(TankTab.Items); + public Action ShowConsumables => () => SelectTab(TankTab.Consumables); + public Action ShowBuffs => () => SelectTab(TankTab.Buffs); + public Action ShowRoute => () => SelectTab(TankTab.Route); + public Action ShowMeta => () => SelectTab(TankTab.Meta); - public bool SettingsOpen { get; private set; } + /// Keeps the gameplay window off login/character-select screens. + public bool WindowAvailable => _host.Automation.IsAvailable; - /// Keeps the windows off the character-select and login screens. - public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen; - public bool SettingsVisible => _host.Automation.IsAvailable && SettingsOpen; + internal ExpressionValue EvaluateExpression(string source) => + _expressions.Evaluate(source); + + internal IReadOnlyCollection ExpressionFunctionNames => + _expressions.Functions.Select(static function => function.Name).ToArray(); + + public bool OptionsSelected => _activeTab == TankTab.Options; + public bool ProfilesSelected => _activeTab == TankTab.Profiles; + public bool VitalsSelected => _activeTab == TankTab.Vitals; + public bool MonstersSelected => _activeTab == TankTab.Monsters; + public bool ItemsSelected => _activeTab == TankTab.Items; + public bool ConsumablesSelected => _activeTab == TankTab.Consumables; + public bool BuffsSelected => _activeTab == TankTab.Buffs; + public bool RouteSelected => _activeTab == TankTab.Route; + public bool MetaSelected => _activeTab == TankTab.Meta; + + public bool OptionsTabEnabled => true; + public bool ProfilesTabEnabled => true; + public bool VitalsTabEnabled => true; + public bool MonstersTabEnabled => true; + public bool ItemsTabEnabled => true; + public bool ConsumablesTabEnabled => true; + public bool BuffsTabEnabled => true; + public bool RouteTabEnabled => true; + public bool MetaTabEnabled => true; + + public bool OptionsVisible => OptionsSelected + && !_lootEditorVisible + && !_advancedOptionsVisible; + public bool ProfilesVisible => ProfilesSelected && !_lootEditorVisible; + public bool VitalsVisible => VitalsSelected && !_lootEditorVisible; + public bool MonstersVisible => MonstersSelected && !_lootEditorVisible; + public bool ItemsVisible => ItemsSelected && !_lootEditorVisible; + public bool ConsumablesVisible => ConsumablesSelected && !_lootEditorVisible; + public bool BuffsVisible => BuffsSelected && !_lootEditorVisible; + public bool RouteVisible => RouteSelected && !_lootEditorVisible; + public bool MetaVisible => MetaSelected && !_lootEditorVisible; + public bool LootEditorVisible => _lootEditorVisible; + public bool AdvancedOptionsVisible => _advancedOptionsVisible; + public IReadOnlyList AdvancedOptionNames => VtankOptionCatalog.Names; + public int SelectedAdvancedOptionIndex => _selectedAdvancedOption; + public string AdvancedOptionName => VtankOptionCatalog.Names[ + Math.Clamp( + _selectedAdvancedOption, + 0, + VtankOptionCatalog.Names.Length - 1)]; + public string AdvancedOptionValueDraft => _advancedOptionValueDraft; + public string AdvancedOptionNotice => _advancedOptionNotice; + public Action ShowAdvancedOptions => () => + { + _advancedOptionsVisible = true; + LoadAdvancedOptionDraft(); + }; + public Action HideAdvancedOptions => () => _advancedOptionsVisible = false; + public Action SelectAdvancedOption => index => + { + _selectedAdvancedOption = Math.Clamp( + index, + 0, + VtankOptionCatalog.Names.Length - 1); + LoadAdvancedOptionDraft(); + }; + public Action SetAdvancedOptionValueDraft => value => + _advancedOptionValueDraft = value; + public Action SubmitAdvancedOption => value => + { + _advancedOptionValueDraft = value; + ApplyAdvancedOptionCore(); + }; + public Action ApplyAdvancedOption => ApplyAdvancedOptionCore; /// The button is Force Buff; while a pass runs it cancels. - public string ButtonText => _running ? "Stop" : "Force Buff"; - public string Status => _status; + public string BuffButtonText => _running ? "Stop buffing" : "Force buff"; + public string BuffStatus => _status; + public string CombatButtonText => _combat.ButtonText; + public string CombatStatus => _combat.Status; + public string CombatTarget => _combat.TargetText; + public string CombatMode => _combat.ModeText; + public string VitalStatus => _vitalRecharge.Status; + public string DispelStatus => _dispel.Status; + public string InventoryMaintenanceStatus => _inventoryMaintenance.Status; + public string CraftingStatus => _crafting.Status; + public string ItemManaRechargeStatus => _itemManaRecharge.Status; + public string LootStatus => _loot.Status; + public bool CombatEnabled => _combatSettings.Enabled; + public bool BuffingEnabled => _buffSettings.Enabled; + public bool IdlePeaceModeEnabled => _combatSettings.IdlePeaceMode; + public bool IdleBuffTopoffEnabled => _buffSettings.IdleBuffTopoff; + public bool ManaChargesWhenOffEnabled => + _inventorySettings.ManaChargesWhenOff; + public bool AutoFellowManagementEnabled => + _combatSettings.AutoFellowManagement; + public string FellowshipManagerStatus => _fellowshipManager.Status; + public bool AutoStackEnabled => _inventorySettings.AutoStack; + public bool AutoCramEnabled => _inventorySettings.AutoCram; + public bool AutoCraftItemsEnabled => _inventorySettings.AutoCraftItems; + public bool CastDispelSelfEnabled => _vitalSettings.CastDispelSelf; + public bool UseDispelItemsEnabled => _vitalSettings.UseDispelItems; + public bool RefillWornManaEnabled => _inventorySettings.RefillWornMana; + public float RefillWornManaValue => _inventorySettings.RefillWornManaPercent / 100f; + public string RefillWornManaText => + $"Refill worn mana below {_inventorySettings.RefillWornManaPercent}%"; + public string ItemProfileText => ProfileText( + "Weapons / Wands / Shields / Pets", + _combatSettings.CombatItemNames); + public string ConsumableProfileText => ProfileText( + "Gems / Food / Kits / Potions / Charges / Grenades / Lockpicks", + _combatSettings.ConsumableNames); + public string ProfileNotice => _profileNotice; + public Action AddSelectedItem => () => AddSelectedProfileItem(noBuffs: false); + public Action AddSelectedItemNoBuffs => () => AddSelectedProfileItem(noBuffs: true); + public Action AddSelectedConsumable => AddSelectedConsumableCore; + public Action AddAllPeas => AddAllPeasCore; + public IReadOnlyList ItemRows => _itemRows; + public IReadOnlyList ConsumableRows => _consumableRows; + public int SelectedItemRowIndex => _selectedItemRow; + public int SelectedConsumableRowIndex => _selectedConsumableRow; + public Action SelectItemRow => index => + _selectedItemRow = ClampRow(index, _itemRows.Count); + public Action SelectConsumableRow => index => + _selectedConsumableRow = ClampRow(index, _consumableRows.Count); + public Action RemoveSelectedItem => RemoveSelectedItemCore; + public Action RemoveSelectedConsumable => RemoveSelectedConsumableCore; + public Action ToggleAutoStack => () => + { + _inventorySettings.AutoStack = !_inventorySettings.AutoStack; + _inventoryMaintenance.Reset(); + SaveProfile(); + }; + public Action ToggleAutoCram => () => + { + _inventorySettings.AutoCram = !_inventorySettings.AutoCram; + _inventoryMaintenance.Reset(); + SaveProfile(); + }; + public Action ToggleAutoCraftItems => () => + { + _inventorySettings.AutoCraftItems = !_inventorySettings.AutoCraftItems; + _crafting.Reset(); + SaveProfile(); + }; + public Action ToggleCastDispelSelf => () => SetMetaOption( + "CastDispelSelf", + ExpressionValue.Boolean(!_vitalSettings.CastDispelSelf)); + public Action ToggleUseDispelItems => () => SetMetaOption( + "UseDispelItems", + ExpressionValue.Boolean(!_vitalSettings.UseDispelItems)); + public Action ToggleRefillWornMana => () => + { + _inventorySettings.RefillWornMana = !_inventorySettings.RefillWornMana; + _itemManaRecharge.Reset(); + SaveProfile(); + }; + public Action SetRefillWornMana => value => + { + _inventorySettings.RefillWornManaPercent = Math.Clamp( + (int)MathF.Round(value * 100f), + 0, + 99); + SaveProfile(); + }; + + // ── Loot profile / editor ──────────────────────────────────────────── + public bool LootEnabled => _inventorySettings.Loot.Enabled; + public bool LootPriorityBoostEnabled => + _inventorySettings.Loot.PriorityBoost; + public bool LootAllCorpsesEnabled => + _inventorySettings.Loot.LootAllCorpses; + public bool LootFellowCorpsesEnabled => + _inventorySettings.Loot.LootFellowCorpses; + public bool LootOnlyRareCorpsesEnabled => + _inventorySettings.Loot.LootOnlyRareCorpses; + public bool ReadUnknownScrollsEnabled => + _inventorySettings.Loot.ReadUnknownScrolls; + public IReadOnlyList LootProfileNames => + _lootProfiles.AvailableNames; + public string LootProfileName => _lootProfiles.Selected; + public string LootProfileNameDraft => _lootProfileNameDraft; + public IReadOnlyList LootClassifierNames + { + get + { + var names = new List { "VTClassic" }; + names.AddRange(_host.LootClassifiers.Available.Select(FormatClassifier)); + string selected = SelectedLootClassifier; + if (!names.Contains(selected, StringComparer.Ordinal)) + names.Add(selected); + return names; + } + } + public string SelectedLootClassifier + { + get + { + string id = _inventorySettings.Loot.ExternalClassifierId; + if (string.IsNullOrWhiteSpace(id)) + return "VTClassic"; + foreach (PluginLootClassifierInfo info in + _host.LootClassifiers.Available) + { + if (string.Equals(info.Id, id, StringComparison.OrdinalIgnoreCase)) + return FormatClassifier(info); + } + return $"Unavailable [{id}]"; + } + } + public string LootRangeText => + $"Corpse range {_inventorySettings.Loot.CorpseApproachRange:0}m"; + public IReadOnlyList LootRuleRows => _lootRuleRows; + public int SelectedLootRuleIndex => _selectedLootRule; + public string LootExpressionDraft => _lootExpressionDraft; + public string LootEditorNotice => _lootEditorNotice; + public IReadOnlyList LootActionNames => Enum.GetNames(); + public string SelectedLootAction => SelectedLootRule?.Action.ToString() + ?? LootAction.Keep.ToString(); + public string LootPriorityText => + $"Priority {SelectedLootRule?.Priority ?? 0}"; + public string LootKeepCountText => + $"Keep up to {SelectedLootRule?.KeepCount ?? 1}"; + public Action ToggleLooting => () => + { + _inventorySettings.Loot.Enabled = !_inventorySettings.Loot.Enabled; + _loot.Reset(); + SaveProfile(); + }; + public Action ToggleLootPriorityBoost => () => + { + _inventorySettings.Loot.PriorityBoost = + !_inventorySettings.Loot.PriorityBoost; + SaveProfile(); + }; + public Action ToggleLootAllCorpses => () => + { + _inventorySettings.Loot.LootAllCorpses = + !_inventorySettings.Loot.LootAllCorpses; + SaveProfile(); + }; + public Action ToggleLootFellowCorpses => () => + { + _inventorySettings.Loot.LootFellowCorpses = + !_inventorySettings.Loot.LootFellowCorpses; + SaveProfile(); + }; + public Action ToggleLootOnlyRareCorpses => () => + { + _inventorySettings.Loot.LootOnlyRareCorpses = + !_inventorySettings.Loot.LootOnlyRareCorpses; + SaveProfile(); + }; + public Action ToggleReadUnknownScrolls => () => + { + _inventorySettings.Loot.ReadUnknownScrolls = + !_inventorySettings.Loot.ReadUnknownScrolls; + SaveProfile(); + }; + public Action ShowLootEditor => () => + { + _activeTab = TankTab.Profiles; + _lootEditorVisible = true; + RefreshLootEditor(); + }; + public Action SelectLootProfile => SelectLootProfileCore; + public Action SelectLootClassifier => value => + { + string? id = ResolveClassifierId(value); + if (id is null) + { + _profileLifecycleNotice = $"Loot engine '{value}' is unavailable."; + return; + } + _inventorySettings.Loot.ExternalClassifierId = id; + _loot.Reset(); + SaveProfile(); + _profileLifecycleNotice = id.Length == 0 + ? "Loot engine set to VTClassic." + : $"Loot engine set to {SelectedLootClassifier}."; + }; + public Action SetLootProfileNameDraft => value => + _lootProfileNameDraft = value; + public Action CreateNamedLootProfile => value => + { + _lootProfileNameDraft = value; + CreateLootProfileCore(copyCurrent: false); + }; + public Action CreateLootProfile => () => + CreateLootProfileCore(copyCurrent: false); + public Action CopyLootProfile => () => + CreateLootProfileCore(copyCurrent: true); + public Action ClearLootProfile => ClearLootProfileCore; + public Action CloseLootEditor => () => _lootEditorVisible = false; + public Action SelectLootRule => SelectLootRuleCore; + public Action SetLootExpressionDraft => value => + _lootExpressionDraft = value; + public Action ApplyLootExpression => value => + { + _lootExpressionDraft = value; + ApplyLootExpressionCore(); + }; + public Action ApplyLootRule => ApplyLootExpressionCore; + public Action AddLootRule => AddLootRuleCore; + public Action RemoveLootRule => RemoveLootRuleCore; + public Action MoveLootRuleUp => () => MoveLootRule(-1); + public Action MoveLootRuleDown => () => MoveLootRule(1); + public Action SelectLootAction => SelectLootActionCore; + public Action LootPriorityDown => () => UpdateSelectedLootRule(rule => + rule.Priority = Math.Max(-1000, rule.Priority - 1)); + public Action LootPriorityUp => () => UpdateSelectedLootRule(rule => + rule.Priority = Math.Min(1000, rule.Priority + 1)); + public Action LootKeepCountDown => () => UpdateSelectedLootRule(rule => + rule.KeepCount = Math.Max(0, rule.KeepCount - 1)); + public Action LootKeepCountUp => () => UpdateSelectedLootRule(rule => + rule.KeepCount = Math.Min(100000, rule.KeepCount + 1)); + public Action LootRangeDown => () => + { + _inventorySettings.Loot.CorpseApproachRange = Math.Max( + 2f, + _inventorySettings.Loot.CorpseApproachRange - 2f); + SaveProfile(); + }; + public Action LootRangeUp => () => + { + _inventorySettings.Loot.CorpseApproachRange = Math.Min( + 100f, + _inventorySettings.Loot.CorpseApproachRange + 2f); + SaveProfile(); + }; + + // ── Navigation / route profiles ────────────────────────────────────── + public bool NavigationEnabled => _navigationSettings.Enabled; + public bool NavigationPriorityEnabled => _navigationSettings.Priority; + public bool FollowAroundCornersEnabled => + _navigationSettings.FollowAroundCorners; + public bool OpenDoorsEnabled => _navigationSettings.OpenDoors; + public string NavigationStatus => _navigation.Status; + public IReadOnlyList RouteRows => _routeRows; + public int SelectedRouteWaypointIndex => _selectedRouteWaypoint; + public IReadOnlyList RouteModeNames => Enum.GetNames(); + public string SelectedRouteMode => _navigationSettings.Mode.ToString(); + public IReadOnlyList RouteRecallNames => + Enum.GetNames(); + public string SelectedRouteRecall => _routeRecallKind.ToString(); + public IReadOnlyList RouteProfileNames => + _routeProfiles.AvailableNames; + public string SelectedRouteProfile => _routeProfiles.Selected; + public string RouteProfileNameDraft => _routeProfileNameDraft; + public string RouteNotice => _routeNotice; + public string RouteChatDraft => _routeChatDraft; + public string RoutePauseText => $"{_routePauseSeconds} seconds"; + public string RouteMinimumDistanceText => string.Create( + CultureInfo.InvariantCulture, + $"Follow/Nav Min Distance: {_navigationSettings.MinimumDistanceMeters:0.0}m"); + public string RouteFollowTargetText => + _navigationSettings.FollowTargetObjectId == 0u + ? "Follow target: [None]" + : $"Follow target: {_navigationSettings.FollowTargetName}"; + public string RouteAddPositionText => _routeAddToEnd + ? "Add to End" + : "Insert After Selection"; + + public Action ToggleNavigation => () => + { + _navigationSettings.Enabled = !_navigationSettings.Enabled; + _navigation.Reset(); + SaveRouteProfile(); + }; + public Action ToggleNavigationPriority => () => + { + _navigationSettings.Priority = !_navigationSettings.Priority; + SaveRouteProfile(); + }; + public Action ToggleFollowAroundCorners => () => + { + _navigationSettings.FollowAroundCorners = + !_navigationSettings.FollowAroundCorners; + _navigation.Reset(); + SaveRouteProfile(); + }; + public Action ToggleOpenDoors => () => + { + _navigationSettings.OpenDoors = !_navigationSettings.OpenDoors; + _navigation.Reset(); + SaveRouteProfile(); + }; + public Action SelectRouteMode => value => + { + if (!Enum.TryParse(value, ignoreCase: true, out RouteMode mode)) + return; + _navigationSettings.Mode = mode; + if (mode == RouteMode.Target) + CaptureFollowTarget(); + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + }; + public Action SelectRouteRecall => value => + { + if (Enum.TryParse(value, ignoreCase: true, out RouteRecallKind recall)) + _routeRecallKind = recall; + }; + public Action SelectRouteWaypoint => index => + _selectedRouteWaypoint = ClampRow(index, _navigationSettings.Waypoints.Count); + public Action AddRoutePoint => AddRoutePointCore; + public Action AddRouteUseSelected => () => + AddSelectedObjectWaypoint(RouteWaypointType.UseNpc); + public Action AddRouteOpenVendor => () => + AddSelectedObjectWaypoint(RouteWaypointType.OpenVendor); + public Action AddRoutePortal => () => + AddSelectedObjectWaypoint(RouteWaypointType.PortalByName); + public Action AddRouteRecall => AddRouteRecallCore; + public Action AddRoutePause => AddRoutePauseCore; + public Action AddRouteChat => AddRouteChatCore; + public Action AddRouteCheckpoint => AddRouteCheckpointCore; + public Action AddRouteJump => AddRouteJumpCore; + public Action RemoveRouteWaypoint => RemoveRouteWaypointCore; + public Action MoveRouteWaypointUp => () => MoveRouteWaypoint(-1); + public Action MoveRouteWaypointDown => () => MoveRouteWaypoint(1); + public Action ToggleRouteAddPosition => () => + _routeAddToEnd = !_routeAddToEnd; + public Action RoutePauseDown => () => + _routePauseSeconds = Math.Max(0, _routePauseSeconds - 1); + public Action RoutePauseUp => () => + _routePauseSeconds = Math.Min(3600, _routePauseSeconds + 1); + public Action RouteMinimumDistanceDown => () => + { + _navigationSettings.MinimumDistanceMeters = Math.Max( + 0.5d, + _navigationSettings.MinimumDistanceMeters - 0.5d); + SaveRouteProfile(); + }; + public Action RouteMinimumDistanceUp => () => + { + _navigationSettings.MinimumDistanceMeters = Math.Min( + 50d, + _navigationSettings.MinimumDistanceMeters + 0.5d); + SaveRouteProfile(); + }; + public Action SetRouteChatDraft => value => + _routeChatDraft = value; + public Action SetRouteProfileNameDraft => value => + _routeProfileNameDraft = value; + public Action SelectRouteProfile => SelectRouteProfileCore; + public Action CreateNamedRouteProfile => value => + { + _routeProfileNameDraft = value; + CreateRouteProfileCore(copyCurrent: false); + }; + public Action CreateRouteProfile => () => + CreateRouteProfileCore(copyCurrent: false); + public Action CopyRouteProfile => () => + CreateRouteProfileCore(copyCurrent: true); + public Action ClearRouteProfile => ClearRouteProfileCore; + public Action SetFollowTarget => CaptureFollowTarget; + + // ── Meta profile / editor ──────────────────────────────────────────── + public bool MetaEnabled => _meta.Enabled; + public string MetaState => _meta.CurrentState; + public string MetaStateText => $"State: {MetaState}"; + public string MetaStatus => _meta.Status; + public IReadOnlyList MetaRows => _metaRows; + public int SelectedMetaRuleIndex => _selectedMetaRule; + public IReadOnlyList MetaConditionNames => + Enum.GetNames(); + public IReadOnlyList MetaActionNames => Enum.GetNames(); + public string SelectedMetaCondition => _metaConditionKind.ToString(); + public string SelectedMetaAction => _metaActionKind.ToString(); + public string MetaStateDraft => _metaStateDraft; + public string MetaConditionTextDraft => _metaConditionTextDraft; + public string MetaActionTextDraft => _metaActionTextDraft; + public string MetaSecondaryTextDraft => _metaSecondaryTextDraft; + public string MetaNumberText => _metaNumber.ToString(CultureInfo.InvariantCulture); + public string MetaNumberLabel => $"N: {MetaNumberText}"; + public string MetaSecondaryNumberText => + _metaSecondaryNumber.ToString(CultureInfo.InvariantCulture); + public string MetaSecondaryNumberLabel => $"N2: {MetaSecondaryNumberText}"; + public string MetaNotice => _metaNotice; + public IReadOnlyList MetaProfileNames => _metaProfiles.AvailableNames; + public string SelectedMetaProfile => _metaProfiles.Selected; + public string MetaProfileNameDraft => _metaProfileNameDraft; + public Action ToggleMeta => () => + { + _meta.SetEnabled(!_meta.Enabled); + _combatSettings.MetaState = _meta.CurrentState; + }; + public Action SelectMetaRule => SelectMetaRuleCore; + public Action SelectMetaCondition => value => + { + if (Enum.TryParse(value, ignoreCase: true, out MetaConditionKind parsed)) + _metaConditionKind = parsed; + }; + public Action SelectMetaAction => value => + { + if (Enum.TryParse(value, ignoreCase: true, out MetaActionKind parsed)) + _metaActionKind = parsed; + }; + public Action SetMetaStateDraft => value => _metaStateDraft = value; + public Action SetMetaConditionTextDraft => value => + _metaConditionTextDraft = value; + public Action SetMetaActionTextDraft => value => + _metaActionTextDraft = value; + public Action SetMetaSecondaryTextDraft => value => + _metaSecondaryTextDraft = value; + public Action MetaNumberDown => () => _metaNumber--; + public Action MetaNumberUp => () => _metaNumber++; + public Action MetaSecondaryNumberDown => () => _metaSecondaryNumber--; + public Action MetaSecondaryNumberUp => () => _metaSecondaryNumber++; + public Action AddMetaRule => AddMetaRuleCore; + public Action ApplyMetaRule => ApplyMetaRuleCore; + public Action RemoveMetaRule => RemoveMetaRuleCore; + public Action MoveMetaRuleUp => () => MoveMetaRule(-1); + public Action MoveMetaRuleDown => () => MoveMetaRule(1); + public Action SetMetaProfileNameDraft => value => + _metaProfileNameDraft = value; + public Action SelectMetaProfile => SelectMetaProfileCore; + public Action CreateNamedMetaProfile => value => + { + _metaProfileNameDraft = value; + CreateMetaProfileCore(copyCurrent: false); + }; + public Action CreateMetaProfile => () => CreateMetaProfileCore(copyCurrent: false); + public Action CopyMetaProfile => () => CreateMetaProfileCore(copyCurrent: true); + public Action ClearMetaProfile => ClearMetaProfileCore; + + // ── Profiles tab ───────────────────────────────────────────────────── + public IReadOnlyList MacroProfileNames => _profiles.AvailableNames; + public string SelectedMacroProfile => _profiles.Selected; + public string ProfileNameDraft => _profileNameDraft; + public string ProfileLifecycleNotice => ProfileRecoveryNotice + ?? _profileLifecycleNotice; + private string? ProfileRecoveryNotice => + _profiles.RecoveryNotice + ?? _lootProfiles.RecoveryNotice + ?? _routeProfiles.RecoveryNotice + ?? _metaProfiles.RecoveryNotice; + public bool MineOnlyEnabled => _profiles.MineOnly; + public Action SetProfileNameDraft => value => + _profileNameDraft = value; + public Action SelectMacroProfile => SelectProfile; + public Action CreateNamedProfile => value => + { + _profileNameDraft = value; + CreateProfileCore(copyCurrent: false); + }; + public Action CreateProfile => () => CreateProfileCore(copyCurrent: false); + public Action CopyProfile => () => CreateProfileCore(copyCurrent: true); + public Action ClearProfile => ClearProfileCore; + public Action ToggleMineOnly => () => + { + string before = _profiles.Selected; + _profiles.SetMineOnly(!_profiles.MineOnly); + if (!string.Equals(before, _profiles.Selected, StringComparison.OrdinalIgnoreCase)) + LoadSelectedProfile(); + _profileLifecycleNotice = _profiles.MineOnly + ? "Showing profiles owned by this character." + : "Showing profiles from all characters."; + }; + + // ── Monsters editor ────────────────────────────────────────────────── + public IReadOnlyList MonsterRows => _monsterRows; + public int SelectedMonsterRuleIndex => _selectedMonsterRule; + public string MonsterExpressionDraft => _monsterExpressionDraft; + public string MonsterEditorNotice => _monsterEditorNotice; + public IReadOnlyList DamageTypeNames => MonsterDamageNames; + public IReadOnlyList ExtraVulnerabilityNames => + MonsterExtraVulnerabilityNames; + public IReadOnlyList PetDamageTypeNames => MonsterPetDamageNames; + public string SelectedDamageType => DamageTypeDisplay( + SelectedMonsterActions.DamageType); + public string SelectedExtraVulnerability => + DamageTypeDisplay(SelectedMonsterActions.ExtraVulnerability); + public string SelectedPetDamage => DamageTypeDisplay( + SelectedMonsterActions.PetDamageType); + public string MonsterPriorityText => + $"Priority {SelectedMonsterActions.BoundedPriority}"; + public string MonsterEquipmentText => + $"Weapon {ItemDisplayName( + SelectedMonsterActions.WeaponObjectId, + SelectedMonsterActions.WeaponName)} " + + $"Offhand {ItemDisplayName( + SelectedMonsterActions.OffhandObjectId, + SelectedMonsterActions.OffhandName)}"; + + public Action SelectMonsterRule => SelectMonsterRuleCore; + public Action SetMonsterExpressionDraft => value => + _monsterExpressionDraft = value; + public Action ApplyMonsterExpression => value => + { + _monsterExpressionDraft = value; + ApplyMonsterExpressionCore(); + }; + public Action ApplyMonsterRule => ApplyMonsterExpressionCore; + public Action AddMonsterRule => AddMonsterRuleCore; + public Action AddSelectedMonster => AddSelectedMonsterCore; + public Action RemoveMonsterRule => RemoveMonsterRuleCore; + public Action MoveMonsterRuleUp => () => MoveMonsterRule(-1); + public Action MoveMonsterRuleDown => () => MoveMonsterRule(1); + public Action MonsterPriorityDown => () => UpdateSelectedMonsterActions( + actions => actions with { Priority = Math.Max(-1, actions.Priority - 1) }); + public Action MonsterPriorityUp => () => UpdateSelectedMonsterActions( + actions => actions with { Priority = Math.Min(4, actions.Priority + 1) }); + public Action SelectMonsterDamage => value => + SetMonsterDamage(value, extra: false); + public Action SelectMonsterExtraVulnerability => value => + SetMonsterDamage(value, extra: true); + public Action SelectMonsterPetDamage => value => + { + if (TryParseDamageType(value, out MonsterDamageType parsed)) + { + UpdateSelectedMonsterActions(actions => actions with + { + PetDamageType = parsed, + }); + } + }; + public Action SetMonsterWeapon => () => SetSelectedMonsterEquipment(offhand: false); + public Action SetMonsterOffhand => () => SetSelectedMonsterEquipment(offhand: true); + public Action ClearMonsterEquipment => () => UpdateSelectedMonsterActions( + actions => actions with + { + WeaponObjectId = 0u, + OffhandObjectId = 0u, + WeaponName = string.Empty, + OffhandName = string.Empty, + }); + + public bool MonsterFester => HasMonsterFlag(MonsterActionFlags.Fester); + public bool MonsterBroadside => HasMonsterFlag(MonsterActionFlags.Broadside); + public bool MonsterGravityWell => HasMonsterFlag(MonsterActionFlags.GravityWell); + public bool MonsterImperil => HasMonsterFlag(MonsterActionFlags.Imperil); + public bool MonsterYield => HasMonsterFlag(MonsterActionFlags.Yield); + public bool MonsterVulnerability => HasMonsterFlag(MonsterActionFlags.Vulnerability); + public bool MonsterAttack => HasMonsterFlag(MonsterActionFlags.Attack); + public bool MonsterRing => HasMonsterFlag(MonsterActionFlags.Ring); + public bool MonsterStreak => HasMonsterFlag(MonsterActionFlags.Streak); + public bool MonsterWeakening => HasMonsterFlag(MonsterActionFlags.WeakeningCurse); + public bool MonsterFestering => HasMonsterFlag(MonsterActionFlags.FesteringCurse); + public bool MonsterCorruption => HasMonsterFlag(MonsterActionFlags.Corruption); + public bool MonsterDestructive => HasMonsterFlag(MonsterActionFlags.DestructiveCurse); + public bool MonsterCorrosion => HasMonsterFlag(MonsterActionFlags.Corrosion); + public Action ToggleMonsterFester => () => ToggleMonsterFlag(MonsterActionFlags.Fester); + public Action ToggleMonsterBroadside => () => ToggleMonsterFlag(MonsterActionFlags.Broadside); + public Action ToggleMonsterGravityWell => () => ToggleMonsterFlag(MonsterActionFlags.GravityWell); + public Action ToggleMonsterImperil => () => ToggleMonsterFlag(MonsterActionFlags.Imperil); + public Action ToggleMonsterYield => () => ToggleMonsterFlag(MonsterActionFlags.Yield); + public Action ToggleMonsterVulnerability => () => ToggleMonsterFlag(MonsterActionFlags.Vulnerability); + public Action ToggleMonsterAttack => () => ToggleMonsterFlag(MonsterActionFlags.Attack); + public Action ToggleMonsterRing => () => ToggleMonsterFlag(MonsterActionFlags.Ring); + public Action ToggleMonsterStreak => () => ToggleMonsterFlag(MonsterActionFlags.Streak); + public Action ToggleMonsterWeakening => () => ToggleMonsterFlag(MonsterActionFlags.WeakeningCurse); + public Action ToggleMonsterFestering => () => ToggleMonsterFlag(MonsterActionFlags.FesteringCurse); + public Action ToggleMonsterCorruption => () => ToggleMonsterFlag(MonsterActionFlags.Corruption); + public Action ToggleMonsterDestructive => () => ToggleMonsterFlag(MonsterActionFlags.DestructiveCurse); + public Action ToggleMonsterCorrosion => () => ToggleMonsterFlag(MonsterActionFlags.Corrosion); /// Vitals line, using the same numbers the character panel shows. public string Vitals => _vitals; @@ -100,9 +963,15 @@ internal sealed class MossTankPanel public string RebuffText => $"Rebuff when under: {_buffSettings.RebuffWhenUnderSeconds / 60.0:0.#} min"; - public string ManaFloorText => $"Convert below mana: {Percent(_vitalSettings.ManaFloor)}"; - public string ManaTargetText => $"Stop converting at: {Percent(_vitalSettings.ManaTarget)}"; - public string StaminaFloorText => $"Keep stamina above: {Percent(_vitalSettings.StaminaFloor)}"; + public string NormalHealthText => Percent(_vitalSettings.NormalHealth); + public string NormalStaminaText => Percent(_vitalSettings.NormalStamina); + public string NormalManaText => Percent(_vitalSettings.NormalMana); + public string NoTargetHealthText => Percent(_vitalSettings.NoTargetHealth); + public string NoTargetStaminaText => Percent(_vitalSettings.NoTargetStamina); + public string NoTargetManaText => Percent(_vitalSettings.NoTargetMana); + public string HelperHealthText => Percent(_vitalSettings.HelperHealth); + public string HelperStaminaText => Percent(_vitalSettings.HelperStamina); + public string HelperManaText => Percent(_vitalSettings.HelperMana); public string VitalUpkeepText => $"Stamina to Mana / Revitalize: {OnOff(_vitalSettings.Enabled)}"; public string TrainedOnlyText => @@ -120,46 +989,2575 @@ internal sealed class MossTankPanel public string OtherText => $"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}"; - public Action DifficultyDown => () => _buffSettings.SkillExcessOverDifficulty = - Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5); - public Action DifficultyUp => () => _buffSettings.SkillExcessOverDifficulty = - Math.Min(100, _buffSettings.SkillExcessOverDifficulty + 5); + public bool VitalUpkeepEnabled => _vitalSettings.Enabled; + public bool HelpOthersEnabled => _vitalSettings.HelpOthers; + public bool TrainedOnlyEnabled => _buffSettings.BuffTrainedSkillsOnly; + public bool AttributesEnabled => _buffSettings.BuffAttributes; + public bool ProtectionsEnabled => _buffSettings.BuffProtections; + public bool AurasEnabled => _buffSettings.BuffAuras; + public bool BanesEnabled => _buffSettings.BuffBanes; + public bool RegenerationEnabled => _buffSettings.BuffRegeneration; + public bool OtherEnabled => _buffSettings.BuffOther; - public Action RebuffDown => () => _buffSettings.RebuffWhenUnderSeconds = - Math.Max(30, _buffSettings.RebuffWhenUnderSeconds - 30); - public Action RebuffUp => () => _buffSettings.RebuffWhenUnderSeconds = - Math.Min(1800, _buffSettings.RebuffWhenUnderSeconds + 30); + public string TargetMethodText => + $"Target selection: {_combatSettings.SelectionMethod}"; + public string TargetLockText => + $"Target lock: {OnOff(_combatSettings.TargetLock)}"; + public string AttackRangeText => + $"Maximum target range: {_combatSettings.MaximumRange:0}m"; + public string MonsterRangeValueText => + _combatSettings.MaximumRange.ToString("0.#", CultureInfo.InvariantCulture); + public string RingRangeValueText => + _combatSettings.RingDistance.ToString("0.#", CultureInfo.InvariantCulture); + public string ApproachRangeValueText => + _combatSettings.ApproachDistance.ToString("0.#", CultureInfo.InvariantCulture); + public string FollowNavMinimumValueText => + _navigationSettings.MinimumDistanceMeters.ToString( + "0.#", + CultureInfo.InvariantCulture); + public string AngleRangeText => + $"Angle-selection range: {_combatSettings.TargetSelectAngleRange:0}m"; + public string AttackHeightText => + $"Physical attack height: {_combatSettings.AttackHeight}"; + public string AttackPowerText => + $"Power / accuracy: {_combatSettings.AttackPower * 100f:0}%"; + public bool TargetLockEnabled => _combatSettings.TargetLock; + public bool SummonPetsEnabled => _combatSettings.SummonPets; + public bool CustomPetRangeEnabled => + _combatSettings.PetRangeMode == PetRangeMode.Custom; + public string PetRangeText => _combatSettings.PetRangeMode == PetRangeMode.Custom + ? $"Pet range: {_combatSettings.PetCustomRange:0}m" + : $"Pet range: attack ({_combatSettings.MaximumRange:0}m)"; + public string PetCustomRangeValueText => + _combatSettings.PetCustomRange.ToString("0.#", CultureInfo.InvariantCulture); + public string PetDensityText => + $"Pet min. monsters: {_combatSettings.PetMonsterDensity}"; + public string PetDensityValueText => + _combatSettings.PetMonsterDensity.ToString(CultureInfo.InvariantCulture); + public float NormalHealthValue => (float)_vitalSettings.NormalHealth; + public float NormalStaminaValue => (float)_vitalSettings.NormalStamina; + public float NormalManaValue => (float)_vitalSettings.NormalMana; + public float NoTargetHealthValue => (float)_vitalSettings.NoTargetHealth; + public float NoTargetStaminaValue => (float)_vitalSettings.NoTargetStamina; + public float NoTargetManaValue => (float)_vitalSettings.NoTargetMana; + public float HelperHealthValue => (float)_vitalSettings.HelperHealth; + public float HelperStaminaValue => (float)_vitalSettings.HelperStamina; + public float HelperManaValue => (float)_vitalSettings.HelperMana; + public float AttackPowerValue => _combatSettings.AttackPower; - public Action ManaFloorDown => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, -1); - public Action ManaFloorUp => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, +1); + public Action SetNormalHealth => value => + UpdateVital(() => _vitalSettings.NormalHealth = Clamp(value)); + public Action SetNormalStamina => value => + UpdateVital(() => _vitalSettings.NormalStamina = Clamp(value)); + public Action SetNormalMana => value => + UpdateVital(() => _vitalSettings.NormalMana = Clamp(value)); + public Action SetNoTargetHealth => value => + UpdateVital(() => _vitalSettings.NoTargetHealth = Clamp(value)); + public Action SetNoTargetStamina => value => + UpdateVital(() => _vitalSettings.NoTargetStamina = Clamp(value)); + public Action SetNoTargetMana => value => + UpdateVital(() => _vitalSettings.NoTargetMana = Clamp(value)); + public Action SetHelperHealth => value => + UpdateVital(() => _vitalSettings.HelperHealth = Clamp(value)); + public Action SetHelperStamina => value => + UpdateVital(() => _vitalSettings.HelperStamina = Clamp(value)); + public Action SetHelperMana => value => + UpdateVital(() => _vitalSettings.HelperMana = Clamp(value)); + public Action SetAttackPower => value => UpdateProfile(() => + _combatSettings.AttackPower = Math.Clamp(value, 0f, 1f)); - public Action ManaTargetDown => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, -1); - public Action ManaTargetUp => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, +1); + public Action DifficultyDown => () => UpdateProfile(() => + _buffSettings.SkillExcessOverDifficulty = + Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5)); + public Action DifficultyUp => () => UpdateProfile(() => + _buffSettings.SkillExcessOverDifficulty = + Math.Min(100, _buffSettings.SkillExcessOverDifficulty + 5)); - public Action StaminaFloorDown => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, -1); - public Action StaminaFloorUp => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, +1); + public Action RebuffDown => () => UpdateProfile(() => + _buffSettings.RebuffWhenUnderSeconds = + Math.Max(30, _buffSettings.RebuffWhenUnderSeconds - 30)); + public Action RebuffUp => () => UpdateProfile(() => + _buffSettings.RebuffWhenUnderSeconds = + Math.Min(1800, _buffSettings.RebuffWhenUnderSeconds + 30)); - public Action ToggleVitalUpkeep => () => _vitalSettings.Enabled = !_vitalSettings.Enabled; - public Action ToggleTrainedOnly => () => - _buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly; - public Action ToggleAttributes => () => - _buffSettings.BuffAttributes = !_buffSettings.BuffAttributes; - public Action ToggleProtections => () => - _buffSettings.BuffProtections = !_buffSettings.BuffProtections; - public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras; - public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes; - public Action ToggleRegeneration => - () => _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration; - public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther; + public Action ToggleVitalUpkeep => () => + { + _vitalSettings.Enabled = !_vitalSettings.Enabled; + if (!_vitalSettings.Enabled) + _vitalRecharge.Reset(); + SaveProfile(); + }; + public Action ToggleBuffing => () => SetMetaOption( + "EnableBuffing", + ExpressionValue.Boolean(!_buffSettings.Enabled)); + public Action ToggleIdlePeaceMode => () => SetMetaOption( + "IdlePeaceMode", + ExpressionValue.Boolean(!_combatSettings.IdlePeaceMode)); + public Action ToggleIdleBuffTopoff => () => SetMetaOption( + "IdleBuffTopoff", + ExpressionValue.Boolean(!_buffSettings.IdleBuffTopoff)); + public Action ToggleManaChargesWhenOff => () => SetMetaOption( + "ManaChargesWhenOff", + ExpressionValue.Boolean(!_inventorySettings.ManaChargesWhenOff)); + public Action ToggleHelpOthers => () => UpdateVital(() => + _vitalSettings.HelpOthers = !_vitalSettings.HelpOthers); + public Action ToggleTrainedOnly => () => UpdateProfile(() => + _buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly); + public Action ToggleAttributes => () => UpdateProfile(() => + _buffSettings.BuffAttributes = !_buffSettings.BuffAttributes); + public Action ToggleProtections => () => UpdateProfile(() => + _buffSettings.BuffProtections = !_buffSettings.BuffProtections); + public Action ToggleAuras => () => UpdateProfile(() => + _buffSettings.BuffAuras = !_buffSettings.BuffAuras); + public Action ToggleBanes => () => UpdateProfile(() => + _buffSettings.BuffBanes = !_buffSettings.BuffBanes); + public Action ToggleRegeneration => () => UpdateProfile(() => + _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration); + public Action ToggleOther => () => UpdateProfile(() => + _buffSettings.BuffOther = !_buffSettings.BuffOther); + public Action CycleTargetMethod => () => UpdateProfile(() => + _combatSettings.SelectionMethod = _combatSettings.SelectionMethod switch + { + TargetSelectionMethod.Range => TargetSelectionMethod.Angle, + TargetSelectionMethod.Angle => TargetSelectionMethod.Both, + _ => TargetSelectionMethod.Range, + }); + public Action ToggleTargetLock => () => UpdateProfile(() => + _combatSettings.TargetLock = !_combatSettings.TargetLock); + public Action ToggleSummonPets => () => UpdateProfile(() => + _combatSettings.SummonPets = !_combatSettings.SummonPets); + public Action TogglePetRangeMode => () => UpdateProfile(() => + _combatSettings.PetRangeMode = _combatSettings.PetRangeMode == PetRangeMode.Custom + ? PetRangeMode.AttackDistance + : PetRangeMode.Custom); + public Action PetRangeDown => () => UpdateProfile(() => + _combatSettings.PetCustomRange = + Math.Max(1f, _combatSettings.PetCustomRange - 1f)); + public Action PetRangeUp => () => UpdateProfile(() => + _combatSettings.PetCustomRange = + Math.Min(100f, _combatSettings.PetCustomRange + 1f)); + public Action SetPetCustomRangeText => value => + SetDistanceText(value, 1f, 100f, distance => + _combatSettings.PetCustomRange = distance); + public Action PetDensityDown => () => UpdateProfile(() => + _combatSettings.PetMonsterDensity = + Math.Max(1, _combatSettings.PetMonsterDensity - 1)); + public Action PetDensityUp => () => UpdateProfile(() => + _combatSettings.PetMonsterDensity = + Math.Min(25, _combatSettings.PetMonsterDensity + 1)); + public Action SetPetDensityText => value => + { + if (!int.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int density)) + { + return; + } + _combatSettings.PetMonsterDensity = Math.Clamp(density, 1, 25); + SaveProfile(); + }; + public Action AttackRangeDown => () => UpdateProfile(() => + _combatSettings.MaximumRange = + Math.Max(2f, _combatSettings.MaximumRange - 2f)); + public Action AttackRangeUp => () => UpdateProfile(() => + _combatSettings.MaximumRange = + Math.Min(100f, _combatSettings.MaximumRange + 2f)); + public Action SetMonsterRangeText => value => + SetDistanceText(value, 1f, 100f, distance => + _combatSettings.MaximumRange = distance); + public Action SetRingRangeText => value => + SetDistanceText(value, 1f, 100f, distance => + _combatSettings.RingDistance = distance); + public Action SetApproachRangeText => value => + SetDistanceText(value, 0f, 100f, distance => + _combatSettings.ApproachDistance = distance); + public Action SetFollowNavMinimumText => value => + SetDistanceText(value, 0.5f, 50f, distance => + { + _navigationSettings.MinimumDistanceMeters = distance; + SaveRouteProfile(); + }, saveProfile: false); + public Action AngleRangeDown => () => UpdateProfile(() => + _combatSettings.TargetSelectAngleRange = + Math.Max(2f, _combatSettings.TargetSelectAngleRange - 2f)); + public Action AngleRangeUp => () => UpdateProfile(() => + _combatSettings.TargetSelectAngleRange = Math.Min( + _combatSettings.MaximumRange, + _combatSettings.TargetSelectAngleRange + 2f)); + public Action CycleAttackHeight => () => UpdateProfile(() => + _combatSettings.AttackHeight = _combatSettings.AttackHeight switch + { + PluginAttackHeight.High => PluginAttackHeight.Medium, + PluginAttackHeight.Medium => PluginAttackHeight.Low, + _ => PluginAttackHeight.High, + }); + public Action AttackPowerDown => () => UpdateProfile(() => + _combatSettings.AttackPower = + Math.Max(0f, MathF.Round(_combatSettings.AttackPower - 0.1f, 2))); + public Action AttackPowerUp => () => UpdateProfile(() => + _combatSettings.AttackPower = + Math.Min(1f, MathF.Round(_combatSettings.AttackPower + 0.1f, 2))); - private static double Step(double value, int direction) => - Math.Clamp(Math.Round(value + direction * 0.05, 2), 0.0, 1.0); + private static double Clamp(float value) => Math.Clamp((double)value, 0d, 1d); + + private void UpdateVital(Action update) + { + UpdateProfile(update); + } + + private void UpdateProfile(Action update) + { + update(); + SaveProfile(); + } + + private void SetDistanceText( + string text, + float minimum, + float maximum, + Action apply, + bool saveProfile = true) + { + if (!float.TryParse( + text, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out float distance) + && !float.TryParse( + text, + NumberStyles.Float, + CultureInfo.CurrentCulture, + out distance)) + { + return; + } + apply(Math.Clamp(distance, minimum, maximum)); + if (saveProfile) + SaveProfile(); + } private static string Percent(double fraction) => (fraction * 100).ToString("0", CultureInfo.InvariantCulture) + "%"; private static string OnOff(bool value) => value ? "on" : "off"; + private static string ProfileText(string heading, IEnumerable names) + { + string[] entries = names + .OrderBy(static name => name, StringComparer.Ordinal) + .Take(8) + .ToArray(); + return entries.Length == 0 + ? $"{heading}: [None]" + : $"{heading}: {string.Join(" | ", entries)}"; + } + + private void RefreshItemEditors() + { + RefreshConsumableCategories(); + _itemRows = _combatSettings.CombatItemNames + .OrderBy(static name => name, StringComparer.Ordinal) + .Select(name => _noBuffItemNames.Contains(name) + ? name + " [no buffs]" + : name) + .ToArray(); + _consumableRows = _combatSettings.ConsumableNames + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + _selectedItemRow = ClampRow(_selectedItemRow, _itemRows.Count); + _selectedConsumableRow = ClampRow( + _selectedConsumableRow, + _consumableRows.Count); + } + + private static int ClampRow(int index, int count) => count == 0 + ? 0 + : Math.Clamp(index, 0, count - 1); + + private void RemoveSelectedItemCore() + { + string[] names = _combatSettings.CombatItemNames + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + if (names.Length == 0) + { + _profileNotice = "The Items profile is empty."; + return; + } + string removed = names[ClampRow(_selectedItemRow, names.Length)]; + _combatSettings.CombatItemNames.Remove(removed); + _noBuffItemNames.Remove(removed); + _profileNotice = $"Removed {removed}."; + RefreshItemEditors(); + SaveProfile(); + } + + private void RemoveSelectedConsumableCore() + { + string[] names = _combatSettings.ConsumableNames + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + if (names.Length == 0) + { + _profileNotice = "The Consumables profile is empty."; + return; + } + string removed = names[ClampRow(_selectedConsumableRow, names.Length)]; + _combatSettings.ConsumableNames.Remove(removed); + _combatSettings.ConsumableCategories.Remove(removed); + _profileNotice = $"Removed {removed}."; + RefreshItemEditors(); + SaveProfile(); + } + + private LootRule? SelectedLootRule => _inventorySettings.Loot.Rules.Count == 0 + ? null + : _inventorySettings.Loot.Rules[Math.Clamp( + _selectedLootRule, + 0, + _inventorySettings.Loot.Rules.Count - 1)]; + + private static string FormatClassifier(PluginLootClassifierInfo info) => + $"{info.DisplayName} [{info.Id}]"; + + private string? ResolveClassifierId(string? value) + { + if (string.Equals(value?.Trim(), "VTClassic", StringComparison.OrdinalIgnoreCase)) + return string.Empty; + foreach (PluginLootClassifierInfo info in _host.LootClassifiers.Available) + { + if (string.Equals(value?.Trim(), FormatClassifier(info), + StringComparison.Ordinal) + || string.Equals(value?.Trim(), info.Id, + StringComparison.OrdinalIgnoreCase)) + { + return info.Id; + } + } + return null; + } + + private void RefreshLootEditor(bool retainDraft = false) + { + int count = _inventorySettings.Loot.Rules.Count; + _selectedLootRule = count == 0 + ? 0 + : Math.Clamp(_selectedLootRule, 0, count - 1); + if (!retainDraft) + _lootExpressionDraft = SelectedLootRule?.Expression ?? "*"; + _lootRuleRows = _inventorySettings.Loot.Rules + .Select((rule, index) => + $"{index + 1}. {rule.Action} P{rule.Priority} " + + (rule.VtankRequirements.Count == 0 + ? rule.Expression + : $"[VTClassic: {rule.VtankRequirements.Count} requirements]")) + .ToArray(); + } + + private void SelectLootRuleCore(int index) + { + if (index < 0 || index >= _inventorySettings.Loot.Rules.Count) + return; + _selectedLootRule = index; + _lootExpressionDraft = + _inventorySettings.Loot.Rules[index].Expression; + _lootEditorNotice = $"Editing rule {index + 1}."; + RefreshLootEditor(retainDraft: true); + } + + private void SelectLootProfileCore(string name) + { + SaveProfile(); + if (!_lootProfiles.Select(name)) + { + _lootEditorNotice = $"Loot profile '{name}' is unavailable."; + return; + } + LoadLootProfile(); + _lootEditorNotice = $"Loaded loot profile {_lootProfiles.Selected}."; + } + + private void CreateLootProfileCore(bool copyCurrent) + { + if (!_lootProfiles.Create( + _lootProfileNameDraft, + copyCurrent, + _inventorySettings.Loot.Rules, + out string notice, + _inventorySettings.Loot)) + { + _lootEditorNotice = notice; + return; + } + _lootProfileNameDraft = string.Empty; + LoadLootProfile(); + _lootEditorNotice = notice; + } + + private void ClearLootProfileCore() + { + _lootProfiles.ClearCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + _loot.Reset(); + RefreshLootEditor(); + _lootEditorNotice = $"Cleared {_lootProfiles.Selected}."; + } + + private void LoadLootProfile() + { + if (!_lootProfiles.LoadCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot)) + { + _lootProfiles.SaveCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + } + _loot.Reset(); + RefreshLootEditor(); + } + + private void ApplyLootExpressionCore() + { + if (SelectedLootRule is not { } rule) + { + _lootEditorNotice = "Add a loot rule first."; + return; + } + try + { + _ = LootRuleExpression.Compile(_lootExpressionDraft); + rule.Expression = _lootExpressionDraft; + rule.CustomExpression = string.Empty; + rule.VtankRequirements.Clear(); + _lootEditorNotice = $"Updated {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + catch (FormatException error) + { + _lootEditorNotice = error.Message; + } + } + + private void AddLootRuleCore() + { + var rule = new LootRule + { + Name = $"Rule {_inventorySettings.Loot.Rules.Count + 1}", + Expression = "*", + Action = LootAction.Keep, + }; + _inventorySettings.Loot.Rules.Add(rule); + _selectedLootRule = _inventorySettings.Loot.Rules.Count - 1; + _lootExpressionDraft = rule.Expression; + _lootEditorNotice = $"Added {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void RemoveLootRuleCore() + { + if (SelectedLootRule is not { } rule) + { + _lootEditorNotice = "The loot profile is empty."; + return; + } + _inventorySettings.Loot.Rules.RemoveAt(_selectedLootRule); + _selectedLootRule = Math.Min( + _selectedLootRule, + Math.Max(0, _inventorySettings.Loot.Rules.Count - 1)); + _lootEditorNotice = $"Removed {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void MoveLootRule(int direction) + { + if (SelectedLootRule is not { } rule) + return; + int destination = _selectedLootRule + Math.Sign(direction); + if (destination < 0 || destination >= _inventorySettings.Loot.Rules.Count) + return; + _inventorySettings.Loot.Rules.RemoveAt(_selectedLootRule); + _inventorySettings.Loot.Rules.Insert(destination, rule); + _selectedLootRule = destination; + _lootEditorNotice = $"Moved {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void SelectLootActionCore(string value) + { + if (SelectedLootRule is not { } rule + || !Enum.TryParse(value, ignoreCase: true, out LootAction action)) + { + return; + } + rule.Action = action; + _lootEditorNotice = $"{rule.Name}: {action}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void UpdateSelectedLootRule(Action update) + { + if (SelectedLootRule is not { } rule) + return; + update(rule); + _lootEditorNotice = $"Updated {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void RefreshRouteEditor() + { + _selectedRouteWaypoint = ClampRow( + _selectedRouteWaypoint, + _navigationSettings.Waypoints.Count); + int active = _navigation.CurrentWaypointIndex; + _routeRows = _navigationSettings.Waypoints + .Select((waypoint, index) => + $"{(index == active && _navigationSettings.Enabled ? "<<" : " ")} " + + waypoint.DisplayText) + .ToArray(); + } + + private void AddRoutePointCore() + { + PluginNavigationSnapshot snapshot = + _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable) + { + _routeNotice = "Current position is unavailable."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Point, + Position = snapshot.Position, + }); + _routeNotice = $"Added point {RouteWaypoint.FormatPosition(snapshot.Position)}."; + } + + private void AddRouteCheckpointCore() + { + PluginNavigationSnapshot snapshot = + _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable) + { + _routeNotice = "Current position is unavailable."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Checkpoint, + Position = snapshot.Position, + }); + _routeNotice = "Added checkpoint."; + } + + private void AddSelectedObjectWaypoint(RouteWaypointType type) + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + if (selected == 0u + || !_host.Automation.Navigation.TryGetObject( + selected, + out PluginNavigationObject target)) + { + _routeNotice = "Select a live portal, NPC, or vendor first."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = type, + Position = target.Position, + ObjectId = target.ObjectId, + ObjectName = target.Name, + }); + _routeNotice = $"Added {type}: {target.Name}."; + } + + private void AddRouteRecallCore() + { + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Recall, + Recall = _routeRecallKind, + Position = _host.Automation.Navigation.Snapshot.Position, + }); + _routeNotice = $"Added {RouteWaypoint.RecallDisplayName(_routeRecallKind)}."; + } + + private void AddRoutePauseCore() + { + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Pause, + DurationMilliseconds = _routePauseSeconds * 1000, + Position = _host.Automation.Navigation.Snapshot.Position, + }); + _routeNotice = $"Added {_routePauseSeconds}-second pause."; + } + + private void AddRouteChatCore() + { + string text = _routeChatDraft.Trim(); + if (text.Length == 0) + { + _routeNotice = "Enter a chat command first."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.ChatCommand, + Text = text, + Position = _host.Automation.Navigation.Snapshot.Position, + }); + _routeNotice = $"Added chat command {text}."; + } + + private void AddRouteJumpCore() + { + PluginNavigationSnapshot snapshot = + _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable) + { + _routeNotice = "Current heading is unavailable."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Jump, + Position = snapshot.Position, + JumpHeadingDegrees = snapshot.Position.HeadingDegrees, + JumpRun = true, + JumpChargeMilliseconds = 1000, + JumpDirection = RouteJumpDirection.Forward, + }); + _routeNotice = "Added forward jump."; + } + + private void AddRouteWaypoint(RouteWaypoint waypoint) + { + int insertion = _routeAddToEnd + ? _navigationSettings.Waypoints.Count + : Math.Min( + _navigationSettings.Waypoints.Count, + _selectedRouteWaypoint + 1); + _navigationSettings.Waypoints.Insert(insertion, waypoint); + _selectedRouteWaypoint = insertion; + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + } + + private void RemoveRouteWaypointCore() + { + if (_navigationSettings.Waypoints.Count == 0) + { + _routeNotice = "The route is empty."; + return; + } + int index = ClampRow( + _selectedRouteWaypoint, + _navigationSettings.Waypoints.Count); + string removed = _navigationSettings.Waypoints[index].DisplayText; + _navigationSettings.Waypoints.RemoveAt(index); + _selectedRouteWaypoint = ClampRow( + index, + _navigationSettings.Waypoints.Count); + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + _routeNotice = $"Removed {removed}."; + } + + private void MoveRouteWaypoint(int direction) + { + if (_navigationSettings.Waypoints.Count < 2) + return; + int source = ClampRow( + _selectedRouteWaypoint, + _navigationSettings.Waypoints.Count); + int destination = source + Math.Sign(direction); + if (destination < 0 || destination >= _navigationSettings.Waypoints.Count) + return; + RouteWaypoint waypoint = _navigationSettings.Waypoints[source]; + _navigationSettings.Waypoints.RemoveAt(source); + _navigationSettings.Waypoints.Insert(destination, waypoint); + _selectedRouteWaypoint = destination; + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + } + + private void CaptureFollowTarget() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + if (selected == 0u + || !_host.Automation.Navigation.TryGetObject( + selected, + out PluginNavigationObject target)) + { + _routeNotice = "Select a live object to follow first."; + return; + } + _navigationSettings.FollowTargetObjectId = target.ObjectId; + _navigationSettings.FollowTargetName = target.Name; + _navigationSettings.Mode = RouteMode.Target; + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + _routeNotice = $"Following {target.Name}."; + } + + private void SelectRouteProfileCore(string name) + { + SaveRouteProfile(); + if (!_routeProfiles.Select(name)) + { + _routeNotice = $"Route profile '{name}' is unavailable."; + return; + } + LoadRouteProfile(); + _routeNotice = $"Loaded route {_routeProfiles.Selected}."; + } + + private void CreateRouteProfileCore(bool copyCurrent) + { + if (!_routeProfiles.Create( + _routeProfileNameDraft, + copyCurrent, + _navigationSettings, + out string notice)) + { + _routeNotice = notice; + return; + } + _routeProfileNameDraft = string.Empty; + LoadRouteProfile(); + _routeNotice = notice; + } + + private void ClearRouteProfileCore() + { + _routeProfiles.ClearCurrent(_navigationSettings); + _navigation.Reset(); + RefreshRouteEditor(); + _routeNotice = $"Cleared {_routeProfiles.Selected}."; + } + + private void LoadRouteProfile() + { + if (!_routeProfiles.LoadCurrent(_navigationSettings)) + _routeProfiles.SaveCurrent(_navigationSettings); + if (_initialized) + ApplyPersistedOptionOverrides(); + _navigation.Reset(); + RefreshRouteEditor(); + } + + private void SaveRouteProfile() => + _routeProfiles.SaveCurrent(_navigationSettings); + + private void SelectTab(TankTab tab) + { + _activeTab = tab; + _lootEditorVisible = false; + _advancedOptionsVisible = false; + } + + private void LoadAdvancedOptionDraft() + { + _advancedOptionValueDraft = GetMetaOption(AdvancedOptionName) + .ToDisplayString(); + _advancedOptionNotice = $"Editing {AdvancedOptionName}."; + } + + private void ApplyAdvancedOptionCore() + { + if (!TryParseOptionValue( + _advancedOptionValueDraft.Trim(), + out ExpressionValue value)) + { + _advancedOptionNotice = "Enter a value first."; + return; + } + try + { + if (!SetMetaOption(AdvancedOptionName, value)) + { + _advancedOptionNotice = $"{AdvancedOptionName} is unavailable."; + return; + } + LoadAdvancedOptionDraft(); + _advancedOptionNotice = $"Applied {AdvancedOptionName}."; + } + catch (Exception exception) when (exception is FormatException + or OverflowException) + { + _advancedOptionNotice = exception.Message; + } + } + + private MonsterRule SelectedMonsterRule => _combatSettings.Rules.Count == 0 + ? new MonsterRule("DEFAULT", 0) + : _combatSettings.Rules[Math.Clamp( + _selectedMonsterRule, + 0, + _combatSettings.Rules.Count - 1)]; + private MonsterRuleActions SelectedMonsterActions => SelectedMonsterRule.Actions; + + private void RefreshMonsterEditor(bool retainDraft = false) + { + if (_combatSettings.Rules.Count == 0) + _combatSettings.Rules.Add(new MonsterRule("DEFAULT", 0)); + _selectedMonsterRule = Math.Clamp( + _selectedMonsterRule, + 0, + _combatSettings.Rules.Count - 1); + if (!retainDraft) + _monsterExpressionDraft = SelectedMonsterRule.Expression; + _monsterRows = _combatSettings.Rules.Select(FormatMonsterRow).ToArray(); + } + + private static string FormatMonsterRow(MonsterRule rule) + { + MonsterRuleActions actions = rule.Actions; + static char Mark(MonsterActionFlags flags, MonsterActionFlags flag) => + (flags & flag) != 0 ? '●' : '○'; + return string.Concat( + Mark(actions.Flags, MonsterActionFlags.Fester), " ", + Mark(actions.Flags, MonsterActionFlags.Broadside), " ", + Mark(actions.Flags, MonsterActionFlags.GravityWell), " ", + Mark(actions.Flags, MonsterActionFlags.Imperil), " ", + Mark(actions.Flags, MonsterActionFlags.Yield), " ", + Mark(actions.Flags, MonsterActionFlags.Vulnerability), " ", + Mark(actions.Flags, MonsterActionFlags.Attack), " ", + Mark(actions.Flags, MonsterActionFlags.Ring), " ", + Mark(actions.Flags, MonsterActionFlags.Streak), " ", + Mark(actions.Flags, MonsterActionFlags.WeakeningCurse), " ", + Mark(actions.Flags, MonsterActionFlags.FesteringCurse), " ", + Mark(actions.Flags, MonsterActionFlags.Corruption), " ", + Mark(actions.Flags, MonsterActionFlags.DestructiveCurse), " ", + Mark(actions.Flags, MonsterActionFlags.Corrosion), " ", + rule.Expression, " P", actions.BoundedPriority.ToString( + CultureInfo.InvariantCulture), " ", actions.DamageType.ToString()); + } + + private void SelectMonsterRuleCore(int index) + { + if (index < 0 || index >= _combatSettings.Rules.Count) + return; + _selectedMonsterRule = index; + _monsterExpressionDraft = SelectedMonsterRule.Expression; + _monsterEditorNotice = $"Editing row {index + 1}."; + RefreshMonsterEditor(); + } + + private void ApplyMonsterExpressionCore() + { + string expression = _monsterExpressionDraft.Trim(); + if (expression.Length == 0) + { + _monsterEditorNotice = "Monster expression cannot be empty."; + return; + } + try + { + _combatSettings.Rules[_selectedMonsterRule] = new MonsterRule( + expression, + SelectedMonsterActions); + _monsterEditorNotice = $"Updated {expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + catch (FormatException error) + { + _monsterEditorNotice = error.Message; + } + } + + private void AddMonsterRuleCore() => AddMonsterRuleCore("New monster"); + + private void AddMonsterRuleCore(string expression) + { + try + { + var rule = new MonsterRule(expression, new MonsterRuleActions()); + _combatSettings.Rules.Add(rule); + _selectedMonsterRule = _combatSettings.Rules.Count - 1; + _monsterEditorNotice = $"Added {expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + catch (FormatException error) + { + _monsterEditorNotice = error.Message; + } + } + + private void AddSelectedMonsterCore() + { + uint selectedId = _host.Selection.SelectedObjectId ?? 0u; + PluginCombatTarget selected = default; + bool found = false; + foreach (PluginCombatTarget candidate in + _host.Automation.Combat.CaptureHostileTargets(float.MaxValue)) + { + if (candidate.ObjectId != selectedId) + continue; + selected = candidate; + found = true; + break; + } + if (!found || string.IsNullOrWhiteSpace(selected.Name)) + { + _monsterEditorNotice = "Select a monster in the world first."; + return; + } + AddMonsterRuleCore(EscapeMonsterLiteral(selected.Name)); + } + + private static string EscapeMonsterLiteral(string value) + { + const string operators = "%/*+-#><=&|()"; + var result = new System.Text.StringBuilder(value.Length + 8); + foreach (char character in value) + { + if (char.IsDigit(character) + || character == '\\' + || operators.Contains(character)) + { + result.Append('\\'); + } + result.Append(character); + } + return result.ToString(); + } + + private void RemoveMonsterRuleCore() + { + if (SelectedMonsterRule.IsDefault) + { + _monsterEditorNotice = "DEFAULT cannot be removed."; + return; + } + string removed = SelectedMonsterRule.Expression; + _combatSettings.Rules.RemoveAt(_selectedMonsterRule); + _selectedMonsterRule = Math.Min( + _selectedMonsterRule, + _combatSettings.Rules.Count - 1); + _monsterEditorNotice = $"Removed {removed}."; + RefreshMonsterEditor(); + SaveProfile(); + } + + private void MoveMonsterRule(int direction) + { + int destination = _selectedMonsterRule + Math.Sign(direction); + if (destination < 0 || destination >= _combatSettings.Rules.Count) + return; + MonsterRule current = _combatSettings.Rules[_selectedMonsterRule]; + _combatSettings.Rules.RemoveAt(_selectedMonsterRule); + _combatSettings.Rules.Insert(destination, current); + _selectedMonsterRule = destination; + _monsterEditorNotice = $"Moved {current.Expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + + private bool HasMonsterFlag(MonsterActionFlags flag) => + (SelectedMonsterActions.Flags & flag) != 0; + + private void ToggleMonsterFlag(MonsterActionFlags flag) => + UpdateSelectedMonsterActions(actions => actions with + { + Flags = actions.Flags ^ flag, + }); + + private void SetMonsterDamage(string value, bool extra) + { + if (!TryParseDamageType(value, out MonsterDamageType parsed)) + return; + UpdateSelectedMonsterActions(actions => extra + ? actions with { ExtraVulnerability = parsed } + : actions with { DamageType = parsed }); + } + + private static string DamageTypeDisplay(MonsterDamageType value) => value switch + { + MonsterDamageType.Electric => "Lightning", + MonsterDamageType.VoidBasic or MonsterDamageType.Nether => "Void Basic", + MonsterDamageType.DrainAuto => "Drain Auto", + MonsterDamageType.PlayerAuto => "PAuto", + _ => value.ToString(), + }; + + private static bool TryParseDamageType( + string value, + out MonsterDamageType parsed) + { + parsed = value.Trim() switch + { + string name when name.Equals("Lightning", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.Electric, + string name when name.Equals("Void Basic", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.VoidBasic, + string name when name.Equals("Drain Auto", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.DrainAuto, + string name when name.Equals("PAuto", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.PlayerAuto, + _ => (MonsterDamageType)(-1), + }; + return (int)parsed >= 0 + || Enum.TryParse(value, ignoreCase: true, out parsed); + } + + private void SetSelectedMonsterEquipment(bool offhand) + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + _monsterEditorNotice = "Select an owned weapon or offhand item first."; + return; + } + _combatSettings.CombatItemNames.Add(item.Name); + _combatSettings.CombatItemObjectIds.Add(item.ObjectId); + RefreshItemEditors(); + UpdateSelectedMonsterActions(actions => offhand + ? actions with + { + OffhandObjectId = item.ObjectId, + OffhandName = item.Name, + } + : actions with + { + WeaponObjectId = item.ObjectId, + WeaponName = item.Name, + }); + } + + private string ItemDisplayName(uint objectId, string durableName) + { + if (!string.IsNullOrWhiteSpace(durableName)) + return durableName; + if (objectId == 0u) + return ""; + foreach (PluginInventoryItem item in + _host.Automation.Items.CaptureOwnedItems()) + { + if (item.ObjectId == objectId) + return item.Name; + } + return $"0x{objectId:X8}"; + } + + private void UpdateSelectedMonsterActions( + Func update) + { + MonsterRule selected = SelectedMonsterRule; + _combatSettings.Rules[_selectedMonsterRule] = new MonsterRule( + selected.Expression, + update(selected.Actions)); + _monsterEditorNotice = $"Updated {selected.Expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + + private void AddSelectedProfileItem(bool noBuffs) + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + _profileNotice = "Select an owned inventory item first."; + return; + } + _combatSettings.CombatItemObjectIds.Add(item.ObjectId); + _combatSettings.CombatItemNames.Add(item.Name); + if (noBuffs) + _noBuffItemNames.Add(item.Name); + else + _noBuffItemNames.Remove(item.Name); + _profileNotice = noBuffs + ? $"Added {item.Name} (no buffs)." + : $"Added {item.Name}."; + RefreshItemEditors(); + SaveProfile(); + } + + private void AddSelectedConsumableCore() + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + _profileNotice = "Select an owned consumable first."; + return; + } + _combatSettings.ConsumableNames.Add(item.Name); + _combatSettings.ConsumableCategories[item.Name] = + ConsumableClassifier.Classify(item); + _profileNotice = $"Added {item.Name}."; + RefreshItemEditors(); + SaveProfile(); + } + + private void AddAllPeasCore() + { + bool added = _combatSettings.ConsumableNames.Add( + CraftingPlanner.AllPeas); + _combatSettings.ConsumableCategories[CraftingPlanner.AllPeas] = + ConsumableCategory.AllPeas; + _profileNotice = added + ? "Added [All Peas]." + : "[All Peas] is already in this profile."; + if (added) + { + RefreshItemEditors(); + SaveProfile(); + } + } + + private void RefreshConsumableCategories() + { + foreach (string stale in _combatSettings.ConsumableCategories.Keys + .Where(name => !_combatSettings.ConsumableNames.Contains(name)) + .ToArray()) + { + _combatSettings.ConsumableCategories.Remove(stale); + } + foreach (string name in _combatSettings.ConsumableNames) + { + if (!_combatSettings.ConsumableCategories.ContainsKey(name)) + { + _combatSettings.ConsumableCategories[name] = + ConsumableClassifier.ClassifyName(name); + } + } + foreach (PluginInventoryItem item in + _host.Automation.Items.CaptureOwnedItems()) + { + if (_combatSettings.ConsumableNames.Contains(item.Name)) + { + _combatSettings.ConsumableCategories[item.Name] = + ConsumableClassifier.Classify(item); + } + } + } + + private bool TryGetSelectedInventoryItem(out PluginInventoryItem selected) + { + uint selectedId = _host.Selection.SelectedObjectId ?? 0u; + if (selectedId != 0u) + { + foreach (PluginInventoryItem item in + _host.Automation.Items.CaptureOwnedItems()) + { + if (item.ObjectId == selectedId) + { + selected = item; + return true; + } + } + } + selected = default; + return false; + } + + private MetaRule? SelectedMetaRule => + (uint)_selectedMetaRule < (uint)_metaProfile.Rules.Count + ? _metaProfile.Rules[_selectedMetaRule] + : null; + + private void RefreshMetaEditor() + { + _metaRows = _metaProfile.Rules.Select(static rule => + $"{rule.State,-16} {DescribeMetaCondition(rule.Condition),-34} " + + DescribeMetaAction(rule.Action)).ToArray(); + _selectedMetaRule = ClampRow(_selectedMetaRule, _metaProfile.Rules.Count); + if (SelectedMetaRule is not MetaRule selected) + return; + _metaStateDraft = selected.State; + _metaConditionKind = selected.Condition.Kind; + _metaConditionTextDraft = selected.Condition.Text; + _metaActionKind = selected.Action.Kind; + _metaActionTextDraft = selected.Action.Text; + _metaSecondaryTextDraft = selected.Action.SecondaryText; + _metaNumber = checked((int)Math.Clamp( + selected.Condition.Number, + int.MinValue, + int.MaxValue)); + _metaSecondaryNumber = checked((int)Math.Clamp( + selected.Condition.SecondaryNumber, + int.MinValue, + int.MaxValue)); + } + + private void SelectMetaRuleCore(int index) + { + _selectedMetaRule = ClampRow(index, _metaProfile.Rules.Count); + RefreshMetaEditor(); + _metaNotice = SelectedMetaRule is null + ? "Add a rule or select one to edit." + : "Editing the selected ordered Meta rule."; + } + + private void AddMetaRuleCore() + { + if (!TryBuildMetaRule(out MetaRule rule, out string error)) + { + _metaNotice = error; + return; + } + _metaProfile.Rules.Add(rule); + _selectedMetaRule = _metaProfile.Rules.Count - 1; + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Added rule in {rule.State}."; + } + + private void ApplyMetaRuleCore() + { + if (SelectedMetaRule is not MetaRule current) + { + AddMetaRuleCore(); + return; + } + if (!TryBuildMetaRule(out MetaRule replacement, out string error)) + { + _metaNotice = error; + return; + } + replacement.Id = current.Id; + _metaProfile.Rules[_selectedMetaRule] = replacement; + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Updated rule in {replacement.State}."; + } + + private bool TryBuildMetaRule(out MetaRule rule, out string error) + { + string state = string.IsNullOrWhiteSpace(_metaStateDraft) + ? MetaEngine.DefaultState + : _metaStateDraft.Trim(); + try + { + if (_metaConditionKind == MetaConditionKind.Expression) + _ = ExpressionProgram.Compile(_metaConditionTextDraft); + if (_metaActionKind is MetaActionKind.ExpressionAction + or MetaActionKind.ChatExpression) + { + _ = ExpressionProgram.Compile(_metaActionTextDraft); + } + rule = new MetaRule + { + State = state, + Condition = new MetaCondition + { + Kind = _metaConditionKind, + Text = _metaConditionTextDraft, + Number = _metaNumber, + SecondaryNumber = _metaSecondaryNumber, + }, + Action = new MetaAction + { + Kind = _metaActionKind, + Text = _metaActionTextDraft, + SecondaryText = _metaSecondaryTextDraft, + Number = _metaNumber, + SecondaryNumber = _metaSecondaryNumber, + }, + }; + error = string.Empty; + return true; + } + catch (Exception exception) + { + rule = new MetaRule(); + error = exception.Message; + return false; + } + } + + private void RemoveMetaRuleCore() + { + if (SelectedMetaRule is not MetaRule selected) + return; + _metaProfile.Rules.RemoveAt(_selectedMetaRule); + _selectedMetaRule = Math.Min( + _selectedMetaRule, + Math.Max(0, _metaProfile.Rules.Count - 1)); + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Removed rule from {selected.State}."; + } + + private void MoveMetaRule(int direction) + { + int destination = _selectedMetaRule + Math.Sign(direction); + if ((uint)_selectedMetaRule >= (uint)_metaProfile.Rules.Count + || (uint)destination >= (uint)_metaProfile.Rules.Count) + { + return; + } + MetaRule rule = _metaProfile.Rules[_selectedMetaRule]; + _metaProfile.Rules.RemoveAt(_selectedMetaRule); + _metaProfile.Rules.Insert(destination, rule); + _selectedMetaRule = destination; + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Moved {rule.State} rule."; + } + + private void SelectMetaProfileCore(string name) + { + SaveMetaProfile(); + if (!_metaProfiles.Select(name)) + { + _metaNotice = $"Meta profile '{name}' is unavailable."; + return; + } + LoadMetaProfile(); + _metaNotice = $"Loaded Meta profile {_metaProfiles.Selected}."; + } + + private void CreateMetaProfileCore(bool copyCurrent) + { + if (!_metaProfiles.Create( + _metaProfileNameDraft, + copyCurrent, + _metaProfile, + out string notice)) + { + _metaNotice = notice; + return; + } + _metaProfileNameDraft = string.Empty; + LoadMetaProfile(); + _metaNotice = notice; + } + + private void ClearMetaProfileCore() + { + _metaProfile = _metaProfiles.ClearCurrent(); + _meta.ReplaceProfile(_metaProfile); + _selectedMetaRule = 0; + RefreshMetaEditor(); + _metaNotice = $"Cleared Meta profile {_metaProfiles.Selected}."; + } + + private void LoadMetaProfile() + { + _metaProfile = _metaProfiles.LoadCurrent(); + _meta.ReplaceProfile(_metaProfile); + if (_initialized) + ApplyPersistedOptionOverrides(); + _selectedMetaRule = 0; + RefreshMetaEditor(); + } + + private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile); + + private static string DescribeMetaCondition(MetaCondition condition) => + condition.Kind switch + { + MetaConditionKind.Expression => $"Expression: {condition.Text}", + MetaConditionKind.ChatMessage or MetaConditionKind.ChatMessageCapture => + $"{condition.Kind}: {condition.Text}", + MetaConditionKind.Always or MetaConditionKind.Never => + condition.Kind.ToString(), + _ => $"{condition.Kind} {condition.Number:0.###}", + }; + + private static string DescribeMetaAction(MetaAction action) => action.Kind switch + { + MetaActionKind.SetMetaState or MetaActionKind.CallMetaState + or MetaActionKind.ChatCommand or MetaActionKind.ExpressionAction + or MetaActionKind.ChatExpression => $"{action.Kind}: {action.Text}", + _ => action.Kind.ToString(), + }; + + private double DistanceFromAnyRoutePoint() + { + PluginNavigationSnapshot player = _host.Automation.Navigation.Snapshot; + if (!player.IsAvailable) + return double.PositiveInfinity; + double nearest = double.PositiveInfinity; + foreach (RouteWaypoint waypoint in _navigationSettings.Waypoints) + { + if (waypoint.Position.CellId == 0u) + continue; + nearest = Math.Min( + nearest, + player.Position.HorizontalDistanceMeters(waypoint.Position)); + } + return nearest; + } + + private void LoadEmbeddedNavigationRoute(string source) + { + _navigation.Reset(); + if (!VtankNavRouteSerializer.TryLoad( + source, + _navigationSettings, + _host.Automation.Spells, + out string error)) + { + _routeNotice = $"Embedded route rejected: {error}"; + _host.Log.Warn($"MossTank Meta embedded route rejected: {error}"); + return; + } + _routeProfiles.SaveCurrent(_navigationSettings); + _selectedRouteWaypoint = 0; + RefreshRouteEditor(); + _routeNotice = $"Loaded embedded route ({_navigationSettings.Waypoints.Count} points)."; + } + + private int CountMonstersByPriority(int priority, double distance) + { + int count = 0; + foreach (PluginCombatTarget target in _host.Automation.Combat + .CaptureHostileTargets(checked((float)distance))) + { + if (_combatSettings.ResolveRule(target).Priority == priority) + count++; + } + return count; + } + + private ExpressionValue GetMetaOption(string name) + { + string key = name.Trim(); + return key.ToLowerInvariant() switch + { + "enablebuffing" => ExpressionValue.Boolean(_buffSettings.Enabled), + "enablecombat" => ExpressionValue.Boolean(_combatSettings.Enabled), + "enablenav" or "enablenavigation" or "enableautonavigator" => + ExpressionValue.Boolean(_navigationSettings.Enabled), + "enablelooting" => ExpressionValue.Boolean(_inventorySettings.Loot.Enabled), + "enablemeta" => ExpressionValue.Boolean(_meta.Enabled), + "spelldiffexcessthreshold-hunt" => ExpressionValue.Number( + _combatSettings.HuntSkillExcessOverDifficulty), + "spelldiffexcessthreshold-buff" => ExpressionValue.Number( + _buffSettings.SkillExcessOverDifficulty), + "arrowheadfletchdiffexcessthreshold" => ExpressionValue.Number( + _inventorySettings.ArrowheadFletchDifficultyExcess), + "dohelp" => ExpressionValue.Boolean(_vitalSettings.HelpOthers), + "monsterrange" => ExpressionValue.Number(_combatSettings.MaximumRange), + "attackdistance" => ExpressionValue.Number( + _combatSettings.MaximumRange / 240d), + "attackminimumdistance" => ExpressionValue.Number( + _combatSettings.MinimumRange / 240d), + "approachdistance" => ExpressionValue.Number( + _combatSettings.ApproachDistance / 240d), + "ringdistance" => ExpressionValue.Number(_combatSettings.RingDistance / 240d), + "arcrange" => ExpressionValue.Number(_combatSettings.ArcRange / 240d), + "targetselectanglerange" => ExpressionValue.Number( + _combatSettings.TargetSelectAngleRange / 240d), + "corpseapproachrange-max" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseApproachRange / 240d), + "corpseapproachrange-min" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseMinimumApproachRange / 240d), + "navclosestoprange" => ExpressionValue.Number( + _navigationSettings.MinimumDistanceMeters / 240d), + "navfarstoprange" => ExpressionValue.Number( + _navigationSettings.MaximumDistanceMeters / 240d), + "useportaldistance" => ExpressionValue.Number( + _navigationSettings.PortalUseDistanceMeters / 240d), + "helperdistancehitp" => ExpressionValue.Number( + _vitalSettings.HelperHealthDistance / 240d), + "helperdistancestam" => ExpressionValue.Number( + _vitalSettings.HelperStaminaDistance / 240d), + "helperdistancemana" => ExpressionValue.Number( + _vitalSettings.HelperManaDistance / 240d), + "minimumringtargets" => ExpressionValue.Number( + _combatSettings.MinimumRingTargets), + "defaultmeleeattackheight" => ExpressionValue.Number( + (int)_combatSettings.AttackHeight), + "defaultmeleeattackpower" or "attackpower" => + ExpressionValue.Number(_combatSettings.AttackPower), + "targetlock" => ExpressionValue.Boolean(_combatSettings.TargetLock), + "idlepeacemode" => ExpressionValue.Boolean( + _combatSettings.IdlePeaceMode), + "stopmacroondeath" => ExpressionValue.Boolean( + _combatSettings.StopMacroOnDeath), + "jumpoutwandcasting" => ExpressionValue.Boolean( + _combatSettings.JumpOutWandCasting), + "dojiggle" => ExpressionValue.Boolean(_combatSettings.DoJiggle), + "randomhelperbuffs" => ExpressionValue.Boolean( + _buffSettings.RandomHelperBuffs), + "randomhelperintervalseconds" => ExpressionValue.Number( + _buffSettings.RandomHelperIntervalSeconds), + "idlebufftopoff" => ExpressionValue.Boolean( + _buffSettings.IdleBuffTopoff), + "idlebufftopofftimeseconds" => ExpressionValue.Number( + _buffSettings.IdleBuffTopoffSeconds), + "buffprofile-prots" => ExpressionValue.String( + _buffSettings.ProtectionElements), + "buffprofile-banes" => ExpressionValue.String( + _buffSettings.BaneElements), + "buffprofile_prots" => ExpressionValue.Number( + _buffSettings.ProtectionProfileMode), + "buffprofile_banes" => ExpressionValue.Number( + _buffSettings.BaneProfileMode), + "targetselectmethod" => ExpressionValue.Number( + (int)_combatSettings.SelectionMethod + 1), + "autoattackpower" => ExpressionValue.Boolean( + _combatSettings.AutoAttackPower), + "userecklessness" => ExpressionValue.Boolean( + _combatSettings.UseRecklessness), + "debuffeachfirst" => ExpressionValue.Number( + (int)_combatSettings.DebuffEachFirst), + "debuffselectionmethod" => ExpressionValue.Number( + (int)_combatSettings.DebuffSelectionMethod), + "debuffprecastseconds" => ExpressionValue.Number( + _combatSettings.DebuffPrecastSeconds), + "switchwandstodebuff" => ExpressionValue.Boolean( + _combatSettings.SwitchWandsToDebuff), + "usearcs" => ExpressionValue.Boolean(_combatSettings.UseArcs), + "deleteghostmonsters" => ExpressionValue.Boolean( + _combatSettings.DeleteGhostMonsters), + "ghostmonsterspellattemptcount" => ExpressionValue.Number( + _combatSettings.GhostMonsterSpellAttemptCount), + "blacklistmonsterattemptcount" => ExpressionValue.Number( + _combatSettings.BlacklistMonsterAttemptCount), + "blacklistmonstertimeoutseconds" => ExpressionValue.Number( + _combatSettings.BlacklistMonsterTimeoutSeconds), + "deleteghostmonstersbyhptracker" => ExpressionValue.Boolean( + _combatSettings.DeleteGhostMonstersByHealthTracker), + "ghostdeletehptrackerseconds" => ExpressionValue.Number( + _combatSettings.GhostDeleteHealthTrackerSeconds), + "summonpets" => ExpressionValue.Boolean(_combatSettings.SummonPets), + "petrangemode" => ExpressionValue.Number((int)_combatSettings.PetRangeMode), + "petcustomrange" => ExpressionValue.Number( + _combatSettings.PetCustomRange / 240d), + "petmonsterdensity" => ExpressionValue.Number( + _combatSettings.PetMonsterDensity), + "petrefillcount-idle" => ExpressionValue.Number( + _combatSettings.PetRefillCountIdle), + "petrefillcount-normal" => ExpressionValue.Number( + _combatSettings.PetRefillCountNormal), + "openapproachdoors" or "opendoors" => + ExpressionValue.Boolean(_navigationSettings.OpenDoors), + "dooridrange" => ExpressionValue.Number( + _navigationSettings.DoorIdentifyRangeMeters / 240d), + "dooropenrange" => ExpressionValue.Number( + _navigationSettings.DoorOpenRangeMeters / 240d), + "doorlockpickdiffexcessthreshold" => ExpressionValue.Number( + _navigationSettings.DoorLockpickExcessThreshold), + "navpriorityboost" => ExpressionValue.Boolean( + _navigationSettings.Priority), + "followaroundcorners" => ExpressionValue.Boolean( + _navigationSettings.FollowAroundCorners), + "autofellowmanagement" => ExpressionValue.Boolean( + _combatSettings.AutoFellowManagement), + "enablestack" or "enableautostack" => + ExpressionValue.Boolean(_inventorySettings.AutoStack), + "autostack" => ExpressionValue.Boolean(_inventorySettings.AutoStack), + "enablecram" or "enableautocram" => + ExpressionValue.Boolean(_inventorySettings.AutoCram), + "autocram" => ExpressionValue.Boolean(_inventorySettings.AutoCram), + "autocraftitems" => ExpressionValue.Boolean(_inventorySettings.AutoCraftItems), + "splitpeas" => ExpressionValue.Boolean(_inventorySettings.SplitPeas), + "spellcompmin-critical" => ExpressionValue.Number( + _inventorySettings.CriticalComponentMinimum), + "spellcompmin-normal" => ExpressionValue.Number( + _inventorySettings.NormalComponentMinimum), + "spellcompmin-idle" => ExpressionValue.Number( + _inventorySettings.IdleComponentMinimum), + "idlecraftcount_healthkits" or "idlecraftcount-healthkits" => + ExpressionValue.Number( + _inventorySettings.IdleHealthKitCount), + "idlecraftcount_stamkits" or "idlecraftcount-stamkits" => + ExpressionValue.Number( + _inventorySettings.IdleStaminaKitCount), + "idlecraftcount_manakits" or "idlecraftcount-manakits" => + ExpressionValue.Number( + _inventorySettings.IdleManaKitCount), + "idlecraftcount_healthfood" or "idlecraftcount-healthfood" => + ExpressionValue.Number( + _inventorySettings.IdleHealthFoodCount), + "idlecraftcount_stamfood" or "idlecraftcount-stamfood" => + ExpressionValue.Number( + _inventorySettings.IdleStaminaFoodCount), + "idlecraftcount_manafood" or "idlecraftcount-manafood" => + ExpressionValue.Number( + _inventorySettings.IdleManaFoodCount), + "refillwornmana" => ExpressionValue.Boolean( + _inventorySettings.RefillWornMana), + "manachargeswhenoff" => ExpressionValue.Boolean( + _inventorySettings.ManaChargesWhenOff), + "refillwornmana-item-manapercent" => ExpressionValue.Number( + _inventorySettings.RefillWornManaPercent), + "readunknownscrolls" => ExpressionValue.Boolean( + _inventorySettings.Loot.ReadUnknownScrolls), + "lootallcorpses" => ExpressionValue.Boolean( + _inventorySettings.Loot.LootAllCorpses), + "lootfellowcorpses" => ExpressionValue.Boolean( + _inventorySettings.Loot.LootFellowCorpses), + "lootpriorityboost" => ExpressionValue.Boolean( + _inventorySettings.Loot.PriorityBoost), + "lootonlyrarecorpses" => ExpressionValue.Boolean( + _inventorySettings.Loot.LootOnlyRareCorpses), + "combinesalvage" => ExpressionValue.Boolean( + _inventorySettings.Loot.CombineSalvage), + "manastonelootcount" => ExpressionValue.Number( + _inventorySettings.Loot.ManaStoneLootCount), + "manatankminimummana" => ExpressionValue.Number( + _inventorySettings.Loot.ManaTankMinimumMana), + "corpsecachetimeoutminutes" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseCacheTimeoutMinutes), + "corpseitemappearancetimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseItemAppearanceTimeoutSeconds), + "corpseitemidtimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseItemIdentifyTimeoutSeconds), + "corpseopentimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseOpenTimeoutSeconds), + "blacklistcorpseopenattemptcount" => ExpressionValue.Number( + _inventorySettings.Loot.BlacklistCorpseOpenAttemptCount), + "blacklistcorpseopentimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.BlacklistCorpseOpenTimeoutSeconds), + "corpselootitemmaxattempts" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseLootItemMaxAttempts), + "minimumhealkitsuccesschance" => ExpressionValue.Number( + _vitalSettings.MinimumHealKitSuccessChance), + "usehealersheart" => ExpressionValue.Boolean( + _vitalSettings.UseHealersHeart), + "rechargeboosttimeseconds" => ExpressionValue.Number( + _vitalSettings.RechargeBoostTimeSeconds), + "rechargeboostamount" => ExpressionValue.Number( + _vitalSettings.RechargeBoostAmount), + "clearlevelboostflagoncast" => ExpressionValue.Boolean( + _vitalSettings.ClearLevelBoostFlagOnCast), + "whoyougonnacall" => ExpressionValue.Boolean( + _combatSettings.WhoYouGonnaCall), + "castdispelself" => ExpressionValue.Boolean( + _vitalSettings.CastDispelSelf), + "usedispelitems" => ExpressionValue.Boolean( + _vitalSettings.UseDispelItems), + "usedispeldrum" => ExpressionValue.Boolean( + _vitalSettings.UseDispelDrum), + "usekitsinmagicmode" => ExpressionValue.Boolean( + _vitalSettings.UseKitsInMagicMode), + "gotopeacemodetousekits" => ExpressionValue.Boolean( + _vitalSettings.GoToPeaceModeToUseKits), + "staminatohealthmultiplier" => ExpressionValue.Number( + _vitalSettings.StaminaToHealthMultiplier), + "manatohealthmultiplier" => ExpressionValue.Number( + _vitalSettings.ManaToHealthMultiplier), + "recharge-norm-hitp" => ExpressionValue.Number( + _vitalSettings.NormalHealth * 100d), + "recharge-norm-stam" => ExpressionValue.Number( + _vitalSettings.NormalStamina * 100d), + "recharge-norm-mana" => ExpressionValue.Number( + _vitalSettings.NormalMana * 100d), + "recharge-notarg-hitp" => ExpressionValue.Number( + _vitalSettings.NoTargetHealth * 100d), + "recharge-notarg-stam" => ExpressionValue.Number( + _vitalSettings.NoTargetStamina * 100d), + "recharge-notarg-mana" => ExpressionValue.Number( + _vitalSettings.NoTargetMana * 100d), + "recharge-helper-hitp" => ExpressionValue.Number( + _vitalSettings.HelperHealth * 100d), + "recharge-helper-stam" => ExpressionValue.Number( + _vitalSettings.HelperStamina * 100d), + "recharge-helper-mana" => ExpressionValue.Number( + _vitalSettings.HelperMana * 100d), + "rebufftimeremainingseconds" => ExpressionValue.Number( + _buffSettings.RebuffWhenUnderSeconds), + "buffcastrecast_seconds" => ExpressionValue.Number( + _buffSettings.BuffCastRecastSeconds), + "buffcastrecastreset_seconds" => ExpressionValue.Number( + _buffSettings.BuffCastRecastResetSeconds), + "blacklistedspellcomps" => ExpressionValue.String( + _buffSettings.BlacklistedSpellComponents), + "droptopeacemoderetrycount" => ExpressionValue.Number( + _vitalSettings.DropToPeaceModeRetryCount), + "fastcastbuffs" => ExpressionValue.Boolean( + _buffSettings.FastCastBuffs), + "usebreakableturnto" => ExpressionValue.Boolean( + _combatSettings.UseBreakableTurnTo), + "useprojectileawareness" => ExpressionValue.Boolean( + _combatSettings.UseProjectileAwareness), + "collisionprojectileradius" => ExpressionValue.Number( + _combatSettings.CollisionProjectileRadius), + "collisionstepdistance" => ExpressionValue.Number( + _combatSettings.CollisionStepDistance), + "showcollisiondebug" => ExpressionValue.Boolean( + _combatSettings.ShowCollisionDebug), + "maximumcollisioncheckspertick" => ExpressionValue.Number( + _combatSettings.MaximumCollisionChecksPerTick), + "usespecialammo" => ExpressionValue.Number( + _combatSettings.UseSpecialAmmo), + "spellrangefudge" => ExpressionValue.Number( + _combatSettings.SpellRangeFudge), + "buffwithuntrained-item" => ExpressionValue.Number( + _buffSettings.BuffWithUntrainedItemSkill), + "buffwithuntrained-creature" => ExpressionValue.Number( + _buffSettings.BuffWithUntrainedCreatureSkill), + "buffwithuntrained-life" => ExpressionValue.Number( + _buffSettings.BuffWithUntrainedLifeSkill), + "allowdebufffallback" => ExpressionValue.Boolean( + _combatSettings.AllowDebuffFallback), + "rechargehandlerset" => ExpressionValue.String( + _vitalSettings.RechargeHandlerSet), + _ => _combatSettings.DynamicSettings.TryGetValue(key, out MonsterValue value) + ? ToExpressionValue(value) + : ToExpressionValue(VtankOptionCatalog.Default(key)), + }; + } + + private static ExpressionValue ToExpressionValue(MonsterValue value) => + value.Kind switch + { + MonsterValueKind.Number => ExpressionValue.Number(value.Number), + MonsterValueKind.Boolean => ExpressionValue.Boolean(value.Boolean), + _ => ExpressionValue.String(value.Text), + }; + + private double GetDynamicNumber(string name, double fallback) => + _combatSettings.DynamicSettings.TryGetValue(name, out MonsterValue value) + && value.Kind == MonsterValueKind.Number + ? value.Number + : fallback; + + private void RegisterVtankExpressionFunctions() + { + ExpressionFunctionRegistry functions = _expressions.Registry; + functions.Register("vtsetmetastate", 1, 1, (_, args) => + { + _meta.Transition(args[0].AsString("vtsetmetastate")); + _combatSettings.MetaState = _meta.CurrentState; + return ExpressionValue.One; + }, "vtsetmetastate[state]"); + functions.Register("vtgetmetastate", 0, 0, (_, _) => + ExpressionValue.String(_meta.CurrentState), "vtgetmetastate[]"); + functions.Register("vtgetmeta", 0, 0, (_, _) => + ExpressionValue.String(_metaProfiles.Selected), "vtgetmeta[]"); + functions.Register("vtsetsetting", 2, 2, (_, args) => + { + string name = args[0].AsString("vtsetsetting"); + ExpressionValue value = args[1]; + if (value.Kind == ExpressionValueKind.String + && double.TryParse( + value.AsString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double number)) + { + value = ExpressionValue.Number(number); + } + return ExpressionValue.Boolean(SetMetaOption(name, value)); + }, "vtsetsetting[setting,value]"); + functions.Register("vtgetsetting", 1, 1, (_, args) => + ExpressionValue.String(GetMetaOption( + args[0].AsString("vtgetsetting")).ToDisplayString()), + "vtgetsetting[setting]"); + functions.Register("uboptset", 2, 2, (_, args) => + ExpressionValue.Boolean(SetMetaOption( + args[0].AsString("uboptset"), + args[1])), + "uboptset[setting,value]"); + functions.Register("uboptget", 1, 1, (_, args) => + GetMetaOption(args[0].AsString("uboptget")), + "uboptget[setting]"); + functions.Register("actiontrygiveprofile", 2, 2, (_, args) => + ExpressionValue.Boolean(_profileGive.TryStart( + args[0].AsString("actiontrygiveprofile"), + args[1].AsString("actiontrygiveprofile"))), + "actiontrygiveprofile[lootprofile,target]"); + functions.Register("vtmacroenabled", 0, 0, (_, _) => + ExpressionValue.Boolean(_combat.Enabled), + "vtmacroenabled[]"); + } + + private bool SetMetaOption(string name, ExpressionValue value) + { + string canonical = VtankOptionCatalog.IsKnown(name) + ? VtankOptionCatalog.Canonical(name) + : name.Trim(); + string key = canonical.ToLowerInvariant(); + switch (key) + { + case "enablebuffing": + _buffSettings.Enabled = value.IsTruthy; + break; + case "enablecombat": + _combatSettings.Enabled = value.IsTruthy; + break; + case "enablenav": + case "enablenavigation": + case "enableautonavigator": + _navigationSettings.Enabled = value.IsTruthy; + break; + case "enablelooting": + _inventorySettings.Loot.Enabled = value.IsTruthy; + break; + case "enablemeta": + _meta.SetEnabled(value.IsTruthy); + break; + case "spelldiffexcessthreshold-hunt": + _combatSettings.HuntSkillExcessOverDifficulty = Math.Clamp( + value.AsInt32("SpellDiffExcessThreshold-Hunt"), -100, 500); + break; + case "arrowheadfletchdiffexcessthreshold": + _inventorySettings.ArrowheadFletchDifficultyExcess = Math.Clamp( + value.AsInt32("ArrowheadFletchDiffExcessThreshold"), + -100, + 500); + break; + case "dohelp": + _vitalSettings.HelpOthers = value.IsTruthy; + break; + case "monsterrange": + _combatSettings.MaximumRange = Math.Clamp( + checked((float)value.AsNumber("MonsterRange")), 1f, 100f); + break; + case "attackdistance": + _combatSettings.MaximumRange = Math.Clamp( + checked((float)(value.AsNumber("AttackDistance") * 240d)), + 1f, + 100f); + break; + case "attackminimumdistance": + _combatSettings.MinimumRange = Math.Clamp( + checked((float)(value.AsNumber("AttackMinimumDistance") * 240d)), + 0f, + 100f); + break; + case "approachdistance": + _combatSettings.ApproachDistance = Math.Clamp( + checked((float)(value.AsNumber("ApproachDistance") * 240d)), + 0f, + 100f); + break; + case "ringdistance": + _combatSettings.RingDistance = Math.Clamp( + checked((float)(value.AsNumber("RingDistance") * 240d)), + 1f, + 100f); + break; + case "arcrange": + _combatSettings.ArcRange = Math.Clamp( + checked((float)(value.AsNumber("ArcRange") * 240d)), + 1f, + 100f); + break; + case "targetselectanglerange": + _combatSettings.TargetSelectAngleRange = Math.Clamp( + checked((float)(value.AsNumber("TargetSelectAngleRange") * 240d)), + 1f, + 100f); + break; + case "corpseapproachrange-max": + _inventorySettings.Loot.CorpseApproachRange = Math.Clamp( + checked((float)(value.AsNumber("CorpseApproachRange-Max") * 240d)), + 1f, + 100f); + break; + case "corpseapproachrange-min": + _inventorySettings.Loot.CorpseMinimumApproachRange = Math.Clamp( + checked((float)(value.AsNumber("CorpseApproachRange-Min") * 240d)), + 0f, + 100f); + break; + case "navclosestoprange": + _navigationSettings.MinimumDistanceMeters = Math.Clamp( + value.AsNumber("NavCloseStopRange") * 240d, + 0.5d, + 50d); + break; + case "navfarstoprange": + _navigationSettings.MaximumDistanceMeters = Math.Clamp( + value.AsNumber("NavFarStopRange") * 240d, + _navigationSettings.MinimumDistanceMeters, + 240_000_000d); + break; + case "useportaldistance": + _navigationSettings.PortalUseDistanceMeters = Math.Clamp( + value.AsNumber("UsePortalDistance") * 240d, + 0.5d, + 50d); + break; + case "helperdistancehitp": + _vitalSettings.HelperHealthDistance = Math.Clamp( + checked((float)(value.AsNumber("HelperDistanceHitP") * 240d)), + 1f, + 100f); + break; + case "helperdistancestam": + _vitalSettings.HelperStaminaDistance = Math.Clamp( + checked((float)(value.AsNumber("HelperDistanceStam") * 240d)), + 1f, + 100f); + break; + case "helperdistancemana": + _vitalSettings.HelperManaDistance = Math.Clamp( + checked((float)(value.AsNumber("HelperDistanceMana") * 240d)), + 1f, + 100f); + break; + case "minimumringtargets": + _combatSettings.MinimumRingTargets = Math.Clamp( + value.AsInt32("MinimumRingTargets"), 1, 25); + break; + case "defaultmeleeattackheight": + _combatSettings.AttackHeight = (PluginAttackHeight)Math.Clamp( + value.AsInt32("DefaultMeleeAttackHeight"), 1, 3); + break; + case "defaultmeleeattackpower": + case "attackpower": + _combatSettings.AttackPower = Math.Clamp( + checked((float)value.AsNumber("AttackPower")), 0f, 1f); + break; + case "targetlock": + _combatSettings.TargetLock = value.IsTruthy; + break; + case "idlepeacemode": + _combatSettings.IdlePeaceMode = value.IsTruthy; + break; + case "stopmacroondeath": + _combatSettings.StopMacroOnDeath = value.IsTruthy; + break; + case "jumpoutwandcasting": + _combatSettings.JumpOutWandCasting = value.IsTruthy; + break; + case "dojiggle": + _combatSettings.DoJiggle = value.IsTruthy; + break; + case "randomhelperbuffs": + _buffSettings.RandomHelperBuffs = value.IsTruthy; + break; + case "randomhelperintervalseconds": + _buffSettings.RandomHelperIntervalSeconds = Math.Clamp( + value.AsNumber("RandomHelperIntervalSeconds"), 0.25d, 3600d); + break; + case "idlebufftopoff": + _buffSettings.IdleBuffTopoff = value.IsTruthy; + break; + case "idlebufftopofftimeseconds": + _buffSettings.IdleBuffTopoffSeconds = Math.Clamp( + value.AsNumber("IdleBuffTopoffTimeSeconds"), + 30d, + 7200d); + break; + case "buffprofile-prots": + _buffSettings.ProtectionElements = NormalizeElementProfile( + value.ToDisplayString()); + break; + case "buffprofile-banes": + _buffSettings.BaneElements = NormalizeElementProfile( + value.ToDisplayString()); + break; + case "buffprofile_prots": + _buffSettings.ProtectionProfileMode = Math.Clamp( + value.AsInt32("BuffProfile_Prots"), 1, 8); + break; + case "buffprofile_banes": + _buffSettings.BaneProfileMode = Math.Clamp( + value.AsInt32("BuffProfile_Banes"), 1, 8); + break; + case "targetselectmethod": + _combatSettings.SelectionMethod = (TargetSelectionMethod)Math.Clamp( + value.AsInt32("TargetSelectMethod") - 1, 0, 2); + break; + case "autoattackpower": + _combatSettings.AutoAttackPower = value.IsTruthy; + break; + case "userecklessness": + _combatSettings.UseRecklessness = value.IsTruthy; + break; + case "debuffeachfirst": + _combatSettings.DebuffEachFirst = (DebuffEachFirst)Math.Clamp( + value.AsInt32("DebuffEachFirst"), 1, 3); + break; + case "debuffselectionmethod": + _combatSettings.DebuffSelectionMethod = + (DebuffSelectionMethod)Math.Clamp( + value.AsInt32("DebuffSelectionMethod"), 1, 2); + break; + case "debuffprecastseconds": + _combatSettings.DebuffPrecastSeconds = Math.Clamp( + value.AsNumber("DebuffPrecastSeconds"), 0d, 60d); + break; + case "switchwandstodebuff": + _combatSettings.SwitchWandsToDebuff = value.IsTruthy; + break; + case "usearcs": + _combatSettings.UseArcs = value.IsTruthy; + break; + case "deleteghostmonsters": + _combatSettings.DeleteGhostMonsters = value.IsTruthy; + break; + case "ghostmonsterspellattemptcount": + _combatSettings.GhostMonsterSpellAttemptCount = Math.Clamp( + value.AsInt32("GhostMonsterSpellAttemptCount"), 1, 1000); + break; + case "blacklistmonsterattemptcount": + _combatSettings.BlacklistMonsterAttemptCount = Math.Clamp( + value.AsInt32("BlacklistMonsterAttemptCount"), 1, 20); + break; + case "blacklistmonstertimeoutseconds": + _combatSettings.BlacklistMonsterTimeoutSeconds = Math.Clamp( + value.AsNumber("BlacklistMonsterTimeoutSeconds"), 1d, 3600d); + break; + case "deleteghostmonstersbyhptracker": + _combatSettings.DeleteGhostMonstersByHealthTracker = value.IsTruthy; + break; + case "ghostdeletehptrackerseconds": + _combatSettings.GhostDeleteHealthTrackerSeconds = Math.Clamp( + value.AsNumber("GhostDeleteHPTrackerSeconds"), 1d, 300d); + break; + case "summonpets": + _combatSettings.SummonPets = value.IsTruthy; + break; + case "petrangemode": + _combatSettings.PetRangeMode = (PetRangeMode)Math.Clamp( + value.AsInt32("PetRangeMode"), 0, 1); + break; + case "petcustomrange": + _combatSettings.PetCustomRange = Math.Clamp( + checked((float)(value.AsNumber("PetCustomRange") * 240d)), + 1f, + 100f); + break; + case "petmonsterdensity": + _combatSettings.PetMonsterDensity = Math.Clamp( + value.AsInt32("PetMonsterDensity"), 1, 25); + break; + case "petrefillcount-idle": + _combatSettings.PetRefillCountIdle = Math.Clamp( + value.AsInt32("PetRefillCount-Idle"), 0, 3); + break; + case "petrefillcount-normal": + _combatSettings.PetRefillCountNormal = Math.Clamp( + value.AsInt32("PetRefillCount-Normal"), 0, 3); + break; + case "openapproachdoors": + case "opendoors": + _navigationSettings.OpenDoors = value.IsTruthy; + break; + case "dooridrange": + _navigationSettings.DoorIdentifyRangeMeters = Math.Clamp( + value.AsNumber("DoorIDRange") * 240d, 1d, 100d); + break; + case "dooropenrange": + _navigationSettings.DoorOpenRangeMeters = Math.Clamp( + value.AsNumber("DoorOpenRange") * 240d, + 0.5d, + _navigationSettings.DoorIdentifyRangeMeters); + break; + case "doorlockpickdiffexcessthreshold": + _navigationSettings.DoorLockpickExcessThreshold = Math.Clamp( + value.AsInt32("DoorLockpickDiffExcessThreshold"), -500, 500); + break; + case "navpriorityboost": + _navigationSettings.Priority = value.IsTruthy; + break; + case "followaroundcorners": + _navigationSettings.FollowAroundCorners = value.IsTruthy; + break; + case "autofellowmanagement": + _combatSettings.AutoFellowManagement = value.IsTruthy; + break; + case "enablestack": + case "enableautostack": + case "autostack": + _inventorySettings.AutoStack = value.IsTruthy; + break; + case "enablecram": + case "enableautocram": + case "autocram": + _inventorySettings.AutoCram = value.IsTruthy; + break; + case "autocraftitems": + _inventorySettings.AutoCraftItems = value.IsTruthy; + break; + case "splitpeas": + _inventorySettings.SplitPeas = value.IsTruthy; + break; + case "spellcompmin-critical": + _inventorySettings.CriticalComponentMinimum = Math.Clamp( + value.AsInt32("SpellCompMin-Critical"), 0, 1000); + break; + case "spellcompmin-normal": + _inventorySettings.NormalComponentMinimum = Math.Clamp( + value.AsInt32("SpellCompMin-Normal"), 0, 1000); + break; + case "spellcompmin-idle": + _inventorySettings.IdleComponentMinimum = Math.Clamp( + value.AsInt32("SpellCompMin-Idle"), 0, 1000); + break; + case "idlecraftcount_healthkits": + case "idlecraftcount-healthkits": + _inventorySettings.IdleHealthKitCount = Math.Clamp( + value.AsInt32("IdleCraftCount_HealthKits"), 0, 1000); + break; + case "idlecraftcount_stamkits": + case "idlecraftcount-stamkits": + _inventorySettings.IdleStaminaKitCount = Math.Clamp( + value.AsInt32("IdleCraftCount_StamKits"), 0, 1000); + break; + case "idlecraftcount_manakits": + case "idlecraftcount-manakits": + _inventorySettings.IdleManaKitCount = Math.Clamp( + value.AsInt32("IdleCraftCount_ManaKits"), 0, 1000); + break; + case "idlecraftcount_healthfood": + case "idlecraftcount-healthfood": + _inventorySettings.IdleHealthFoodCount = Math.Clamp( + value.AsInt32("IdleCraftCount_HealthFood"), 0, 1000); + break; + case "idlecraftcount_stamfood": + case "idlecraftcount-stamfood": + _inventorySettings.IdleStaminaFoodCount = Math.Clamp( + value.AsInt32("IdleCraftCount_StamFood"), 0, 1000); + break; + case "idlecraftcount_manafood": + case "idlecraftcount-manafood": + _inventorySettings.IdleManaFoodCount = Math.Clamp( + value.AsInt32("IdleCraftCount_ManaFood"), 0, 1000); + break; + case "refillwornmana": + _inventorySettings.RefillWornMana = value.IsTruthy; + break; + case "manachargeswhenoff": + _inventorySettings.ManaChargesWhenOff = value.IsTruthy; + break; + case "refillwornmana-item-manapercent": + _inventorySettings.RefillWornManaPercent = Math.Clamp( + value.AsInt32("RefillWornMana-Item-ManaPercent"), 0, 100); + break; + case "readunknownscrolls": + _inventorySettings.Loot.ReadUnknownScrolls = value.IsTruthy; + break; + case "lootallcorpses": + _inventorySettings.Loot.LootAllCorpses = value.IsTruthy; + break; + case "lootfellowcorpses": + _inventorySettings.Loot.LootFellowCorpses = value.IsTruthy; + break; + case "lootpriorityboost": + _inventorySettings.Loot.PriorityBoost = value.IsTruthy; + break; + case "lootonlyrarecorpses": + _inventorySettings.Loot.LootOnlyRareCorpses = value.IsTruthy; + break; + case "combinesalvage": + _inventorySettings.Loot.CombineSalvage = value.IsTruthy; + break; + case "manastonelootcount": + _inventorySettings.Loot.ManaStoneLootCount = Math.Clamp( + value.AsInt32("ManaStoneLootCount"), 0, 1000); + break; + case "manatankminimummana": + _inventorySettings.Loot.ManaTankMinimumMana = Math.Clamp( + value.AsInt32("ManaTankMinimumMana"), 1, int.MaxValue); + break; + case "corpsecachetimeoutminutes": + _inventorySettings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp( + value.AsNumber("CorpseCacheTimeoutMinutes"), 1d, 1440d); + break; + case "corpseitemappearancetimeoutseconds": + _inventorySettings.Loot.CorpseItemAppearanceTimeoutSeconds = + Math.Clamp( + value.AsNumber("CorpseItemAppearanceTimeoutSeconds"), + 0d, + 300d); + break; + case "corpseitemidtimeoutseconds": + _inventorySettings.Loot.CorpseItemIdentifyTimeoutSeconds = + Math.Clamp( + value.AsNumber("CorpseItemIDTimeoutSeconds"), + 1d, + 600d); + break; + case "corpseopentimeoutseconds": + _inventorySettings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp( + value.AsNumber("CorpseOpenTimeoutSeconds"), 0.1d, 60d); + break; + case "blacklistcorpseopenattemptcount": + _inventorySettings.Loot.BlacklistCorpseOpenAttemptCount = Math.Clamp( + value.AsInt32("BlacklistCorpseOpenAttemptCount"), 1, 1000); + break; + case "blacklistcorpseopentimeoutseconds": + _inventorySettings.Loot.BlacklistCorpseOpenTimeoutSeconds = Math.Clamp( + value.AsNumber("BlacklistCorpseOpenTimeoutSeconds"), 1d, 3600d); + break; + case "corpselootitemmaxattempts": + _inventorySettings.Loot.CorpseLootItemMaxAttempts = Math.Clamp( + value.AsInt32("CorpseLootItemMaxAttempts"), 1, 1000); + break; + case "minimumhealkitsuccesschance": + _vitalSettings.MinimumHealKitSuccessChance = Math.Clamp( + value.AsInt32("MinimumHealKitSuccessChance"), 0, 100); + break; + case "usehealersheart": + _vitalSettings.UseHealersHeart = value.IsTruthy; + _vitalRecharge.Reset(); + break; + case "rechargeboosttimeseconds": + _vitalSettings.RechargeBoostTimeSeconds = Math.Clamp( + value.AsNumber("RechargeBoostTimeSeconds"), 0d, 300d); + break; + case "rechargeboostamount": + _vitalSettings.RechargeBoostAmount = Math.Clamp( + value.AsInt32("RechargeBoostAmount"), 0, 1000); + break; + case "clearlevelboostflagoncast": + _vitalSettings.ClearLevelBoostFlagOnCast = value.IsTruthy; + break; + case "whoyougonnacall": + _combatSettings.WhoYouGonnaCall = value.IsTruthy; + break; + case "castdispelself": + _vitalSettings.CastDispelSelf = value.IsTruthy; + _dispel.Reset(); + break; + case "usedispelitems": + _vitalSettings.UseDispelItems = value.IsTruthy; + _dispel.Reset(); + break; + case "usedispeldrum": + _vitalSettings.UseDispelDrum = value.IsTruthy; + _dispel.Reset(); + break; + case "usekitsinmagicmode": + _vitalSettings.UseKitsInMagicMode = value.IsTruthy; + break; + case "gotopeacemodetousekits": + _vitalSettings.GoToPeaceModeToUseKits = value.IsTruthy; + break; + case "staminatohealthmultiplier": + _vitalSettings.StaminaToHealthMultiplier = Math.Clamp( + value.AsNumber("StaminaToHealthMultiplier"), 0d, 10d); + break; + case "manatohealthmultiplier": + _vitalSettings.ManaToHealthMultiplier = Math.Clamp( + value.AsNumber("ManaToHealthMultiplier"), 0d, 10d); + break; + case "recharge-norm-hitp": + _vitalSettings.NormalHealth = Math.Clamp( + value.AsNumber("Recharge-Norm-HitP") / 100d, 0d, 1d); + break; + case "recharge-norm-stam": + _vitalSettings.NormalStamina = Math.Clamp( + value.AsNumber("Recharge-Norm-Stam") / 100d, 0d, 1d); + break; + case "recharge-norm-mana": + _vitalSettings.NormalMana = Math.Clamp( + value.AsNumber("Recharge-Norm-Mana") / 100d, 0d, 1d); + break; + case "recharge-notarg-hitp": + _vitalSettings.NoTargetHealth = Math.Clamp( + value.AsNumber("Recharge-NoTarg-HitP") / 100d, 0d, 1d); + break; + case "recharge-notarg-stam": + _vitalSettings.NoTargetStamina = Math.Clamp( + value.AsNumber("Recharge-NoTarg-Stam") / 100d, 0d, 1d); + break; + case "recharge-notarg-mana": + _vitalSettings.NoTargetMana = Math.Clamp( + value.AsNumber("Recharge-NoTarg-Mana") / 100d, 0d, 1d); + break; + case "recharge-helper-hitp": + _vitalSettings.HelperHealth = Math.Clamp( + value.AsNumber("Recharge-Helper-HitP") / 100d, 0d, 1d); + break; + case "recharge-helper-stam": + _vitalSettings.HelperStamina = Math.Clamp( + value.AsNumber("Recharge-Helper-Stam") / 100d, 0d, 1d); + break; + case "recharge-helper-mana": + _vitalSettings.HelperMana = Math.Clamp( + value.AsNumber("Recharge-Helper-Mana") / 100d, 0d, 1d); + break; + case "spelldiffexcessthreshold-buff": + _buffSettings.SkillExcessOverDifficulty = Math.Clamp( + value.AsInt32("SpellDiffExcessThreshold-Buff"), -100, 100); + break; + case "rebufftimeremainingseconds": + _buffSettings.RebuffWhenUnderSeconds = Math.Clamp( + value.AsNumber("RebuffTimeRemainingSeconds"), 0d, 3600d); + break; + case "buffcastrecast_seconds": + _buffSettings.BuffCastRecastSeconds = Math.Clamp( + value.AsNumber("BuffCastRecast_Seconds"), 0d, 3600d); + break; + case "buffcastrecastreset_seconds": + _buffSettings.BuffCastRecastResetSeconds = Math.Clamp( + value.AsNumber("BuffCastRecastReset_Seconds"), 0d, 3600d); + break; + case "blacklistedspellcomps": + _buffSettings.BlacklistedSpellComponents = + value.ToDisplayString(); + _combatSettings.BlacklistedSpellComponents = + _buffSettings.BlacklistedSpellComponents; + break; + case "droptopeacemoderetrycount": + _vitalSettings.DropToPeaceModeRetryCount = Math.Clamp( + value.AsInt32("DropToPeaceModeRetryCount"), 1, 1000); + break; + case "fastcastbuffs": + _buffSettings.FastCastBuffs = value.IsTruthy; + break; + case "usebreakableturnto": + _combatSettings.UseBreakableTurnTo = value.IsTruthy; + break; + case "useprojectileawareness": + _combatSettings.UseProjectileAwareness = value.IsTruthy; + break; + case "collisionprojectileradius": + _combatSettings.CollisionProjectileRadius = Math.Clamp( + checked((float)value.AsNumber("CollisionProjectileRadius")), + 0f, + 10f); + break; + case "collisionstepdistance": + _combatSettings.CollisionStepDistance = Math.Clamp( + checked((float)value.AsNumber("CollisionStepDistance")), + 0.01f, + 10f); + break; + case "showcollisiondebug": + _combatSettings.ShowCollisionDebug = value.IsTruthy; + break; + case "maximumcollisioncheckspertick": + _combatSettings.MaximumCollisionChecksPerTick = Math.Clamp( + value.AsInt32("MaximumCollisionChecksPerTick"), 1, 100_000); + break; + case "usespecialammo": + _combatSettings.UseSpecialAmmo = Math.Clamp( + value.AsInt32("UseSpecialAmmo"), 0, 3); + break; + case "spellrangefudge": + _combatSettings.SpellRangeFudge = Math.Clamp( + checked((float)value.AsNumber("SpellRangeFudge")), + 0f, + 75f); + break; + case "buffwithuntrained-item": + _buffSettings.BuffWithUntrainedItemSkill = Math.Clamp( + value.AsInt32("BuffWithUntrained-Item"), 0, 275); + break; + case "buffwithuntrained-creature": + _buffSettings.BuffWithUntrainedCreatureSkill = Math.Clamp( + value.AsInt32("BuffWithUntrained-Creature"), 0, 275); + break; + case "buffwithuntrained-life": + _buffSettings.BuffWithUntrainedLifeSkill = Math.Clamp( + value.AsInt32("BuffWithUntrained-Life"), 0, 275); + break; + case "allowdebufffallback": + _combatSettings.AllowDebuffFallback = value.IsTruthy; + break; + case "rechargehandlerset": + _vitalSettings.RechargeHandlerSet = value.ToDisplayString(); + break; + default: + break; + } + _combatSettings.DynamicSettings[canonical] = ToMonsterValue(value); + if (!_applyingProfileOptions) + SaveProfile(); + return true; + } + + private static MonsterValue ToMonsterValue(ExpressionValue value) => + value.Kind switch + { + ExpressionValueKind.Boolean => MonsterValue.FromBoolean(value.IsTruthy), + ExpressionValueKind.Number => MonsterValue.FromNumber(value.AsNumber()), + _ => MonsterValue.FromText(value.ToDisplayString()), + }; + + private static string NormalizeElementProfile(string value) + { + const string order = "ALFCBPS"; + var result = new StringBuilder(order.Length); + foreach (char element in order) + { + if (value.IndexOf(element, StringComparison.OrdinalIgnoreCase) >= 0) + result.Append(element); + } + return result.ToString(); + } + + private void SelectProfile(string name) + { + SaveProfile(); + if (!_profiles.Select(name)) + { + _profileLifecycleNotice = $"Profile '{name}' is unavailable."; + return; + } + LoadSelectedProfile(); + _profileLifecycleNotice = $"Loaded {_profiles.Selected}."; + } + + private void CreateProfileCore(bool copyCurrent) + { + if (!_profiles.Create( + _profileNameDraft, + copyCurrent, + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames, + out string notice)) + { + _profileLifecycleNotice = notice; + return; + } + _profileNameDraft = string.Empty; + _profileLifecycleNotice = notice; + ResetProfileConsumers(); + } + + private void ClearProfileCore() + { + _profiles.ClearCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + _profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults."; + ResetProfileConsumers(); + } + + private void LoadSelectedProfile() + { + _profiles.LoadCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + LoadLootProfile(); + LoadRouteProfile(); + ApplyPersistedOptionOverrides(); + ResetProfileConsumers(); + } + + private void ResetProfileConsumers() + { + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _navigation.Reset(); + _coverageSpellSnapshot = null; + _coverageRefreshRemaining = 0d; + RefreshMonsterEditor(); + RefreshItemEditors(); + RefreshLootEditor(); + RefreshRouteEditor(); + } + + private void ClearMossTankActionLocks() + { + ClearFastCastMovement(); + _buffCastRecastRemaining = 0d; + _randomHelperRemaining = 0d; + _combat.ClearActionLocks(); + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.ClearActionLocks(); + } + + private void FakeImperil() + { + uint target = _host.Selection.SelectedObjectId ?? 0u; + if (target == 0u + || !_host.Automation.Objects.TryGet(target, out PluginWorldObject value) + || value.ObjectClass != PluginObjectClass.Monster) + { + WriteVtank("Select a monster first."); + return; + } + _combat.RecordFakeImperil(target); + WriteVtank("Fake cast complete."); + } + + private void EnsureCharacterProfile() + { + string characterName = _host.Automation.Character.Name; + bool macroChanged = _profiles.BindCharacter(characterName); + bool lootChanged = _lootProfiles.BindCharacter(characterName); + bool routeChanged = _routeProfiles.BindCharacter(characterName); + bool metaChanged = _metaProfiles.BindCharacter(characterName); + if (!macroChanged && !lootChanged && !routeChanged && !metaChanged) + return; + if (macroChanged) + LoadSelectedProfile(); + else + { + if (lootChanged) + LoadLootProfile(); + if (routeChanged) + LoadRouteProfile(); + if (metaChanged) + LoadMetaProfile(); + } + if (macroChanged && metaChanged) + LoadMetaProfile(); + _profileLifecycleNotice = $"Loaded {_profiles.Selected} for " + + (_host.Automation.Character.Name.Length == 0 + ? "this character." + : _host.Automation.Character.Name + "."); + } + + private void SaveProfile() + { + _profiles.SaveCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + _lootProfiles.SaveCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + SaveRouteProfile(); + SaveMetaProfile(); + } + + private void ApplyPersistedOptionOverrides() + { + KeyValuePair[] overrides = _combatSettings + .DynamicSettings + .Where(pair => VtankOptionCatalog.IsKnown(pair.Key)) + .ToArray(); + if (overrides.Length == 0) + return; + _applyingProfileOptions = true; + try + { + foreach ((string name, MonsterValue value) in overrides) + SetMetaOption(name, ToExpressionValue(value)); + } + finally + { + _applyingProfileOptions = false; + } + } + // ── the loop ────────────────────────────────────────────────────────── private void Announce(string text) => _host.Automation.Chat.PostSystemMessage($"[MossTank] {text}"); @@ -176,6 +3574,14 @@ internal sealed class MossTankPanel return; } + StartForceBuff(); + } + + private void StartForceBuff() + { + if (_running) + return; + IAutomationSurface automation = _host.Automation; if (!automation.IsAvailable) { @@ -183,26 +3589,78 @@ internal sealed class MossTankPanel return; } - // Always a force pass: the button is Virindi Tank's Force Buff, which - // recasts everything rather than only what has lapsed. - _queue = BuildPlan(automation, force: true); + StartBuffPass( + automation, + force: true, + rebuffWhenUnderSeconds: null, + announce: true); + } + + private void CancelForceBuffCore() + { + if (!_running || !_forcePass) + return; + Stop("Stopped."); + Announce("Stopped."); + } + + private void StartBuffPass( + IAutomationSurface automation, + bool force, + double? rebuffWhenUnderSeconds, + bool announce) + { + double? effectiveThreshold = rebuffWhenUnderSeconds; + if (!force && _buffCastRecastRemaining > 0d) + { + effectiveThreshold = (effectiveThreshold + ?? _buffSettings.RebuffWhenUnderSeconds) + + _buffSettings.BuffCastRecastSeconds; + } + _queue = BuildPlan( + automation, + force, + effectiveThreshold); _queueIndex = 0; _castThisPass = 0; _sinceProgress = 0; - _selectionBeforePass = _host.Selection.SelectedObjectId; + _forcePass = force; + _announceBuffPass = announce; + if (_queue.Count > 0) + { + _selectionBeforePass = _host.Selection.SelectedObjectId; + _buffCastRecastRemaining = Math.Max( + 0d, + _buffSettings.BuffCastRecastResetSeconds); + } _running = _queue.Count > 0; - _status = _queue.Count == 0 - ? "Nothing to buff." - : $"Force buffing 0/{_queue.Count}…"; - _host.Log.Info($"MossTank: force pass started, {_queue.Count} buff(s) queued"); - Announce(_queue.Count == 0 - ? "Nothing to buff — no known self-buffs match your skills." - : $"Force buffing — {_queue.Count} spell(s)."); + if (_queue.Count == 0) + { + if (force) + _status = "Nothing to buff."; + if (announce) + { + Announce( + "Nothing to buff — no known self-buffs match your skills."); + } + return; + } + + string kind = force ? "Force buffing" : "Buffing"; + _status = $"{kind} 0/{_queue.Count}…"; + _host.Log.Info( + $"MossTank: {(force ? "force" : "automatic")} pass started, " + + $"{_queue.Count} buff(s) queued"); + if (announce) + Announce($"{kind} — {_queue.Count} spell(s)."); } private void Stop(string status) { + ClearFastCastMovement(); _running = false; + _forcePass = false; + _announceBuffPass = false; _queue = new List(); _queueIndex = 0; _status = status; @@ -218,28 +3676,229 @@ internal sealed class MossTankPanel _selectionBeforePass = null; } - private List BuildPlan(IAutomationSurface automation, bool force) => - BuffPlan.Build( + private List BuildPlan( + IAutomationSurface automation, + bool force, + double? rebuffWhenUnderSeconds = null) + { + List plan = BuffPlan.Build( BuffProfile.Build(automation.Spells.KnownSelfBuffs), automation.Character.Skills, automation.Character.Attributes, automation.Character.ActiveEnchantments, _buffSettings, - force); + force, + rebuffWhenUnderSeconds, + automation.Character.Level); + plan.RemoveAll(spell => SpellComponentPolicy.UsesBlacklistedComponent( + automation.Spells, + spell, + _buffSettings.BlacklistedSpellComponents)); + return plan; + } - private Dictionary SkillLevels(IAutomationSurface automation) + private void ToggleMacro() => SetMacroRunning(!_combat.Enabled); + + private void SetMacroRunning(bool running) { - var levels = new Dictionary(); - foreach (PluginSkillInfo skill in automation.Character.Skills) - levels[skill.SkillId] = skill.Current; - return levels; + if (_combat.Enabled == running) + return; + _combat.Toggle(); + if (running || _combat.Enabled) + return; + + // A stopped VTank macro owns no movement or staged maintenance work. + // Worn-mana upkeep is intentionally not reset: VTank's + // ManaChargesWhenOff option permits that one controller to continue. + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.Reset(); } /// Driven by on the host update thread. public void OnTick(double elapsedSeconds) { + bool automationAvailable = _host.Automation.IsAvailable; + if (!automationAvailable) + { + if (_automationWasAvailable) + HandleSessionEnded(); + _automationWasAvailable = false; + RefreshDisplayBindings(elapsedSeconds); + return; + } + if (!_automationWasAvailable) + { + _automationWasAvailable = true; + HandleSessionStarted(); + } + + ObserveFastCastMovement(elapsedSeconds); + _buffCastRecastRemaining = Math.Max( + 0d, + _buffCastRecastRemaining - Math.Max(0d, elapsedSeconds)); + EnsureCharacterProfile(); + ShowFirstRunGuidance(); + ObserveCommandPortalState(); + bool macroRunning = _combat.Enabled; + if (macroRunning + && _combatSettings.StopMacroOnDeath + && _host.Automation.IsAvailable + && _host.Automation.Character.MaxHealth > 0u + && _host.Automation.Character.CurrentHealth == 0u) + { + SetMacroRunning(false); + macroRunning = false; + Announce("Macro stopped because the character died."); + } + _fellowshipManager.Tick( + elapsedSeconds, + macroRunning && AutoFellowManagementEnabled); + if (macroRunning) + _meta.OnTick(elapsedSeconds); + _combatSettings.MetaState = _meta.CurrentState; RefreshDisplayBindings(elapsedSeconds); + bool commandJumpOwnsAction = TickCommandJump(elapsedSeconds); + bool giveOwnsAction = _profileGive.Tick( + elapsedSeconds, + canAct: !_running && !commandJumpOwnsAction); + bool criticalCraftOwnsAction = _crafting.TickCritical( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction); + bool vitalOwnsAction = _vitalRecharge.Tick( + elapsedSeconds, + (macroRunning || _running) + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction, + noTarget: !_combat.HasTarget); + TickAutomaticBuffing( + elapsedSeconds, + macroRunning, + canAct: !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction); + bool dispelOwnsAction = _dispel.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction); + bool manaRechargeOwnsAction = _itemManaRecharge.Tick( + canAct: (macroRunning || _inventorySettings.ManaChargesWhenOff) + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction); + bool craftingOwnsAction = _crafting.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction); + bool idleCraftingOwnsAction = _crafting.TickIdle( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !craftingOwnsAction + && !_combat.HasTarget); + bool lootOwnsAction = _loot.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && (_inventorySettings.Loot.PriorityBoost + || !_combat.HasTarget)); + bool inventoryOwnsAction = _inventoryMaintenance.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !lootOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && !_combat.HasTarget); + bool navigationOwnsAction = _navigation.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !lootOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && !inventoryOwnsAction + && (_navigationSettings.Priority || !_combat.HasTarget)); + bool randomHelperOwnsAction = TickRandomHelper( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !lootOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && !inventoryOwnsAction + && !navigationOwnsAction + && !_combat.HasTarget); + _combat.SetPaused( + _running || commandJumpOwnsAction || giveOwnsAction + || criticalCraftOwnsAction || vitalOwnsAction + || dispelOwnsAction + || manaRechargeOwnsAction + || lootOwnsAction + || craftingOwnsAction + || idleCraftingOwnsAction + || inventoryOwnsAction + || randomHelperOwnsAction + || (navigationOwnsAction && _navigationSettings.Priority)); + _combat.OnTick(elapsedSeconds, _navigationSettings.Enabled); + if (_activeTab == TankTab.Route) + RefreshRouteEditor(); + if (!_running) return; @@ -265,14 +3924,37 @@ internal sealed class MossTankPanel if (automation.Magic.IsCasting) return; - if (TryVitalUpkeep(automation)) + if (vitalOwnsAction) + return; + + if (inventoryOwnsAction) + return; + + if (craftingOwnsAction) + return; + + if (idleCraftingOwnsAction) + return; + + if (manaRechargeOwnsAction) + return; + + if (lootOwnsAction) + return; + + if (giveOwnsAction) return; if (_queueIndex >= _queue.Count) { + bool announce = _announceBuffPass; + bool force = _forcePass; Stop($"Done — {_castThisPass} cast(s)."); - _host.Log.Info($"MossTank: force pass complete ({_castThisPass} cast)"); - Announce($"Finished — {_castThisPass} spell(s) cast."); + _host.Log.Info( + $"MossTank: {(force ? "force" : "automatic")} pass complete " + + $"({_castThisPass} cast)"); + if (announce) + Announce($"Finished — {_castThisPass} spell(s) cast."); return; } @@ -280,10 +3962,238 @@ internal sealed class MossTankPanel // Advance on both outcomes. A spell that will not go now (missing // components, a gate that stays shut) must not block the rest of the // queue behind it; the status line names why it was skipped. - TryCast(automation, next, $"Force buffing {_castThisPass + 1}/{_queue.Count}"); + TryCast( + automation, + next, + $"{(_forcePass ? "Force buffing" : "Buffing")} " + + $"{_castThisPass + 1}/{_queue.Count}"); _queueIndex++; } + private void TickAutomaticBuffing( + double elapsedSeconds, + bool macroRunning, + bool canAct) + { + _automaticBuffScanRemaining -= Math.Max(0d, elapsedSeconds); + if (!macroRunning || !_buffSettings.Enabled || _running || !canAct + || _automaticBuffScanRemaining > 0d) + { + return; + } + + _automaticBuffScanRemaining = 1d; + double threshold = !_combat.HasTarget && _buffSettings.IdleBuffTopoff + ? _buffSettings.IdleBuffTopoffSeconds + : _buffSettings.RebuffWhenUnderSeconds; + StartBuffPass( + _host.Automation, + force: false, + rebuffWhenUnderSeconds: threshold, + announce: false); + } + + private bool TickRandomHelper(double elapsedSeconds, bool canAct) + { + _randomHelperRemaining = Math.Max( + 0d, + _randomHelperRemaining - Math.Max(0d, elapsedSeconds)); + if (!canAct + || !_buffSettings.RandomHelperBuffs + || _randomHelperRemaining > 0d + || !_host.Automation.IsAvailable) + { + return false; + } + if (_host.Automation.Magic.IsCasting) + return true; + + PluginNavigationSnapshot navigation = + _host.Automation.Navigation.Snapshot; + if (!navigation.IsAvailable) + return false; + PluginWorldObject[] players = _host.Automation.Objects.CaptureObjects() + .Where(value => value.ObjectClass == PluginObjectClass.Player + && value.ObjectId != _host.Automation.Character.ObjectId + && value.HasPosition + && navigation.Position.HorizontalDistanceMeters(value.Position) + < 18d) + .OrderBy(static value => value.ObjectId) + .ToArray(); + if (players.Length == 0) + return false; + + string[] stems = + [ + "Endurance Other", "Regeneration Other", "Rejuvenation Other", + "Armor Other", "Blade Protection Other", + "Bludgeoning Protection Other", "Cold Protection Other", + "Fire Protection Other", "Lightning Protection Other", + "Piercing Protection Other", "Acid Protection Other", + ]; + int attempts = players.Length * stems.Length; + for (int offset = 0; offset < attempts; offset++) + { + int slot = (_randomHelperCursor + offset) % attempts; + PluginWorldObject player = players[slot % players.Length]; + string stem = stems[(slot / players.Length) % stems.Length]; + PluginSpellInfo spell = _host.Automation.Spells.KnownSelfBuffs + .Where(value => value.Name.StartsWith( + stem, + StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(static value => value.Quality) + .ThenByDescending(static value => value.Tier) + .FirstOrDefault(); + if (spell.SpellId == 0u + || SpellComponentPolicy.UsesBlacklistedComponent( + _host.Automation.Spells, + spell, + _buffSettings.BlacklistedSpellComponents) + || _host.Automation.Magic.EvaluateGate( + spell.SpellId, + player.ObjectId) != PluginCastGate.Ready + || !_host.Automation.Magic.Cast( + spell.SpellId, + player.ObjectId)) + { + continue; + } + _randomHelperCursor = (slot + 1) % attempts; + _randomHelperRemaining = Math.Max( + 0.25d, + _buffSettings.RandomHelperIntervalSeconds); + _host.Log.Info( + $"MossTank: random helper {spell.Name} -> {player.Name}"); + return true; + } + _randomHelperCursor = (_randomHelperCursor + 1) % attempts; + return false; + } + + private void ShowFirstRunGuidance() + { + if (!_firstRunGuidancePending || !_host.Automation.IsAvailable) + return; + _firstRunGuidancePending = false; + const string guidance = "First run: choose profiles, configure the " + + "Options tab, then press Run Macro. Minimize with –; MossTank " + + "keeps running from the right-side plugin shelf. Put VTank " + + ".nav/.utl/.met files in imports and use /vt nav, /vt loot, or " + + "/vt meta import ."; + Announce(guidance); + try + { + _host.Storage.WriteText("onboarding/v1.txt", "shown"); + } + catch (Exception error) + { + _host.Log.Warn( + "MossTank could not persist first-run guidance state: " + + error.Message); + } + } + + private static bool NeedsFirstRunGuidance(IPluginHost host) + { + if (!host.Storage.IsAvailable) + return false; + try + { + return string.IsNullOrWhiteSpace( + host.Storage.ReadText("onboarding/v1.txt")); + } + catch (Exception error) + { + host.Log.Warn( + "MossTank could not read first-run guidance state: " + + error.Message); + return false; + } + } + + public void Disable() + { + if (_combat.Enabled) + SetMacroRunning(false); + if (_running) + Stop("Stopped."); + _vitalRecharge.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.Reset(); + _fellowshipManager.Reset(); + _meta.SetEnabled(false); + _metaViews.DestroyAll(); + _expressions.DestroyAuxiliaryViews(); + _expressions.ClearSession(); + } + + private void HandleSessionEnded() + { + // Combat owns the authoritative physical abort and all of its receipt + // cursors. Drive its existing session-loss path before resetting the + // sibling schedulers. + if (_combat.Enabled) + _combat.OnTick(0d, navigationEnabled: false); + + ClearFastCastMovement(); + _running = false; + _forcePass = false; + _announceBuffPass = false; + _queue.Clear(); + _queueIndex = 0; + _selectionBeforePass = null; + _status = "Lost the session."; + ResetSessionScopedControllers(); + } + + private void HandleSessionStarted() + { + // Re-baseline portal/death/chat state against the NEW session. This is + // also required for a same-character relog, where identity-based + // persistence cannot itself distinguish the old and new sessions. + _meta.ResetSession(); + _expressions.ClearSession(); + _expressions.DestroyAuxiliaryViews(); + _metaViews.DestroyAll(); + ResetCommandSession(); + _automaticBuffScanRemaining = 0d; + _buffCastRecastRemaining = 0d; + _randomHelperRemaining = 0d; + _randomHelperCursor = 0; + _coverageSpellSnapshot = null; + _coverageRefreshRemaining = 0d; + _status = "Idle."; + } + + private void ResetSessionScopedControllers() + { + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.Reset(); + _fellowshipManager.Reset(); + _meta.ResetSession(); + _metaViews.DestroyAll(); + _expressions.DestroyAuxiliaryViews(); + _expressions.ClearSession(); + ResetCommandSession(); + _automaticBuffScanRemaining = 0d; + _buffCastRecastRemaining = 0d; + _randomHelperRemaining = 0d; + _randomHelperCursor = 0; + _coverageSpellSnapshot = null; + _coverageRefreshRemaining = 0d; + _combatSettings.MetaState = MetaEngine.DefaultState; + } + private void RefreshDisplayBindings(double elapsedSeconds) { IAutomationSurface automation = _host.Automation; @@ -352,28 +4262,6 @@ internal sealed class MossTankPanel _coverageRefreshRemaining = CoverageRefreshIntervalSeconds; } - private bool TryVitalUpkeep(IAutomationSurface automation) - { - VitalAction action = VitalPlan.Decide(automation.Character, _vitalSettings); - if (action == VitalAction.None) - return false; - - string stem = action == VitalAction.StaminaToMana - ? VitalPlan.StaminaToManaStem - : VitalPlan.RevitalizeStem; - - if (!VitalPlan.TryFind( - automation.Spells.KnownSelfBuffs, stem, SkillLevels(automation), - _buffSettings.SkillExcessOverDifficulty, out PluginSpellInfo spell)) - { - // Not knowing the conversion is not an error — plenty of characters - // do not have it. Fall through to buffing rather than stalling. - return false; - } - - return TryCast(automation, spell, action.ToString()); - } - private bool TryCast( IAutomationSurface automation, PluginSpellInfo spell, string label) { @@ -406,10 +4294,68 @@ internal sealed class MossTankPanel return false; } + BeginFastCastMovement(automation, spell); _castThisPass++; _sinceProgress = 0; _status = $"{label}: {spell.Name}"; _host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})"); return true; } + + private void BeginFastCastMovement( + IAutomationSurface automation, + in PluginSpellInfo spell) + { + if (!_buffSettings.FastCastBuffs || !IsVtankInstantCast(spell)) + return; + // VTank excludes War (school 1) and Void (school 5). The plugin API + // projects schools as their retail skill ids: 34 and 43 respectively. + if (spell.School is 34u or 43u) + return; + + PluginNavigationCommandStatus result = automation.Navigation + .SetMovementIntent(new PluginMovementIntent(Forward: true)); + if (result != PluginNavigationCommandStatus.Accepted) + return; + _fastCastMovementActive = true; + _fastCastStartCompletionRevision = automation.Magic.LastCompletion.Revision; + _fastCastMovementElapsed = 0d; + } + + private void ObserveFastCastMovement(double elapsedSeconds) + { + if (!_fastCastMovementActive) + return; + _fastCastMovementElapsed += Math.Max(0d, elapsedSeconds); + IMagicCommands magic = _host.Automation.Magic; + bool receiptArrived = magic.LastCompletion.Revision + != _fastCastStartCompletionRevision; + bool castEnded = _fastCastMovementElapsed >= 0.2d && !magic.IsCasting; + if (receiptArrived || castEnded || _fastCastMovementElapsed >= 10d) + ClearFastCastMovement(); + } + + private void ClearFastCastMovement() + { + if (!_fastCastMovementActive) + return; + _host.Automation.Navigation.ClearMovementIntent(); + _fastCastMovementActive = false; + _fastCastStartCompletionRevision = 0; + _fastCastMovementElapsed = 0d; + } + + private static bool IsVtankInstantCast(in PluginSpellInfo spell) + { + if (spell.Difficulty < 50) + return true; + if (spell.IsUntargeted + && !spell.IsFellowship + && spell.DurationSeconds >= 60f + && spell.School is 31u or 33u) + { + return true; + } + return spell.Family is >= 243u and <= 249u or 639u; + } } diff --git a/src/AcDream.Plugins.MossTank/MossTankPlugin.cs b/src/AcDream.Plugins.MossTank/MossTankPlugin.cs index 7b158272..dbcd3974 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPlugin.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPlugin.cs @@ -17,6 +17,7 @@ public sealed class MossTankPlugin : IAcDreamPlugin private IPluginHost? _host; private MossTankPanel? _panel; private Action? _tick; + private IDisposable? _commandRegistration; public void Initialize(IPluginHost host) { @@ -36,10 +37,19 @@ public sealed class MossTankPlugin : IAcDreamPlugin string directory = Path.GetDirectoryName(typeof(MossTankPlugin).Assembly.Location) ?? "."; - // Two panels with complementary visible bindings stand in for a tab - // control: only one is ever on screen, and switching is just an Action. - _host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank.xml"), _panel); - _host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank-settings.xml"), _panel); + _host.Ui.AddPanel( + new PluginPanelDescriptor("main", "MossTank") + { + IconText = "MT", + StartVisible = true, + ShowInSidePanel = true, + }, + Path.Combine(directory, "mosstank.xml"), + _panel); + + _commandRegistration = _host.Commands.Register( + "vt", + _panel.ExecuteVtankCommand); _tick = _panel.OnTick; _host.Events.Tick += _tick; @@ -55,7 +65,10 @@ public sealed class MossTankPlugin : IAcDreamPlugin { if (_host is not null && _tick is not null) _host.Events.Tick -= _tick; + _commandRegistration?.Dispose(); + _commandRegistration = null; _tick = null; + _panel?.Disable(); _host?.Log.Info("MossTank disabled"); } } diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs b/src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs new file mode 100644 index 00000000..12701bf8 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs @@ -0,0 +1,46 @@ +using System.Security.Cryptography; +using System.Text; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Preserves an unreadable profile before a caller falls back to defaults. +/// Recovery is intentionally append-only and manifest-scoped; a corrupt file +/// is never deleted or silently overwritten as part of load. +/// +internal static class MossTankProfileRecovery +{ + internal static string Preserve( + IPluginHost host, + string family, + string key, + string? content, + Exception error) + { + string summary = $"{family} profile '{key}' could not be loaded: " + + error.Message; + if (!host.Storage.IsAvailable || string.IsNullOrEmpty(content)) + return summary; + + try + { + byte[] identity = SHA256.HashData( + Encoding.UTF8.GetBytes(key + "\n" + content)); + string recoveryKey = $"recovery/{family.ToLowerInvariant()}/" + + $"{Convert.ToHexString(identity)[..16]}.txt"; + string payload = $"Original key: {key}\n" + + $"Load error: {error.Message}\n\n" + + content; + host.Storage.WriteText(recoveryKey, payload); + return summary + $" Raw data was preserved as {recoveryKey}."; + } + catch (Exception backupError) + { + host.Log.Warn( + $"MossTank could not preserve corrupt {family} profile: " + + backupError.Message); + return summary; + } + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs new file mode 100644 index 00000000..7f501444 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs @@ -0,0 +1,922 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank-compatible macro profile lifecycle. "By char" resolves to a distinct +/// durable document per character; named profiles are explicit shared copies. +/// +internal sealed class MossTankProfileStore +{ + public const string ByCharacter = "By char"; + private const string LegacyKey = "profile.json"; + private const string IndexKey = "profiles/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private ProfileIndex _index; + private string _characterName = string.Empty; + private string _selected = ByCharacter; + + public MossTankProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new ProfileIndex(); + _index.Profiles ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter + ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase); + } + + public string Selected => _selected; + public bool MineOnly => _index.MineOnly; + public string? RecoveryNotice { get; private set; } + + public IReadOnlyList AvailableNames + { + get + { + IEnumerable entries = _index.Profiles; + if (MineOnly && !string.IsNullOrWhiteSpace(_characterName)) + { + entries = entries.Where(entry => string.Equals( + entry.Owner, + _characterName, + StringComparison.OrdinalIgnoreCase)); + } + return new[] { ByCharacter } + .Concat(entries.Select(static entry => entry.Name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + + /// Returns true when a different character/profile must be loaded. + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase)) + return false; + + _characterName = normalized; + string selectionKey = CharacterSelectionKey(); + _selected = _index.SelectedByCharacter.TryGetValue( + selectionKey, + out string? selected) + && IsKnown(selected) + ? CanonicalName(selected) + : ByCharacter; + if (!_index.SelectedByCharacter.ContainsKey(selectionKey)) + { + _index.SelectedByCharacter[selectionKey] = _selected; + SaveIndex(); + } + return true; + } + + public void SetMineOnly(bool value) + { + if (_index.MineOnly == value) + return; + _index.MineOnly = value; + if (!AvailableNames.Contains(_selected, StringComparer.OrdinalIgnoreCase)) + { + _selected = ByCharacter; + _index.SelectedByCharacter[CharacterSelectionKey()] = _selected; + } + SaveIndex(); + } + + public bool Select(string? name) + { + string normalized = NormalizeName(name); + if (!IsKnown(normalized)) + return false; + _selected = CanonicalName(normalized); + _index.SelectedByCharacter[CharacterSelectionKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames, + out string notice) + { + string normalized = NormalizeName(name); + if (!ValidNamedProfile(normalized, out notice)) + return false; + + ProfileDocument document = copyCurrent + ? ProfileDocument.Capture( + combat, buffs, vitals, inventory, noBuffItemNames) + : ProfileDocument.CreateDefaults(); + Write(ProfileKey(normalized, byCharacter: false), document); + int existing = _index.Profiles.FindIndex(entry => string.Equals( + entry.Name, + normalized, + StringComparison.OrdinalIgnoreCase)); + var entry = new ProfileEntry { Name = normalized, Owner = _characterName }; + if (existing >= 0) + _index.Profiles[existing] = entry; + else + _index.Profiles.Add(entry); + _selected = normalized; + _index.SelectedByCharacter[CharacterSelectionKey()] = normalized; + SaveIndex(); + document.Apply(combat, buffs, vitals, inventory, noBuffItemNames); + notice = copyCurrent + ? $"Copied current settings to {normalized}." + : $"Created profile {normalized}."; + return true; + } + + public void LoadCurrent( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) + { + ProfileDocument? document = Read(CurrentProfileKey()); + if (document is null + && _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + document = Read(LegacyKey); + } + (document ?? ProfileDocument.CreateDefaults()).Apply( + combat, + buffs, + vitals, + inventory, + noBuffItemNames); + } + + public void SaveCurrent( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) => Write( + CurrentProfileKey(), + ProfileDocument.Capture( + combat, buffs, vitals, inventory, noBuffItemNames)); + + public void ClearCurrent( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) + { + ProfileDocument defaults = ProfileDocument.CreateDefaults(); + defaults.Apply(combat, buffs, vitals, inventory, noBuffItemNames); + Write(CurrentProfileKey(), defaults); + } + + /// + /// VTank's opt setinall: update every named profile and every + /// character profile known to the durable index, including the active + /// character even when it has never selected a named profile. + /// + public int SetOptionInAll(string name, MonsterValue value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var keys = new HashSet(StringComparer.Ordinal); + foreach (ProfileEntry entry in _index.Profiles) + keys.Add(ProfileKey(entry.Name, byCharacter: false)); + foreach (string character in _index.SelectedByCharacter.Keys) + { + keys.Add(ProfileKey( + character.Equals("_default", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : character, + byCharacter: true)); + } + keys.Add(ProfileKey(_characterName, byCharacter: true)); + + foreach (string key in keys) + { + ProfileDocument document = Read(key) + ?? ProfileDocument.CreateDefaults(); + document.Combat ??= CombatProfileDocument.Capture(new CombatSettings()); + document.Combat.DynamicSettings ??= + new Dictionary( + StringComparer.OrdinalIgnoreCase); + document.Combat.DynamicSettings[name] = DynamicSettingDocument.From(value); + Write(key, document); + } + return keys.Count; + } + + private static bool ValidNamedProfile(string name, out string notice) + { + if (name.Length is < 1 or > 64) + { + notice = "Enter a profile name (1-64 characters)."; + return false; + } + if (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in character profile."; + return false; + } + notice = string.Empty; + return true; + } + + private bool IsKnown(string name) => + name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + || _index.Profiles.Any(entry => entry.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private string CanonicalName(string name) => + name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : _index.Profiles.First(entry => entry.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)).Name; + + private string CurrentProfileKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(_selected, byCharacter: false); + + private static string ProfileKey(string value, bool byCharacter) + { + string identity = (byCharacter ? "char:" : "named:") + + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"profiles/macro/{hash}.json"; + } + + private string CharacterSelectionKey() => string.IsNullOrWhiteSpace( + _characterName) ? "_default" : _characterName; + private static string NormalizeName(string? name) => name?.Trim() ?? string.Empty; + + private T? Read(string key) where T : class + { + if (!_host.Storage.IsAvailable) + return null; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "macro", + key, + json, + error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + private void Write(string key, T document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank profile could not be saved: {error.Message}"); + } + } + + private void SaveIndex() => Write(IndexKey, _index); + + private sealed class ProfileIndex + { + public int Version { get; set; } = 1; + public bool MineOnly { get; set; } = true; + public List Profiles { get; set; } = []; + public Dictionary SelectedByCharacter { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class ProfileEntry + { + public string Name { get; set; } = string.Empty; + public string Owner { get; set; } = string.Empty; + } + + private sealed class ProfileDocument + { + public int Version { get; set; } = 6; + // Version-2 compatibility fields remain at the top level. + public string[] ItemNames { get; set; } = []; + public string[] ConsumableNames { get; set; } = []; + public Dictionary ConsumableCategories + { get; set; } = new(StringComparer.Ordinal); + public string[] NoBuffItemNames { get; set; } = []; + public CombatProfileDocument? Combat { get; set; } + public BuffProfileDocument? Buffs { get; set; } + public VitalProfileDocument? Vitals { get; set; } + public InventoryProfileDocument? Inventory { get; set; } + + public static ProfileDocument Capture( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) => new() + { + ItemNames = Sorted(combat.CombatItemNames), + ConsumableNames = Sorted(combat.ConsumableNames), + ConsumableCategories = combat.ConsumableCategories.ToDictionary( + static pair => pair.Key, + static pair => pair.Value, + StringComparer.Ordinal), + NoBuffItemNames = Sorted(noBuffItemNames), + Combat = CombatProfileDocument.Capture(combat), + Buffs = BuffProfileDocument.Capture(buffs), + Vitals = VitalProfileDocument.Capture(vitals), + Inventory = InventoryProfileDocument.Capture(inventory), + }; + + public static ProfileDocument CreateDefaults() => Capture( + new CombatSettings(), + new BuffSettings(), + new VitalSettings(), + new InventorySettings(), + new HashSet(StringComparer.Ordinal)); + + public void Apply( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) + { + (Combat ?? CombatProfileDocument.Capture(new CombatSettings())) + .Apply(combat); + (Buffs ?? BuffProfileDocument.Capture(new BuffSettings())).Apply(buffs); + (Vitals ?? VitalProfileDocument.Capture(new VitalSettings())).Apply(vitals); + (Inventory ?? InventoryProfileDocument.Capture(new InventorySettings())) + .Apply(inventory); + Replace(combat.CombatItemNames, ItemNames); + combat.CombatItemObjectIds.Clear(); + Replace(combat.ConsumableNames, ConsumableNames); + combat.ConsumableCategories.Clear(); + foreach ((string name, ConsumableCategory category) in + ConsumableCategories + ?? new Dictionary()) + { + if (combat.ConsumableNames.Contains(name)) + combat.ConsumableCategories[name] = category; + } + Replace(noBuffItemNames, NoBuffItemNames); + } + } + + private sealed class InventoryProfileDocument + { + public bool ManaChargesWhenOff { get; set; } = true; + public bool AutoStack { get; set; } = true; + public bool AutoCram { get; set; } + public bool AutoCraftItems { get; set; } = true; + public bool SplitPeas { get; set; } = true; + public int CriticalComponentMinimum { get; set; } = 4; + public int NormalComponentMinimum { get; set; } = 20; + public int IdleComponentMinimum { get; set; } = 20; + public int IdleHealthKitCount { get; set; } = 2; + public int IdleStaminaKitCount { get; set; } = 2; + public int IdleManaKitCount { get; set; } = 2; + public int IdleHealthFoodCount { get; set; } = 15; + public int IdleStaminaFoodCount { get; set; } = 15; + public int IdleManaFoodCount { get; set; } = 15; + public bool RefillWornMana { get; set; } = true; + public int RefillWornManaPercent { get; set; } = 33; + public double ScanIntervalSeconds { get; set; } = 0.25d; + public bool EnableLooting { get; set; } + public string LootClassifierId { get; set; } = string.Empty; + public bool LootPriorityBoost { get; set; } + public bool LootAllCorpses { get; set; } + public bool LootFellowCorpses { get; set; } + public bool LootOnlyRareCorpses { get; set; } + public bool ReadUnknownScrolls { get; set; } = true; + public bool CombineSalvage { get; set; } = true; + public int ManaStoneLootCount { get; set; } = 4; + public int ManaTankMinimumMana { get; set; } = 1000; + public float CorpseApproachRange { get; set; } = 40f; + public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d; + public int BlacklistCorpseOpenAttemptCount { get; set; } = 30; + public double BlacklistCorpseOpenTimeoutSeconds { get; set; } = 200d; + public double CorpseCacheTimeoutMinutes { get; set; } = 60d; + public int CorpseLootItemMaxAttempts { get; set; } = 20; + public double LootScanIntervalSeconds { get; set; } = 0.25d; + public LootRuleDocument[] LootRules { get; set; } = []; + + public static InventoryProfileDocument Capture(InventorySettings settings) => + new() + { + ManaChargesWhenOff = settings.ManaChargesWhenOff, + AutoStack = settings.AutoStack, + AutoCram = settings.AutoCram, + AutoCraftItems = settings.AutoCraftItems, + SplitPeas = settings.SplitPeas, + CriticalComponentMinimum = settings.CriticalComponentMinimum, + NormalComponentMinimum = settings.NormalComponentMinimum, + IdleComponentMinimum = settings.IdleComponentMinimum, + IdleHealthKitCount = settings.IdleHealthKitCount, + IdleStaminaKitCount = settings.IdleStaminaKitCount, + IdleManaKitCount = settings.IdleManaKitCount, + IdleHealthFoodCount = settings.IdleHealthFoodCount, + IdleStaminaFoodCount = settings.IdleStaminaFoodCount, + IdleManaFoodCount = settings.IdleManaFoodCount, + RefillWornMana = settings.RefillWornMana, + RefillWornManaPercent = settings.RefillWornManaPercent, + ScanIntervalSeconds = settings.ScanIntervalSeconds, + EnableLooting = settings.Loot.Enabled, + LootClassifierId = settings.Loot.ExternalClassifierId, + LootPriorityBoost = settings.Loot.PriorityBoost, + LootAllCorpses = settings.Loot.LootAllCorpses, + LootFellowCorpses = settings.Loot.LootFellowCorpses, + LootOnlyRareCorpses = settings.Loot.LootOnlyRareCorpses, + ReadUnknownScrolls = settings.Loot.ReadUnknownScrolls, + CombineSalvage = settings.Loot.CombineSalvage, + ManaStoneLootCount = settings.Loot.ManaStoneLootCount, + ManaTankMinimumMana = settings.Loot.ManaTankMinimumMana, + CorpseApproachRange = settings.Loot.CorpseApproachRange, + CorpseOpenTimeoutSeconds = + settings.Loot.CorpseOpenTimeoutSeconds, + BlacklistCorpseOpenAttemptCount = + settings.Loot.BlacklistCorpseOpenAttemptCount, + BlacklistCorpseOpenTimeoutSeconds = + settings.Loot.BlacklistCorpseOpenTimeoutSeconds, + CorpseCacheTimeoutMinutes = + settings.Loot.CorpseCacheTimeoutMinutes, + CorpseLootItemMaxAttempts = + settings.Loot.CorpseLootItemMaxAttempts, + LootScanIntervalSeconds = settings.Loot.ScanIntervalSeconds, + LootRules = settings.Loot.Rules + .Select(LootRuleDocument.From) + .ToArray(), + }; + + public void Apply(InventorySettings settings) + { + settings.ManaChargesWhenOff = ManaChargesWhenOff; + settings.AutoStack = AutoStack; + settings.AutoCram = AutoCram; + settings.AutoCraftItems = AutoCraftItems; + settings.SplitPeas = SplitPeas; + settings.CriticalComponentMinimum = Math.Clamp( + CriticalComponentMinimum, 0, 1000); + settings.NormalComponentMinimum = Math.Clamp( + NormalComponentMinimum, 0, 1000); + settings.IdleComponentMinimum = Math.Clamp( + IdleComponentMinimum, 0, 1000); + settings.IdleHealthKitCount = Math.Clamp(IdleHealthKitCount, 0, 1000); + settings.IdleStaminaKitCount = Math.Clamp(IdleStaminaKitCount, 0, 1000); + settings.IdleManaKitCount = Math.Clamp(IdleManaKitCount, 0, 1000); + settings.IdleHealthFoodCount = Math.Clamp(IdleHealthFoodCount, 0, 1000); + settings.IdleStaminaFoodCount = Math.Clamp(IdleStaminaFoodCount, 0, 1000); + settings.IdleManaFoodCount = Math.Clamp(IdleManaFoodCount, 0, 1000); + settings.RefillWornMana = RefillWornMana; + settings.RefillWornManaPercent = Math.Clamp( + RefillWornManaPercent, + 0, + 99); + settings.ScanIntervalSeconds = Math.Clamp( + ScanIntervalSeconds, + 0.05d, + 10d); + settings.Loot.Enabled = EnableLooting; + settings.Loot.ExternalClassifierId = LootClassifierId?.Trim() + ?? string.Empty; + settings.Loot.PriorityBoost = LootPriorityBoost; + settings.Loot.LootAllCorpses = LootAllCorpses; + settings.Loot.LootFellowCorpses = LootFellowCorpses; + settings.Loot.LootOnlyRareCorpses = LootOnlyRareCorpses; + settings.Loot.ReadUnknownScrolls = ReadUnknownScrolls; + settings.Loot.CombineSalvage = CombineSalvage; + settings.Loot.ManaStoneLootCount = Math.Clamp( + ManaStoneLootCount, + 0, + 100); + settings.Loot.ManaTankMinimumMana = Math.Clamp( + ManaTankMinimumMana, + 1, + int.MaxValue); + settings.Loot.CorpseApproachRange = Math.Clamp( + CorpseApproachRange, + 2f, + 100f); + settings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp( + CorpseOpenTimeoutSeconds, + 0.25d, + 30d); + settings.Loot.BlacklistCorpseOpenAttemptCount = Math.Clamp( + BlacklistCorpseOpenAttemptCount, + 1, + 1000); + settings.Loot.BlacklistCorpseOpenTimeoutSeconds = Math.Clamp( + BlacklistCorpseOpenTimeoutSeconds, + 1d, + 3600d); + settings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp( + CorpseCacheTimeoutMinutes, + 1d, + 1440d); + settings.Loot.CorpseLootItemMaxAttempts = Math.Clamp( + CorpseLootItemMaxAttempts, + 1, + 100); + settings.Loot.ScanIntervalSeconds = Math.Clamp( + LootScanIntervalSeconds, + 0.05d, + 5d); + settings.Loot.Rules.Clear(); + foreach (LootRuleDocument rule in LootRules ?? []) + settings.Loot.Rules.Add(rule.ToRule()); + } + } + + private sealed class LootRuleDocument + { + public string Name { get; set; } = "Rule"; + public string Expression { get; set; } = "*"; + public LootAction Action { get; set; } = LootAction.Keep; + public int KeepCount { get; set; } = 1; + public int Priority { get; set; } + + public static LootRuleDocument From(LootRule rule) => new() + { + Name = rule.Name, + Expression = rule.Expression, + Action = rule.Action, + KeepCount = rule.KeepCount, + Priority = rule.Priority, + }; + + public LootRule ToRule() => new() + { + Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(), + Expression = string.IsNullOrWhiteSpace(Expression) + ? "*" + : Expression.Trim(), + Action = Action, + KeepCount = Math.Clamp(KeepCount, 0, 100000), + Priority = Math.Clamp(Priority, -1000, 1000), + }; + } + + private sealed class CombatProfileDocument + { + public bool Enabled { get; set; } = true; + public float MaximumRange { get; set; } = 5f; + public float ApproachDistance { get; set; } + public bool IdlePeaceMode { get; set; } + public TargetSelectionMethod SelectionMethod { get; set; } = + TargetSelectionMethod.Both; + public float TargetSelectAngleRange { get; set; } = 5f; + public bool TargetLock { get; set; } + public PluginAttackHeight AttackHeight { get; set; } = + PluginAttackHeight.Medium; + public float AttackPower { get; set; } = 0.5f; + public bool AutoAttackPower { get; set; } = true; + public bool UseRecklessness { get; set; } = true; + public double ScanIntervalSeconds { get; set; } = 0.25; + public DebuffEachFirst DebuffEachFirst { get; set; } = DebuffEachFirst.One; + public DebuffSelectionMethod DebuffSelectionMethod { get; set; } = + DebuffSelectionMethod.Skill; + public double DebuffPrecastSeconds { get; set; } = 5d; + public bool SwitchWandsToDebuff { get; set; } + public bool UseArcs { get; set; } = true; + public float ArcRange { get; set; } = 5f; + public float RingDistance { get; set; } = 5f; + public int MinimumRingTargets { get; set; } = 4; + public bool DeleteGhostMonsters { get; set; } = true; + public int GhostMonsterSpellAttemptCount { get; set; } = 200; + public int BlacklistMonsterAttemptCount { get; set; } = 4; + public double BlacklistMonsterTimeoutSeconds { get; set; } = 120d; + public bool DeleteGhostMonstersByHealthTracker { get; set; } = true; + public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d; + public bool SummonPets { get; set; } = true; + public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance; + public float PetCustomRange { get; set; } = 5f; + public int PetMonsterDensity { get; set; } = 1; + public int PetRefillCountIdle { get; set; } = 3; + public int PetRefillCountNormal { get; set; } = 1; + public string MetaState { get; set; } = "Default"; + public Dictionary DynamicSettings { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + public MonsterRuleDocument[] Rules { get; set; } = + [MonsterRuleDocument.From(new MonsterRule("DEFAULT", 0))]; + + public static CombatProfileDocument Capture(CombatSettings value) => new() + { + Enabled = value.Enabled, + MaximumRange = value.MaximumRange, + ApproachDistance = value.ApproachDistance, + IdlePeaceMode = value.IdlePeaceMode, + SelectionMethod = value.SelectionMethod, + TargetSelectAngleRange = value.TargetSelectAngleRange, + TargetLock = value.TargetLock, + AttackHeight = value.AttackHeight, + AttackPower = value.AttackPower, + AutoAttackPower = value.AutoAttackPower, + UseRecklessness = value.UseRecklessness, + ScanIntervalSeconds = value.ScanIntervalSeconds, + DebuffEachFirst = value.DebuffEachFirst, + DebuffSelectionMethod = value.DebuffSelectionMethod, + DebuffPrecastSeconds = value.DebuffPrecastSeconds, + SwitchWandsToDebuff = value.SwitchWandsToDebuff, + UseArcs = value.UseArcs, + ArcRange = value.ArcRange, + RingDistance = value.RingDistance, + MinimumRingTargets = value.MinimumRingTargets, + DeleteGhostMonsters = value.DeleteGhostMonsters, + GhostMonsterSpellAttemptCount = value.GhostMonsterSpellAttemptCount, + BlacklistMonsterAttemptCount = value.BlacklistMonsterAttemptCount, + BlacklistMonsterTimeoutSeconds = value.BlacklistMonsterTimeoutSeconds, + DeleteGhostMonstersByHealthTracker = value.DeleteGhostMonstersByHealthTracker, + GhostDeleteHealthTrackerSeconds = value.GhostDeleteHealthTrackerSeconds, + SummonPets = value.SummonPets, + PetRangeMode = value.PetRangeMode, + PetCustomRange = value.PetCustomRange, + PetMonsterDensity = value.PetMonsterDensity, + PetRefillCountIdle = value.PetRefillCountIdle, + PetRefillCountNormal = value.PetRefillCountNormal, + MetaState = value.MetaState, + DynamicSettings = value.DynamicSettings.ToDictionary( + static pair => pair.Key, + static pair => DynamicSettingDocument.From(pair.Value), + StringComparer.OrdinalIgnoreCase), + Rules = value.Rules.Select(MonsterRuleDocument.From).ToArray(), + }; + + public void Apply(CombatSettings value) + { + value.Enabled = Enabled; + value.MaximumRange = Math.Clamp(MaximumRange, 2f, 100f); + value.ApproachDistance = Math.Clamp(ApproachDistance, 0f, 100f); + value.IdlePeaceMode = IdlePeaceMode; + value.SelectionMethod = SelectionMethod; + value.TargetSelectAngleRange = Math.Clamp( + TargetSelectAngleRange, 2f, value.MaximumRange); + value.TargetLock = TargetLock; + value.AttackHeight = AttackHeight; + value.AttackPower = Math.Clamp(AttackPower, 0f, 1f); + value.AutoAttackPower = AutoAttackPower; + value.UseRecklessness = UseRecklessness; + value.ScanIntervalSeconds = Math.Clamp(ScanIntervalSeconds, 0.05, 5d); + value.DebuffEachFirst = DebuffEachFirst; + value.DebuffSelectionMethod = DebuffSelectionMethod; + value.DebuffPrecastSeconds = Math.Clamp(DebuffPrecastSeconds, 0d, 60d); + value.SwitchWandsToDebuff = SwitchWandsToDebuff; + value.UseArcs = UseArcs; + value.ArcRange = Math.Clamp(ArcRange, 1f, 100f); + value.RingDistance = Math.Clamp(RingDistance, 1f, 100f); + value.MinimumRingTargets = Math.Clamp(MinimumRingTargets, 1, 25); + value.DeleteGhostMonsters = DeleteGhostMonsters; + value.GhostMonsterSpellAttemptCount = Math.Clamp( + GhostMonsterSpellAttemptCount, 1, 1000); + value.BlacklistMonsterAttemptCount = Math.Clamp( + BlacklistMonsterAttemptCount, 1, 20); + value.BlacklistMonsterTimeoutSeconds = Math.Clamp( + BlacklistMonsterTimeoutSeconds, 1d, 3600d); + value.DeleteGhostMonstersByHealthTracker = DeleteGhostMonstersByHealthTracker; + value.GhostDeleteHealthTrackerSeconds = Math.Clamp( + GhostDeleteHealthTrackerSeconds, 1d, 300d); + value.SummonPets = SummonPets; + value.PetRangeMode = PetRangeMode; + value.PetCustomRange = Math.Clamp(PetCustomRange, 1f, 100f); + value.PetMonsterDensity = Math.Clamp(PetMonsterDensity, 1, 25); + value.PetRefillCountIdle = Math.Clamp(PetRefillCountIdle, 0, 3); + value.PetRefillCountNormal = Math.Clamp(PetRefillCountNormal, 0, 3); + value.MetaState = string.IsNullOrWhiteSpace(MetaState) + ? "Default" + : MetaState; + value.DynamicSettings.Clear(); + foreach ((string name, DynamicSettingDocument setting) in + DynamicSettings ?? new Dictionary()) + { + value.DynamicSettings[name] = setting.ToValue(); + } + value.Rules.Clear(); + foreach (MonsterRuleDocument rule in Rules ?? []) + { + try { value.Rules.Add(rule.ToRule()); } + catch (FormatException) { } + } + if (!value.Rules.Any(static rule => rule.IsDefault)) + value.Rules.Add(new MonsterRule("DEFAULT", 0)); + } + } + + private sealed class DynamicSettingDocument + { + public MonsterValueKind Kind { get; set; } + public double Number { get; set; } + public string Text { get; set; } = string.Empty; + public bool Boolean { get; set; } + + public static DynamicSettingDocument From(MonsterValue value) => new() + { + Kind = value.Kind, + Number = value.Number, + Text = value.Text, + Boolean = value.Boolean, + }; + + public MonsterValue ToValue() => Kind switch + { + MonsterValueKind.Number => MonsterValue.FromNumber(Number), + MonsterValueKind.Boolean => MonsterValue.FromBoolean(Boolean), + _ => MonsterValue.FromText(Text ?? string.Empty), + }; + } + + private sealed class MonsterRuleDocument + { + public string Expression { get; set; } = "DEFAULT"; + public MonsterActionFlags Flags { get; set; } = MonsterActionFlags.Attack; + public int Priority { get; set; } + public MonsterDamageType DamageType { get; set; } = MonsterDamageType.Auto; + public MonsterDamageType ExtraVulnerability { get; set; } = + MonsterDamageType.Auto; + public uint WeaponObjectId { get; set; } + public uint OffhandObjectId { get; set; } + public string WeaponName { get; set; } = string.Empty; + public string OffhandName { get; set; } = string.Empty; + public MonsterDamageType PetDamageType { get; set; } = + MonsterDamageType.PlayerAuto; + + public static MonsterRuleDocument From(MonsterRule rule) => new() + { + Expression = rule.Expression, + Flags = rule.Actions.Flags, + Priority = rule.Actions.Priority, + DamageType = rule.Actions.DamageType, + ExtraVulnerability = rule.Actions.ExtraVulnerability, + WeaponObjectId = rule.Actions.WeaponObjectId, + OffhandObjectId = rule.Actions.OffhandObjectId, + WeaponName = rule.Actions.WeaponName, + OffhandName = rule.Actions.OffhandName, + PetDamageType = rule.Actions.PetDamageType, + }; + + public MonsterRule ToRule() => new(Expression, new MonsterRuleActions + { + Flags = Flags, + Priority = Math.Clamp(Priority, -1, 4), + DamageType = DamageType, + ExtraVulnerability = ExtraVulnerability, + WeaponObjectId = WeaponObjectId, + OffhandObjectId = OffhandObjectId, + WeaponName = WeaponName ?? string.Empty, + OffhandName = OffhandName ?? string.Empty, + PetDamageType = PetDamageType, + }); + } + + private sealed class BuffProfileDocument + { + public bool Enabled { get; set; } = true; + public bool IdleBuffTopoff { get; set; } + public double IdleBuffTopoffSeconds { get; set; } = 1200d; + public double RebuffWhenUnderSeconds { get; set; } = 300d; + public int SkillExcessOverDifficulty { get; set; } = 5; + public bool BuffAttributes { get; set; } = true; + public bool BuffProtections { get; set; } = true; + public bool BuffAuras { get; set; } = true; + public bool BuffBanes { get; set; } = true; + public bool BuffRegeneration { get; set; } = true; + public bool BuffOther { get; set; } + public bool BuffTrainedSkillsOnly { get; set; } = true; + + public static BuffProfileDocument Capture(BuffSettings value) => new() + { + Enabled = value.Enabled, + IdleBuffTopoff = value.IdleBuffTopoff, + IdleBuffTopoffSeconds = value.IdleBuffTopoffSeconds, + RebuffWhenUnderSeconds = value.RebuffWhenUnderSeconds, + SkillExcessOverDifficulty = value.SkillExcessOverDifficulty, + BuffAttributes = value.BuffAttributes, + BuffProtections = value.BuffProtections, + BuffAuras = value.BuffAuras, + BuffBanes = value.BuffBanes, + BuffRegeneration = value.BuffRegeneration, + BuffOther = value.BuffOther, + BuffTrainedSkillsOnly = value.BuffTrainedSkillsOnly, + }; + + public void Apply(BuffSettings value) + { + value.Enabled = Enabled; + value.IdleBuffTopoff = IdleBuffTopoff; + value.IdleBuffTopoffSeconds = Math.Clamp( + IdleBuffTopoffSeconds, 30d, 7200d); + value.RebuffWhenUnderSeconds = Math.Clamp( + RebuffWhenUnderSeconds, 30d, 1800d); + value.SkillExcessOverDifficulty = Math.Clamp( + SkillExcessOverDifficulty, -100, 100); + value.BuffAttributes = BuffAttributes; + value.BuffProtections = BuffProtections; + value.BuffAuras = BuffAuras; + value.BuffBanes = BuffBanes; + value.BuffRegeneration = BuffRegeneration; + value.BuffOther = BuffOther; + value.BuffTrainedSkillsOnly = BuffTrainedSkillsOnly; + } + } + + private sealed class VitalProfileDocument + { + public bool Enabled { get; set; } = true; + public double NormalHealth { get; set; } = 0.75; + public double NormalStamina { get; set; } = 0.50; + public double NormalMana { get; set; } = 0.50; + public double NoTargetHealth { get; set; } = 0.01; + public double NoTargetStamina { get; set; } = 0.01; + public double NoTargetMana { get; set; } = 0.01; + public double HelperHealth { get; set; } = 0.20; + public double HelperStamina { get; set; } = 0.01; + public double HelperMana { get; set; } = 0.01; + public bool HelpOthers { get; set; } = true; + + public static VitalProfileDocument Capture(VitalSettings value) => new() + { + Enabled = value.Enabled, + NormalHealth = value.NormalHealth, + NormalStamina = value.NormalStamina, + NormalMana = value.NormalMana, + NoTargetHealth = value.NoTargetHealth, + NoTargetStamina = value.NoTargetStamina, + NoTargetMana = value.NoTargetMana, + HelperHealth = value.HelperHealth, + HelperStamina = value.HelperStamina, + HelperMana = value.HelperMana, + HelpOthers = value.HelpOthers, + }; + + public void Apply(VitalSettings value) + { + value.Enabled = Enabled; + value.NormalHealth = Clamp(NormalHealth); + value.NormalStamina = Clamp(NormalStamina); + value.NormalMana = Clamp(NormalMana); + value.NoTargetHealth = Clamp(NoTargetHealth); + value.NoTargetStamina = Clamp(NoTargetStamina); + value.NoTargetMana = Clamp(NoTargetMana); + value.HelperHealth = Clamp(HelperHealth); + value.HelperStamina = Clamp(HelperStamina); + value.HelperMana = Clamp(HelperMana); + value.HelpOthers = HelpOthers; + } + + private static double Clamp(double value) => Math.Clamp(value, 0d, 1d); + } + + private static string[] Sorted(IEnumerable values) => values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .OrderBy(static value => value, StringComparer.Ordinal) + .ToArray(); + + private static void Replace(ISet target, IEnumerable? values) + { + target.Clear(); + if (values is null) + return; + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + target.Add(value); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs new file mode 100644 index 00000000..e1773615 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -0,0 +1,437 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Independent VTank navigation-profile lifecycle. The selected route is +/// remembered per character; "By char" is a private route document and named +/// profiles are reusable copies. +/// +internal sealed class MossTankRouteProfileStore +{ + public const string ByCharacter = "By char"; + private const string IndexKey = "profiles/route/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private IndexDocument _index; + private string _characterName = string.Empty; + private string _selected = ByCharacter; + + public MossTankRouteProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new IndexDocument(); + _index.Names ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase); + } + + public string Selected => _selected; + public string? RecoveryNotice { get; private set; } + public IReadOnlyList AvailableNames => new[] { ByCharacter } + .Concat(_index.Names) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase)) + return false; + _characterName = normalized; + _selected = _index.SelectedByCharacter.TryGetValue( + SelectionKey(), + out string? selected) + && IsKnown(selected) + ? CanonicalName(selected) + : ByCharacter; + return true; + } + + public bool Select(string? name) + { + string normalized = name?.Trim() ?? string.Empty; + if (!IsKnown(normalized)) + return false; + _selected = CanonicalName(normalized); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + NavigationSettings current, + out string notice) + { + string normalized = name?.Trim() ?? string.Empty; + if (normalized.Length is < 1 or > 64) + { + notice = "Enter a route profile name (1-64 characters)."; + return false; + } + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in route profile."; + return false; + } + Write( + ProfileKey(normalized, byCharacter: false), + copyCurrent + ? RouteDocument.Capture(current) + : new RouteDocument()); + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings()); + notice = copyCurrent + ? $"Copied route to {_selected}." + : $"Created route profile {_selected}."; + return true; + } + + public bool LoadCurrent(NavigationSettings target) + { + ArgumentNullException.ThrowIfNull(target); + RouteDocument? document = Read(CurrentKey()); + if (document is null) + return false; + document.Apply(target); + return true; + } + + public void SaveCurrent(NavigationSettings settings) + { + Write(CurrentKey(), RouteDocument.Capture(settings)); + WriteLegacyExport( + _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? string.IsNullOrWhiteSpace(_characterName) + ? ByCharacter + : _characterName + : _selected, + settings); + } + + public bool TryImportLegacy( + string? name, + NavigationSettings target, + ISpellCatalog spells, + out string notice) + { + string normalized = name?.Trim() ?? string.Empty; + if (!_host.Storage.IsAvailable || normalized.Length == 0) + { + notice = "Legacy navigation storage is unavailable."; + return false; + } + string? key = _host.Storage.List("imports") + .Concat(_host.Storage.List("exports")) + .FirstOrDefault(candidate => + candidate.EndsWith(".nav", StringComparison.OrdinalIgnoreCase) + && Path.GetFileNameWithoutExtension(candidate).Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + string? source = key is null ? null : _host.Storage.ReadText(key); + if (string.IsNullOrWhiteSpace(source)) + { + notice = $"VTank navigation file '{normalized}.nav' was not found in imports."; + return false; + } + if (!VtankNavRouteSerializer.TryLoad(source, target, spells, out string error)) + { + notice = $"Could not import {Path.GetFileName(key)}: {error}"; + return false; + } + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + SaveCurrent(target); + notice = $"Imported VTank navigation profile {_selected}."; + return true; + } + + public void ClearCurrent(NavigationSettings target) + { + target.Enabled = false; + target.Priority = false; + target.Mode = RouteMode.Circular; + target.MinimumDistanceMeters = 2d; + target.FollowTargetObjectId = 0u; + target.FollowTargetName = string.Empty; + target.FollowAroundCorners = true; + target.OpenDoors = false; + target.Waypoints.Clear(); + SaveCurrent(target); + } + + private bool IsKnown(string? name) => name is not null + && (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + || _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase)); + + private string CanonicalName(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : _index.Names.First(entry => entry.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private string CurrentKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(_selected, byCharacter: false); + + private static string ProfileKey(string value, bool byCharacter) + { + string identity = (byCharacter ? "char:" : "named:") + + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"profiles/route/{hash}.json"; + } + + private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName) + ? "_default" + : _characterName; + + private T? Read(string key) where T : class + { + if (!_host.Storage.IsAvailable) + return null; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "route", + key, + json, + error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + private void Write(string key, T document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank route profile could not be saved: {error.Message}"); + } + } + + private void SaveIndex() => Write(IndexKey, _index); + + private void WriteLegacyExport(string name, NavigationSettings settings) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText( + $"exports/{LegacyFileName(name)}.nav", + VtankNavRouteSerializer.Save(settings)); + } + catch (Exception error) + { + _host.Log.Warn( + $"MossTank VTank navigation export could not be saved: {error.Message}"); + } + } + + private static string LegacyFileName(string name) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var result = new StringBuilder(name.Length); + foreach (char value in name.Trim()) + { + result.Append(value is '/' or '\\' || invalid.Contains(value) + ? '_' + : value); + } + return result.Length == 0 ? "Route" : result.ToString(); + } + + private sealed class IndexDocument + { + public int Version { get; set; } = 1; + public List Names { get; set; } = []; + public Dictionary SelectedByCharacter { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class RouteDocument + { + public int Version { get; set; } = 1; + public bool Enabled { get; set; } + public bool Priority { get; set; } + public RouteMode Mode { get; set; } = RouteMode.Circular; + public double MinimumDistanceMeters { get; set; } = 2d; + public uint FollowTargetObjectId { get; set; } + public string FollowTargetName { get; set; } = string.Empty; + public bool FollowAroundCorners { get; set; } = true; + public bool OpenDoors { get; set; } + public double DoorIdentifyRangeMeters { get; set; } = 20d; + public double DoorOpenRangeMeters { get; set; } = 4d; + public int DoorLockpickExcessThreshold { get; set; } = -50; + public WaypointDocument[] Waypoints { get; set; } = []; + + public static RouteDocument Capture(NavigationSettings value) => new() + { + Enabled = value.Enabled, + Priority = value.Priority, + Mode = value.Mode, + MinimumDistanceMeters = value.MinimumDistanceMeters, + FollowTargetObjectId = value.FollowTargetObjectId, + FollowTargetName = value.FollowTargetName, + FollowAroundCorners = value.FollowAroundCorners, + OpenDoors = value.OpenDoors, + DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters, + DoorOpenRangeMeters = value.DoorOpenRangeMeters, + DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold, + Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(), + }; + + public void Apply(NavigationSettings value) + { + value.Enabled = Enabled; + value.Priority = Priority; + value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular; + value.MinimumDistanceMeters = Math.Clamp( + MinimumDistanceMeters, + 0.5d, + 50d); + value.FollowTargetObjectId = FollowTargetObjectId; + value.FollowTargetName = FollowTargetName ?? string.Empty; + value.FollowAroundCorners = FollowAroundCorners; + value.OpenDoors = OpenDoors; + value.DoorIdentifyRangeMeters = Math.Clamp( + DoorIdentifyRangeMeters, 1d, 100d); + value.DoorOpenRangeMeters = Math.Clamp( + DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters); + value.DoorLockpickExcessThreshold = Math.Clamp( + DoorLockpickExcessThreshold, -500, 500); + value.Waypoints.Clear(); + foreach (WaypointDocument waypoint in Waypoints ?? []) + value.Waypoints.Add(waypoint.ToWaypoint()); + } + } + + private sealed class WaypointDocument + { + public RouteWaypointType Type { get; set; } + public uint CellId { get; set; } + public double EastWest { get; set; } + public double NorthSouth { get; set; } + public double Elevation { get; set; } + public float HeadingDegrees { get; set; } + public bool IsOutdoor { get; set; } + public uint ObjectId { get; set; } + public string ObjectName { get; set; } = string.Empty; + public int LegacyObjectClass { get; set; } + public bool LegacyReferenceValid { get; set; } = true; + public string Text { get; set; } = string.Empty; + public int DurationMilliseconds { get; set; } = 5000; + public RouteRecallKind Recall { get; set; } + public uint RecallSpellId { get; set; } + public string RecallSpellName { get; set; } = string.Empty; + public float JumpHeadingDegrees { get; set; } + public bool JumpRun { get; set; } + public int JumpChargeMilliseconds { get; set; } = 1000; + public RouteJumpDirection JumpDirection { get; set; } + + public static WaypointDocument From(RouteWaypoint value) => new() + { + Type = value.Type, + CellId = value.Position.CellId, + EastWest = value.Position.EastWest, + NorthSouth = value.Position.NorthSouth, + Elevation = value.Position.Elevation, + HeadingDegrees = value.Position.HeadingDegrees, + IsOutdoor = value.Position.IsOutdoor, + ObjectId = value.ObjectId, + ObjectName = value.ObjectName, + LegacyObjectClass = value.LegacyObjectClass, + LegacyReferenceValid = value.LegacyReferenceValid, + Text = value.Text, + DurationMilliseconds = value.DurationMilliseconds, + Recall = value.Recall, + RecallSpellId = value.RecallSpellId, + RecallSpellName = value.RecallSpellName, + JumpHeadingDegrees = value.JumpHeadingDegrees, + JumpRun = value.JumpRun, + JumpChargeMilliseconds = value.JumpChargeMilliseconds, + JumpDirection = value.JumpDirection, + }; + + public RouteWaypoint ToWaypoint() => new() + { + Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point, + Position = new PluginNavigationPosition( + CellId, + EastWest, + NorthSouth, + Elevation, + HeadingDegrees, + IsOutdoor), + ObjectId = ObjectId, + ObjectName = ObjectName ?? string.Empty, + LegacyObjectClass = LegacyObjectClass, + LegacyReferenceValid = LegacyReferenceValid, + Text = Text ?? string.Empty, + DurationMilliseconds = Math.Clamp(DurationMilliseconds, 0, 3_600_000), + Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone, + RecallSpellId = RecallSpellId, + RecallSpellName = RecallSpellName ?? string.Empty, + JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) + ? JumpHeadingDegrees + : 0f, + JumpRun = JumpRun, + JumpChargeMilliseconds = Math.Clamp( + JumpChargeMilliseconds, + 0, + 10_000), + JumpDirection = Enum.IsDefined(JumpDirection) + ? JumpDirection + : RouteJumpDirection.Forward, + }; + } +} diff --git a/src/AcDream.Plugins.MossTank/Navigation.cs b/src/AcDream.Plugins.MossTank/Navigation.cs new file mode 100644 index 00000000..839972ad --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Navigation.cs @@ -0,0 +1,1110 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum RouteMode +{ + Circular, + Linear, + Target, + Once, +} + +internal enum RouteWaypointType +{ + Point = 0, + Portal = 1, + Recall = 2, + Pause = 3, + ChatCommand = 4, + OpenVendor = 5, + PortalByName = 6, + UseNpc = 7, + Checkpoint = 8, + Jump = 9, +} + +internal enum RouteRecallKind +{ + Lifestone, + Marketplace, + PrimaryPortal, + SecondaryPortal, +} + +internal enum RouteJumpDirection +{ + Forward, + StrafeLeft, + StrafeRight, +} + +internal sealed class RouteWaypoint +{ + public RouteWaypointType Type { get; set; } + public PluginNavigationPosition Position { get; set; } + public uint ObjectId { get; set; } + public string ObjectName { get; set; } = string.Empty; + /// Decal ObjectClass retained for exact VTank NAV interchange. + public int LegacyObjectClass { get; set; } + /// VTank's serialized coordinate-valid bit for Portal2/UseNPC. + public bool LegacyReferenceValid { get; set; } = true; + public string Text { get; set; } = string.Empty; + public int DurationMilliseconds { get; set; } = 5000; + public RouteRecallKind Recall { get; set; } + public uint RecallSpellId { get; set; } + public string RecallSpellName { get; set; } = string.Empty; + public float JumpHeadingDegrees { get; set; } + public bool JumpRun { get; set; } + public int JumpChargeMilliseconds { get; set; } = 1000; + public RouteJumpDirection JumpDirection { get; set; } + + public RouteWaypoint Clone() => new() + { + Type = Type, + Position = Position, + ObjectId = ObjectId, + ObjectName = ObjectName, + LegacyObjectClass = LegacyObjectClass, + LegacyReferenceValid = LegacyReferenceValid, + Text = Text, + DurationMilliseconds = DurationMilliseconds, + Recall = Recall, + RecallSpellId = RecallSpellId, + RecallSpellName = RecallSpellName, + JumpHeadingDegrees = JumpHeadingDegrees, + JumpRun = JumpRun, + JumpChargeMilliseconds = JumpChargeMilliseconds, + JumpDirection = JumpDirection, + }; + + public string DisplayText => Type switch + { + RouteWaypointType.Point => $"Point: {FormatPosition(Position)}", + RouteWaypointType.Portal => $"Portal: {ObjectLabel}", + RouteWaypointType.Recall => $"Recall: {RecallLabel}", + RouteWaypointType.Pause => string.Create( + CultureInfo.InvariantCulture, + $"Pause: {DurationMilliseconds / 1000d:0.###} seconds"), + RouteWaypointType.ChatCommand => $"Chat command: {Text}", + RouteWaypointType.OpenVendor => ObjectId == 0u + ? "Close Vendor" + : $"Open Vendor: {ObjectLabel}", + RouteWaypointType.PortalByName => $"Portal: {ObjectLabel}", + RouteWaypointType.UseNpc => $"Use NPC: {ObjectLabel}", + RouteWaypointType.Checkpoint => $"Checkpoint: {FormatPosition(Position)}", + RouteWaypointType.Jump => + $"Jump: {JumpHeadingDegrees.ToString("0.0", CultureInfo.InvariantCulture)}d, " + + $"{JumpChargeMilliseconds.ToString(CultureInfo.InvariantCulture)}ms" + + (JumpRun ? ", Shift" : string.Empty) + + $", {JumpDirectionDisplayName(JumpDirection)}", + _ => Type.ToString(), + }; + + private string ObjectLabel => string.IsNullOrWhiteSpace(ObjectName) + ? $"0x{ObjectId:X8}" + : ObjectName; + + internal string RecallLabel => !string.IsNullOrWhiteSpace(RecallSpellName) + ? RecallSpellName + : RecallSpellId != 0u + ? RecallSpellId.ToString(CultureInfo.InvariantCulture) + : RecallDisplayName(Recall); + + internal static string FormatPosition(in PluginNavigationPosition value) + { + string northSouth = value.NorthSouth >= 0d ? "N" : "S"; + string eastWest = value.EastWest >= 0d ? "E" : "W"; + return "(" + + Math.Abs(value.NorthSouth).ToString("0.###", CultureInfo.InvariantCulture) + + northSouth + + ", " + + Math.Abs(value.EastWest).ToString("0.###", CultureInfo.InvariantCulture) + + eastWest + + ")"; + } + + internal static string RecallDisplayName(RouteRecallKind value) => value switch + { + RouteRecallKind.Lifestone => "Lifestone Recall", + RouteRecallKind.Marketplace => "Marketplace Recall", + RouteRecallKind.PrimaryPortal => "Primary Portal Recall", + RouteRecallKind.SecondaryPortal => "Secondary Portal Recall", + _ => value.ToString(), + }; + + private static string JumpDirectionDisplayName(RouteJumpDirection value) => + value switch + { + RouteJumpDirection.StrafeLeft => "Strafe Left", + RouteJumpDirection.StrafeRight => "Strafe Right", + _ => "Forward", + }; +} + +internal sealed class NavigationSettings +{ + public bool Enabled { get; set; } + public bool Priority { get; set; } + public RouteMode Mode { get; set; } = RouteMode.Circular; + public double MinimumDistanceMeters { get; set; } = 2d; + /// + /// VTank's far stop range is the outer validity bound for a navigation + /// rule, not a second arrival threshold. + /// + public double MaximumDistanceMeters { get; set; } = 999999d * 240d; + public double PortalUseDistanceMeters { get; set; } = 4d; + public uint FollowTargetObjectId { get; set; } + public string FollowTargetName { get; set; } = string.Empty; + public bool FollowAroundCorners { get; set; } = true; + public bool OpenDoors { get; set; } + public double DoorIdentifyRangeMeters { get; set; } = 20d; + public double DoorOpenRangeMeters { get; set; } = 4d; + public int DoorLockpickExcessThreshold { get; set; } = -50; + public List Waypoints { get; } = []; +} + +/// +/// VTank's navigation state machine over acdream's canonical movement input. +/// Its steering constants are the official fd.cs behavior: turn outside four +/// degrees, continue forward while turning only inside 45 degrees when farther +/// than three metres (15 degrees when nearer), and stop at NavCloseStopRange. +/// +internal sealed class NavigationController +{ + private const float HeadingToleranceDegrees = 4f; + private const float FarMovingTurnLimitDegrees = 45f; + private const float NearMovingTurnLimitDegrees = 15f; + private const double NearTargetMeters = 3d; + private const double ChatInitialDelaySeconds = 0.2d; + private const double UseRetrySeconds = 2d; + private const double PortalTimeoutSeconds = 30d; + private const double ObjectReacquireRadiusMeters = 2.5d; + private const double PortalExitDistanceMeters = 15d; + private const double RecallExitDistanceMeters = 2.4d; + private const double JumpLaunchGraceSeconds = 0.25d; + private const double JumpCompletionTimeoutSeconds = 3d; + private const double CheckpointRetrySeconds = 15d; + private const double FollowBreadcrumbSpacingMeters = 0.096d; + private const double FollowPathCaptureRangeMeters = 240d; + private const double FollowPathArrivalMeters = 2.4d; + private const double DoorActionTimeoutSeconds = 5d; + private const uint LockpickPublicFlag = 0x00020000u; + private const uint LockpickSkillId = 23u; + private const uint LockpickModifierProperty = 40u; + + private readonly IPluginHost _host; + private readonly NavigationSettings _settings; + private int _index; + private bool _reverse; + private bool _onceComplete; + private RouteWaypoint? _activeAction; + private double _actionElapsed; + private double _retryElapsed; + private long _useCompletionBaseline; + private ulong _chatBaseline; + private bool _actionSent; + private bool _sawPortalSpace; + private bool _jumpReleased; + private bool _jumpAligned; + private bool _jumpSawAirborne; + private double _jumpChargeElapsed; + private double _jumpReleaseElapsed; + private double _checkpointElapsed; + private readonly List _followPath = []; + private uint _activeDoorObjectId; + private uint _activeLockpickObjectId; + private double _doorElapsed; + private double _doorRetryElapsed; + private PluginNavigationPosition _portalOrigin; + private bool _hasPortalOrigin; + private bool _hadMovementIntent; + private string _status = "Navigation disabled."; + + public NavigationController(IPluginHost host, NavigationSettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status => _status; + public int CurrentWaypointIndex => _index; + public bool Reversing => _reverse; + + public void ToggleReverse() + { + _reverse = !_reverse; + _status = $"Nav backwards is {_reverse}."; + } + + public void Reset() + { + StopMovement(); + _index = 0; + _reverse = false; + _onceComplete = false; + _checkpointElapsed = 0d; + _followPath.Clear(); + ClearDoor(); + ClearAction(); + _status = _settings.Enabled + ? "Route ready." + : "Navigation disabled."; + } + + public void ClearActionLocks() + { + StopMovement(); + _checkpointElapsed = 0d; + ClearDoor(); + ClearAction(); + _status = _settings.Enabled + ? "Route action locks cleared." + : "Navigation disabled."; + } + + public bool Tick(double elapsedSeconds, bool canAct) + { + elapsedSeconds = double.IsFinite(elapsedSeconds) + ? Math.Max(0d, elapsedSeconds) + : 0d; + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot snapshot = navigation.Snapshot; + if (!_settings.Enabled || !snapshot.IsAvailable) + { + StopMovement(); + _status = _settings.Enabled + ? "Waiting for the world." + : "Navigation disabled."; + return false; + } + if (snapshot.IsPortalSpace) + { + StopMovement(); + if (_activeAction?.Type is RouteWaypointType.Portal + or RouteWaypointType.PortalByName + or RouteWaypointType.Recall) + { + _sawPortalSpace = true; + } + _status = "Waiting for portal space."; + return _activeAction is not null; + } + if (!canAct) + { + StopMovement(); + _status = "Navigation paused."; + return false; + } + + if (TickDoor(navigation, snapshot, elapsedSeconds)) + return true; + + if (_settings.Mode == RouteMode.Target) + return TickFollow(navigation, snapshot); + if (_onceComplete || _settings.Waypoints.Count == 0) + { + StopMovement(); + _status = _onceComplete ? "Once route complete." : "Route is empty."; + return false; + } + + _index = Math.Clamp(_index, 0, _settings.Waypoints.Count - 1); + RouteWaypoint waypoint = _settings.Waypoints[_index]; + if (waypoint.Type == RouteWaypointType.Point) + { + double distance = snapshot.Position.HorizontalDistanceMeters( + waypoint.Position); + if (distance > BoundedMaximumDistance()) + { + StopMovement(); + _status = $"Waypoint is outside NavFarStopRange ({distance:0.0}m)."; + return false; + } + if (distance <= BoundedMinimumDistance()) + { + StopMovement(); + AdvanceWaypoint(); + return true; + } + _status = string.Create( + CultureInfo.InvariantCulture, + $"Waypoint {_index + 1}/{_settings.Waypoints.Count}: {distance:0.0}m"); + return Steer(navigation, snapshot.Position, waypoint.Position, distance); + } + if (waypoint.Type == RouteWaypointType.Checkpoint) + return TickCheckpoint(navigation, snapshot, waypoint, elapsedSeconds); + + StopMovement(); + return TickAction(waypoint, elapsedSeconds, snapshot); + } + + private bool TickFollow( + INavigationAutomation navigation, + in PluginNavigationSnapshot snapshot) + { + if (_settings.FollowTargetObjectId == 0u + || !navigation.TryGetObject( + _settings.FollowTargetObjectId, + out PluginNavigationObject target)) + { + StopMovement(); + _status = "Follow target unavailable."; + return false; + } + double distance = snapshot.Position.HorizontalDistanceMeters( + target.Position); + if (distance > BoundedMaximumDistance()) + { + StopMovement(); + _status = $"Follow target is outside NavFarStopRange ({distance:0.0}m)."; + return false; + } + if (distance <= BoundedMinimumDistance()) + { + StopMovement(); + _status = $"Following {target.Name}: holding {distance:0.0}m."; + return false; + } + PluginNavigationPosition destination = CaptureFollowDestination( + snapshot.Position, + target.Position); + double destinationDistance = snapshot.Position.HorizontalDistanceMeters( + destination); + _status = $"Following {target.Name}: {distance:0.0}m."; + return Steer(navigation, snapshot.Position, destination, destinationDistance); + } + + private PluginNavigationPosition CaptureFollowDestination( + in PluginNavigationPosition current, + in PluginNavigationPosition target) + { + if (!_settings.FollowAroundCorners) + { + _followPath.Clear(); + return target; + } + + if (_followPath.Count == 0 + || _followPath[^1].HorizontalDistanceMeters(target) + >= FollowBreadcrumbSpacingMeters) + { + _followPath.Add(target); + } + + for (int index = _followPath.Count - 1; index >= 1; index--) + { + if (DistanceToSegmentMeters( + current, + _followPath[index - 1], + _followPath[index]) < FollowPathArrivalMeters + && current.HorizontalDistanceMeters(_followPath[index]) + < FollowPathCaptureRangeMeters) + { + _followPath.RemoveRange(0, index); + break; + } + } + return _followPath.Count == 0 ? target : _followPath[0]; + } + + private bool TickDoor( + INavigationAutomation navigation, + in PluginNavigationSnapshot snapshot, + double elapsedSeconds) + { + if (!_settings.OpenDoors) + { + ClearDoor(); + return false; + } + + IReadOnlyList objects = navigation.CaptureObjects(); + PluginNavigationObject door = default; + bool found = false; + double nearest = _settings.DoorIdentifyRangeMeters; + foreach (PluginNavigationObject candidate in objects) + { + if (!candidate.IsDoor || candidate.IsOpen) + continue; + double distance = snapshot.Position.HorizontalDistanceMeters( + candidate.Position); + if (_activeDoorObjectId != 0u + && candidate.ObjectId == _activeDoorObjectId) + { + door = candidate; + nearest = distance; + found = true; + break; + } + if (_activeDoorObjectId == 0u && distance <= nearest) + { + door = candidate; + nearest = distance; + found = true; + } + } + + if (!found) + { + ClearDoor(); + return false; + } + if (!door.HasLockState) + { + if (nearest <= _settings.DoorIdentifyRangeMeters + && _host.Automation.Loot.Appraisal.AwaitingObjectId == 0u) + { + _ = _host.Automation.Loot.Identify(door.ObjectId); + } + if (nearest <= _settings.DoorOpenRangeMeters) + { + StopMovement(); + _status = $"Identifying door: {door.Name}."; + return true; + } + return false; + } + if (nearest > _settings.DoorOpenRangeMeters) + { + ClearDoor(); + return false; + } + + if (_activeDoorObjectId == 0u) + { + _activeDoorObjectId = door.ObjectId; + _activeLockpickObjectId = door.IsLocked + ? SelectLockpick(door.LockDifficulty) + : 0u; + if (door.IsLocked && _activeLockpickObjectId == 0u) + { + _status = $"Locked door skipped: {door.Name}."; + ClearDoor(); + return false; + } + } + + StopMovement(); + _doorElapsed += elapsedSeconds; + _doorRetryElapsed += elapsedSeconds; + if (_doorElapsed >= DoorActionTimeoutSeconds) + { + _status = $"Door timed out: {door.Name}."; + ClearDoor(); + return false; + } + if (_doorRetryElapsed == elapsedSeconds || _doorRetryElapsed >= UseRetrySeconds) + { + PluginItemCommandResult result = _activeLockpickObjectId == 0u + ? _host.Automation.Items.Use(door.ObjectId) + : _host.Automation.Items.Apply( + _activeLockpickObjectId, + door.ObjectId); + _doorRetryElapsed = 0d; + _status = result.Accepted + ? _activeLockpickObjectId == 0u + ? $"Opening door: {door.Name}." + : $"Picking lock: {door.Name}." + : $"Waiting for door: {door.Name}."; + } + return true; + } + + private uint SelectLockpick(int difficulty) + { + if (!_host.Automation.Character.TryGetSkill( + LockpickSkillId, + out PluginSkillInfo skill) + || skill.Current < Math.Max( + 0, + difficulty + _settings.DoorLockpickExcessThreshold)) + { + return 0u; + } + + uint selected = 0u; + double bestModifier = double.MinValue; + foreach (PluginInventoryItem item in _host.Automation.Items.CaptureOwnedItems()) + { + if ((item.PublicFlags & LockpickPublicFlag) == 0u) + continue; + double modifier = 0d; + if (_host.Automation.Items.TryCaptureProperties( + item.ObjectId, + out PluginItemProperties properties) + && properties.Floats.TryGetValue( + LockpickModifierProperty, + out double current)) + { + modifier = current; + } + if (modifier <= bestModifier) + continue; + bestModifier = modifier; + selected = item.ObjectId; + } + return selected; + } + + private void ClearDoor() + { + _activeDoorObjectId = 0u; + _activeLockpickObjectId = 0u; + _doorElapsed = 0d; + _doorRetryElapsed = 0d; + } + + private bool TickCheckpoint( + INavigationAutomation navigation, + in PluginNavigationSnapshot snapshot, + RouteWaypoint waypoint, + double elapsedSeconds) + { + double liveDistance = snapshot.Position.HorizontalDistanceMeters( + waypoint.Position); + if (liveDistance > BoundedMaximumDistance()) + { + StopMovement(); + _checkpointElapsed = 0d; + _status = $"Checkpoint is outside NavFarStopRange ({liveDistance:0.0}m)."; + return false; + } + if (liveDistance > BoundedMinimumDistance()) + { + _checkpointElapsed = 0d; + _status = $"Checkpoint {_index + 1}/{_settings.Waypoints.Count}: {liveDistance:0.0}m"; + return Steer( + navigation, + snapshot.Position, + waypoint.Position, + liveDistance); + } + + StopMovement(); + PluginNavigationPosition confirmed = snapshot.ConfirmedPositionRevision == 0UL + ? snapshot.Position + : snapshot.ConfirmedPosition; + double confirmedDistance = confirmed.HorizontalDistanceMeters( + waypoint.Position); + if (confirmedDistance <= BoundedMinimumDistance()) + { + _checkpointElapsed = 0d; + AdvanceWaypoint(); + return true; + } + + _checkpointElapsed += elapsedSeconds; + _status = $"Checkpoint: waiting for server ({confirmedDistance:0.0}m)."; + if (_checkpointElapsed >= CheckpointRetrySeconds) + { + _checkpointElapsed = 0d; + _hadMovementIntent = navigation.SetMovementIntent( + new PluginMovementIntent(Forward: true, Run: false)) + == PluginNavigationCommandStatus.Accepted; + _status = "Checkpoint: nudging for server confirmation."; + } + return true; + } + + private bool Steer( + INavigationAutomation navigation, + in PluginNavigationPosition current, + in PluginNavigationPosition target, + double distanceMeters) + { + float desired = DesiredHeading(current, target); + float delta = SignedHeadingDelta(current.HeadingDegrees, desired); + float absolute = Math.Abs(delta); + bool turnRight = delta > HeadingToleranceDegrees; + bool turnLeft = delta < -HeadingToleranceDegrees; + bool forward = absolute <= HeadingToleranceDegrees + || (distanceMeters > NearTargetMeters + ? absolute <= FarMovingTurnLimitDegrees + : absolute <= NearMovingTurnLimitDegrees); + var intent = new PluginMovementIntent( + Forward: forward, + TurnLeft: turnLeft, + TurnRight: turnRight, + Run: true); + PluginNavigationCommandStatus result = + navigation.SetMovementIntent(intent); + _hadMovementIntent = result == PluginNavigationCommandStatus.Accepted; + return _hadMovementIntent; + } + + private bool TickAction( + RouteWaypoint waypoint, + double elapsedSeconds, + in PluginNavigationSnapshot navigation) + { + if (!ReferenceEquals(_activeAction, waypoint)) + { + ClearAction(); + _activeAction = waypoint; + _useCompletionBaseline = _host.Automation.Items.LastCompletion.Revision; + _chatBaseline = _host.Automation.Chat.CaptureMessages(0) + .Select(static message => message.Sequence) + .DefaultIfEmpty() + .Max(); + } + _actionElapsed += elapsedSeconds; + _retryElapsed += elapsedSeconds; + + switch (waypoint.Type) + { + case RouteWaypointType.Pause: + _status = $"Pause: {Math.Max(0d, waypoint.DurationMilliseconds / 1000d - _actionElapsed):0.0}s"; + if (_actionElapsed * 1000d >= Math.Max(0, waypoint.DurationMilliseconds)) + CompleteAction(); + return true; + + case RouteWaypointType.ChatCommand: + _status = $"Chat command: {waypoint.Text}"; + if (_actionElapsed < ChatInitialDelaySeconds) + return true; + if (!_actionSent) + { + _actionSent = _host.Automation.Chat.Submit(waypoint.Text); + if (!_actionSent) + { + _status = "Chat command was refused."; + return true; + } + } + CompleteAction(); + return true; + + case RouteWaypointType.Recall: + return TickRecall(waypoint, navigation); + + case RouteWaypointType.Portal: + case RouteWaypointType.PortalByName: + case RouteWaypointType.UseNpc: + case RouteWaypointType.OpenVendor: + return TickUse(waypoint, navigation); + + case RouteWaypointType.Jump: + return TickJump(waypoint, elapsedSeconds, navigation); + + default: + CompleteAction(); + return true; + } + } + + private bool TickUse( + RouteWaypoint waypoint, + in PluginNavigationSnapshot navigation) + { + if (waypoint.Type is RouteWaypointType.PortalByName + or RouteWaypointType.UseNpc) + { + bool currentStillExists = waypoint.ObjectId != 0u + && _host.Automation.Navigation.TryGetObject( + waypoint.ObjectId, + out PluginNavigationObject current) + && (string.IsNullOrWhiteSpace(waypoint.ObjectName) + || current.Name.Equals( + waypoint.ObjectName, + StringComparison.OrdinalIgnoreCase)); + if (!currentStillExists) + { + if (!_host.Automation.Navigation.TryFindObject( + waypoint.ObjectName, + waypoint.Position, + ObjectReacquireRadiusMeters, + out PluginNavigationObject reacquired)) + { + _status = $"Finding {waypoint.ObjectName}."; + if (_actionElapsed >= PortalTimeoutSeconds) + CompleteAction(); + return true; + } + waypoint.ObjectId = reacquired.ObjectId; + _actionSent = false; + _retryElapsed = UseRetrySeconds; + } + } + if (waypoint.Type == RouteWaypointType.OpenVendor + && waypoint.ObjectId != 0u + && _host.Automation.Items.ActiveVendorObjectId == waypoint.ObjectId) + { + CompleteAction(); + return true; + } + if (waypoint.ObjectId == 0u) + { + _status = "Waypoint object is unavailable; continuing."; + CompleteAction(); + return true; + } + + if ((waypoint.Type is RouteWaypointType.Portal + or RouteWaypointType.PortalByName) + && _host.Automation.Navigation.TryGetObject( + waypoint.ObjectId, + out PluginNavigationObject portal)) + { + double distance = navigation.Position.HorizontalDistanceMeters( + portal.Position); + if (distance > Math.Clamp( + _settings.PortalUseDistanceMeters, + 0.5d, + 50d)) + { + _status = $"Approaching {waypoint.ObjectName} ({distance:0.0}m)."; + return Steer( + _host.Automation.Navigation, + navigation.Position, + portal.Position, + distance); + } + } + + if (waypoint.Type == RouteWaypointType.UseNpc + && HasNpcResponse(waypoint.ObjectName)) + { + CompleteAction(); + return true; + } + + PluginItemUseCompletion completion = + _host.Automation.Items.LastCompletion; + if (_actionSent + && completion.Revision > _useCompletionBaseline + && completion.SourceObjectId == waypoint.ObjectId) + { + _useCompletionBaseline = completion.Revision; + if (!completion.IsSuccess) + { + _actionSent = false; + _retryElapsed = UseRetrySeconds; + } + } + + if (navigation.IsPortalSpace) + _sawPortalSpace = true; + if (_sawPortalSpace && !navigation.IsPortalSpace) + { + if (waypoint.Type == RouteWaypointType.PortalByName + && _hasPortalOrigin + && navigation.Position.HorizontalDistanceMeters(_portalOrigin) + <= PortalExitDistanceMeters) + { + _sawPortalSpace = false; + _actionSent = false; + _retryElapsed = UseRetrySeconds; + _status = "Portal exit stayed near its origin; retrying."; + return true; + } + CompleteAction(); + return true; + } + if (_actionElapsed >= PortalTimeoutSeconds) + { + _status = $"Use timed out: {waypoint.ObjectName}."; + CompleteAction(); + return true; + } + if (!_actionSent || _retryElapsed >= UseRetrySeconds) + { + PluginItemCommandResult result = + _host.Automation.Items.Use(waypoint.ObjectId); + _actionSent |= result.Accepted; + if (result.Accepted && !_hasPortalOrigin) + { + _portalOrigin = navigation.Position; + _hasPortalOrigin = true; + } + _retryElapsed = 0d; + _status = result.Accepted + ? $"Using {waypoint.ObjectName}." + : $"Waiting to use {waypoint.ObjectName}."; + } + return true; + } + + private bool HasNpcResponse(string npcName) + { + IReadOnlyList messages = + _host.Automation.Chat.CaptureMessages(_chatBaseline); + foreach (PluginChatMessage message in messages) + { + _chatBaseline = Math.Max(_chatBaseline, message.Sequence); + if (message.Sender.Equals(npcName, StringComparison.OrdinalIgnoreCase) + || message.Text.StartsWith( + npcName + " tells you, ", + StringComparison.OrdinalIgnoreCase) + || message.Text.StartsWith( + npcName + " gives you", + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + private bool TickRecall( + RouteWaypoint waypoint, + in PluginNavigationSnapshot navigation) + { + if (navigation.IsPortalSpace) + _sawPortalSpace = true; + if (_sawPortalSpace && !navigation.IsPortalSpace) + { + if (!_hasPortalOrigin + || navigation.Position.HorizontalDistanceMeters(_portalOrigin) + > RecallExitDistanceMeters) + { + CompleteAction(); + return true; + } + _sawPortalSpace = false; + _actionSent = false; + _retryElapsed = UseRetrySeconds; + } + if (_actionElapsed >= PortalTimeoutSeconds) + { + _status = "Recall timed out; continuing route."; + CompleteAction(); + return true; + } + if (!_actionSent || _retryElapsed >= UseRetrySeconds) + { + bool accepted = SubmitRecall(waypoint); + _actionSent |= accepted; + if (accepted && !_hasPortalOrigin) + { + _portalOrigin = navigation.Position; + _hasPortalOrigin = true; + } + _retryElapsed = 0d; + } + _status = $"Recall: {waypoint.RecallLabel}."; + return true; + } + + private bool SubmitRecall(RouteWaypoint waypoint) + { + if (waypoint.RecallSpellId != 0u) + return _host.Automation.Magic.Cast(waypoint.RecallSpellId); + + RouteRecallKind recall = waypoint.Recall; + string? command = recall switch + { + RouteRecallKind.Lifestone => "/lifestone", + RouteRecallKind.Marketplace => "/marketplace", + _ => null, + }; + if (command is not null) + return _host.Automation.Chat.Submit(command); + + string needle = recall == RouteRecallKind.PrimaryPortal + ? "Primary Portal Recall" + : "Secondary Portal Recall"; + PluginSpellInfo? spell = _host.Automation.Spells.KnownSelfBuffs + .FirstOrDefault(value => value.Name.Equals( + needle, + StringComparison.OrdinalIgnoreCase)); + return spell is { SpellId: not 0u } found + && _host.Automation.Magic.Cast(found.SpellId); + } + + private bool TickJump( + RouteWaypoint waypoint, + double elapsedSeconds, + in PluginNavigationSnapshot navigation) + { + if (!_jumpReleased) + { + if (!_jumpAligned) + { + float delta = SignedHeadingDelta( + navigation.Position.HeadingDegrees, + waypoint.JumpHeadingDegrees); + if (Math.Abs(delta) > HeadingToleranceDegrees) + { + PluginMovementIntent turn = new( + TurnLeft: delta < 0f, + TurnRight: delta > 0f, + Run: waypoint.JumpRun); + _hadMovementIntent = _host.Automation.Navigation + .SetMovementIntent(turn) + == PluginNavigationCommandStatus.Accepted; + _status = $"Aligning jump: {Math.Abs(delta):0.0}d."; + return true; + } + _jumpAligned = true; + } + + _jumpChargeElapsed += elapsedSeconds; + bool hold = _jumpChargeElapsed * 1000d + < Math.Max(0, waypoint.JumpChargeMilliseconds); + if (hold) + { + PluginMovementIntent intent = JumpIntent(waypoint, jump: true); + _hadMovementIntent = _host.Automation.Navigation + .SetMovementIntent(intent) + == PluginNavigationCommandStatus.Accepted; + _status = $"Charging jump: {waypoint.JumpChargeMilliseconds}ms."; + return true; + } + PluginMovementIntent release = JumpIntent(waypoint, jump: false); + _ = _host.Automation.Navigation.SetMovementIntent(release); + _jumpReleased = true; + _jumpReleaseElapsed = 0d; + _status = "Jump released."; + return true; + } + _jumpReleaseElapsed += elapsedSeconds; + _jumpSawAirborne |= navigation.IsAirborne; + if (navigation.IsAirborne + || (!_jumpSawAirborne + && _jumpReleaseElapsed < JumpCompletionTimeoutSeconds) + || (_jumpSawAirborne + && _jumpReleaseElapsed < JumpLaunchGraceSeconds)) + return true; + CompleteAction(); + return true; + } + + private static PluginMovementIntent JumpIntent( + RouteWaypoint waypoint, + bool jump) => waypoint.JumpDirection switch + { + RouteJumpDirection.StrafeLeft => new PluginMovementIntent( + StrafeLeft: true, Run: waypoint.JumpRun, Jump: jump), + RouteJumpDirection.StrafeRight => new PluginMovementIntent( + StrafeRight: true, Run: waypoint.JumpRun, Jump: jump), + _ => new PluginMovementIntent( + Forward: true, Run: waypoint.JumpRun, Jump: jump), + }; + + private void CompleteAction() + { + ClearAction(); + AdvanceWaypoint(); + } + + private void AdvanceWaypoint() + { + ClearAction(); + _checkpointElapsed = 0d; + int count = _settings.Waypoints.Count; + if (count == 0) + return; + switch (_settings.Mode) + { + case RouteMode.Circular: + _index = !_reverse + ? (_index + 1) % count + : (_index - 1 + count) % count; + break; + case RouteMode.Linear: + if (!_reverse) + { + _index++; + if (_index >= count) + { + _index = Math.Max(0, count - 1); + _reverse = true; + } + } + else + { + _index--; + if (_index < 0) + { + _index = 0; + _reverse = false; + } + } + break; + case RouteMode.Once: + _settings.Waypoints.RemoveAt(_index); + _index = 0; + _onceComplete = _settings.Waypoints.Count == 0; + break; + } + } + + private void ClearAction() + { + _activeAction = null; + _actionElapsed = 0d; + _retryElapsed = 0d; + _useCompletionBaseline = 0; + _chatBaseline = 0; + _actionSent = false; + _sawPortalSpace = false; + _jumpReleased = false; + _jumpAligned = false; + _jumpSawAirborne = false; + _jumpChargeElapsed = 0d; + _jumpReleaseElapsed = 0d; + _portalOrigin = default; + _hasPortalOrigin = false; + } + + private void StopMovement() + { + if (!_hadMovementIntent) + return; + _ = _host.Automation.Navigation.ClearMovementIntent(); + _hadMovementIntent = false; + } + + private double BoundedMinimumDistance() => Math.Clamp( + _settings.MinimumDistanceMeters, + 0.5d, + 50d); + + private double BoundedMaximumDistance() => Math.Max( + BoundedMinimumDistance(), + _settings.MaximumDistanceMeters); + + internal static float DesiredHeading( + in PluginNavigationPosition from, + in PluginNavigationPosition to) + { + double dx = to.EastWest - from.EastWest; + double dy = to.NorthSouth - from.NorthSouth; + double heading = Math.Atan2(dx, dy) * 180d / Math.PI; + if (heading < 0d) + heading += 360d; + return (float)heading; + } + + internal static float SignedHeadingDelta(float current, float desired) + { + float delta = (desired - current) % 360f; + if (delta > 180f) + delta -= 360f; + else if (delta < -180f) + delta += 360f; + return delta; + } + + internal static double DistanceToSegmentMeters( + in PluginNavigationPosition point, + in PluginNavigationPosition start, + in PluginNavigationPosition end) + { + double dx = end.EastWest - start.EastWest; + double dy = end.NorthSouth - start.NorthSouth; + double lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= double.Epsilon) + return point.HorizontalDistanceMeters(start); + double projection = ((point.EastWest - start.EastWest) * dx + + (point.NorthSouth - start.NorthSouth) * dy) / lengthSquared; + projection = Math.Clamp(projection, 0d, 1d); + double nearestX = start.EastWest + projection * dx; + double nearestY = start.NorthSouth + projection * dy; + double deltaX = point.EastWest - nearestX; + double deltaY = point.NorthSouth - nearestY; + return Math.Sqrt(deltaX * deltaX + deltaY * deltaY) * 240d; + } +} diff --git a/src/AcDream.Plugins.MossTank/PetAutomation.cs b/src/AcDream.Plugins.MossTank/PetAutomation.cs new file mode 100644 index 00000000..b70c5b42 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/PetAutomation.cs @@ -0,0 +1,315 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum PetAutomationActionKind +{ + None, + Refill, + Summon, +} + +internal readonly record struct PetAutomationChoice( + PetAutomationActionKind Kind, + PluginInventoryItem Device, + PluginInventoryItem Tool, + PluginCombatTarget Target, + MonsterDamageType DamageType) +{ + public static PetAutomationChoice None => default; +} + +/// +/// VTank combat-pet policy. The host still owns inventory, item use and the +/// spawned pet; this type only chooses a device and waits for the exact +/// server UseDone receipt. +/// +internal sealed class PetAutomation +{ + private const double RetailPetCooldownSeconds = 45d; + private const double RefusalRetrySeconds = 1d; + + private long _observedCompletionRevision; + private uint _pendingSourceId; + private PetAutomationActionKind _pendingKind; + private double _nextSummonAt; + private double _nextRefillAt; + + public bool Tick( + IItemAutomation automation, + ICharacterInfo character, + IReadOnlyList targets, + CombatSettings settings, + double now, + out string status) + { + ArgumentNullException.ThrowIfNull(automation); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(settings); + + ObserveCompletion(automation.LastCompletion, now, out string? completion); + if (completion is not null) + status = completion; + else + status = string.Empty; + + if (_pendingSourceId != 0u) + { + status = _pendingKind == PetAutomationActionKind.Refill + ? "Refilling combat pet" + : "Summoning combat pet"; + return true; + } + if (!settings.SummonPets || !automation.IsAvailable) + return false; + if (automation.IsBusy) + { + status = "Waiting to use combat pet"; + return true; + } + + IReadOnlyList items = automation.CaptureOwnedItems(); + PetAutomationChoice choice = Select( + items, + targets, + character, + settings, + automation.ActiveOwnedPetCount, + now >= _nextRefillAt, + now >= _nextSummonAt); + if (choice.Kind == PetAutomationActionKind.None) + return false; + + PluginItemCommandResult result = choice.Kind == PetAutomationActionKind.Refill + ? automation.Apply(choice.Tool.ObjectId, choice.Device.ObjectId) + : automation.Use(choice.Device.ObjectId); + if (result.Status == PluginItemCommandStatus.Started) + { + _pendingSourceId = choice.Kind == PetAutomationActionKind.Refill + ? choice.Tool.ObjectId + : choice.Device.ObjectId; + _pendingKind = choice.Kind; + status = choice.Kind == PetAutomationActionKind.Refill + ? $"Refilling {choice.Device.Name}" + : $"Summoning {choice.Device.Name} for {choice.Target.Name}"; + return true; + } + + if (choice.Kind == PetAutomationActionKind.Refill) + _nextRefillAt = now + RefusalRetrySeconds; + else + _nextSummonAt = now + RefusalRetrySeconds; + status = result.Notice + ?? $"Combat pet action refused: {result.Status}"; + return true; + } + + internal static PetAutomationChoice Select( + IReadOnlyList items, + IReadOnlyList targets, + ICharacterInfo character, + CombatSettings settings, + int activeOwnedPetCount, + bool allowRefill, + bool allowSummon) + { + if (!settings.SummonPets || activeOwnedPetCount > 0) + return PetAutomationChoice.None; + + float range = settings.PetRangeMode == PetRangeMode.Custom + ? settings.PetCustomRange + : settings.MaximumRange; + int density = Math.Max(1, settings.PetMonsterDensity); + var eligible = new List<(PluginCombatTarget Target, ResolvedMonsterRule Rule)>(); + foreach (PluginCombatTarget target in targets) + { + if (target.Distance > range) + continue; + ResolvedMonsterRule rule = settings.ResolveRule(target); + if (rule.Priority < 0 + || rule.Actions.PetDamageType == MonsterDamageType.None) + { + continue; + } + eligible.Add((target, rule)); + } + if (eligible.Count < density) + return PetAutomationChoice.None; + + eligible.Sort(static (left, right) => + { + int priority = right.Rule.Priority.CompareTo(left.Rule.Priority); + return priority != 0 + ? priority + : left.Target.Distance.CompareTo(right.Target.Distance); + }); + (PluginCombatTarget selectedTarget, ResolvedMonsterRule targetRule) = eligible[0]; + MonsterDamageType desired = ResolveDesiredDamage(targetRule.Actions); + + PluginInventoryItem? device = SelectDevice( + items, + character, + desired, + settings, + allowFallback: targetRule.Actions.PetDamageType + == MonsterDamageType.PlayerAuto); + if (device is not { } selected) + return PetAutomationChoice.None; + + int refillThreshold = Math.Max(0, settings.PetRefillCountNormal); + if (allowRefill + && selected.MaximumStructure > 0 + && selected.Structure <= refillThreshold + && selected.Structure < selected.MaximumStructure + && FindSpirit(items) is { } spirit) + { + return new PetAutomationChoice( + PetAutomationActionKind.Refill, + selected, + spirit, + selectedTarget, + desired); + } + if (!allowSummon || selected.Structure <= 0) + return PetAutomationChoice.None; + return new PetAutomationChoice( + PetAutomationActionKind.Summon, + selected, + default, + selectedTarget, + desired); + } + + private static MonsterDamageType ResolveDesiredDamage( + MonsterRuleActions actions) + { + if (actions.PetDamageType != MonsterDamageType.PlayerAuto) + return actions.PetDamageType; + return actions.DamageType is + MonsterDamageType.Bludgeon or MonsterDamageType.Acid + or MonsterDamageType.Fire or MonsterDamageType.Cold + or MonsterDamageType.Electric + ? actions.DamageType + : MonsterDamageType.Auto; + } + + private static PluginInventoryItem? SelectDevice( + IReadOnlyList items, + ICharacterInfo character, + MonsterDamageType desired, + CombatSettings settings, + bool allowFallback) + { + PluginInventoryItem? exact = null; + PluginInventoryItem? fallback = null; + foreach (PluginInventoryItem item in items) + { + if (!settings.CombatItemObjectIds.Contains(item.ObjectId) + && !settings.CombatItemNames.Contains(item.Name)) + { + continue; + } + if (!item.IsPetDevice || !CanUse(item, character)) + continue; + MonsterDamageType damage = PetDeviceCatalog.DamageType( + item.WeenieClassId); + if (fallback is null || Better(item, fallback.Value)) + fallback = item; + if (desired != MonsterDamageType.Auto && damage != desired) + continue; + if (exact is null || Better(item, exact.Value)) + exact = item; + } + if (exact is not null) + return exact; + return desired == MonsterDamageType.Auto || allowFallback + ? fallback + : null; + } + + private static bool CanUse( + in PluginInventoryItem item, + ICharacterInfo character) + { + if (item.SummoningMastery != 0 + && item.SummoningMastery != character.SummoningMastery) + { + return false; + } + if (item.UseRequiresSkill == 0) + return true; + if (!character.TryGetSkill((uint)item.UseRequiresSkill, out PluginSkillInfo skill) + || skill.Current < item.UseRequiresSkillLevel) + { + return false; + } + return item.UseRequiresSkillSpecialized == 0 + || skill.Training == PluginSkillTraining.Specialized; + } + + private static bool Better( + in PluginInventoryItem candidate, + in PluginInventoryItem incumbent) + { + int candidateRating = candidate.GearDamage + + candidate.GearCriticalChance + + candidate.GearCriticalDamage; + int incumbentRating = incumbent.GearDamage + + incumbent.GearCriticalChance + + incumbent.GearCriticalDamage; + if (candidate.UseRequiresSkillLevel != incumbent.UseRequiresSkillLevel) + return candidate.UseRequiresSkillLevel > incumbent.UseRequiresSkillLevel; + if (candidateRating != incumbentRating) + return candidateRating > incumbentRating; + if (candidate.Structure != incumbent.Structure) + return candidate.Structure > incumbent.Structure; + return candidate.ObjectId < incumbent.ObjectId; + } + + private static PluginInventoryItem? FindSpirit( + IReadOnlyList items) + { + foreach (PluginInventoryItem item in items) + { + if (item.WeenieClassId == PetDeviceCatalog.EncapsulatedSpiritWeenieClassId + && item.StackSize > 0) + { + return item; + } + } + return null; + } + + private void ObserveCompletion( + PluginItemUseCompletion completion, + double now, + out string? status) + { + status = null; + if (completion.Revision == 0 + || completion.Revision == _observedCompletionRevision) + { + return; + } + _observedCompletionRevision = completion.Revision; + if (_pendingSourceId == 0u + || completion.SourceObjectId != _pendingSourceId) + { + return; + } + + PetAutomationActionKind completed = _pendingKind; + _pendingSourceId = 0u; + _pendingKind = PetAutomationActionKind.None; + if (completed == PetAutomationActionKind.Summon) + _nextSummonAt = now + RetailPetCooldownSeconds; + else + _nextRefillAt = now + RefusalRetrySeconds; + status = completion.IsSuccess + ? completed == PetAutomationActionKind.Summon + ? "Combat pet summoned" + : "Combat pet refilled" + : $"Combat pet failed (0x{completion.WeenieError:X})"; + } +} diff --git a/src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs b/src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs new file mode 100644 index 00000000..43f7dde6 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs @@ -0,0 +1,51 @@ +namespace AcDream.Plugins.MossTank; + +/// +/// End-of-retail combat-pet device element table. The device WCIDs and damage +/// types are the server content mapping consumed by PetDevice; keeping the data +/// here lets MossTank implement VTank's PetDmg column without guessing from an +/// item's localized name. +/// +internal static class PetDeviceCatalog +{ + public const uint EncapsulatedSpiritWeenieClassId = 49485u; + + public static MonsterDamageType DamageType(uint deviceWeenieClassId) => + deviceWeenieClassId switch + { + 48878u or 48880u or 48882u or 48884u or 48886u or 48888u or 48890u => MonsterDamageType.Bludgeon, + 48972u or 49213u or 49214u or 49215u or 49216u or 49217u or 49218u or 49219u + or 49234u or 49235u or 49236u or 49237u or 49238u or 49239u or 49261u or 49262u + or 49263u or 49264u or 49265u or 49266u or 49267u or 49282u or 49283u or 49284u + or 49285u or 49286u or 49287u or 49288u or 49310u or 49311u or 49312u or 49313u + or 49314u or 49315u or 49316u or 49338u or 49339u or 49340u or 49341u or 49342u + or 49343u or 49344u or 49366u or 49367u or 49368u or 49369u or 49370u or 49371u + or 49372u or 49421u or 49422u or 49423u or 49424u or 49425u or 49426u or 49427u + or 49524u or 49525u or 49526u or 49527u or 49528u or 49529u or 49530u => MonsterDamageType.Acid, + 48942u or 48944u or 48945u or 48946u or 48947u or 48948u or 48956u or 48957u + or 48959u or 48961u or 48963u or 48965u or 48967u or 48969u or 49247u or 49248u + or 49249u or 49250u or 49251u or 49252u or 49253u or 49296u or 49297u or 49298u + or 49299u or 49300u or 49301u or 49302u or 49324u or 49325u or 49326u or 49327u + or 49328u or 49329u or 49330u or 49352u or 49353u or 49354u or 49355u or 49356u + or 49357u or 49358u or 49380u or 49381u or 49382u or 49383u or 49384u or 49385u + or 49386u or 49435u or 49436u or 49437u or 49438u or 49439u or 49440u or 49441u + or 49531u or 49532u or 49533u or 49534u or 49535u or 49536u or 49537u => MonsterDamageType.Fire, + 49212u or 49227u or 49228u or 49229u or 49230u or 49231u or 49232u or 49233u + or 49254u or 49255u or 49256u or 49257u or 49258u or 49259u or 49260u or 49275u + or 49276u or 49277u or 49278u or 49279u or 49280u or 49281u or 49303u or 49304u + or 49305u or 49306u or 49307u or 49308u or 49309u or 49331u or 49332u or 49333u + or 49334u or 49335u or 49336u or 49337u or 49359u or 49360u or 49361u or 49362u + or 49363u or 49364u or 49365u or 49387u or 49388u or 49389u or 49390u or 49391u + or 49392u or 49442u or 49443u or 49444u or 49445u or 49446u or 49447u or 49448u + or 49538u or 49539u or 49540u or 49541u or 49542u or 49543u or 49544u => MonsterDamageType.Cold, + 49220u or 49221u or 49222u or 49223u or 49224u or 49225u or 49226u or 49240u + or 49241u or 49242u or 49243u or 49244u or 49245u or 49246u or 49268u or 49269u + or 49270u or 49271u or 49272u or 49273u or 49274u or 49289u or 49290u or 49291u + or 49292u or 49293u or 49294u or 49295u or 49317u or 49318u or 49319u or 49320u + or 49321u or 49322u or 49323u or 49345u or 49346u or 49347u or 49348u or 49349u + or 49350u or 49351u or 49373u or 49374u or 49375u or 49376u or 49377u or 49378u + or 49379u or 49428u or 49429u or 49430u or 49431u or 49432u or 49433u or 49434u + or 49545u or 49546u or 49547u or 49548u or 49549u or 49550u or 49551u => MonsterDamageType.Electric, + _ => MonsterDamageType.Auto, + }; +} diff --git a/src/AcDream.Plugins.MossTank/ProfileGiveController.cs b/src/AcDream.Plugins.MossTank/ProfileGiveController.cs new file mode 100644 index 00000000..965ff5dc --- /dev/null +++ b/src/AcDream.Plugins.MossTank/ProfileGiveController.cs @@ -0,0 +1,247 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// UtilityBelt-compatible named-profile item giver. It classifies a stable +/// inventory snapshot up front and then submits exactly one canonical give at +/// a time, advancing only after the server completion or the owned-object view +/// confirms that the item left inventory. +/// +internal sealed class ProfileGiveController +{ + private const double GiveTimeoutSeconds = 10d; + private const int MaximumAttemptsPerItem = 5; + + private static readonly PluginItemProperties EmptyProperties = new( + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary()); + + private readonly IPluginHost _host; + private readonly MossTankLootProfileStore _profiles; + private readonly Queue _pending = new(); + private uint _targetObjectId; + private uint _waitingObjectId; + private long _completionRevision; + private double _waitingSeconds; + private int _attempts; + private int _given; + private string _profileName = string.Empty; + private string _targetName = string.Empty; + + public ProfileGiveController( + IPluginHost host, + MossTankLootProfileStore profiles) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _profiles = profiles ?? throw new ArgumentNullException(nameof(profiles)); + } + + public bool IsRunning { get; private set; } + public string Status { get; private set; } = "Item giver idle."; + + public bool TryStart(string? profileName, string? targetName) + { + if (IsRunning || !_host.Automation.IsAvailable) + return false; + + string requestedProfile = profileName?.Trim() ?? string.Empty; + string requestedTarget = targetName?.Trim() ?? string.Empty; + PluginWorldObject target = _host.Automation.Objects.CaptureObjects() + .Where(obj => obj.ObjectClass is PluginObjectClass.Player + or PluginObjectClass.Npc) + .Where(obj => obj.Name.Equals( + requestedTarget, + StringComparison.OrdinalIgnoreCase)) + .Where(obj => obj.ObjectId != _host.Automation.Character.ObjectId) + .OrderBy(obj => DistanceFromPlayer(obj)) + .ThenBy(static obj => obj.ObjectId) + .FirstOrDefault(); + if (target.ObjectId == 0u) + { + Status = $"Item giver target not found: {requestedTarget}."; + return false; + } + + var rules = new List(); + if (!_profiles.TryLoadNamed(requestedProfile, rules)) + { + Status = $"Item giver profile not found: {requestedProfile}."; + return false; + } + + IReadOnlyList owned = + _host.Automation.Items.CaptureOwnedItems(); + _pending.Clear(); + foreach (PluginInventoryItem item in owned + .Where(static item => !item.IsEquipped && item.WielderObjectId == 0u) + .OrderBy(static item => item.ObjectId)) + { + PluginItemProperties properties = _host.Automation.Items + .TryCaptureProperties(item.ObjectId, out PluginItemProperties value) + ? value + : EmptyProperties; + if (MatchesGiveProfile(item, properties, rules)) + _pending.Enqueue(item.ObjectId); + } + + _targetObjectId = target.ObjectId; + _profileName = requestedProfile; + _targetName = target.Name; + _waitingObjectId = 0u; + _attempts = 0; + _given = 0; + _waitingSeconds = 0d; + IsRunning = true; + Status = _pending.Count == 0 + ? $"No items match {_profileName}." + : $"Giving {_pending.Count} item(s) to {_targetName}."; + return true; + } + + public bool Tick(double elapsedSeconds, bool canAct) + { + if (!IsRunning) + return false; + if (!_host.Automation.IsAvailable + || !_host.Automation.Objects.TryGet( + _targetObjectId, + out PluginWorldObject target) + || target.ObjectClass is not (PluginObjectClass.Player + or PluginObjectClass.Npc)) + { + Stop("Item giver stopped: target vanished."); + return false; + } + + if (_waitingObjectId != 0u) + { + _waitingSeconds += Math.Max(0d, elapsedSeconds); + PluginInventoryCompletion completion = + _host.Automation.Items.LastInventoryCompletion; + bool itemStillOwned = _host.Automation.Items.CaptureOwnedItems() + .Any(item => item.ObjectId == _waitingObjectId); + if (!itemStillOwned + || (completion.Revision > _completionRevision + && completion.Kind == PluginInventoryCommandKind.Give + && completion.SourceObjectId == _waitingObjectId)) + { + if (!itemStillOwned || completion.IsSuccess) + _given++; + _pending.Dequeue(); + _waitingObjectId = 0u; + _attempts = 0; + _waitingSeconds = 0d; + } + else if (_waitingSeconds >= GiveTimeoutSeconds) + { + if (_attempts >= MaximumAttemptsPerItem) + { + _pending.Dequeue(); + _waitingObjectId = 0u; + _attempts = 0; + _waitingSeconds = 0d; + } + else + { + _waitingObjectId = 0u; + _waitingSeconds = 0d; + } + } + return true; + } + + if (_pending.Count == 0) + { + Stop($"Item giver finished: {_given} item(s) given to {_targetName}."); + return false; + } + if (!canAct || _host.Automation.Items.IsBusy) + return true; + + uint objectId = _pending.Peek(); + if (!_host.Automation.Items.CaptureOwnedItems() + .Any(item => item.ObjectId == objectId)) + { + _pending.Dequeue(); + return true; + } + + long baselineRevision = + _host.Automation.Items.LastInventoryCompletion.Revision; + PluginItemCommandResult result = _host.Automation.Items.Give( + objectId, + _targetObjectId); + if (result.Accepted) + { + _waitingObjectId = objectId; + _completionRevision = baselineRevision; + _waitingSeconds = 0d; + _attempts++; + Status = $"Giving item {_given + 1} to {_targetName}…"; + } + else if (result.Status is PluginItemCommandStatus.InvalidItem + or PluginItemCommandStatus.InvalidTarget + or PluginItemCommandStatus.Refused + or PluginItemCommandStatus.Unavailable) + { + _pending.Dequeue(); + _attempts = 0; + } + return true; + } + + public void Reset() + { + _pending.Clear(); + _targetObjectId = 0u; + _waitingObjectId = 0u; + _completionRevision = 0; + _waitingSeconds = 0d; + _attempts = 0; + _given = 0; + IsRunning = false; + Status = "Item giver idle."; + } + + private bool MatchesGiveProfile( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList rules) + { + foreach (LootRule rule in rules) + { + if (!rule.IsMatch(item, properties, _host, out _)) + continue; + return rule.Action is LootAction.Keep or LootAction.KeepUpTo; + } + return false; + } + + private double DistanceFromPlayer(in PluginWorldObject target) + { + PluginNavigationSnapshot player = _host.Automation.Navigation.Snapshot; + if (!player.IsAvailable || !target.HasPosition) + return double.MaxValue; + double dx = target.Position.NorthSouth - player.Position.NorthSouth; + double dy = target.Position.EastWest - player.Position.EastWest; + return Math.Sqrt((dx * dx) + (dy * dy)); + } + + private void Stop(string status) + { + _pending.Clear(); + _targetObjectId = 0u; + _waitingObjectId = 0u; + _completionRevision = 0; + _waitingSeconds = 0d; + _attempts = 0; + IsRunning = false; + Status = status; + } +} diff --git a/src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs b/src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs new file mode 100644 index 00000000..dda867cb --- /dev/null +++ b/src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank's BlacklistedSpellComps gate over the component metadata projected by +/// the host. The legacy setting serializes component id/name pairs, so both +/// forms are accepted for imported profiles. +/// +internal static class SpellComponentPolicy +{ + public static bool UsesBlacklistedComponent( + ISpellCatalog catalog, + in PluginSpellInfo spell, + string setting) + { + if (string.IsNullOrWhiteSpace(setting) + || spell.FormulaComponentIds.Count == 0) + { + return false; + } + foreach (uint componentId in spell.FormulaComponentIds) + { + if (ContainsNumber(setting, componentId)) + return true; + if (!catalog.TryGetComponent( + componentId, + out PluginSpellComponentInfo component)) + { + continue; + } + if (component.Name.Length != 0 + && setting.Contains( + component.Name, + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (ContainsNumber(setting, component.WeenieClassId)) + return true; + } + return false; + } + + private static bool ContainsNumber(string setting, uint value) + { + if (value == 0u) + return false; + string decimalText = value.ToString(CultureInfo.InvariantCulture); + string hexText = value.ToString("X", CultureInfo.InvariantCulture); + return ContainsDelimited(setting, decimalText) + || setting.Contains("0x" + hexText, StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsDelimited(string text, string token) + { + int start = 0; + while ((start = text.IndexOf( + token, + start, + StringComparison.OrdinalIgnoreCase)) >= 0) + { + int end = start + token.Length; + bool left = start == 0 || !char.IsDigit(text[start - 1]); + bool right = end == text.Length || !char.IsDigit(text[end]); + if (left && right) + return true; + start = end; + } + return false; + } +} diff --git a/src/AcDream.Plugins.MossTank/VitalPlan.cs b/src/AcDream.Plugins.MossTank/VitalPlan.cs index 1062baef..637b3f68 100644 --- a/src/AcDream.Plugins.MossTank/VitalPlan.cs +++ b/src/AcDream.Plugins.MossTank/VitalPlan.cs @@ -2,91 +2,184 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; -/// What MossTank wants to do about the character's vitals right now. +internal enum VitalKind +{ + Health = 2, + Stamina = 4, + Mana = 6, +} + +/// What the legacy conversion-only helper wants to do. public enum VitalAction { None = 0, - /// Convert stamina into mana. StaminaToMana, - /// Restore stamina, so stamina-to-mana has something to convert. Revitalize, } -/// Thresholds for vital upkeep, following VTank's Recharge-* settings. +/// +/// VTank's nine Recharge-* sliders and its recharge-handler options. +/// Values are normalized 0..1 at the plugin/UI seam; VTank stores percentages. +/// public sealed class VitalSettings { - /// - /// Whether to convert vitals at all. VTank does this by default through its - /// Recharge-* thresholds, but it is surprising the first time a buff pass - /// spends your stamina, so it is worth being able to turn off. - /// public bool Enabled { get; set; } = true; - /// Convert stamina to mana below this fraction of max mana. - public double ManaFloor { get; set; } = 0.50; + // defaultsettings.usd, verbatim. + public double NormalHealth { get; set; } = 0.75; + public double NormalStamina { get; set; } = 0.50; + public double NormalMana { get; set; } = 0.50; + public double NoTargetHealth { get; set; } = 0.01; + public double NoTargetStamina { get; set; } = 0.01; + public double NoTargetMana { get; set; } = 0.01; + public double HelperHealth { get; set; } = 0.20; + public double HelperStamina { get; set; } = 0.01; + public double HelperMana { get; set; } = 0.01; + public float HelperHealthDistance { get; set; } = 59.6f; + public float HelperStaminaDistance { get; set; } = 59.6f; + public float HelperManaDistance { get; set; } = 32f; - /// Stop converting once mana is back above this fraction. - public double ManaTarget { get; set; } = 0.85; + public bool HelpOthers { get; set; } = true; + public bool UseHealersHeart { get; set; } = true; + public double RechargeBoostTimeSeconds { get; set; } = 5d; + public int RechargeBoostAmount { get; set; } = 40; + public bool ClearLevelBoostFlagOnCast { get; set; } = true; + public int DropToPeaceModeRetryCount { get; set; } = 34; + public string RechargeHandlerSet { get; set; } = "RechargeHandlerSet"; + public bool UseKitsInMagicMode { get; set; } = true; + public bool GoToPeaceModeToUseKits { get; set; } + public int MinimumHealKitSuccessChance { get; set; } = 95; + public double StaminaToHealthMultiplier { get; set; } = 1.9; + public double ManaToHealthMultiplier { get; set; } = 2.8; + public bool CastDispelSelf { get; set; } + public bool UseDispelItems { get; set; } + public bool UseDispelDrum { get; set; } - /// Refuse to drain stamina below this fraction — the conversion - /// takes half your stamina, and stranding the character at zero is worse - /// than being short of mana. - public double StaminaFloor { get; set; } = 0.35; + // Compatibility aliases for the first MossTank prototype. Keeping them + // avoids breaking plugin-side callers while the implementation now follows + // VTank's actual nine-threshold model. + public double ManaFloor + { + get => NormalMana; + set => NormalMana = Clamp(value); + } + + public double ManaTarget + { + get => NormalMana; + set => NormalMana = Clamp(value); + } + + public double StaminaFloor + { + get => NormalStamina; + set => NormalStamina = Clamp(value); + } + + internal double Threshold(VitalKind vital, bool noTarget) => vital switch + { + VitalKind.Health => noTarget + ? Math.Max(NormalHealth, NoTargetHealth) + : NormalHealth, + VitalKind.Stamina => noTarget + ? Math.Max(NormalStamina, NoTargetStamina) + : NormalStamina, + VitalKind.Mana => noTarget + ? Math.Max(NormalMana, NoTargetMana) + : NormalMana, + _ => 0d, + }; + + internal double NormalThreshold(VitalKind vital) => vital switch + { + VitalKind.Health => NormalHealth, + VitalKind.Stamina => NormalStamina, + VitalKind.Mana => NormalMana, + _ => 0d, + }; + + private static double Clamp(double value) => Math.Clamp(value, 0d, 1d); } -/// -/// Picks the vital-upkeep spell to cast, if any. -/// -/// -/// -/// The loop the user asked for: when mana runs low, convert stamina into mana; -/// when that leaves stamina low, restore stamina with Revitalize, which lets -/// the conversion continue. -/// -/// -/// These spells cannot be identified by family. Retail groups the vital -/// transfers by source vital, so family 89 contains both "Stamina to -/// Health" and "Stamina to Mana", and family 87 both "Health to Mana" and -/// "Health to Stamina". Picking the strongest tier in a family would therefore -/// convert into the wrong vital roughly half the time. They are identified by -/// their retail name stem instead, which is stable and comes from the same -/// spell table. -/// -/// +/// Pure retail threshold and spell-selection policy. public static class VitalPlan { + public const string HealSelfStem = "Heal Self"; public const string StaminaToManaStem = "Stamina to Mana"; public const string RevitalizeStem = "Revitalize"; - public static VitalAction Decide(ICharacterInfo character, VitalSettings settings) + internal static VitalKind? DecideNeed( + ICharacterInfo character, + VitalSettings settings, + bool noTarget, + int healthCurrentAdjustment = 0, + int staminaCurrentAdjustment = 0, + int manaCurrentAdjustment = 0) { if (!settings.Enabled) - return VitalAction.None; + return null; - double mana = Fraction(character.CurrentMana, character.MaxMana); - double stamina = Fraction(character.CurrentStamina, character.MaxStamina); - - // Unknown vitals (no session, or nothing published yet) must not be - // read as "empty" — that would cast on a character that is fine. - if (character.MaxMana == 0 || character.MaxStamina == 0) - return VitalAction.None; - - if (mana >= settings.ManaTarget) - return VitalAction.None; - - if (mana < settings.ManaFloor) + // cr.cs checks in this exact order. + foreach (VitalKind vital in new[] + { + VitalKind.Health, + VitalKind.Stamina, + VitalKind.Mana, + }) { - return stamina > settings.StaminaFloor - ? VitalAction.StaminaToMana - : VitalAction.Revitalize; + (uint current, uint maximum) = Read(character, vital); + if (maximum == 0u) + continue; + int adjustment = vital switch + { + VitalKind.Health => healthCurrentAdjustment, + VitalKind.Stamina => staminaCurrentAdjustment, + VitalKind.Mana => manaCurrentAdjustment, + _ => 0, + }; + current = adjustment <= 0 + ? current + : (uint)Math.Max(0L, (long)current - adjustment); + if ((double)current / maximum < settings.Threshold(vital, noTarget)) + return vital; } + return null; + } - return VitalAction.None; + internal static bool IsBelowNormal( + ICharacterInfo character, + VitalSettings settings, + VitalKind vital) + { + (uint current, uint maximum) = Read(character, vital); + return maximum != 0u + && (double)current / maximum < settings.NormalThreshold(vital); + } + + internal static int Percent(ICharacterInfo character, VitalKind vital) + { + (uint current, uint maximum) = Read(character, vital); + return maximum == 0u ? 100 : (int)(100u * current / maximum); } /// - /// The strongest castable spell whose name contains . + /// Compatibility helper for the original MossTank mana-conversion tests. + /// The executable controller now uses . /// + public static VitalAction Decide(ICharacterInfo character, VitalSettings settings) + { + if (!settings.Enabled || character.MaxMana == 0 || character.MaxStamina == 0) + return VitalAction.None; + double mana = (double)character.CurrentMana / character.MaxMana; + if (mana >= settings.NormalMana) + return VitalAction.None; + double stamina = (double)character.CurrentStamina / character.MaxStamina; + return stamina > settings.NormalStamina + ? VitalAction.StaminaToMana + : VitalAction.Revitalize; + } + + /// The strongest castable learned spell whose name contains a stem. public static bool TryFind( IReadOnlyList known, string stem, @@ -96,7 +189,6 @@ public static class VitalPlan { pick = default; bool found = false; - foreach (PluginSpellInfo spell in known) { if (spell.Name.IndexOf(stem, StringComparison.OrdinalIgnoreCase) < 0) @@ -107,7 +199,9 @@ public static class VitalPlan { continue; } - if (!found || spell.Tier > pick.Tier) + if (!found + || spell.Quality > pick.Quality + || (spell.Quality == pick.Quality && spell.Tier > pick.Tier)) { pick = spell; found = true; @@ -116,6 +210,13 @@ public static class VitalPlan return found; } - private static double Fraction(uint current, uint max) => - max == 0 ? 1.0 : (double)current / max; + private static (uint Current, uint Maximum) Read( + ICharacterInfo character, + VitalKind vital) => vital switch + { + VitalKind.Health => (character.CurrentHealth, character.MaxHealth), + VitalKind.Stamina => (character.CurrentStamina, character.MaxStamina), + VitalKind.Mana => (character.CurrentMana, character.MaxMana), + _ => (0u, 0u), + }; } diff --git a/src/AcDream.Plugins.MossTank/VitalRecharge.cs b/src/AcDream.Plugins.MossTank/VitalRecharge.cs new file mode 100644 index 00000000..9785a94f --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VitalRecharge.cs @@ -0,0 +1,1103 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum VitalRechargeSourceKind +{ + LearnedSpell, + CasterItem, + Kit, + Food, +} + +internal readonly record struct VitalRechargeChoice( + VitalKind Vital, + VitalRechargeSourceKind SourceKind, + string Name, + uint SpellId, + uint ItemObjectId, + PluginCombatMode? RequiredMode) +{ + public bool UsesItem => SourceKind != VitalRechargeSourceKind.LearnedSpell; + public uint TargetObjectId { get; init; } +} + +internal enum VitalRechargeMethod +{ + RegularSpell, + StaminaToHealth, + ManaToHealth, + HealthToStamina, + HealthToMana, + Kit, + Food, +} + +/// +/// VTank's default RechargeHandlerSet, including its stance- and +/// current-percentage-dependent order. The host supplies raw inventory and +/// spell data; this class owns all policy. +/// +internal static class VitalRechargePlanner +{ + private const uint HealingSkill = 21u; + private const uint CasterItemType = 0x00008000u; + + public static bool TryPlan( + VitalKind vital, + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + out VitalRechargeChoice choice) + { + ArgumentNullException.ThrowIfNull(automation); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(combatSettings); + + PluginCombatMode mode = automation.Combat.Snapshot.Mode; + int percent = VitalPlan.Percent(automation.Character, vital); + IReadOnlyList handlers = Handlers( + vital, + mode == PluginCombatMode.Magic, + percent, + settings.RechargeHandlerSet); + IReadOnlyList items = + automation.Items.CaptureOwnedItems(); + + foreach (VitalRechargeMethod handler in handlers) + { + if (TryHandler( + handler, + vital, + automation, + settings, + combatSettings, + items, + out choice)) + { + return true; + } + } + choice = default; + return false; + } + + public static bool TryPlanHelper( + IAutomationSurface automation, + VitalSettings settings, + out VitalRechargeChoice choice) => TryPlanHelper( + automation, + settings, + new CombatSettings(), + out choice); + + public static bool TryPlanHelper( + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + out VitalRechargeChoice choice) + { + ArgumentNullException.ThrowIfNull(automation); + ArgumentNullException.ThrowIfNull(settings); + if (!settings.HelpOthers || !automation.Fellowship.IsInFellowship) + { + choice = default; + return false; + } + + IReadOnlyList members = + automation.Fellowship.CaptureMembers(); + foreach ((VitalKind vital, double threshold, float distance, uint baseSpell) + in new[] + { + (VitalKind.Health, settings.HelperHealth, + settings.HelperHealthDistance, (uint)SpellId.AdjaSGift), + (VitalKind.Stamina, settings.HelperStamina, + settings.HelperStaminaDistance, (uint)SpellId.Replenish), + (VitalKind.Mana, settings.HelperMana, + settings.HelperManaDistance, (uint)SpellId.GiftOfEssence), + }) + { + PluginFellowMember? target = Lowest(members, vital, threshold, distance); + if (target is not { } fellow) + continue; + if (vital == VitalKind.Health + && TryHealersHeart( + automation, + settings, + fellow, + out choice)) + { + return true; + } + if (!automation.Spells.TryGet(baseSpell, out PluginSpellInfo basis) + || !TryFindFamily( + automation.Spells.KnownSelfBuffs, + basis.Family, + automation.Character, + automation.Spells, + combatSettings.BlacklistedSpellComponents, + out PluginSpellInfo spell)) + { + continue; + } + + choice = new VitalRechargeChoice( + vital, + VitalRechargeSourceKind.LearnedSpell, + spell.Name, + spell.SpellId, + 0u, + PluginCombatMode.Magic) + { + TargetObjectId = fellow.ObjectId, + }; + return true; + } + choice = default; + return false; + } + + private static bool TryHealersHeart( + IAutomationSurface automation, + VitalSettings settings, + in PluginFellowMember target, + out VitalRechargeChoice choice) + { + choice = default; + if (!settings.UseHealersHeart + || !automation.Character.TryGetSkill(33u, out PluginSkillInfo life) + || life.Current < 245u + || !automation.Character.TryGetSkill(14u, out PluginSkillInfo secondary) + || secondary.Current < 105u) + { + return false; + } + + PluginInventoryItem selected = default; + int rank = 0; + foreach (PluginInventoryItem item in automation.Items.CaptureOwnedItems()) + { + int candidateRank = item.Name switch + { + "Legendary Seed of Mornings" => 2, + "The Healer's Heart" => 1, + _ => 0, + }; + if (candidateRank <= rank) + continue; + selected = item; + rank = candidateRank; + } + if (selected.ObjectId == 0u) + return false; + + choice = new VitalRechargeChoice( + VitalKind.Health, + VitalRechargeSourceKind.CasterItem, + selected.Name, + 0u, + selected.ObjectId, + null) + { + TargetObjectId = target.ObjectId, + }; + return true; + } + + internal static IReadOnlyList Handlers( + VitalKind vital, + bool magicMode, + int currentPercent, + string? handlerSet = null) + { + IReadOnlyList defaults; + if (magicMode) + { + defaults = vital switch + { + VitalKind.Health when currentPercent <= 15 => + [ + VitalRechargeMethod.StaminaToHealth, + VitalRechargeMethod.ManaToHealth, + VitalRechargeMethod.RegularSpell, + VitalRechargeMethod.Food, + VitalRechargeMethod.Kit, + ], + VitalKind.Health => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.StaminaToHealth, + VitalRechargeMethod.ManaToHealth, + VitalRechargeMethod.RegularSpell, + VitalRechargeMethod.Food, + ], + VitalKind.Stamina => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.RegularSpell, + VitalRechargeMethod.Food, + ], + VitalKind.Mana => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.Food, + VitalRechargeMethod.RegularSpell, + ], + _ => [], + }; + } + else + { + defaults = vital switch + { + VitalKind.Health when currentPercent <= 10 => + [ + VitalRechargeMethod.Food, + VitalRechargeMethod.Kit, + VitalRechargeMethod.StaminaToHealth, + VitalRechargeMethod.RegularSpell, + ], + VitalKind.Health when currentPercent <= 15 => + [ + VitalRechargeMethod.Food, + VitalRechargeMethod.Kit, + VitalRechargeMethod.RegularSpell, + ], + VitalKind.Health => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.Food, + VitalRechargeMethod.RegularSpell, + ], + VitalKind.Stamina or VitalKind.Mana => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.Food, + VitalRechargeMethod.RegularSpell, + ], + _ => [], + }; + } + + return TryParseHandlerSet( + handlerSet, + HandlerContext(vital, magicMode, currentPercent), + out VitalRechargeMethod[] custom) + ? custom + : defaults; + } + + private static string HandlerContext( + VitalKind vital, + bool magicMode, + int currentPercent) + { + string stance = magicMode ? "magic" : "combat"; + string vitalName = vital.ToString().ToLowerInvariant(); + string band = vital == VitalKind.Health + ? magicMode + ? currentPercent <= 15 ? "low" : "normal" + : currentPercent <= 10 + ? "critical" + : currentPercent <= 15 ? "low" : "normal" + : "normal"; + return $"{stance}-{vitalName}-{band}"; + } + + private static bool TryParseHandlerSet( + string? source, + string context, + out VitalRechargeMethod[] handlers) + { + handlers = []; + if (string.IsNullOrWhiteSpace(source) + || source.Equals("RechargeHandlerSet", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string selected = source; + if (source.Contains('=')) + { + selected = string.Empty; + string fallbackContext = context.EndsWith( + "-critical", + StringComparison.Ordinal) + || context.EndsWith("-low", StringComparison.Ordinal) + ? context[..context.LastIndexOf('-')] + "-normal" + : string.Empty; + foreach (string segment in source.Split( + ';', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries)) + { + int equals = segment.IndexOf('='); + if (equals <= 0) + continue; + string key = segment[..equals].Trim(); + if (key.Equals(context, StringComparison.OrdinalIgnoreCase)) + { + selected = segment[(equals + 1)..]; + break; + } + if (selected.Length == 0 + && fallbackContext.Length != 0 + && key.Equals( + fallbackContext, + StringComparison.OrdinalIgnoreCase)) + { + selected = segment[(equals + 1)..]; + } + } + } + if (string.IsNullOrWhiteSpace(selected)) + return false; + + var parsed = new List(); + foreach (string token in selected.Split( + [',', '>', '|'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + string normalized = token.Replace(" ", string.Empty) + .Replace("-", string.Empty) + .ToLowerInvariant(); + VitalRechargeMethod? value = normalized switch + { + "regularspell" => VitalRechargeMethod.RegularSpell, + "staminatohealth" => VitalRechargeMethod.StaminaToHealth, + "manatohealth" => VitalRechargeMethod.ManaToHealth, + "healthtostamina" => VitalRechargeMethod.HealthToStamina, + "healthtomana" => VitalRechargeMethod.HealthToMana, + "kit" or "kitrecharge" => VitalRechargeMethod.Kit, + "food" or "rechargewithfood" => VitalRechargeMethod.Food, + _ => null, + }; + if (value is { } method) + parsed.Add(method); + } + if (parsed.Count == 0) + return false; + handlers = [.. parsed]; + return true; + } + + private static bool TryHandler( + VitalRechargeMethod method, + VitalKind vital, + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + switch (method) + { + case VitalRechargeMethod.Kit: + return TryKit( + vital, + automation, + settings, + combatSettings, + items, + out choice); + case VitalRechargeMethod.Food: + return TryFood(vital, combatSettings, items, out choice); + } + + (string stem, VitalKind? sourceVital) = SpellStem(method, vital); + if (stem.Length == 0 + || sourceVital is { } source + && vital != VitalKind.Health + && VitalPlan.IsBelowNormal(automation.Character, settings, source)) + { + choice = default; + return false; + } + if (!TrySpell( + vital, + stem, + automation, + combatSettings, + items, + out choice)) + { + return false; + } + if (method is VitalRechargeMethod.StaminaToHealth + or VitalRechargeMethod.ManaToHealth + && !ConversionWorthwhile( + method, + choice.SpellId, + automation, + settings)) + { + choice = default; + return false; + } + return true; + } + + private static (string Stem, VitalKind? SourceVital) SpellStem( + VitalRechargeMethod method, + VitalKind vital) => method switch + { + VitalRechargeMethod.RegularSpell => vital switch + { + VitalKind.Health => (VitalPlan.HealSelfStem, null), + VitalKind.Stamina => (VitalPlan.RevitalizeStem, null), + VitalKind.Mana => (VitalPlan.StaminaToManaStem, VitalKind.Stamina), + _ => (string.Empty, null), + }, + VitalRechargeMethod.StaminaToHealth => + ("Stamina to Health", VitalKind.Stamina), + VitalRechargeMethod.ManaToHealth => + ("Mana to Health", VitalKind.Mana), + VitalRechargeMethod.HealthToStamina => + ("Health to Stamina", VitalKind.Health), + VitalRechargeMethod.HealthToMana => + ("Health to Mana", VitalKind.Health), + _ => (string.Empty, null), + }; + + private static bool TrySpell( + VitalKind vital, + string stem, + IAutomationSurface automation, + CombatSettings settings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + bool found = false; + PluginSpellInfo bestSpell = default; + uint bestItem = 0u; + + foreach (PluginSpellInfo spell in automation.Spells.KnownSelfBuffs) + { + if (!Matches(spell.Name, stem)) + continue; + if (SpellComponentPolicy.UsesBlacklistedComponent( + automation.Spells, + spell, + settings.BlacklistedSpellComponents)) + continue; + if (!CanCast(automation.Character, spell)) + continue; + if (!found || Better(spell, 0u, bestSpell, bestItem)) + { + found = true; + bestSpell = spell; + bestItem = 0u; + } + } + + foreach (PluginInventoryItem item in items) + { + if (!settings.CombatItemObjectIds.Contains(item.ObjectId) + && !settings.CombatItemNames.Contains(item.Name)) + { + continue; + } + if ((item.ItemType & CasterItemType) == 0u) + continue; + + foreach (uint spellId in ItemSpellIds(item)) + { + if (!automation.Spells.TryGet(spellId, out PluginSpellInfo spell) + || !Matches(spell.Name, stem)) + { + continue; + } + if (SpellComponentPolicy.UsesBlacklistedComponent( + automation.Spells, + spell, + settings.BlacklistedSpellComponents)) + { + continue; + } + if (!found || Better(spell, item.ObjectId, bestSpell, bestItem)) + { + found = true; + bestSpell = spell; + bestItem = item.ObjectId; + } + } + } + + if (!found) + { + choice = default; + return false; + } + choice = new VitalRechargeChoice( + vital, + bestItem == 0u + ? VitalRechargeSourceKind.LearnedSpell + : VitalRechargeSourceKind.CasterItem, + bestSpell.Name, + bestSpell.SpellId, + bestItem, + PluginCombatMode.Magic); + return true; + } + + private static bool TryKit( + VitalKind vital, + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + PluginCombatMode mode = automation.Combat.Snapshot.Mode; + if (mode == PluginCombatMode.Magic && !settings.UseKitsInMagicMode) + { + choice = default; + return false; + } + if (vital != VitalKind.Stamina && automation.Character.CurrentStamina < 15u) + { + choice = default; + return false; + } + if (!automation.Character.TryGetSkill(HealingSkill, out PluginSkillInfo healing) + || healing.Training is not (PluginSkillTraining.Trained + or PluginSkillTraining.Specialized)) + { + choice = default; + return false; + } + + bool found = false; + PluginInventoryItem best = default; + foreach (PluginInventoryItem item in items) + { + if (!combatSettings.ConsumableNames.Contains(item.Name) + || item.BoosterVital != (int)vital + || item.UseRequiresSkill != (int)HealingSkill + || item.UseRequiresSkillLevel > healing.Current + || item.UseRequiresSkillSpecialized != 0 + && healing.Training != PluginSkillTraining.Specialized + || HealKitChance( + healing.Current, + item.BoostValue, + automation.Character, + vital, + mode) * 100d < settings.MinimumHealKitSuccessChance) + { + continue; + } + if (!found + || item.HealKitModifier > best.HealKitModifier + || item.HealKitModifier == best.HealKitModifier + && item.ObjectId < best.ObjectId) + { + best = item; + found = true; + } + } + if (!found) + { + choice = default; + return false; + } + + choice = new VitalRechargeChoice( + vital, + VitalRechargeSourceKind.Kit, + best.Name, + 0u, + best.ObjectId, + settings.GoToPeaceModeToUseKits + ? PluginCombatMode.Peace + : null); + return true; + } + + private static bool TryFood( + VitalKind vital, + CombatSettings settings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + foreach (PluginInventoryItem item in items) + { + if (settings.ConsumableNames.Contains(item.Name) + && item.BoosterVital == (int)vital + && item.UseRequiresSkill != (int)HealingSkill) + { + choice = new VitalRechargeChoice( + vital, + VitalRechargeSourceKind.Food, + item.Name, + 0u, + item.ObjectId, + null); + return true; + } + } + choice = default; + return false; + } + + internal static double HealKitChance( + uint healingSkill, + int skillBonus, + ICharacterInfo character, + VitalKind vital, + PluginCombatMode mode) + { + (uint current, uint maximum) = vital switch + { + VitalKind.Health => (character.CurrentHealth, character.MaxHealth), + VitalKind.Stamina => (character.CurrentStamina, character.MaxStamina), + VitalKind.Mana => (character.CurrentMana, character.MaxMana), + _ => (0u, 0u), + }; + double multiplier = mode == PluginCombatMode.Peace ? 2d : 2.2d; + double missing = Math.Max(0d, (double)maximum - current); + double difficulty = Math.Ceiling(multiplier * missing); + return 1d - 1d / (1d + Math.Exp( + 0.03d * (healingSkill + skillBonus - difficulty))); + } + + private static bool ConversionWorthwhile( + VitalRechargeMethod method, + uint conversionSpellId, + IAutomationSurface automation, + VitalSettings settings) + { + if (!automation.Spells.TryGet( + conversionSpellId, + out PluginSpellInfo conversion)) + { + return false; + } + + int ordinaryHeal = 0; + foreach (PluginSpellInfo spell in automation.Spells.KnownSelfBuffs) + { + if (!Matches(spell.Name, VitalPlan.HealSelfStem) + || !CanCast(automation.Character, spell)) + { + continue; + } + ordinaryHeal = Math.Max(ordinaryHeal, EstimatedOrdinaryHeal(spell.Name)); + } + + int missing = checked((int)Math.Max( + 0L, + (long)automation.Character.MaxHealth + - automation.Character.CurrentHealth)); + if (ordinaryHeal > missing) + return false; + + VitalKind source = method == VitalRechargeMethod.StaminaToHealth + ? VitalKind.Stamina + : VitalKind.Mana; + int sourceCurrent = source == VitalKind.Stamina + ? checked((int)automation.Character.CurrentStamina) + : checked((int)automation.Character.CurrentMana) - 30; + sourceCurrent = Math.Max(0, sourceCurrent); + int converted = Math.Min( + missing, + EstimatedTransfer(conversion.Name, sourceCurrent)); + double multiplier = source == VitalKind.Stamina + ? settings.StaminaToHealthMultiplier + : settings.ManaToHealthMultiplier; + return ordinaryHeal * multiplier < missing + && ordinaryHeal * multiplier < converted; + } + + internal static int EstimatedOrdinaryHeal(string spellName) => spellName switch + { + "Heal Self I" => 17, + "Heal Self II" => 25, + "Heal Self III" => 32, + "Heal Self IV" => 45, + "Heal Self V" => 67, + "Heal Self VI" => 87, + "Adja's Intervention" => 115, + "Incantation of Heal Self" => 135, + _ => 10, + }; + + internal static int EstimatedTransfer(string spellName, int sourceCurrent) + { + (double multiplier, int cap) = spellName switch + { + _ when spellName.EndsWith(" I", StringComparison.Ordinal) => + (0.9, 50), + _ when spellName.EndsWith(" II", StringComparison.Ordinal) => + (1.0, 100), + _ when spellName.EndsWith(" III", StringComparison.Ordinal) => + (1.1, 150), + _ when spellName.EndsWith(" IV", StringComparison.Ordinal) => + (1.2, 200), + _ when spellName.EndsWith(" V", StringComparison.Ordinal) => + (1.35, int.MaxValue), + _ when spellName.EndsWith(" VI", StringComparison.Ordinal) => + (1.5, int.MaxValue), + _ => (1.75, int.MaxValue), + }; + return Math.Min( + cap, + checked((int)Math.Floor(sourceCurrent * multiplier))); + } + + private static PluginFellowMember? Lowest( + IReadOnlyList members, + VitalKind vital, + double threshold, + float maximumDistance) + { + PluginFellowMember? best = null; + double bestFraction = double.PositiveInfinity; + foreach (PluginFellowMember member in members) + { + if (member.Distance > maximumDistance) + continue; + (uint current, uint maximum) = vital switch + { + VitalKind.Health => (member.CurrentHealth, member.MaxHealth), + VitalKind.Stamina => (member.CurrentStamina, member.MaxStamina), + VitalKind.Mana => (member.CurrentMana, member.MaxMana), + _ => (0u, 0u), + }; + if (maximum == 0u) + continue; + double fraction = (double)current / maximum; + if (fraction < threshold && fraction < bestFraction) + { + best = member; + bestFraction = fraction; + } + } + return best; + } + + private static bool TryFindFamily( + IReadOnlyList known, + uint family, + ICharacterInfo character, + ISpellCatalog catalog, + string blacklistedComponents, + out PluginSpellInfo pick) + { + pick = default; + bool found = false; + foreach (PluginSpellInfo spell in known) + { + if (spell.Family != family + || SpellComponentPolicy.UsesBlacklistedComponent( + catalog, + spell, + blacklistedComponents) + || !CanCast(character, spell)) + continue; + if (!found + || spell.Quality > pick.Quality + || spell.Quality == pick.Quality && spell.Tier > pick.Tier) + { + pick = spell; + found = true; + } + } + return found; + } + + private static bool CanCast(ICharacterInfo character, PluginSpellInfo spell) => + spell.School == 0u + || !character.TryGetSkill(spell.School, out PluginSkillInfo skill) + || skill.Current >= spell.Difficulty; + + private static bool Better( + PluginSpellInfo candidate, + uint candidateItem, + PluginSpellInfo current, + uint currentItem) + { + int quality = candidate.Quality.CompareTo(current.Quality); + if (quality != 0) + return quality > 0; + // dz.cs/m.cs: a direct learned spell wins the final source tie. + if ((candidateItem == 0u) != (currentItem == 0u)) + return candidateItem == 0u; + return candidateItem < currentItem; + } + + private static bool Matches(string name, string stem) + { + if (name.IndexOf(stem, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + return stem switch + { + VitalPlan.HealSelfStem => name.Equals( + "Adja's Intervention", + StringComparison.OrdinalIgnoreCase), + VitalPlan.RevitalizeStem => name.Equals( + "Robustification", + StringComparison.OrdinalIgnoreCase), + VitalPlan.StaminaToManaStem => name.Equals( + "Meditative Trance", + StringComparison.OrdinalIgnoreCase), + _ => false, + }; + } + + private static IEnumerable ItemSpellIds(PluginInventoryItem item) + { + if (item.SpellId != 0u) + yield return item.SpellId; + foreach (uint spellId in item.AppraisedSpellIds) + { + if (spellId != 0u && spellId != item.SpellId) + yield return spellId; + } + } +} + +/// One server-receipt-driven self-recharge state machine. +internal sealed class VitalRechargeController +{ + private readonly IPluginHost _host; + private readonly VitalSettings _settings; + private readonly CombatSettings _combatSettings; + private Pending? _pending; + private double _retryDelay; + private double _pendingSeconds; + private double _healthBoostRemaining; + private double _staminaBoostRemaining; + private double _manaBoostRemaining; + + public VitalRechargeController( + IPluginHost host, + VitalSettings settings, + CombatSettings combatSettings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _combatSettings = combatSettings + ?? throw new ArgumentNullException(nameof(combatSettings)); + } + + public string Status { get; private set; } = "Vitals idle"; + + /// True while recharge owns the action slot and combat must pause. + public bool Tick(double elapsedSeconds, bool enabled, bool noTarget) + { + IAutomationSurface automation = _host.Automation; + double elapsed = Math.Max(0d, elapsedSeconds); + _retryDelay = Math.Max(0d, _retryDelay - elapsed); + _healthBoostRemaining = Math.Max(0d, _healthBoostRemaining - elapsed); + _staminaBoostRemaining = Math.Max(0d, _staminaBoostRemaining - elapsed); + _manaBoostRemaining = Math.Max(0d, _manaBoostRemaining - elapsed); + if (!enabled || !_settings.Enabled || !automation.IsAvailable) + { + _pending = null; + ClearBoosts(); + Status = "Vitals idle"; + return false; + } + + if (_pending is { } pending) + { + _pendingSeconds += Math.Max(0d, elapsedSeconds); + if (TryComplete(automation, pending)) + { + if (_settings.ClearLevelBoostFlagOnCast + && pending.Choice.SourceKind + == VitalRechargeSourceKind.LearnedSpell + && IsLevelBoostSpell(pending.Choice)) + { + ClearBoost(pending.Choice.Vital); + } + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0.25d; + } + else if (_pendingSeconds >= 15d) + { + Status = $"Timed out: {pending.Choice.Name}"; + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 1d; + } + else + { + Status = $"Recharging {pending.Choice.Vital}: {pending.Choice.Name}"; + return true; + } + } + + VitalKind? need = VitalPlan.DecideNeed( + automation.Character, + _settings, + noTarget, + _healthBoostRemaining > 0d ? _settings.RechargeBoostAmount : 0, + _staminaBoostRemaining > 0d ? _settings.RechargeBoostAmount : 0, + _manaBoostRemaining > 0d ? _settings.RechargeBoostAmount : 0); + VitalRechargeChoice helper = default; + if (need is null + && !VitalRechargePlanner.TryPlanHelper( + automation, + _settings, + _combatSettings, + out helper)) + { + Status = "Vitals ready"; + return false; + } + if (_retryDelay > 0d || automation.Magic.IsCasting || automation.Items.IsBusy) + return true; + + VitalRechargeChoice choice; + if (need is null) + { + choice = helper; + } + else if (!VitalRechargePlanner.TryPlan( + need.Value, + automation, + _settings, + _combatSettings, + out choice)) + { + Status = $"No {need.Value} recharge available"; + _retryDelay = 1d; + return false; + } + + if (choice.RequiredMode is { } required + && automation.Combat.Snapshot.Mode != required) + { + if (required == PluginCombatMode.Magic) + ArmBoost(choice.Vital); + PluginCombatCommandResult mode = automation.Combat.EnterMode(required); + Status = mode.Accepted + ? $"Switching to {required} for {choice.Name}" + : $"Waiting for {required}: {choice.Name}"; + return true; + } + + long revision = choice.SourceKind == VitalRechargeSourceKind.LearnedSpell + ? automation.Magic.LastCompletion.Revision + : automation.Items.LastCompletion.Revision; + bool started = Start(automation, choice); + if (!started) + { + Status = $"Waiting to use {choice.Name}"; + _retryDelay = 0.25d; + return true; + } + _pending = new Pending(choice, revision); + _pendingSeconds = 0d; + Status = $"Recharging {choice.Vital}: {choice.Name}"; + return true; + } + + public void Reset() + { + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0d; + ClearBoosts(); + Status = "Vitals idle"; + } + + private void ArmBoost(VitalKind vital) + { + double duration = Math.Max(0d, _settings.RechargeBoostTimeSeconds); + switch (vital) + { + case VitalKind.Health: + _healthBoostRemaining = duration; + break; + case VitalKind.Stamina: + _staminaBoostRemaining = duration; + break; + case VitalKind.Mana: + _manaBoostRemaining = duration; + break; + } + } + + private void ClearBoost(VitalKind vital) + { + switch (vital) + { + case VitalKind.Health: + _healthBoostRemaining = 0d; + break; + case VitalKind.Stamina: + _staminaBoostRemaining = 0d; + break; + case VitalKind.Mana: + _manaBoostRemaining = 0d; + break; + } + } + + private void ClearBoosts() + { + _healthBoostRemaining = 0d; + _staminaBoostRemaining = 0d; + _manaBoostRemaining = 0d; + } + + private static bool IsLevelBoostSpell(in VitalRechargeChoice choice) => + choice.Vital switch + { + VitalKind.Health => choice.Name.Equals( + "Adja's Intervention", + StringComparison.OrdinalIgnoreCase), + VitalKind.Stamina => choice.Name.Equals( + "Robustification", + StringComparison.OrdinalIgnoreCase), + VitalKind.Mana => choice.Name.Equals( + "Meditative Trance", + StringComparison.OrdinalIgnoreCase), + _ => false, + }; + + private static bool Start( + IAutomationSurface automation, + VitalRechargeChoice choice) + { + if (choice.SourceKind == VitalRechargeSourceKind.LearnedSpell) + { + PluginCastGate gate = choice.TargetObjectId == 0u + ? automation.Magic.EvaluateGate(choice.SpellId) + : automation.Magic.EvaluateGate( + choice.SpellId, + choice.TargetObjectId); + return gate == PluginCastGate.Ready + && (choice.TargetObjectId == 0u + ? automation.Magic.Cast(choice.SpellId) + : automation.Magic.Cast( + choice.SpellId, + choice.TargetObjectId)); + } + + PluginItemCommandResult result = choice.SourceKind switch + { + VitalRechargeSourceKind.Food => + automation.Items.Use(choice.ItemObjectId), + _ => automation.Items.Apply( + choice.ItemObjectId, + choice.TargetObjectId == 0u + ? automation.Character.ObjectId + : choice.TargetObjectId), + }; + return result.Accepted; + } + + private static bool TryComplete(IAutomationSurface automation, Pending pending) + { + if (pending.Choice.SourceKind == VitalRechargeSourceKind.LearnedSpell) + return automation.Magic.LastCompletion.Revision > pending.Revision; + return automation.Items.LastCompletion.Revision > pending.Revision; + } + + private sealed record Pending(VitalRechargeChoice Choice, long Revision); +} diff --git a/src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs b/src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs new file mode 100644 index 00000000..23713c51 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using System.Reflection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum VtankPrismaticAmmoPolicy +{ + Any, + NoPrismatic, + ForcePrismatic, +} + +internal readonly record struct VtankAmmunitionOption( + string Name, + int LauncherType, + int WieldRequirement, + int Element, + int Quality, + int SpecialMask, + uint SecondarySkill, + int SecondaryRequirement); + +/// +/// The complete 120-row AmmunitionOptions table from VTank's official +/// GameInfoDB. Selection retains bv.cs ordering and equal-quality replacement. +/// +internal static class VtankAmmunitionDatabase +{ + private const string ResourceSuffix = ".VtankAmmunitionOptions.tsv"; + private static readonly Lazy Loaded = new(Load); + + public static IReadOnlyList Options => Loaded.Value; + + public static int LauncherType(uint ammoType) => ammoType switch + { + 0x001u or 0x008u or 0x040u => 5, + 0x002u or 0x010u or 0x080u => 6, + 0x004u or 0x020u or 0x100u => 7, + _ => 0, + }; + + public static VtankAmmunitionOption? Select( + int launcherType, + MonsterDamageType damage, + VtankPrismaticAmmoPolicy prismatic, + int enabledSpecialMask, + ICharacterInfo character, + Func isAvailable) + { + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(isAvailable); + int desiredElement = Element(damage); + if (launcherType == 0 || desiredElement < 0) + return null; + + VtankAmmunitionOption? best = null; + int bestQuality = int.MinValue; + foreach (VtankAmmunitionOption option in Loaded.Value) + { + if (option.LauncherType != launcherType) + continue; + int quality = option.Quality; + if (prismatic == VtankPrismaticAmmoPolicy.ForcePrismatic + && option.Element != 100) + { + quality -= 1000; + } + if (option.Element != desiredElement) + { + if (option.Element != 100) + continue; + if (prismatic == VtankPrismaticAmmoPolicy.NoPrismatic) + quality -= 1000; + } + if (quality < bestQuality + || !MeetsRequirements(option, character) + || (option.SpecialMask != 0 + && (option.SpecialMask & enabledSpecialMask) == 0) + || !isAvailable(option.Name)) + { + continue; + } + bestQuality = quality; + best = option; + } + return best; + } + + private static bool MeetsRequirements( + in VtankAmmunitionOption option, + ICharacterInfo character) + { + if (option.WieldRequirement > 0) + { + if (!character.TryGetSkill(47u, out PluginSkillInfo missile) + || missile.Training == PluginSkillTraining.Untrained + || missile.Base < option.WieldRequirement) + { + return false; + } + } + if (option.SecondarySkill == 0u || option.SecondaryRequirement == 0) + return true; + return character.TryGetSkill( + option.SecondarySkill, + out PluginSkillInfo secondary) + && secondary.Training != PluginSkillTraining.Untrained + && secondary.Current >= option.SecondaryRequirement; + } + + private static int Element(MonsterDamageType damage) => damage switch + { + MonsterDamageType.Pierce => 0, + MonsterDamageType.Bludgeon => 1, + MonsterDamageType.Slash => 2, + MonsterDamageType.Acid => 3, + MonsterDamageType.Electric => 4, + MonsterDamageType.Cold => 5, + MonsterDamageType.Fire => 6, + // ForcePrismatic still needs a concrete comparison element for the + // official fallback scoring; Pierce is VTank's seed value. + MonsterDamageType.Prismatic => 0, + _ => -1, + }; + + private static VtankAmmunitionOption[] Load() + { + Assembly assembly = typeof(VtankAmmunitionDatabase).Assembly; + string resource = assembly.GetManifestResourceNames().Single( + static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal)); + using Stream stream = assembly.GetManifestResourceStream(resource) + ?? throw new InvalidOperationException( + "The embedded VTank AmmunitionOptions table is missing."); + using var reader = new StreamReader(stream); + var all = new List(120); + while (reader.ReadLine() is { } line) + { + if (line.Length == 0 || line[0] == '#') + continue; + string[] fields = line.Split('\t'); + if (fields.Length != 8) + throw new InvalidDataException("Malformed VTank ammunition row."); + all.Add(new VtankAmmunitionOption( + fields[0], + Parse(fields[1]), + Parse(fields[2]), + Parse(fields[3]), + Parse(fields[4]), + Parse(fields[5]), + (uint)Parse(fields[6]), + Parse(fields[7]))); + } + if (all.Count != 120) + { + throw new InvalidDataException( + $"Expected 120 official VTank ammunition rows, found {all.Count}."); + } + return [.. all]; + } + + private static int Parse(string value) => + int.Parse(value, CultureInfo.InvariantCulture); +} diff --git a/src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv b/src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv new file mode 100644 index 00000000..e5d34730 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv @@ -0,0 +1,121 @@ +# AmmoName LauncherType WieldReq Element Quality Special WieldReq2Skill WieldReq2Value +Barbed Quarrel 6 0 0 4 0 0 0 +Greater Barbed Quarrel 6 0 0 9 0 0 0 +Deadly Barbed Quarrel 6 230 0 22 0 0 0 +Blunt Quarrel 6 0 1 5 0 0 0 +Greater Blunt Quarrel 6 0 1 10 0 0 0 +Deadly Blunt Quarrel 6 230 1 20 0 0 0 +Armor Piercing Quarrel 6 0 0 5 0 0 0 +Greater Armor Piercing Quarrel 6 0 0 10 0 0 0 +Deadly Armor Piercing Quarrel 6 230 0 23 0 0 0 +Frog Crotch Quarrel 6 0 2 5 0 0 0 +Greater Frog Crotch Quarrel 6 0 2 10 0 0 0 +Deadly Frog Crotch Quarrel 6 230 2 23 0 0 0 +Fire Quarrel 6 0 6 5 0 0 0 +Greater Fire Quarrel 6 0 6 10 0 0 0 +Deadly Fire Quarrel 6 230 6 20 0 0 0 +Lightning Quarrel 6 0 4 5 0 0 0 +Greater Lightning Quarrel 6 0 4 10 0 0 0 +Deadly Lightning Quarrel 6 230 4 20 0 0 0 +Acid Quarrel 6 0 3 5 0 0 0 +Greater Acid Quarrel 6 0 3 10 0 0 0 +Deadly Acid Quarrel 6 230 3 20 0 0 0 +Frost Quarrel 6 0 5 5 0 0 0 +Greater Frost Quarrel 6 0 5 10 0 0 0 +Deadly Frost Quarrel 6 230 5 20 0 0 0 +Barbed Atlatl Dart 7 0 0 4 0 0 0 +Greater Barbed Atlatl Dart 7 0 0 9 0 0 0 +Deadly Barbed Atlatl Dart 7 230 0 22 0 0 0 +Blunt Atlatl Dart 7 0 1 5 0 0 0 +Greater Blunt Atlatl Dart 7 0 1 10 0 0 0 +Deadly Blunt Atlatl Dart 7 230 1 20 0 0 0 +Armor Piercing Atlatl Dart 7 0 0 5 0 0 0 +Greater Armor Piercing Atlatl Dart 7 0 0 10 0 0 0 +Deadly Armor Piercing Atlatl Dart 7 230 0 23 0 0 0 +Frog Crotch Atlatl Dart 7 0 2 5 0 0 0 +Greater Frog Crotch Atlatl Dart 7 0 2 10 0 0 0 +Deadly Frog Crotch Atlatl Dart 7 230 2 23 0 0 0 +Fire Atlatl Dart 7 0 6 5 0 0 0 +Greater Fire Atlatl Dart 7 0 6 10 0 0 0 +Deadly Fire Atlatl Dart 7 230 6 20 0 0 0 +Lightning Atlatl Dart 7 0 4 5 0 0 0 +Greater Lightning Atlatl Dart 7 0 4 10 0 0 0 +Deadly Lightning Atlatl Dart 7 230 4 20 0 0 0 +Acid Atlatl Dart 7 0 3 5 0 0 0 +Greater Acid Atlatl Dart 7 0 3 10 0 0 0 +Deadly Acid Atlatl Dart 7 230 3 20 0 0 0 +Frost Atlatl Dart 7 0 5 5 0 0 0 +Greater Frost Atlatl Dart 7 0 5 10 0 0 0 +Deadly Frost Atlatl Dart 7 230 5 20 0 0 0 +Barbed Arrow 5 0 0 4 0 0 0 +Greater Barbed Arrow 5 0 0 9 0 0 0 +Deadly Barbed Arrow 5 230 0 22 0 0 0 +Blunt Arrow 5 0 1 5 0 0 0 +Greater Blunt Arrow 5 0 1 10 0 0 0 +Deadly Blunt Arrow 5 230 1 20 0 0 0 +Armor Piercing Arrow 5 0 0 5 0 0 0 +Greater Armor Piercing Arrow 5 0 0 10 0 0 0 +Deadly Armor Piercing Arrow 5 230 0 23 0 0 0 +Frog Crotch Arrow 5 0 2 5 0 0 0 +Greater Frog Crotch Arrow 5 0 2 10 0 0 0 +Deadly Frog Crotch Arrow 5 230 2 23 0 0 0 +Fire Arrow 5 0 6 5 0 0 0 +Greater Fire Arrow 5 0 6 10 0 0 0 +Deadly Fire Arrow 5 230 6 20 0 0 0 +Lightning Arrow 5 0 4 5 0 0 0 +Greater Lightning Arrow 5 0 4 10 0 0 0 +Deadly Lightning Arrow 5 230 4 20 0 0 0 +Acid Arrow 5 0 3 5 0 0 0 +Greater Acid Arrow 5 0 3 10 0 0 0 +Deadly Acid Arrow 5 230 3 20 0 0 0 +Frost Arrow 5 0 5 5 0 0 0 +Greater Frost Arrow 5 0 5 10 0 0 0 +Deadly Frost Arrow 5 230 5 20 0 0 0 +Deadly Arrow 5 230 0 20 0 0 0 +Deadly Quarrel 6 230 0 20 0 0 0 +Deadly Atlatl Dart 7 230 0 20 0 0 0 +Deadly Broadhead Arrow 5 230 2 20 0 0 0 +Deadly Broadhead Quarrel 6 230 2 20 0 0 0 +Deadly Broadhead Atlatl Dart 7 230 2 20 0 0 0 +Greater Broadhead Atlatl Dart 7 0 2 8 0 0 0 +Greater Broadhead Arrow 5 0 2 8 0 0 0 +Greater Broadhead Quarrel 6 0 2 8 0 0 0 +Arrow 5 0 0 3 0 0 0 +Atlatl Dart 7 0 0 3 0 0 0 +Quarrel 6 0 0 3 0 0 0 +Broadhead Arrow 5 0 2 3 0 0 0 +Broadhead Quarrel 6 0 2 3 0 0 0 +Broadhead Atlatl Dart 7 0 2 3 0 0 0 +Raider Lightning Bolt 6 270 4 30 1 0 0 +Raider Lightning Atlatl Dart 7 270 4 30 1 0 0 +Raider Lightning Arrow 5 270 4 30 1 0 0 +Spectral Chill Arrow 5 270 5 30 2 0 0 +Spectral Chill Bolt 6 270 5 30 2 0 0 +Spectral Chill Atlatl Dart 7 270 5 30 2 0 0 +Olthoi Acid Arrow 5 270 3 30 2 0 0 +Olthoi Acid Bolt 6 270 3 30 2 0 0 +Olthoi Acid Atlatl Dart 7 270 3 30 2 0 0 +Greater Deadly Blunt Arrow 5 270 1 30 0 0 0 +Greater Deadly Blunt Quarrel 6 270 1 30 0 0 0 +Greater Deadly Blunt Atlatl Dart 7 270 1 30 0 0 0 +Gear Blade Slashing Arrow 5 270 2 30 2 0 0 +Gear Blade Slashing Bolt 6 270 2 30 2 0 0 +Gear Blade Slashing Atlatl Dart 7 270 2 30 2 0 0 +Burning Sands Atlatl Dart 7 270 6 30 2 0 0 +Burning Sands Bolt 6 270 6 30 2 0 0 +Burning Sands Arrow 5 270 6 30 2 0 0 +Greater Deadly Armor Piercing Atlatl Dart 7 270 0 33 0 0 0 +Greater Deadly Armor Piercing Arrow 5 270 0 33 0 0 0 +Greater Deadly Armor Piercing Quarrel 6 270 0 33 0 0 0 +Greater Deadly Frog Crotch Atlatl Dart 7 270 2 33 0 0 0 +Greater Deadly Frog Crotch Quarrel 6 270 2 33 0 0 0 +Greater Deadly Frog Crotch Arrow 5 270 2 33 0 0 0 +Deadly Prismatic Atlatl Dart 7 300 100 31 0 37 375 +Deadly Prismatic Quarrel 6 300 100 31 0 37 375 +Deadly Prismatic Arrow 5 300 100 31 0 37 375 +Greater Prismatic Atlatl Dart 7 290 100 26 0 37 350 +Greater Prismatic Quarrel 6 290 100 26 0 37 350 +Greater Prismatic Arrow 5 290 100 26 0 37 350 +Prismatic Atlatl Dart 7 250 100 21 0 37 250 +Prismatic Quarrel 6 250 100 21 0 37 250 +Prismatic Arrow 5 250 100 21 0 37 250 diff --git a/src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs b/src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs new file mode 100644 index 00000000..fe4f70c5 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using System.Reflection; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct VtankCraftRecipe( + string FirstItem, + string SecondItem, + string ResultItem, + int ResultCount, + uint RequiredSkill, + int Difficulty, + int Id); + +/// +/// The complete 757-row CraftInteractions table shipped by VTank's official +/// GameInfoDB. Order is significant: VTank walks matching recipes in database +/// order and recursively tries their ingredients. +/// +internal static class VtankCraftDatabase +{ + private const string ResourceSuffix = ".VtankCraftRecipes.tsv"; + private static readonly Lazy Loaded = new(Load); + + public static IReadOnlyList Recipes => Loaded.Value.All; + + public static IReadOnlyList ForResult(string resultName) + { + if (string.IsNullOrWhiteSpace(resultName)) + return Array.Empty(); + return Loaded.Value.ByResult.TryGetValue( + resultName.Trim(), + out VtankCraftRecipe[]? recipes) + ? recipes + : Array.Empty(); + } + + private static Catalog Load() + { + Assembly assembly = typeof(VtankCraftDatabase).Assembly; + string resource = assembly.GetManifestResourceNames().Single( + static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal)); + using Stream stream = assembly.GetManifestResourceStream(resource) + ?? throw new InvalidOperationException( + "The embedded VTank CraftInteractions table is missing."); + using var reader = new StreamReader(stream); + var all = new List(757); + while (reader.ReadLine() is { } line) + { + if (line.Length == 0 || line[0] == '#') + continue; + string[] fields = line.Split('\t'); + if (fields.Length != 7) + throw new InvalidDataException("Malformed VTank craft row."); + all.Add(new VtankCraftRecipe( + fields[0], + fields[1], + fields[2], + int.Parse(fields[3], CultureInfo.InvariantCulture), + uint.Parse(fields[4], CultureInfo.InvariantCulture), + int.Parse(fields[5], CultureInfo.InvariantCulture), + int.Parse(fields[6], CultureInfo.InvariantCulture))); + } + if (all.Count != 757) + { + throw new InvalidDataException( + $"Expected 757 official VTank craft rows, found {all.Count}."); + } + Dictionary byResult = all + .GroupBy(static recipe => recipe.ResultItem, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.OrderBy(recipe => recipe.Id).ToArray(), + StringComparer.OrdinalIgnoreCase); + return new Catalog(all.ToArray(), byResult); + } + + private sealed record Catalog( + VtankCraftRecipe[] All, + Dictionary ByResult); +} diff --git a/src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv b/src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv new file mode 100644 index 00000000..0e027739 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv @@ -0,0 +1,758 @@ +# Official VTank GameInfoDB CraftInteractions: item1item2resultcountskilldifficultyid +Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Arrowshafts Barbed Arrow 250 37 55 1 +Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Arrowshafts Greater Barbed Arrow 250 37 209 2 +Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Arrowshafts Deadly Barbed Arrow 250 37 220 3 +Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Quarrelshafts Barbed Quarrel 250 37 55 4 +Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Quarrelshafts Greater Barbed Quarrel 250 37 209 5 +Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Quarrelshafts Deadly Barbed Quarrel 250 37 220 6 +Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Barbed Atlatl Dart 250 37 55 7 +Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Barbed Atlatl Dart 250 37 209 8 +Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Barbed Atlatl Dart 250 37 220 9 +Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Arrowshafts Blunt Arrow 250 37 0 10 +Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Arrowshafts Greater Blunt Arrow 250 37 0 11 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 250 37 198 12 +Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Quarrelshafts Blunt Quarrel 250 37 0 13 +Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Quarrelshafts Greater Blunt Quarrel 250 37 0 14 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Deadly Blunt Quarrel 250 37 198 15 +Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Blunt Atlatl Dart 250 37 0 16 +Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Blunt Atlatl Dart 250 37 0 17 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Blunt Atlatl Dart 250 37 198 18 +Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Armor Piercing Arrow 250 37 0 19 +Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Greater Armor Piercing Arrow 250 37 0 20 +Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Deadly Armor Piercing Arrow 250 37 220 21 +Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Armor Piercing Quarrel 250 37 0 22 +Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Greater Armor Piercing Quarrel 250 37 0 23 +Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 250 37 220 24 +Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Armor Piercing Atlatl Dart 250 37 0 25 +Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Armor Piercing Atlatl Dart 250 37 0 26 +Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Armor Piercing Atlatl Dart 250 37 220 27 +Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Frog Crotch Arrow 250 37 0 28 +Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Greater Frog Crotch Arrow 250 37 0 29 +Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Deadly Frog Crotch Arrow 250 37 220 30 +Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Frog Crotch Quarrel 250 37 0 31 +Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Greater Frog Crotch Quarrel 250 37 0 32 +Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 250 37 220 33 +Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Frog Crotch Atlatl Dart 250 37 0 34 +Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Frog Crotch Atlatl Dart 250 37 0 35 +Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frog Crotch Atlatl Dart 250 37 220 36 +Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Arrowshafts Fire Arrow 250 37 0 37 +Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Arrowshafts Greater Fire Arrow 250 37 0 38 +Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Arrowshafts Deadly Fire Arrow 250 37 275 39 +Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Quarrelshafts Fire Quarrel 250 37 0 40 +Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Quarrelshafts Greater Fire Quarrel 250 37 0 41 +Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Quarrelshafts Deadly Fire Quarrel 250 37 275 42 +Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Fire Atlatl Dart 250 37 0 43 +Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Fire Atlatl Dart 250 37 0 44 +Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Fire Atlatl Dart 250 37 275 45 +Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Arrowshafts Lightning Arrow 250 37 0 46 +Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Arrowshafts Greater Lightning Arrow 250 37 0 47 +Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Arrowshafts Deadly Lightning Arrow 250 37 275 48 +Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Quarrelshafts Lightning Quarrel 250 37 0 49 +Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Quarrelshafts Greater Lightning Quarrel 250 37 0 50 +Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Quarrelshafts Deadly Lightning Quarrel 250 37 275 51 +Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Lightning Atlatl Dart 250 37 0 52 +Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Lightning Atlatl Dart 250 37 0 53 +Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Lightning Atlatl Dart 250 37 275 54 +Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Arrowshafts Acid Arrow 250 37 0 55 +Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Arrowshafts Greater Acid Arrow 250 37 0 56 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 250 37 275 57 +Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Quarrelshafts Acid Quarrel 250 37 0 58 +Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Quarrelshafts Greater Acid Quarrel 250 37 0 59 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Quarrelshafts Deadly Acid Quarrel 250 37 275 60 +Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Acid Atlatl Dart 250 37 0 61 +Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Acid Atlatl Dart 250 37 0 62 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Acid Atlatl Dart 250 37 275 63 +Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Arrowshafts Frost Arrow 250 37 0 64 +Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Arrowshafts Greater Frost Arrow 250 37 0 65 +Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Arrowshafts Deadly Frost Arrow 250 37 275 66 +Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Quarrelshafts Frost Quarrel 250 37 0 67 +Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Quarrelshafts Greater Frost Quarrel 250 37 0 68 +Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frost Quarrel 250 37 275 69 +Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Frost Atlatl Dart 250 37 0 70 +Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Frost Atlatl Dart 250 37 0 71 +Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frost Atlatl Dart 250 37 275 72 +Bundle of Barbed Arrowheads Bundle of Arrowshafts Barbed Arrow 10 37 0 73 +Bundle of Greater Barbed Arrowheads Bundle of Arrowshafts Greater Barbed Arrow 10 37 0 74 +Bundle of Deadly Barbed Arrowheads Bundle of Arrowshafts Deadly Barbed Arrow 10 37 0 75 +Bundle of Barbed Arrowheads Bundle of Quarrelshafts Barbed Quarrel 10 37 0 76 +Bundle of Greater Barbed Arrowheads Bundle of Quarrelshafts Greater Barbed Quarrel 10 37 0 77 +Bundle of Deadly Barbed Arrowheads Bundle of Quarrelshafts Deadly Barbed Quarrel 10 37 0 78 +Bundle of Barbed Arrowheads Bundle of Atlatl Dart shafts Barbed Atlatl Dart 10 37 0 79 +Bundle of Greater Barbed Arrowheads Bundle of Atlatl Dart shafts Greater Barbed Atlatl Dart 10 37 0 80 +Bundle of Deadly Barbed Arrowheads Bundle of Atlatl Dart shafts Deadly Barbed Atlatl Dart 10 37 0 81 +Bundle of Blunt Arrowheads Bundle of Arrowshafts Blunt Arrow 10 37 0 82 +Bundle of Greater Blunt Arrowheads Bundle of Arrowshafts Greater Blunt Arrow 10 37 0 83 +Bundle of Deadly Blunt Arrowheads Bundle of Arrowshafts Deadly Blunt Arrow 10 37 0 84 +Bundle of Blunt Arrowheads Bundle of Quarrelshafts Blunt Quarrel 10 37 0 85 +Bundle of Greater Blunt Arrowheads Bundle of Quarrelshafts Greater Blunt Quarrel 10 37 0 86 +Bundle of Deadly Blunt Arrowheads Bundle of Quarrelshafts Deadly Blunt Quarrel 10 37 0 87 +Bundle of Blunt Arrowheads Bundle of Atlatl Dart shafts Blunt Atlatl Dart 10 37 0 88 +Bundle of Greater Blunt Arrowheads Bundle of Atlatl Dart shafts Greater Blunt Atlatl Dart 10 37 0 89 +Bundle of Deadly Blunt Arrowheads Bundle of Atlatl Dart shafts Deadly Blunt Atlatl Dart 10 37 0 90 +Bundle of Armor Piercing Arrowheads Bundle of Arrowshafts Armor Piercing Arrow 10 37 0 91 +Bundle of Greater Armor Piercing Arrowheads Bundle of Arrowshafts Greater Armor Piercing Arrow 10 37 0 92 +Bundle of Deadly Armor Piercing Arrowheads Bundle of Arrowshafts Deadly Armor Piercing Arrow 10 37 0 93 +Bundle of Armor Piercing Arrowheads Bundle of Quarrelshafts Armor Piercing Quarrel 10 37 0 94 +Bundle of Greater Armor Piercing Arrowheads Bundle of Quarrelshafts Greater Armor Piercing Quarrel 10 37 0 95 +Bundle of Deadly Armor Piercing Arrowheads Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 10 37 0 96 +Bundle of Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Armor Piercing Atlatl Dart 10 37 0 97 +Bundle of Greater Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Greater Armor Piercing Atlatl Dart 10 37 0 98 +Bundle of Deadly Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Deadly Armor Piercing Atlatl Dart 10 37 0 99 +Bundle of Frog Crotch Arrowheads Bundle of Arrowshafts Frog Crotch Arrow 10 37 0 100 +Bundle of Greater Frog Crotch Arrowheads Bundle of Arrowshafts Greater Frog Crotch Arrow 10 37 0 101 +Bundle of Deadly Frog Crotch Arrowheads Bundle of Arrowshafts Deadly Frog Crotch Arrow 10 37 0 102 +Bundle of Frog Crotch Arrowheads Bundle of Quarrelshafts Frog Crotch Quarrel 10 37 0 103 +Bundle of Greater Frog Crotch Arrowheads Bundle of Quarrelshafts Greater Frog Crotch Quarrel 10 37 0 104 +Bundle of Deadly Frog Crotch Arrowheads Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 10 37 0 105 +Bundle of Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Frog Crotch Atlatl Dart 10 37 0 106 +Bundle of Greater Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Greater Frog Crotch Atlatl Dart 10 37 0 107 +Bundle of Deadly Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Deadly Frog Crotch Atlatl Dart 10 37 0 108 +Bundle of Fire Arrowheads Bundle of Arrowshafts Fire Arrow 10 37 0 109 +Bundle of Greater Fire Arrowheads Bundle of Arrowshafts Greater Fire Arrow 10 37 0 110 +Bundle of Deadly Fire Arrowheads Bundle of Arrowshafts Deadly Fire Arrow 10 37 0 111 +Bundle of Fire Arrowheads Bundle of Quarrelshafts Fire Quarrel 10 37 0 112 +Bundle of Greater Fire Arrowheads Bundle of Quarrelshafts Greater Fire Quarrel 10 37 0 113 +Bundle of Deadly Fire Arrowheads Bundle of Quarrelshafts Deadly Fire Quarrel 10 37 0 114 +Bundle of Fire Arrowheads Bundle of Atlatl Dart shafts Fire Atlatl Dart 10 37 0 115 +Bundle of Greater Fire Arrowheads Bundle of Atlatl Dart shafts Greater Fire Atlatl Dart 10 37 0 116 +Bundle of Deadly Fire Arrowheads Bundle of Atlatl Dart shafts Deadly Fire Atlatl Dart 10 37 0 117 +Bundle of Lightning Arrowheads Bundle of Arrowshafts Lightning Arrow 10 37 0 118 +Bundle of Greater Lightning Arrowheads Bundle of Arrowshafts Greater Lightning Arrow 10 37 0 119 +Bundle of Deadly Lightning Arrowheads Bundle of Arrowshafts Deadly Lightning Arrow 10 37 0 120 +Bundle of Lightning Arrowheads Bundle of Quarrelshafts Lightning Quarrel 10 37 0 121 +Bundle of Greater Lightning Arrowheads Bundle of Quarrelshafts Greater Lightning Quarrel 10 37 0 122 +Bundle of Deadly Lightning Arrowheads Bundle of Quarrelshafts Deadly Lightning Quarrel 10 37 0 123 +Bundle of Lightning Arrowheads Bundle of Atlatl Dart shafts Lightning Atlatl Dart 10 37 0 124 +Bundle of Greater Lightning Arrowheads Bundle of Atlatl Dart shafts Greater Lightning Atlatl Dart 10 37 0 125 +Bundle of Deadly Lightning Arrowheads Bundle of Atlatl Dart shafts Deadly Lightning Atlatl Dart 10 37 0 126 +Bundle of Acid Arrowheads Bundle of Arrowshafts Acid Arrow 10 37 0 127 +Bundle of Greater Acid Arrowheads Bundle of Arrowshafts Greater Acid Arrow 10 37 0 128 +Bundle of Deadly Acid Arrowheads Bundle of Arrowshafts Deadly Acid Arrow 10 37 0 129 +Bundle of Acid Arrowheads Bundle of Quarrelshafts Acid Quarrel 10 37 0 130 +Bundle of Greater Acid Arrowheads Bundle of Quarrelshafts Greater Acid Quarrel 10 37 0 131 +Bundle of Deadly Acid Arrowheads Bundle of Quarrelshafts Deadly Acid Quarrel 10 37 0 132 +Bundle of Acid Arrowheads Bundle of Atlatl Dart shafts Acid Atlatl Dart 10 37 0 133 +Bundle of Greater Acid Arrowheads Bundle of Atlatl Dart shafts Greater Acid Atlatl Dart 10 37 0 134 +Bundle of Deadly Acid Arrowheads Bundle of Atlatl Dart shafts Deadly Acid Atlatl Dart 10 37 0 135 +Bundle of Frost Arrowheads Bundle of Arrowshafts Frost Arrow 10 37 0 136 +Bundle of Greater Frost Arrowheads Bundle of Arrowshafts Greater Frost Arrow 10 37 0 137 +Bundle of Deadly Frost Arrowheads Bundle of Arrowshafts Deadly Frost Arrow 10 37 0 138 +Bundle of Frost Arrowheads Bundle of Quarrelshafts Frost Quarrel 10 37 0 139 +Bundle of Greater Frost Arrowheads Bundle of Quarrelshafts Greater Frost Quarrel 10 37 0 140 +Bundle of Deadly Frost Arrowheads Bundle of Quarrelshafts Deadly Frost Quarrel 10 37 0 141 +Bundle of Frost Arrowheads Bundle of Atlatl Dart shafts Frost Atlatl Dart 10 37 0 142 +Bundle of Greater Frost Arrowheads Bundle of Atlatl Dart shafts Greater Frost Atlatl Dart 10 37 0 143 +Bundle of Deadly Frost Arrowheads Bundle of Atlatl Dart shafts Deadly Frost Atlatl Dart 10 37 0 144 +Cooking Pot Simple Dried Rations Simple Field Rations 25 39 0 145 +Cooking Pot Elaborate Dried Rations Elaborate Field Rations 25 39 0 146 +Cooking Pot Simple Dried Health Rations Simple Field Health Rations 25 39 0 147 +Cooking Pot Elaborate Dried Health Rations Elaborate Field Health Rations 25 39 0 148 +Cooking Pot Simple Dried Mana Rations Simple Field Mana Rations 25 39 0 149 +Cooking Pot Elaborate Dried Mana Rations Elaborate Field Mana Rations 25 39 0 150 +Mortar and Pestle Hot Pepper Hot Sauce 1 39 0 151 +Hot Sauce Simple Dried Rations Simple Dried Health Rations 1 39 0 152 +Hot Sauce Elaborate Dried Rations Elaborate Dried Health Rations 1 39 0 153 +Cinnamon Simple Dried Rations Simple Dried Mana Rations 1 39 0 154 +Cinnamon Elaborate Dried Rations Elaborate Dried Mana Rations 1 39 0 155 +Treated Mandrake Treated Hyssop Combined Hyssop and Mandrake 1 21 0 156 +Soft Bandages Combined Hyssop and Mandrake Plentiful Healing Kit 1 21 0 157 +Health Infusion Potion of Healing Trade Health Elixir 0 38 0 158 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Deadly Frog Crotch Arrowheads 0 37 0 159 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Deadly Armor Piercing Arrowheads 0 37 0 160 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Deadly Blunt Arrowheads 0 37 0 161 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Deadly Fire Arrowheads 0 37 0 162 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Deadly Frost Arrowheads 0 37 0 163 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Deadly Acid Arrowheads 0 37 0 164 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Deadly Lightning Arrowheads 0 37 0 165 +Concentrated Bloodseeker Oil Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Greater Frog Crotch Arrowheads 0 37 0 166 +Concentrated Bloodseeker Oil Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Greater Armor Piercing Arrowheads 0 37 0 167 +Concentrated Bloodseeker Oil Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Greater Blunt Arrowheads 0 37 0 168 +Concentrated Bloodseeker Oil Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Greater Fire Arrowheads 0 37 0 169 +Concentrated Bloodseeker Oil Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Greater Frost Arrowheads 0 37 0 170 +Concentrated Bloodseeker Oil Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Greater Acid Arrowheads 0 37 0 171 +Concentrated Bloodseeker Oil Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Greater Lightning Arrowheads 0 37 0 172 +Empty Stopped Keg Duke Raoul's Distillation Brew Keg of Duke Raoul's Distillation 0 0 0 173 +Empty Stopped Keg Apothecary Zongo's Stout Brew Keg of Apothecary Zongo's Stout 0 0 0 174 +Empty Stopped Keg Hunter's Stock Amber Brew Keg of Hunter's Stock Amber 0 0 0 175 +Empty Bottles Keg of Apothecary Zongo's Stout Apothecary Zongo's Stout 0 0 0 176 +Empty Bottles Keg of Duke Raoul's Distillation Duke Raoul's Distillation 0 0 0 177 +Empty Bottles Keg of Bobo's Stout Bobo's Stout 0 0 0 178 +Empty Bottles Keg of Tusker Spit Ale Tusker Spit Ale 0 0 0 179 +Empty Bottles Keg of Amber Ape Amber Ape 0 0 0 180 +Empty Bottles Keg of Hunter's Stock Amber Hunter's Stock Amber 0 0 0 181 +Neutral Balm Strong Chorizite Oil Strong Dispel Potion 0 0 0 182 +Neutral Balm Concentrated Chorizite Oil Concentrated Dispel Potion 0 0 0 183 +Neutral Balm Condensed Chorizite Oil Condensed Dispel Potion 0 0 0 184 +Empty Stopped Keg Tusker Spit Brew Keg of Tusker Spit Ale 0 0 0 185 +Empty Stopped Keg Amber Ape Brew Keg of Amber Ape 0 0 0 186 +Empty Stopped Keg Bobo's Stout Brew Keg of Bobo's Stout 0 0 0 187 +Victual Oil Healing Famous Pizza Hearty Healing Famous Pizza 0 39 0 188 +Victual Oil Healing Cake Hearty Healing Cake 0 39 0 189 +Victual Oil Healing Carrot Cake Hearty Healing Carrot Cake 0 39 0 190 +Victual Oil Healing Pizza Hearty Healing Pizza 0 39 0 191 +Victual Oil Healing Applesauce Hearty Healing Applesauce 0 39 0 192 +Victual Oil Healing Spiced Applesauce Hearty Healing Spiced Applesauce 0 39 0 193 +Victual Oil Healing Meat Pie Hearty Healing Meat Pie 0 39 0 194 +Victual Oil Healing Fish Pie Hearty Healing Fish Pie 0 39 0 195 +Victual Oil Healing Chicken Pie Hearty Healing Chicken Pie 0 39 0 196 +Victual Oil Healing Rabbit Pie Hearty Healing Rabbit Pie 0 39 0 197 +Victual Oil Healing Mushroom Pie Hearty Healing Mushroom Pie 0 39 0 198 +Victual Oil Healing Apple Pie Hearty Healing Apple Pie 0 39 0 199 +Victual Oil Healing Spiced Apple Pie Hearty Healing Spiced Apple Pie 0 39 0 200 +Victual Oil Healing Beef Stew Hearty Healing Beef Stew 0 39 0 201 +Victual Oil Healing Fish Stew Hearty Healing Fish Stew 0 39 0 202 +Victual Oil Healing Chicken Stew Hearty Healing Chicken Stew 0 39 0 203 +Victual Oil Healing Rabbit Stew Hearty Healing Rabbit Stew 0 39 0 204 +Victual Oil Healing Mushroom Stew Hearty Healing Mushroom Stew 0 39 0 205 +Victual Oil Healing Carrot Soup Hearty Healing Carrot Soup 0 39 0 206 +Victual Oil Healing Beef Noodle Hearty Healing Beef Noodle 0 39 0 207 +Victual Oil Healing Fish Noodle Hearty Healing Fish Noodle 0 39 0 208 +Victual Oil Healing Chicken Noodle Hearty Healing Chicken Noodle 0 39 0 209 +Victual Oil Healing Rabbit Noodle Hearty Healing Rabbit Noodle 0 39 0 210 +Victual Oil Healing Mushroom Noodle Hearty Healing Mushroom Noodle 0 39 0 211 +Victual Oil Healing Ice Cream Hearty Healing Icecream 0 39 0 212 +Victual Oil Healing Green Tea Ice Cream Hearty Healing Green Tea Ice Cream 0 39 0 213 +Victual Oil Healing Holtburger Hearty Healing Holtburger 0 39 0 214 +Victual Oil Healing Hot Kimchi Hearty Healing Hot Kimchi 0 39 0 215 +Victual Oil Mana Hot Kimchi Hearty Mana Hot Kimchi 0 39 0 216 +Victual Oil Mana Famous Pizza Hearty Mana Famous Pizza 0 39 0 217 +Victual Oil Mana Green Tea Ice Cream Hearty Mana Green Tea Ice Cream 0 39 0 218 +Victual Oil Mana Cake Hearty Mana Cake 0 39 0 219 +Victual Oil Mana Carrot Cake Hearty Mana Carrot Cake 0 39 0 220 +Victual Oil Mana Pizza Hearty Mana Pizza 0 39 0 221 +Victual Oil Mana Applesauce Hearty Mana Applesauce 0 39 0 222 +Victual Oil Mana Spiced Applesauce Hearty Mana Spiced Applesauce 0 39 0 223 +Victual Oil Mana Meat Pie Hearty Mana Meat Pie 0 39 0 224 +Victual Oil Mana Fish Pie Hearty Mana Fish Pie 0 39 0 225 +Victual Oil Mana Chicken Pie Hearty Mana Chicken Pie 0 39 0 226 +Victual Oil Mana Rabbit Pie Hearty Mana Rabbit Pie 0 39 0 227 +Victual Oil Mana Mushroom Pie Hearty Mana Mushroom Pie 0 39 0 228 +Victual Oil Mana Apple Pie Hearty Mana Apple Pie 0 39 0 229 +Victual Oil Mana Spiced Apple Pie Hearty Mana Spiced Apple Pie 0 39 0 230 +Victual Oil Mana Beef Stew Hearty Mana Beef Stew 0 39 0 231 +Victual Oil Mana Fish Stew Hearty Mana Fish Stew 0 39 0 232 +Victual Oil Mana Chicken Stew Hearty Mana Chicken Stew 0 39 0 233 +Victual Oil Mana Rabbit Stew Hearty Mana Rabbit Stew 0 39 0 234 +Victual Oil Mana Mushroom Stew Hearty Mana Mushroom Stew 0 39 0 235 +Victual Oil Mana Carrot Soup Hearty Mana Carrot Soup 0 39 0 236 +Victual Oil Mana Beef Noodle Hearty Mana Beef Noodle 0 39 0 237 +Victual Oil Mana Fish Noodle Hearty Mana Fish Noodle 0 39 0 238 +Victual Oil Mana Chicken Noodle Hearty Mana Chicken Noodle 0 39 0 239 +Victual Oil Mana Rabbit Noodle Hearty Mana Rabbit Noodle 0 39 0 240 +Victual Oil Mana Mushroom Noodle Hearty Mana Mushroom Noodle 0 39 0 241 +Victual Oil Mana Ice Cream Hearty Mana Icecream 0 39 0 242 +Victual Oil Mana Holtburger Hearty Mana Holtburger 0 39 0 243 +Baking Pan Olthoi Chocolate Cake Batter Chocolate Olthoi Cake 0 39 0 244 +Frying Pan Olthoi Egg Fried Olthoi Egg 0 39 0 245 +Cooking Pot Olthoi Egg Hard Boiled Olthoi Egg 0 39 0 246 +Baking Pan Olthoi Cake Batter Olthoi Cake 0 39 0 247 +Baking Pan Olthoi Carrot Cake Batter Olthoi Carrot Cake 0 39 0 248 +Olthoi Pumpkin Pie Filling Dough Olthoi Pumpkin Pie 0 39 0 249 +Olthoi Batter Bread Olthoi Toast 0 39 0 250 +Brine Olthoi Egg Pickled Olthoi Egg 0 39 0 251 +Frying Pan Marinated Olthoi Egg Vesayen Style Fried Olthoi Egg 0 39 0 252 +Hot Sauce Olthoi Egg Marinated Olthoi Egg 0 39 0 253 +Treated Stibnite and Frankincense Crucible Powdered Onyx Gem of Greater Protection 0 38 0 254 +Treated Quicksilver and Frankincense Crucible Powdered Hematite Gem of Greater Piercing Protection 0 38 0 255 +Treated Verdigris and Frankincense Crucible Powdered Turquoise Gem of Greater Bludgeon Protection 0 38 0 256 +Treated Cadmia and Frankincense Crucible Powdered Moonstone Gem of Greater Blade Protection 0 38 0 257 +Treated Brimstone and Frankincense Crucible Powdered Malachite Gem of Greater Acid Protection 0 38 0 258 +Treated Colcothar and Frankincense Crucible Powdered Quartz Gem of Greater Cold Protection 0 38 0 259 +Treated Turpeth and Frankincense Crucible Powdered Carnelian Gem of Greater Fire Protection 0 38 0 260 +Treated Cobalt and Frankincense Crucible Powdered Agate Gem of Greater Lightning Protection 0 38 0 261 +Treated Vitriol and Frankincense Crucible Powdered Bloodstone Gem of Greater Regeneration 0 38 0 262 +Treated Cinnabar and Frankincense Crucible Powdered Amber Gem of Greater Rejuvenation 0 38 0 263 +Treated Gypsum and Frankincense Crucible Powdered Lapis Lazuli Gem of Greater Mana Renewal 0 38 0 264 +Concentrated Health Infusion Concentrated Aqua Incanta Concentrated Health Oil 0 38 0 265 +Concentrated Mana Infusion Concentrated Aqua Incanta Concentrated Mana Oil 0 38 0 266 +Concentrated Victual Infusion Concentrated Aqua Incanta Concentrated Victual Oil 0 38 0 267 +Eye Dropper Concentrated Health Oil Health Oil 0 38 0 268 +Eye Dropper Concentrated Mana Oil Mana Oil 0 38 0 269 +Eye Dropper Concentrated Victual Oil Victual Oil 0 38 0 270 +Concentrated Bloodseeker Infusion Concentrated Aqua Incanta Concentrated Bloodseeker Oil 0 38 0 271 +Concentrated Bloodhunter Infusion Concentrated Aqua Incanta Concentrated Bloodhunter Oil 0 38 0 272 +Concentrated Fire Infusion Concentrated Aqua Incanta Concentrated Fire Oil 0 38 0 273 +Concentrated Frost Infusion Concentrated Aqua Incanta Concentrated Frost Oil 0 38 0 274 +Concentrated Acid Infusion Concentrated Aqua Incanta Concentrated Acid Oil 0 38 0 275 +Concentrated Lightning Infusion Concentrated Aqua Incanta Concentrated Lightning Oil 0 38 0 276 +Bloodhunter Infusion Aqua Incanta Bloodhunter Oil 0 38 0 277 +Bloodseeker Infusion Aqua Incanta Bloodseeker Oil 0 38 0 278 +Lightning Infusion Aqua Incanta Lightning Oil 0 38 0 279 +Fire Infusion Aqua Incanta Fire Oil 0 38 0 280 +Acid Infusion Aqua Incanta Acid Oil 0 38 0 281 +Frost Infusion Aqua Incanta Frost Oil 0 38 0 282 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 0 37 0 283 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 0 37 0 284 +Bundle of Deadly Arrowheads Bundle of Arrowshafts Deadly Arrow 0 37 0 285 +Wrapped Bundle of Greater Blunt Arrowheads Bundle of Arrowshafts Greater Blunt Arrow 0 37 0 286 +Wrapped Bundle of Greater Frog Crotch Arrowheads Bundle of Arrowshafts Greater Frog Crotch Arrow 0 37 0 287 +Baking Pan Dough Bread 0 39 0 288 +Carving Knife Cabbage Coleslaw 0 39 0 289 +Frying Pan Dough Flat Bread 0 39 0 290 +Frying Pan Brimstone-cap Mushroom Fried Mushroom 0 39 0 291 +Brine Egg Pickled Egg 0 39 0 292 +Brine Fish Filet Pickled Fish 0 39 0 293 +Rich Carrot Stock Cheese Carol's Carrot Soup 0 39 0 294 +Cubed Carrot Cake Milk Carrot Cake Soup 0 39 0 295 +Cooking Pot Spiced Pumpkin Pumpkin Soup 0 39 0 296 +Uncooked Rice Grapes Stuffed Grape Leaf 0 39 0 297 +Baking Pan Cheese Filled Mushroom Stuffed Mushroom 0 0 0 298 +Uncooked Rice Fish Filet Sushi 0 39 0 299 +Rat Tail Ground Rabbit Rabbit Sausage 0 39 0 300 +Rat Tail Ground Meat Sausage 0 39 0 301 +Hot Sauce Sausage Spicy Sausage 0 39 0 302 +Metal Press Apple Apple Juice 0 39 0 303 +Bitter Milk Honey Chocolate Milk 0 39 0 304 +Crushed Ice Milk Cold Milk 0 39 0 305 +Egg Spiced Milk Eggnog 0 39 0 306 +Sweetened Hot Milk Cocoa Powder Hot Chocolate 0 39 0 307 +Crushed Ice Mocha Iced Mocha 0 39 0 308 +Mocha Base Milk Mocha 0 39 0 309 +Peppermint Stick Hot Chocolate Peppermint Hot Chocolate 0 0 0 310 +Crushed Ice Rich Mocha Rich Iced Mocha 0 39 0 311 +Cinnamon Mocha Rich Mocha 0 39 0 312 +Slice of Bread Cheese Cheese Sandwich 0 39 0 313 +Slice of Bread Chicken Chicken Sandwich 0 39 0 314 +Ravener Gut Ground Meat Drudge Gut Sausage 0 39 0 315 +Slice of Bread Egg Egg Sandwich 0 39 0 316 +Slice of Bread Fish Fish Sandwich 0 39 0 317 +Frying Pan Cheese Sandwich Grilled Cheese Sandwich 0 39 0 318 +Ground Meat Bread Holtburger 0 39 0 319 +Dough Apple Apple Pie 0 39 0 320 +Heavy Grinder Apple Applesauce 0 39 0 321 +Baking Pan Cake Batter Cake 0 39 0 322 +Monougat Apple Candied Apple 0 39 0 323 +Baking Pan Carrot Cake Batter Carrot Cake 0 39 0 324 +Baking Pan Chocolate Cake Batter Chocolate Cake 0 39 0 325 +Baking Pan Chocolate Cookie Dough Chocolate Cookie 0 39 0 326 +Chocolate Liquor Ice Cream Chocolate Ice Cream 0 39 0 327 +Baking Pan Cookie Dough Cookie 0 39 0 328 +Cocoa Mixture Honey Bar Dark Chocolate 0 39 0 329 +Monougat Bar Dark Chocolate Dark Chocolate Candy Bar 0 39 0 330 +Baking Pan Fruitcake Batter Fruitcake 0 39 0 331 +Baking Pan Ginger Dough Ginger Bread 0 39 0 332 +Frozen Green Tea Honey Green Tea Ice Cream 0 39 0 333 +Frozen Cream Honey Ice Cream 0 39 0 334 +Milky Cocoa Mixture Honey Bar Milk Chocolate 0 39 0 335 +Monougat Bar Milk Chocolate Milk Chocolate Candy Bar 0 39 0 336 +Baking Pan Peppermint Chocolate Cookie Dough Peppermint Chocolate Cookie 0 39 0 337 +Baking Pan Peppermint Cookie Dough Peppermint Cookie 0 39 0 338 +Peppermint Stick Ice Cream Peppermint Ice Cream 0 39 0 339 +Monougat Peppermint Stick Peppermint Monougat Chew 0 39 0 340 +Pumpkin Pie Filling Dough Pumpkin Pie 0 39 0 341 +Dough Spiced Apple Filling Spiced Apple Pie 0 39 0 342 +Cinnamon Applesauce Spiced Applesauce 0 39 0 343 +Raw Noodles Cheese Cragstone Farms Mac and Cheese 0 39 0 344 +Raw Egg Noodles Ground Beef Cragstonanoff 0 39 0 345 +Rice Dough Chicken Chicken Dumpling 0 39 0 346 +Rice Dough Fish Fish Dumpling 0 39 0 347 +Frying Pan Chicken Piece Fried Chicken 0 39 0 348 +Frying Pan Egg Fried Egg 0 39 0 349 +Frying Pan Fish Filet Fried Fish 0 39 0 350 +Frying Pan Rabbit Piece Fried Rabbit 0 39 0 351 +Frying Pan Steak Fried Steak 0 39 0 352 +Skewer Steak Beef Kebob 0 39 0 353 +Skewer Chicken Piece Chicken Kebob 0 39 0 354 +Skewer Fish Filet Fish Kebob 0 39 0 355 +Skewer Brimstone-cap Mushroom Mushroom Kebob 0 39 0 356 +Skewer Rabbit Piece Rabbit Kebob 0 39 0 357 +Brine Cabbage Kimchi 0 39 0 358 +Hot Sauce Kimchi Hot Kimchi 0 39 0 359 +Fire Oil Hot Kimchi Flaming Kimchi 0 39 0 360 +Raw Noodles Steak Beef Noodle 0 39 0 361 +Raw Noodles Chicken Piece Chicken Noodle 0 39 0 362 +Raw Noodles Fish Filet Fish Noodle 0 39 0 363 +Raw Noodles Brimstone-cap Mushroom Mushroom Noodle 0 39 0 364 +Raw Noodles Rabbit Piece Rabbit Noodle 0 39 0 365 +Dough Chicken Piece Chicken Pie 0 39 0 366 +Dough Fish Filet Fish Pie 0 39 0 367 +Dough Steak Meat Pie 0 39 0 368 +Dough Brimstone-cap Mushroom Mushroom Pie 0 39 0 369 +Dough Rabbit Piece Rabbit Pie 0 39 0 370 +Cooking Pot Uncooked Rice Bowl of Rice 0 39 0 371 +Uncooked Rice Steak Beef Rice 0 39 0 372 +Uncooked Rice Chicken Piece Chicken Rice 0 39 0 373 +Uncooked Rice Brimstone-cap Mushroom Mushroom Rice 0 39 0 374 +Uncooked Rice Rabbit Piece Rabbit Rice 0 39 0 375 +Cooking Pot Steak Beef Stew 0 39 0 376 +Cooking Pot Chicken Piece Chicken Stew 0 39 0 377 +Cooking Pot Fish Filet Fish Stew 0 39 0 378 +Cooking Pot Brimstone-cap Mushroom Mushroom Stew 0 39 0 379 +Cooking Pot Rabbit Piece Rabbit Stew 0 39 0 380 +Batter Bread Viamont Toast 0 39 0 381 +Oregano Pizza Famous Pizza 0 39 0 382 +Dough Cheese Pizza 0 39 0 383 +Health Oil Cake Healing Cake 0 39 0 384 +Health Oil Carrot Cake Healing Carrot Cake 0 39 0 385 +Health Oil Pizza Healing Pizza 0 39 0 386 +Health Oil Famous Pizza Healing Famous Pizza 0 39 0 387 +Health Oil Applesauce Healing Applesauce 0 39 0 388 +Health Oil Spiced Applesauce Healing Spiced Applesauce 0 39 0 389 +Health Oil Meat Pie Healing Meat Pie 0 39 0 390 +Health Oil Fish Pie Healing Fish Pie 0 39 0 391 +Health Oil Chicken Pie Healing Chicken Pie 0 39 0 392 +Health Oil Rabbit Pie Healing Rabbit Pie 0 39 0 393 +Health Oil Mushroom Pie Healing Mushroom Pie 0 39 0 394 +Health Oil Apple Pie Healing Apple Pie 0 39 0 395 +Health Oil Spiced Apple Pie Healing Spiced Apple Pie 0 39 0 396 +Health Oil Beef Stew Healing Beef Stew 0 39 0 397 +Health Oil Fish Stew Healing Fish Stew 0 39 0 398 +Health Oil Chicken Stew Healing Chicken Stew 0 39 0 399 +Health Oil Rabbit Stew Healing Rabbit Stew 0 39 0 400 +Health Oil Mushroom Stew Healing Mushroom Stew 0 39 0 401 +Health Oil Carrot Soup Healing Carrot Soup 0 39 0 402 +Health Oil Beef Noodle Healing Beef Noodle 0 39 0 403 +Health Oil Fish Noodle Healing Fish Noodle 0 39 0 404 +Health Oil Chicken Noodle Healing Chicken Noodle 0 39 0 405 +Health Oil Rabbit Noodle Healing Rabbit Noodle 0 39 0 406 +Health Oil Mushroom Noodle Healing Mushroom Noodle 0 39 0 407 +Health Oil Ice Cream Healing Icecream 0 39 0 408 +Health Oil Green Tea Ice Cream Healing Green Tea Ice Cream 0 39 0 409 +Health Oil Holtburger Healing Holtburger 0 39 0 410 +Health Oil Hot Kimchi Healing Hot Kimchi 0 39 0 411 +Victual Oil Cake Hearty Cake 0 39 0 412 +Victual Oil Carrot Cake Hearty Carrot Cake 0 39 0 413 +Victual Oil Pizza Hearty Pizza 0 39 0 414 +Victual Oil Famous Pizza Hearty Famous Pizza 0 39 0 415 +Victual Oil Applesauce Hearty Applesauce 0 39 0 416 +Victual Oil Spiced Applesauce Hearty Spiced Applesauce 0 39 0 417 +Victual Oil Meat Pie Hearty Meat Pie 0 39 0 418 +Victual Oil Fish Pie Hearty Fish Pie 0 39 0 419 +Victual Oil Chicken Pie Hearty Chicken Pie 0 39 0 420 +Victual Oil Rabbit Pie Hearty Rabbit Pie 0 39 0 421 +Victual Oil Mushroom Pie Hearty Mushroom Pie 0 39 0 422 +Victual Oil Apple Pie Hearty Apple Pie 0 39 0 423 +Victual Oil Spiced Apple Pie Hearty Spiced Apple Pie 0 39 0 424 +Victual Oil Beef Stew Hearty Beef Stew 0 39 0 425 +Victual Oil Fish Stew Hearty Fish Stew 0 39 0 426 +Victual Oil Chicken Stew Hearty Chicken Stew 0 39 0 427 +Victual Oil Rabbit Stew Hearty Rabbit Stew 0 39 0 428 +Victual Oil Mushroom Stew Hearty Mushroom Stew 0 39 0 429 +Victual Oil Carrot Soup Hearty Carrot Soup 0 39 0 430 +Victual Oil Beef Noodle Hearty Beef Noodle 0 39 0 431 +Victual Oil Fish Noodle Hearty Fish Noodle 0 39 0 432 +Victual Oil Chicken Noodle Hearty Chicken Noodle 0 39 0 433 +Victual Oil Rabbit Noodle Hearty Rabbit Noodle 0 39 0 434 +Victual Oil Mushroom Noodle Hearty Mushroom Noodle 0 39 0 435 +Victual Oil Ice Cream Hearty Icecream 0 39 0 436 +Victual Oil Green Tea Ice Cream Hearty Green Tea Ice Cream 0 39 0 437 +Victual Oil Holtburger Hearty Holtburger 0 39 0 438 +Victual Oil Hot Kimchi Hearty Hot Kimchi 0 39 0 439 +Mana Oil Cake Mana Cake 0 39 0 440 +Mana Oil Carrot Cake Mana Carrot Cake 0 39 0 441 +Mana Oil Pizza Mana Pizza 0 39 0 442 +Mana Oil Famous Pizza Mana Famous Pizza 0 39 0 443 +Mana Oil Applesauce Mana Applesauce 0 39 0 444 +Mana Oil Spiced Applesauce Mana Spiced Applesauce 0 39 0 445 +Mana Oil Meat Pie Mana Meat Pie 0 39 0 446 +Mana Oil Fish Pie Mana Fish Pie 0 39 0 447 +Mana Oil Chicken Pie Mana Chicken Pie 0 39 0 448 +Mana Oil Rabbit Pie Mana Rabbit Pie 0 39 0 449 +Mana Oil Mushroom Pie Mana Mushroom Pie 0 39 0 450 +Mana Oil Apple Pie Mana Apple Pie 0 39 0 451 +Mana Oil Spiced Apple Pie Mana Spiced Apple Pie 0 39 0 452 +Mana Oil Beef Stew Mana Beef Stew 0 39 0 453 +Mana Oil Fish Stew Mana Fish Stew 0 39 0 454 +Mana Oil Chicken Stew Mana Chicken Stew 0 39 0 455 +Mana Oil Rabbit Stew Mana Rabbit Stew 0 39 0 456 +Mana Oil Mushroom Stew Mana Mushroom Stew 0 39 0 457 +Mana Oil Carrot Soup Mana Carrot Soup 0 39 0 458 +Mana Oil Beef Noodle Mana Beef Noodle 0 39 0 459 +Mana Oil Fish Noodle Mana Fish Noodle 0 39 0 460 +Mana Oil Chicken Noodle Mana Chicken Noodle 0 39 0 461 +Mana Oil Rabbit Noodle Mana Rabbit Noodle 0 39 0 462 +Mana Oil Mushroom Noodle Mana Mushroom Noodle 0 39 0 463 +Mana Oil Ice Cream Mana Icecream 0 39 0 464 +Mana Oil Green Tea Ice Cream Mana Green Tea Ice Cream 0 39 0 465 +Mana Oil Holtburger Mana Holtburger 0 39 0 466 +Mana Oil Hot Kimchi Mana Hot Kimchi 0 39 0 467 +Bloodhunter Oil Bundle of Greater Acid Arrowheads Bundle of Deadly Acid Arrowheads 0 37 0 468 +Bloodhunter Oil Bundle of Greater Arrowheads Bundle of Deadly Arrowheads 0 37 0 469 +Bloodhunter Oil Bundle of Greater Blunt Arrowheads Bundle of Deadly Blunt Arrowheads 0 37 0 470 +Bloodhunter Oil Bundle of Greater Frog Crotch Arrowheads Bundle of Deadly Frog Crotch Arrowheads 0 37 0 471 +Concentrated Fire Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Fire Arrowheads 0 37 0 472 +Concentrated Frost Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Frost Arrowheads 0 37 0 473 +Concentrated Acid Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Acid Arrowheads 0 37 0 474 +Concentrated Lightning Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Lightning Arrowheads 0 37 0 475 +Lightning Oil Bundle of Arrowheads Bundle of Lightning Arrowheads 0 37 0 476 +Fire Oil Bundle of Arrowheads Bundle of Fire Arrowheads 0 37 0 477 +Frost Oil Bundle of Arrowheads Bundle of Frost Arrowheads 0 37 0 478 +Acid Oil Bundle of Arrowheads Bundle of Acid Arrowheads 0 37 0 479 +Bloodseeker Oil Bundle of Blunt Arrowheads Bundle of Greater Blunt Arrowheads 0 37 0 480 +Bloodseeker Oil Bundle of Frog Crotch Arrowheads Bundle of Greater Frog Crotch Arrowheads 0 37 0 481 +Bloodseeker Oil Bundle of Arrowheads Bundle of Greater Arrowheads 0 37 0 482 +Bloodseeker Oil Bundle of Fire Arrowheads Bundle of Greater Fire Arrowheads 0 37 0 483 +Bloodseeker Oil Bundle of Acid Arrowheads Bundle of Greater Acid Arrowheads 0 37 0 484 +Bloodseeker Oil Bundle of Frost Arrowheads Bundle of Greater Frost Arrowheads 0 37 0 485 +Bloodseeker Oil Bundle of Lightning Arrowheads Bundle of Greater Lightning Arrowheads 0 37 0 486 +Eye Dropper Concentrated Health Infusion Health Infusion 0 38 0 487 +Eye Dropper Concentrated Mana Infusion Mana Infusion 0 38 0 488 +Eye Dropper Concentrated Victual Infusion Victual Infusion 0 38 0 489 +Alembic Quicksilver Bloodseeker Infusion 0 38 0 490 +Alembic Stibnite Bloodhunter Infusion 0 38 0 491 +Alembic Cobalt Lightning Infusion 0 38 0 492 +Alembic Turpeth Fire Infusion 0 38 0 493 +Alembic Colcothar Frost Infusion 0 38 0 494 +Alembic Brimstone Acid Infusion 0 38 0 495 +Alembic Vitriol Pea Concentrated Health Infusion 0 38 0 496 +Alembic Gypsum Pea Concentrated Mana Infusion 0 38 0 497 +Alembic Realgar Pea Concentrated Victual Infusion 0 38 0 498 +Alembic Quicksilver Pea Concentrated Bloodseeker Infusion 0 38 0 499 +Alembic Stibnite Pea Concentrated Bloodhunter Infusion 0 38 0 500 +Alembic Turpeth Pea Concentrated Fire Infusion 0 38 0 501 +Alembic Colcothar Pea Concentrated Frost Infusion 0 38 0 502 +Alembic Brimstone Pea Concentrated Acid Infusion 0 38 0 503 +Alembic Cobalt Pea Concentrated Lightning Infusion 0 38 0 504 +Crucible Stibnite Crucible with Stibnite Potion 0 38 0 505 +Crucible with Stibnite Potion Frankincense Stibnite and Frankincense Crucible 0 38 0 506 +Aqua Vitae Stibnite and Frankincense Crucible Treated Stibnite and Frankincense Crucible 0 38 0 507 +Crucible Quicksilver Crucible with Quicksilver Potion 0 38 0 508 +Crucible with Quicksilver Potion Frankincense Quicksilver and Frankincense Crucible 0 38 0 509 +Aqua Vitae Quicksilver and Frankincense Crucible Treated Quicksilver and Frankincense Crucible 0 38 0 510 +Crucible Verdigris Crucible with Verdigris Potion 0 38 0 511 +Crucible with Verdigris Potion Frankincense Verdigris and Frankincense Crucible 0 38 0 512 +Aqua Vitae Verdigris and Frankincense Crucible Treated Verdigris and Frankincense Crucible 0 38 0 513 +Crucible Cadmia Crucible with Cadmia Potion 0 38 0 514 +Crucible with Cadmia Potion Frankincense Cadmia and Frankincense Crucible 0 38 0 515 +Aqua Vitae Cadmia and Frankincense Crucible Treated Cadmia and Frankincense Crucible 0 38 0 516 +Crucible Brimstone Crucible with Brimstone Potion 0 38 0 517 +Crucible with Brimstone Potion Frankincense Brimstone and Frankincense Crucible 0 38 0 518 +Aqua Vitae Brimstone and Frankincense Crucible Treated Brimstone and Frankincense Crucible 0 38 0 519 +Crucible Colcothar Crucible with Colcothar Potion 0 38 0 520 +Crucible with Colcothar Potion Frankincense Colcothar and Frankincense Crucible 0 38 0 521 +Aqua Vitae Colcothar and Frankincense Crucible Treated Colcothar and Frankincense Crucible 0 38 0 522 +Crucible Turpeth Crucible with Turpeth Potion 0 38 0 523 +Crucible with Turpeth Potion Frankincense Turpeth and Frankincense Crucible 0 38 0 524 +Aqua Vitae Turpeth and Frankincense Crucible Treated Turpeth and Frankincense Crucible 0 38 0 525 +Crucible Cobalt Crucible with Cobalt Potion 0 38 0 526 +Crucible with Cobalt Potion Frankincense Cobalt and Frankincense Crucible 0 38 0 527 +Aqua Vitae Cobalt and Frankincense Crucible Treated Cobalt and Frankincense Crucible 0 38 0 528 +Crucible Vitriol Crucible with Vitriol Potion 0 38 0 529 +Crucible with Vitriol Potion Frankincense Vitriol and Frankincense Crucible 0 38 0 530 +Aqua Vitae Vitriol and Frankincense Crucible Treated Vitriol and Frankincense Crucible 0 38 0 531 +Crucible Cinnabar Crucible with Cinnabar Potion 0 38 0 532 +Crucible with Cinnabar Potion Frankincense Cinnabar and Frankincense Crucible 0 38 0 533 +Aqua Vitae Cinnabar and Frankincense Crucible Treated Cinnabar and Frankincense Crucible 0 38 0 534 +Crucible Gypsum Crucible with Gypsum Potion 0 38 0 535 +Crucible with Gypsum Potion Frankincense Gypsum and Frankincense Crucible 0 38 0 536 +Aqua Vitae Gypsum and Frankincense Crucible Treated Gypsum and Frankincense Crucible 0 38 0 537 +Ground Chorizite Vitriol Chorizite 0 0 0 538 +Alembic Chorizite Chorizite Oil 0 0 0 539 +Chorizite Oil Chorizite Oil Strong Chorizite Oil 0 0 0 540 +Chorizite Oil Strong Chorizite Oil Concentrated Chorizite Oil 0 0 0 541 +Chorizite Oil Concentrated Chorizite Oil Condensed Chorizite Oil 0 0 0 542 +Cocoa Mixture Milk Milky Cocoa Mixture 0 39 0 543 +Mortar and Pestle Cinnamon Bark Cinnamon 0 0 0 544 +Heavy Grinder Ginger Ground Ginger 0 0 0 545 +Mortar and Pestle Hot Pepper Hot Sauce 0 0 0 546 +Heavy Grinder Nutmeg Ground Nutmeg 0 0 0 547 +Flour Water Dough 0 0 0 548 +Carving Knife Fish Fish Filet 0 39 0 549 +Carving Knife Brimstone-cap Mushroom Stemless Mushroom 0 39 0 550 +Rennet Milk Cheese 0 39 0 551 +Stemless Mushroom Cheese Cheese Filled Mushroom 0 39 0 552 +Dough Egg Batter 0 39 0 553 +Baking Pan Brown Beans Roasted Beans 0 39 0 554 +Heavy Grinder Roasted Beans Chocolate Liquor 0 39 0 555 +Metal Press Chocolate Liquor Cocoa Powder 0 39 0 556 +Cocoa Powder Milk Bitter Milk 0 39 0 557 +Heavy Grinder Magic Iceball Crushed Ice 0 39 0 558 +Ground Nutmeg Milk Spiced Milk 0 39 0 559 +Cooking Pot Milk Hot Milk 0 39 0 560 +Hot Milk Honey Sweetened Hot Milk 0 39 0 561 +Cocoa Powder Coffee Mocha Base 0 39 0 562 +Whittling Knife Strange Stick Cinnamon Bark 0 39 0 563 +Carving Knife Bread Slice of Bread 0 39 0 564 +Carving Knife Side of Beef Steak 0 39 0 565 +Heavy Grinder Steak Ground Meat 0 39 0 566 +Heavy Grinder Rabbit Piece Ground Rabbit 0 39 0 567 +Batter Flour Cake Batter 0 39 0 568 +Cake Batter Carrot Carrot Cake Batter 0 39 0 569 +Cake Batter Cocoa Powder Chocolate Cake Batter 0 39 0 570 +Dough Honey Cookie Dough 0 39 0 571 +Cocoa Powder Cookie Dough Chocolate Cookie Dough 0 39 0 572 +Chocolate Liquor Cocoa Powder Cocoa Mixture 0 39 0 573 +Cinnamon Mocha Rich Mocha 0 39 0 574 +Cinnamon Brown Lump Spiced Lump 0 39 0 575 +Flour Spiced Lump Spiced Lumpy Flour 0 39 0 576 +Spiced Lumpy Flour Egg Rich Lumpy Flour 0 39 0 577 +Rich Lumpy Flour Red Wine Fruitcake Batter 0 39 0 578 +Ground Ginger Dough Ginger Dough 0 39 0 579 +Frozen Cream Green Tea Frozen Green Tea 0 39 0 580 +Magic Iceball Milk Frozen Cream 0 39 0 581 +Peppermint Stick Chocolate Cookie Dough Peppermint Chocolate Cookie Dough 0 39 0 582 +Peppermint Stick Cookie Dough Peppermint Cookie Dough 0 39 0 583 +Baking Pan Pumpkin Cooked Pumpkin 0 39 0 584 +Cooked Pumpkin Milk Liquid Pumpkin 0 39 0 585 +Liquid Pumpkin Honey Sweetened Pumpkin 0 39 0 586 +Sweetened Pumpkin Cinnamon Spiced Pumpkin 0 39 0 587 +Spiced Pumpkin Egg Pumpkin Pie Filling 0 39 0 588 +Cinnamon Apple Spiced Apple Filling 0 39 0 589 +Carving Knife Chicken Chicken Pieces 0 39 0 590 +Carving Knife Rabbit Carcass Rabbit Pieces 0 39 0 591 +Noodle Cutter Dough Raw Noodles 0 39 0 592 +Dough Olthoi Egg Olthoi Batter 0 39 0 593 +Flour Olthoi Batter Olthoi Cake Batter 0 39 0 594 +Olthoi Cake Batter Carrot Olthoi Carrot Cake Batter 0 39 0 595 +Olthoi Cake Batter Chocolate Powder Olthoi Chocolate Cake Batter 0 39 0 596 +Spiced Pumpkin Filling Olthoi Egg Olthoi Pumpkin Pie Filling 0 39 0 597 +Cooking Pot Carrot Carrot Stock 0 39 0 598 +Carrot Stock Milk Rich Carrot Stock 0 39 0 599 +Mortar and Pestle Uncooked Rice Rice Flour 0 39 0 600 +Rice Flour Water Rice Dough 0 39 0 601 +Carving Knife Carrot Cake Cubed Carrot Cake 0 39 0 602 +Noodle Cutter Batter Raw Egg Noodles 0 39 0 603 +Baking Pan Plain Barley Roasted Barley 0 39 0 604 +Brew Kettle Water Full Brew Kettle 0 39 0 605 +Roasted Barley Full Brew Kettle Dark Wort 0 39 0 606 +Ultra Green Hops Dark Wort Aromatic Dark Wort 0 39 0 607 +Dried Yeast Aromatic Dark Wort Glorious Dark Brew 0 39 0 608 +Amber Barley Full Brew Kettle Amber Wort 0 39 0 609 +Ultra Green Hops Amber Wort Aromatic Amber Wort 0 39 0 610 +Dried Yeast Aromatic Amber Wort Glorious Amber Brew 0 39 0 611 +Plain Barley Full Brew Kettle Sweet Wort 0 39 0 612 +Ultra Green Hops Sweet Wort Aromatic Finished Wort 0 39 0 613 +Dried Yeast Aromatic Finished Wort Glorious Fermented Brew 0 39 0 614 +Moarsmuck Glorious Dark Brew Apothecary Zongo's Stout Brew 0 39 0 615 +Moarsmuck Glorious Amber Brew Hunter's Stock Amber Brew 0 39 0 616 +Moarsmuck Glorious Fermented Brew Duke Raoul's Distillation Brew 0 39 0 617 +Tusker Spit Glorious Dark Brew Bobo's Stout Brew 0 39 0 618 +Tusker Spit Glorious Amber Brew Amber Ape Brew 0 39 0 619 +Tusker Spit Glorious Fermented Brew Tusker Spit Brew 0 39 0 620 +Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Arrowshafts Raider Lightning Arrow 250 37 0 621 +Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Quarrelshafts Raider Lightning Bolt 250 37 0 622 +Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Raider Lightning Atlatl Dart 250 37 0 623 +Wrapped Bundle of Arrowheads Wrapped Bundle of Arrowshafts Arrow 250 37 0 624 +Wrapped Bundle of Arrowheads Wrapped Bundle of Atlatl Dartshafts Atlatl Dart 250 37 0 625 +Wrapped Bundle of Arrowheads Wrapped Bundle of Quarrelshafts Quarrel 250 37 0 626 +Carving Knife Cured Mushroom Stalk Tiriun Stalk Jerky 10 39 100 627 +Hot Sauce Tiriun Mushroom Stalk Cured Mushroom Stalk 1 39 100 628 +Cooking Pot Tiriun Mushroom Spores Roasted Tiriun Spores 1 39 100 629 +Mortar and Pestle Roasted Tiriun Spores Tiriun Spore Powder 10 39 100 630 +Skewer Tiriun Mushroom Cap Roasted Tiriun Cap 1 39 100 631 +Carving Knife Roasted Tiriun Cap Tiriun Cap Wafer 10 39 100 632 +Splitting Tool Lead Pea Lead Scarab 20 33 0 633 +Splitting Tool Iron Pea Iron Scarab 20 33 0 634 +Splitting Tool Copper Pea Copper Scarab 20 33 0 635 +Splitting Tool Silver Pea Silver Scarab 20 33 0 636 +Splitting Tool Gold Pea Gold Scarab 20 33 0 637 +Splitting Tool Pyreal Pea Pyreal Scarab 20 33 0 638 +Splitting Tool Amaranth Pea Amaranth 50 33 0 639 +Splitting Tool Bistort Pea Bistort 50 33 0 640 +Splitting Tool Comfrey Pea Comfrey 50 33 0 641 +Splitting Tool Damiana Pea Damiana 50 33 0 642 +Splitting Tool Dragonsblood Pea Dragonsblood 50 33 0 643 +Splitting Tool Eyebright Pea Eyebright 50 33 0 644 +Splitting Tool Frankincense Pea Frankincense 50 33 0 645 +Splitting Tool Ginseng Pea Ginseng 50 33 0 646 +Splitting Tool Hawthorn Pea Hawthorn 50 33 0 647 +Splitting Tool Henbane Pea Henbane 50 33 0 648 +Splitting Tool Hyssop Pea Hyssop 50 33 0 649 +Splitting Tool Mandrake Pea Mandrake 50 33 0 650 +Splitting Tool Mugwort Pea Mugwort 50 33 0 651 +Splitting Tool Myrrh Pea Myrrh 50 33 0 652 +Splitting Tool Saffron Pea Saffron 50 33 0 653 +Splitting Tool Vervain Pea Vervain 50 33 0 654 +Splitting Tool Wormwood Pea Wormwood 50 33 0 655 +Splitting Tool Yarrow Pea Yarrow 50 33 0 656 +Splitting Tool Powdered Agate Pea Powdered Agate 50 33 0 657 +Splitting Tool Powdered Amber Pea Powdered Amber 50 33 0 658 +Splitting Tool Powdered Azurite Pea Powdered Azurite 50 33 0 659 +Splitting Tool Powdered Bloodstone Pea Powdered Bloodstone 50 33 0 660 +Splitting Tool Powdered Carnelian Pea Powdered Carnelian 50 33 0 661 +Splitting Tool Powdered Hematite Pea Powdered Hematite 50 33 0 662 +Splitting Tool Powdered Lapis Lazuli Pea Powdered Lapis Lazuli 50 33 0 663 +Splitting Tool Powdered Malachite Pea Powdered Malachite 50 33 0 664 +Splitting Tool Powdered Moonstone Pea Powdered Moonstone 50 33 0 665 +Splitting Tool Powdered Onyx Pea Powdered Onyx 50 33 0 666 +Splitting Tool Powdered Quartz Pea Powdered Quartz 50 33 0 667 +Splitting Tool Powdered Turquoise Pea Powdered Turquoise 50 33 0 668 +Splitting Tool Brimstone Pea Brimstone 50 33 0 669 +Splitting Tool Cadmia Pea Cadmia 50 33 0 670 +Splitting Tool Cinnabar Pea Cinnabar 50 33 0 671 +Splitting Tool Cobalt Pea Cobalt 50 33 0 672 +Splitting Tool Colcothar Pea Colcothar 50 33 0 673 +Splitting Tool Gypsum Pea Gypsum 50 33 0 674 +Splitting Tool Quicksilver Pea Quicksilver 50 33 0 675 +Splitting Tool Realgar Pea Realgar 50 33 0 676 +Splitting Tool Stibnite Pea Stibnite 50 33 0 677 +Splitting Tool Turpeth Pea Turpeth 50 33 0 678 +Splitting Tool Verdigris Pea Verdigris 50 33 0 679 +Splitting Tool Vitriol Pea Vitriol 50 33 0 680 +Splitting Tool Poplar Pea Poplar Talisman 20 33 0 681 +Splitting Tool Blackthorn Pea Blackthorn Talisman 20 33 0 682 +Splitting Tool Yew Pea Yew Talisman 20 33 0 683 +Splitting Tool Hemlock Pea Hemlock Talisman 20 33 0 684 +Splitting Tool Alder Pea Alder Talisman 20 33 0 685 +Splitting Tool Ebony Pea Ebony Talisman 20 33 0 686 +Splitting Tool Birch Pea Birch Talisman 20 33 0 687 +Splitting Tool Ashwood Pea Ashwood Talisman 20 33 0 688 +Splitting Tool Elder Pea Elder Talisman 20 33 0 689 +Splitting Tool Rowan Pea Rowan Talisman 20 33 0 690 +Splitting Tool Willow Pea Willow Talisman 20 33 0 691 +Splitting Tool Cedar Pea Cedar Talisman 20 33 0 692 +Splitting Tool Oak Pea Oak Talisman 20 33 0 693 +Splitting Tool Hazel Pea Hazel Talisman 20 33 0 694 +Splitting Tool Red Pea Red Taper 50 33 0 695 +Splitting Tool Pink Pea Pink Taper 50 33 0 696 +Splitting Tool Orange Pea Orange Taper 50 33 0 697 +Splitting Tool Yellow Pea Yellow Taper 50 33 0 698 +Splitting Tool Green Pea Green Taper 50 33 0 699 +Splitting Tool Turquoise Pea Turquoise Taper 50 33 0 700 +Splitting Tool Blue Pea Blue Taper 50 33 0 701 +Splitting Tool Indigo Pea Indigo Taper 50 33 0 702 +Splitting Tool Violet Pea Violet Taper 50 33 0 703 +Splitting Tool Brown Pea Brown Taper 50 33 0 704 +Splitting Tool White Pea White Taper 50 33 0 705 +Splitting Tool Grey Pea Grey Taper 50 33 0 706 +Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Blunt Arrow 250 37 0 707 +Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Blunt Quarrel 250 37 0 708 +Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Blunt Atlatl Dart 250 37 0 709 +Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Arrowshafts Olthoi Acid Arrow 2500 37 0 710 +Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Quarrelshafts Olthoi Acid Bolt 2500 37 0 711 +Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Olthoi Acid Atlatl Dart 2500 37 0 712 +Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Arrowshafts Gear Blade Slashing Arrow 250 37 0 713 +Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Quarrelshafts Gear Blade Slashing Bolt 250 37 0 714 +Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Atlatl Dartshafts Gear Blade Slashing Atlatl Dart 250 37 0 715 +Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Armor Piercing Atlatl Dart 500 37 0 716 +Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Armor Piercing Quarrel 500 37 0 717 +Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Armor Piercing Arrow 500 37 0 718 +Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Atlatl Dartshafts Burning Sands Atlatl Dart 500 37 0 719 +Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Quarrelshafts Burning Sands Bolt 500 37 0 720 +Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Arrowshafts Burning Sands Arrow 500 37 0 721 +Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Frog Crotch Arrow 500 37 0 722 +Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Frog Crotch Quarrel 500 37 0 723 +Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Frog Crotch Atlatl Dart 500 37 0 724 +Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Prismatic Atlatl Dart 500 37 0 725 +Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Deadly Prismatic Quarrel 500 37 0 726 +Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Arrowshafts Deadly Prismatic Arrow 500 37 0 727 +Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Prismatic Atlatl Dart 500 37 0 728 +Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Greater Prismatic Quarrel 500 37 0 729 +Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Arrowshafts Greater Prismatic Arrow 500 37 0 730 +Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Prismatic Atlatl Dart 500 37 0 731 +Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Prismatic Quarrel 500 37 0 732 +Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Arrowshafts Prismatic Arrow 500 37 0 733 +Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Deadly Frog Crotch Arrow 500 37 0 734 +Infinite Deadly Broad Arrowheads Wrapped Bundle of Arrowshafts Deadly Broadhead Arrow 500 37 0 735 +Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Deadly Armor Piercing Arrow 500 37 0 736 +Infinite Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 500 37 0 737 +Infinite Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 500 37 0 738 +Infinite Deadly Fire Arrowheads Wrapped Bundle of Arrowshafts Deadly Fire Arrow 500 37 0 739 +Infinite Deadly Frost Arrowheads Wrapped Bundle of Arrowshafts Deadly Frost Arrow 500 37 0 740 +Infinite Deadly Electric Arrowheads Wrapped Bundle of Arrowshafts Deadly Lightning Arrow 500 37 0 741 +Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 500 37 0 742 +Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frog Crotch Atlatl Dart 500 37 0 743 +Infinite Deadly Broad Arrowheads Wrapped Bundle of Quarrelshafts Deadly Broadhead Quarrel 500 37 0 744 +Infinite Deadly Broad Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Broadhead Atlatl Dart 500 37 0 745 +Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 500 37 0 746 +Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Armor Piercing Atlatl Dart 500 37 0 747 +Infinite Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Deadly Blunt Quarrel 500 37 0 748 +Infinite Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Blunt Atlatl Dart 500 37 0 749 +Infinite Deadly Acid Arrowheads Wrapped Bundle of Quarrelshafts Deadly Acid Quarrel 500 37 0 750 +Infinite Deadly Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Acid Atlatl Dart 500 37 0 751 +Infinite Deadly Fire Arrowheads Wrapped Bundle of Quarrelshafts Deadly Fire Quarrel 500 37 0 752 +Infinite Deadly Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Fire Atlatl Dart 500 37 0 753 +Infinite Deadly Frost Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frost Quarrel 500 37 0 754 +Infinite Deadly Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frost Atlatl Dart 500 37 0 755 +Infinite Deadly Electric Arrowheads Wrapped Bundle of Quarrelshafts Deadly Lightning Quarrel 500 37 0 756 +Infinite Deadly Electric Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Lightning Atlatl Dart 500 37 0 757 diff --git a/src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs b/src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs new file mode 100644 index 00000000..c6c7dede --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs @@ -0,0 +1,289 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// The ordered monster-element preferences from VTank's official +/// GameInfoDB. Name overrides win over CreatureType, matching e0.d(name). +/// Unknown targets retain VTank's final 0..6 element fallback order. +/// +internal static class VtankDamageDatabase +{ + private static readonly MonsterDamageType[] Fallback = + [ + MonsterDamageType.Pierce, + MonsterDamageType.Bludgeon, + MonsterDamageType.Slash, + MonsterDamageType.Acid, + MonsterDamageType.Electric, + MonsterDamageType.Cold, + MonsterDamageType.Fire, + ]; + + private static readonly Dictionary Overrides = + ParseNames(OverrideData); + private static readonly Dictionary Species = + ParseSpecies(SpeciesData); + + public static IReadOnlyList Preferences( + in PluginCombatTarget target) + { + if (!string.IsNullOrWhiteSpace(target.Name) + && Overrides.TryGetValue(target.Name, out MonsterDamageType[]? exact)) + { + return exact; + } + + // Zero is also the plugin contract's "not appraised" sentinel. + if (target.SpeciesId != 0 + && Species.TryGetValue(target.SpeciesId, out MonsterDamageType[]? species)) + { + return species; + } + return Fallback; + } + + public static int PreferenceIndex( + in PluginCombatTarget target, + MonsterDamageType damage) + { + IReadOnlyList preferences = Preferences(target); + for (int i = 0; i < preferences.Count; i++) + { + if (preferences[i] == damage) + return i; + } + + for (int i = 0; i < Fallback.Length; i++) + { + if (Fallback[i] == damage) + return preferences.Count + i; + } + return int.MaxValue; + } + + private static Dictionary ParseNames( + string data) + { + var result = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (ReadOnlySpan line in data.AsSpan().EnumerateLines()) + { + int separator = line.IndexOf('|'); + if (separator <= 0) + continue; + result[line[..separator].ToString()] = ParseElements( + line[(separator + 1)..]); + } + return result; + } + + private static Dictionary ParseSpecies( + string data) + { + var result = new Dictionary(); + foreach (ReadOnlySpan line in data.AsSpan().EnumerateLines()) + { + int separator = line.IndexOf('|'); + if (separator <= 0 + || !int.TryParse(line[..separator], out int species)) + { + continue; + } + result[species] = ParseElements(line[(separator + 1)..]); + } + return result; + } + + private static MonsterDamageType[] ParseElements(ReadOnlySpan text) + { + var result = new List(7); + foreach (Range range in text.Split(';')) + { + if (!int.TryParse(text[range], out int raw)) + continue; + MonsterDamageType mapped = raw switch + { + 0 => MonsterDamageType.Pierce, + 1 => MonsterDamageType.Bludgeon, + 2 => MonsterDamageType.Slash, + 3 => MonsterDamageType.Acid, + 4 => MonsterDamageType.Electric, + 5 => MonsterDamageType.Cold, + 6 => MonsterDamageType.Fire, + _ => MonsterDamageType.None, + }; + if (mapped != MonsterDamageType.None && !result.Contains(mapped)) + result.Add(mapped); + } + return [.. result]; + } + + private const string OverrideData = """ +Magma Golem|5;1;0;2 +Mist Golem|5;4;3;6;2;0;1 +Nubilous Golem|4;5;3;2;0;1 +Plasma Golem|4;5;3;2;0;1 +Vapor Golem|5;4;15;4;3;6;2;0;1 +Damaged Glacial Golem|6;1;0;2 +Fractured Glacial Golem|6;1;0;2 +Tanada Nanjou Shou-jen|3;4;6;5 +Disgraced Nanjou Shou-jen|3;4;6;5 +Magma Golem Exarch|5;1;0;2 +Pillar of Fire|5;2;0 +Infused Blood Golem|5;3;4;1;0;6;2 +Infused Empyrean Blood Golem|5;3;4;2;6;0;1 +Sapphire Golem|0;3;1;5;6;4;2 +High Priestess Xik Minru|1;2;0 +Contained Rift|2;1;0 +Ebon Rift|2;1;0 +Fallen Rift|2;1;0 +Narrow Rift|2;1;0 +Quiddity Rift|2;1;0 +Shallow Rift|2;1;0 +Tenebrous Rift|2;1;0 +Umbral Rift|2;1;0 +Unstable Rift|2;1;0 +Aqueous Golem|4;6;5;3;2;1;0 +Wave Golem|4;6;5;3;2;1;0 +Unstable Magma Golem|5;1;0;2 +Behemoth of Tenkarrdun|5;1;0;2 +Small Magma Golem|5;1;0;2 +Atlan's Crafting Golem|5;1;0;2 +Bur Lizk|5;4;0;2 +Dust Golem|5;4;6;3;0;1;2 +Ancient Magma Golem|5;1;0;2 +Frozen Ice Golem|6;1;0;2 +Frozen Glacial Golem|6;1;0;2 +Forge Golem|5;4;3;1;0;2 +Frozen Gearknight|6 +Diaphanous Nephol Golem|5;4;3;6;2;0;1 +Tenuous Nephol Golem|5;4;3;6;2;0;1 +Turbid Nephol Golem|5;4;3;6;2;0;1 +Wall of Ice|6;1;0;2;3;4 +Scold|5;1;0;2 +Scold Chunk|5;1;0;2 +Scold Lump|5;1;0;2 +Freezing Mist Golem|5;4;3;6;2;0;1 +Frost Golem|6;4;5;3 +Elite Guardian|5;6 +Enraged Ancient Soul|6;3;1;4;5;2;0 +Mudmouth|6;4;3;5;1;0;2 +Fiery Defender|5;2;4;1;0;3 +Follower of Deewain|4;3;1;0;5;2;6 +Chilled Defender|4;3;6;1;2;0;5 +Charged Defender|5;3;4;2;1;0 +Iron Golem Samurai|3;4;5;6;2;1;0 +Clay Golem Samurai|1;5;6;4;3;2;0 +Bronze Golem Samurai|4;3;5;6;2;1;0 +Spectral Nanjou Shou-jen|1;6;2;3;4;5;0 +Spectral Samurai|5;4;3;2;1;6;0 +Spectral Claw Master|1;6;2;3;4;5;0 +"""; + + private const string SpeciesData = """ +0|2 +1|1;0;2;5;6;4;3 +2|4;6;5;2;0;1;3 +3|6;1;2;3;5;0;4 +4|6;1;4;3;2;5;0 +5|4;3;2;0;1;5;6 +6|2;0;5;3;1;4;6 +7|0;2;1;6;3;5;4 +8|6;0;1;5;3;2;4 +9|1;2;0;3;6;5;4 +10|1;4;2;0;5;3;6 +11|2 +12|2 +13|1;3;0;5;6;4;2 +14|6;3;2;1;4;0;5 +15|2;0;1 +16|5;2;4;3;0;1;6 +17|2;0;3;1;5;4;6 +18|2 +19|6;0;1;2;3;5;4 +20|2;0;1;3;4;5 +21|0;4;6;3 +22|6;2;1;4;0;3;5 +23|6;0;1;3;2;5;4 +24|6;3;2 +25|2 +26|5;2;0;6 +27|0;2;1 +28|5;3;0 +29|4;5;2 +30|1;2;0 +31|6;0;1;2;3;4;5 +32|5;1;3 +33|2;0;1 +34|2;0;1 +35|1;0;2 +36|2;6;0 +37|2 +38|5;2;0 +39|6;2;1 +40|2 +41|2 +42|3;2;0 +43|2 +44|2;0;1 +45|2;5;3 +46|6;0;2;1;3;4;5 +47|1;0;2 +48|5;2;0;3;1;6;4 +49|6;2;0;1 +50|2;6;1 +51|2;1;0 +52|6;2;1;0 +53|5;1;2;4;6;3;0 +54|5;2;0;1 +55|1;5;4;2;6;3;0 +56|5;1;3 +57|2;0;5;3;1;4;6 +58|2;0;5;3;1;4;6 +59|5;2;0;3;1;6;4 +60|4;2;0 +61|6;2;0 +62|2;0;1 +63|3;4;6;5;1;2;0 +64|2 +65|2 +66|2 +67|2 +68|2 +69|2 +70|4;3;0;2;1 +71|1;5;4;0;2;6;3 +72|2 +73|2 +74|2 +75|5;0;2;6;1;4;3 +76|2 +77|6;2;0;1;3;4;5 +78|4;6;5;2;3;0;1 +79|2;0;6;5;4;3;1 +80|6;2;1;0 +81|1;0;6;2;3;4;5 +82|2;1;0 +83|4;2;0;1 +84|1;2;0 +85|2 +86|2;0;1 +87|2 +88|1;0;2 +89|0;1;2 +90|2 +91|2 +92|1;0;2 +93|2 +94|2 +95|6;2;1;0 +96|2 +-1|1;0;2;3;4;5;6 +97|6 +98|2;0;1 +99|3;4;1;0;6;5;2 +100|6;3;2;0;1;5;4 +101|3;1;6;0;2;4;5 +"""; +} diff --git a/src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs new file mode 100644 index 00000000..fdf8ab82 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs @@ -0,0 +1,446 @@ +using System.Globalization; +using System.Text; + +namespace AcDream.Plugins.MossTank; + +/// +/// One length-delimited VTClassic requirement. The payload is retained +/// verbatim so a newer VTClassic requirement can survive an acdream edit even +/// when MossTank does not understand that requirement yet. +/// +internal sealed class VtankLootRequirement +{ + public int Type { get; set; } + public string Payload { get; set; } = string.Empty; +} + +internal sealed class VtankSalvageCombineSettings +{ + public string DefaultCombineString { get; set; } = "1-6, 7-8, 9, 10"; + public Dictionary MaterialCombineStrings { get; set; } = + CreateVtankDefaults(); + public Dictionary MaterialValueModeValues { get; set; } = []; + + public VtankSalvageCombineSettings Clone() => new() + { + DefaultCombineString = DefaultCombineString, + MaterialCombineStrings = new Dictionary( + MaterialCombineStrings), + MaterialValueModeValues = new Dictionary( + MaterialValueModeValues), + }; + + private static Dictionary CreateVtankDefaults() + { + const string oneThroughTen = "1-10"; + int[] materials = + [ + 10, 14, 16, 17, 18, 19, 22, 25, 29, 30, 36, 37, 41, + 47, 35, 27, 26, 21, 15, 13, + 50, 49, 34, + 52, 51, + ]; + return materials.ToDictionary( + static material => material, + static _ => oneThroughTen); + } +} + +internal sealed class VtankLootExtraBlock +{ + public string Type { get; set; } = string.Empty; + public string Payload { get; set; } = string.Empty; +} + +internal sealed class VtankLootProfile +{ + public int SourceVersion { get; set; } = 1; + public List Rules { get; set; } = []; + public VtankSalvageCombineSettings SalvageCombine { get; set; } = new(); + public List UnknownBlocks { get; set; } = []; +} + +/// +/// Independent reader/writer for VTClassic's public UTL 1 format. +/// The format was recovered from the MIT-licensed VTClassic source; this is a +/// clean implementation using MossTank's own model and parser. +/// +internal static class VtankLootProfileSerializer +{ + private const string Header = "UTL"; + private const int CurrentVersion = 1; + private const string SalvageBlock = "SalvageCombine"; + private const int DisabledRuleType = 9999; + private static readonly string NewLine = "\r\n"; + + public static bool TryRead( + string? source, + out VtankLootProfile profile, + out string error) + { + profile = new VtankLootProfile(); + error = string.Empty; + if (string.IsNullOrEmpty(source)) + { + error = "The VTClassic loot profile is empty."; + return false; + } + + try + { + var reader = new CharacterReader(source); + string first = reader.ReadLine(); + int version; + int count; + if (string.Equals(first, Header, StringComparison.Ordinal)) + { + version = ParseInt(reader.ReadLine(), "profile version"); + if (version is < 0 or > CurrentVersion) + throw new FormatException( + $"VTClassic loot profile version {version} is not supported."); + count = ParseCount(reader.ReadLine(), "rule count", 100_000); + } + else + { + version = 0; + count = ParseCount(first, "rule count", 100_000); + } + + profile.SourceVersion = version; + for (int index = 0; index < count; index++) + profile.Rules.Add(ReadRule(reader, version, index)); + + while (!reader.End) + { + string blockType = reader.ReadLine(); + if (blockType.Length == 0 && reader.End) + break; + int length = ParseCount( + reader.ReadLine(), + $"{blockType} block length", + 16 * 1024 * 1024); + string payload = reader.ReadCharacters(length); + if (string.Equals( + blockType, + SalvageBlock, + StringComparison.Ordinal)) + { + profile.SalvageCombine = ReadSalvage(payload); + } + else + { + profile.UnknownBlocks.Add(new VtankLootExtraBlock + { + Type = blockType, + Payload = payload, + }); + } + } + return true; + } + catch (FormatException failure) + { + profile = new VtankLootProfile(); + error = failure.Message; + return false; + } + } + + public static string Write(VtankLootProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + var output = new StringBuilder(); + AppendLine(output, Header); + AppendLine(output, CurrentVersion); + AppendLine(output, profile.Rules.Count); + foreach (LootRule rule in profile.Rules) + WriteRule(output, rule); + + WriteBlock(output, SalvageBlock, WriteSalvage(profile.SalvageCombine)); + foreach (VtankLootExtraBlock block in profile.UnknownBlocks) + { + if (string.IsNullOrEmpty(block.Type) + || string.Equals( + block.Type, + SalvageBlock, + StringComparison.Ordinal)) + { + continue; + } + WriteBlock(output, block.Type, block.Payload ?? string.Empty); + } + return output.ToString(); + } + + private static LootRule ReadRule( + CharacterReader reader, + int version, + int ruleIndex) + { + string name = reader.ReadLine(); + string customExpression = version >= 1 + ? reader.ReadLine() + : string.Empty; + string[] fields = reader.ReadLine().Split(';'); + if (fields.Length < 2) + throw new FormatException($"Loot rule {ruleIndex + 1} has an invalid header."); + int priority = ParseInt(fields[0], $"rule {ruleIndex + 1} priority"); + int actionValue = ParseInt(fields[1], $"rule {ruleIndex + 1} action"); + if (actionValue is < 0 or > 10) + throw new FormatException($"Loot rule {ruleIndex + 1} has action {actionValue}."); + + var rule = new LootRule + { + Name = string.IsNullOrWhiteSpace(name) ? $"Rule {ruleIndex + 1}" : name, + Expression = "*", + CustomExpression = customExpression, + Action = (LootAction)actionValue, + Priority = priority, + }; + if (rule.Action == LootAction.KeepUpTo) + { + rule.KeepCount = Math.Max( + 0, + ParseInt(reader.ReadLine(), $"rule {ruleIndex + 1} keep count")); + } + + for (int field = 2; field < fields.Length; field++) + { + int type = ParseInt( + fields[field], + $"rule {ruleIndex + 1} requirement type"); + string payload; + if (version >= 1) + { + int length = ParseCount( + reader.ReadLine(), + $"rule {ruleIndex + 1} requirement length", + 16 * 1024 * 1024); + payload = reader.ReadCharacters(length); + } + else + { + int lines = LegacyPayloadLineCount(type); + if (lines < 0) + throw new FormatException( + $"Version 0 loot rule {ruleIndex + 1} uses unknown requirement {type}."); + var legacy = new StringBuilder(); + for (int line = 0; line < lines; line++) + AppendLine(legacy, reader.ReadLine()); + payload = legacy.ToString(); + } + rule.VtankRequirements.Add(new VtankLootRequirement + { + Type = type, + Payload = payload, + }); + } + return rule; + } + + private static void WriteRule(StringBuilder output, LootRule rule) + { + AppendLine(output, SingleLine(rule.Name, "Rule")); + AppendLine(output, SingleLine(rule.CustomExpression, string.Empty)); + + IReadOnlyList requirements = + ExportRequirements(rule); + var header = new StringBuilder(); + header.Append(rule.Priority.ToString(CultureInfo.InvariantCulture)); + header.Append(';'); + int action = (int)rule.Action is >= 0 and <= 10 + ? (int)rule.Action + : (int)LootAction.NoLoot; + header.Append(action.ToString(CultureInfo.InvariantCulture)); + foreach (VtankLootRequirement requirement in requirements) + { + header.Append(';'); + header.Append(requirement.Type.ToString(CultureInfo.InvariantCulture)); + } + AppendLine(output, header.ToString()); + + if (action == (int)LootAction.KeepUpTo) + AppendLine(output, Math.Max(0, rule.KeepCount)); + foreach (VtankLootRequirement requirement in requirements) + { + string payload = NormalizePayload(requirement.Payload); + AppendLine(output, payload.Length); + output.Append(payload); + } + } + + private static IReadOnlyList ExportRequirements( + LootRule rule) + { + if (rule.VtankRequirements.Count > 0) + return rule.VtankRequirements; + + // VTClassic stores CustomExpression for editors but its classifier does + // not execute it. An arbitrary MossTank expression therefore cannot be + // exported as an empty requirement set (which VTClassic treats as + // match-all); make the legacy copy visibly safe instead. + return + [ + new VtankLootRequirement + { + Type = DisabledRuleType, + Payload = "true" + NewLine, + }, + ]; + } + + private static VtankSalvageCombineSettings ReadSalvage(string payload) + { + var reader = new CharacterReader(payload); + _ = ParseInt(reader.ReadLine(), "salvage block version"); + var result = new VtankSalvageCombineSettings + { + DefaultCombineString = reader.ReadLine(), + MaterialCombineStrings = [], + MaterialValueModeValues = [], + }; + int strings = ParseCount( + reader.ReadLine(), + "salvage material rule count", + 10_000); + for (int index = 0; index < strings; index++) + { + int material = ParseInt(reader.ReadLine(), "salvage material id"); + result.MaterialCombineStrings[material] = reader.ReadLine(); + } + if (reader.End) + return result; + int values = ParseCount( + reader.ReadLine(), + "salvage value-mode count", + 10_000); + for (int index = 0; index < values; index++) + { + int material = ParseInt(reader.ReadLine(), "salvage value material id"); + result.MaterialValueModeValues[material] = ParseInt( + reader.ReadLine(), + "salvage value-mode value"); + } + return result; + } + + private static string WriteSalvage(VtankSalvageCombineSettings? settings) + { + settings ??= new VtankSalvageCombineSettings(); + var output = new StringBuilder(); + AppendLine(output, 1); + AppendLine(output, SingleLine( + settings.DefaultCombineString, + "1-6, 7-8, 9, 10")); + AppendLine(output, settings.MaterialCombineStrings.Count); + foreach ((int material, string combine) in + settings.MaterialCombineStrings.OrderBy(static pair => pair.Key)) + { + AppendLine(output, material); + AppendLine(output, SingleLine(combine, string.Empty)); + } + AppendLine(output, settings.MaterialValueModeValues.Count); + foreach ((int material, int value) in + settings.MaterialValueModeValues.OrderBy(static pair => pair.Key)) + { + AppendLine(output, material); + AppendLine(output, value); + } + return output.ToString(); + } + + private static void WriteBlock( + StringBuilder output, + string type, + string payload) + { + string normalized = NormalizePayload(payload); + AppendLine(output, SingleLine(type, "Unknown")); + AppendLine(output, normalized.Length); + output.Append(normalized); + } + + private static string NormalizePayload(string? payload) => + (payload ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Replace("\n", NewLine, StringComparison.Ordinal); + + private static string SingleLine(string? value, string fallback) + { + string normalized = value ?? fallback; + int lineEnd = normalized.IndexOfAny(['\r', '\n']); + return lineEnd < 0 ? normalized : normalized[..lineEnd]; + } + + private static int LegacyPayloadLineCount(int type) => type switch + { + 0 => 1, + 1 => 2, + 2 or 3 or 4 or 5 or 11 or 12 or 13 or 2003 or 2005 => 2, + 6 or 7 or 8 or 10 or 1001 or 1002 or 1003 or 2000 or 2001 + or 2006 or 2007 or 9999 => 1, + 9 or 1004 or 2008 => 3, + 14 => 5, + 15 or 16 => 6, + 17 or 1000 => 2, + _ => -1, + }; + + private static int ParseCount(string value, string field, int maximum) + { + int parsed = ParseInt(value, field); + if (parsed < 0 || parsed > maximum) + throw new FormatException($"Invalid {field}: {value}."); + return parsed; + } + + private static int ParseInt(string value, string field) => + int.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int parsed) + ? parsed + : throw new FormatException($"Invalid {field}: {value}."); + + private static void AppendLine(StringBuilder output, string value) => + output.Append(value).Append(NewLine); + + private static void AppendLine(StringBuilder output, int value) => + AppendLine(output, value.ToString(CultureInfo.InvariantCulture)); + + private sealed class CharacterReader(string source) + { + private int _position; + + public bool End => _position >= source.Length; + + public string ReadLine() + { + if (End) + throw new FormatException("The VTClassic loot profile ended unexpectedly."); + int start = _position; + while (_position < source.Length + && source[_position] is not ('\r' or '\n')) + { + _position++; + } + string line = source[start.._position]; + if (_position < source.Length && source[_position] == '\r') + _position++; + if (_position < source.Length && source[_position] == '\n') + _position++; + return line; + } + + public string ReadCharacters(int count) + { + if (count < 0 || count > source.Length - _position) + throw new FormatException("A VTClassic length-delimited block is truncated."); + string value = source.Substring(_position, count); + _position += count; + return value; + } + } +} diff --git a/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs new file mode 100644 index 00000000..f3fc0d32 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs @@ -0,0 +1,576 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// Executes VTClassic's typed loot requirements as an AND set. +internal static class VtankLootRequirementEvaluator +{ + private const uint VtankIntBase = 218_103_808u; + private const uint VtankDoubleBase = 167_772_160u; + + private static readonly IReadOnlyDictionary + IntSpellBonuses = new Dictionary + { + [2598] = (VtankIntBase + 34, 2), + [2586] = (VtankIntBase + 34, 4), + [4661] = (VtankIntBase + 34, 7), + [6089] = (VtankIntBase + 34, 10), + [2604] = (28, 20), + [2592] = (28, 40), + [4667] = (28, 60), + [6095] = (28, 80), + }; + + private static readonly IReadOnlyDictionary + DoubleSpellBonuses = new Dictionary + { + [3251] = (152, .01), [3250] = (152, .03), + [4670] = (152, .05), [6098] = (152, .07), + [2603] = (VtankDoubleBase + 12, .03), + [2591] = (VtankDoubleBase + 12, .05), + [4666] = (VtankDoubleBase + 12, .07), + [6094] = (VtankDoubleBase + 12, .09), + [2600] = (29, .03), [3985] = (29, .04), + [2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09), + [3201] = (144, 1.05), [3199] = (144, 1.10), + [3202] = (144, 1.15), [3200] = (144, 1.20), + [6086] = (144, 1.25), [6087] = (144, 1.30), + }; + + private static readonly IReadOnlyDictionary ArmorColorSlots = + new Dictionary(StringComparer.Ordinal) + { + ["Amuli Coat (Chest)"] = [0], + ["Amuli Coat (Collar/Shoulder)"] = [1, 2], + ["Amuli Coat (Arms/Trim)"] = [3, 4, 5, 6, 7], + ["Amuli Legs (Base)"] = [0, 1], + ["Amuli Legs (Trim)"] = [2, 3], + ["Celdon (Base)"] = [0], + ["Celdon (Veins)"] = [1, 2], + ["Chiran Coat (Base/Arms)"] = [0, 1], + ["Chiran Coat (Stripes)"] = [2, 3, 4], + ["Chiran Legs (Girth)"] = [1], + ["Chiran Legs (Legs)"] = [2, 3], + ["Chiran Legs (Trim)"] = [0], + ["Chiran Helm (Horns)"] = [0], + ["Chiran Helm (Base)"] = [1], + ["Haebrean BP (Chest) *"] = [0], + ["Haebrean BP (Ornaments)"] = [1], + ["Haebrean BP (Trim)"] = [2], + ["Haebrean Girth (Base) *"] = [0], + ["Haebrean Girth (Belt/Scales)"] = [1, 2], + ["Haebrean Helm (Base)"] = [0], + ["Haebrean Helm (Mask)"] = [1], + ["Haebrean Pauldrons (Base) *"] = [0], + ["Haebrean Pauldrons (Ornaments)"] = [1], + ["Lorica BP (Veins)"] = [0, 1], + ["Lorica BP (Base)"] = [2, 3], + ["Lorica BP (Neck/Trim) *"] = [4], + ["Lorica Legs (Base)"] = [0], + ["Lorica Legs (Knees/Belt/Crotch) *"] = [1, 2], + ["Lorica Legs (Legs) *"] = [3], + ["Nariyid BP (Circle/Lines)"] = [0, 1], + ["Nariyid BP (Base)"] = [2], + ["Nariyid BP (Shoulders)"] = [3], + ["Nariyid Girth (Base) *"] = [0], + ["Nariyid Girth (Belt/Lines)"] = [2], + ["Nariyid Girth (Ornaments)"] = [3], + ["Nariyid Sleeves (Shoulders)"] = [0], + ["Nariyid Sleeves (Upper Arm)"] = [1, 2], + ["Nariyid Sleeves (Lower Arm)"] = [3], + ["Olthoi BP (Base)"] = [0], + ["Olthoi BP (Veins)"] = [1], + ["Olthoi Alduressa Legs (Girth: Base)"] = [0, 1, 2], + ["Olthoi Alduressa Legs (Girth: Lines)"] = [3], + ["Olthoi Alduressa Legs (Legs: Lines)"] = [4, 5], + ["Olthoi Amuli Coat (Base) *"] = [0, 1], + ["Olthoi Amuli Coat (Trim)"] = [2], + ["Olthoi Amuli Coat (Shoulders)"] = [3], + ["Olthoi Amuli Legs (Trim)"] = [6, 7, 8], + ["Olthoi Koujia Kabuton (Base)"] = [0], + ["Olthoi Koujia Kabuton (Horns)"] = [1], + ["Olthoi Koujia Legs (Base)"] = [0, 1, 2], + ["Olthoi Koujia Legs (Sides/Shins)"] = [3, 4, 5], + ["Scalemail Cuirass (Base)"] = [0], + ["Scalemail Cuirass (Bumps)"] = [1], + ["Scalemail Cuirass (Belt)"] = [2], + ["Tenassa Legs (Line at Side)"] = [0], + ["Tenassa Legs (Base)"] = [1], + ["Tenassa Legs (Hilight)"] = [2], + ["Tenassa BP (Shoulders)"] = [0], + ["Tenassa BP (Base)"] = [1], + ["Yoroi Cuirass (Base)"] = [0, 1], + ["Yoroi Cuirass (Belt)"] = [2], + ["Yoroi Girth (Base)"] = [0], + ["Yoroi Girth (Belt)"] = [1], + }; + + public static bool IsMatch( + IReadOnlyList requirements, + in PluginInventoryItem item, + in PluginItemProperties properties, + IPluginHost? host, + out string? error) + { + try + { + foreach (VtankLootRequirement requirement in requirements) + { + if (!IsMatch(requirement, item, properties, host)) + { + error = null; + return false; + } + } + error = null; + return true; + } + catch (Exception failure) when ( + failure is FormatException or ArgumentException or OverflowException) + { + error = failure.Message; + return false; + } + } + + private static bool IsMatch( + VtankLootRequirement requirement, + in PluginInventoryItem item, + in PluginItemProperties properties, + IPluginHost? host) + { + string[] values = Lines(requirement.Payload); + return requirement.Type switch + { + 0 => SpellNames(item, host).Any(name => Rx(values, 0).IsMatch(name)), + 1 => Rx(values, 0).IsMatch(StringValue( + U32(values, 1), item, properties)), + 2 => IntValue(U32(values, 1), item, properties) <= I32(values, 0), + 3 => IntValue(U32(values, 1), item, properties) >= I32(values, 0), + 4 => (float)DoubleValue(U32(values, 1), item, properties) + <= (float)F64(values, 0), + 5 => (float)DoubleValue(U32(values, 1), item, properties) + >= (float)F64(values, 0), + // VTClassic deliberately retired this requirement; its own Match + // method always returns false. + 6 => false, + 7 => (int)item.ObjectClass == I32(values, 0), + 8 => item.AppraisedSpellIds.Count >= I32(values, 0), + 9 => SpellMatch(values, item, host), + 10 => MinimumDamage(item) >= F64(values, 0), + 11 => (IntValue(U32(values, 1), item, properties) + & I32(values, 0)) > 0, + 12 => IntValue(U32(values, 1), item, properties) == I32(values, 0), + 13 => IntValue(U32(values, 1), item, properties) != I32(values, 0), + 14 => ColorMatch(values, item.Palettes), + 15 => ArmorColorMatch(values, item.Palettes), + 16 => SlotColorMatch(values, item.Palettes), + 17 => ExactPalette(values, item.Palettes), + 1000 => CharacterSkill(host, U32(values, 1), buffed: true) + >= I32(values, 0), + 1001 => (host?.Automation.Character.MainPackFreeSlots ?? 0) + >= I32(values, 0), + 1002 => (host?.Automation.Character.Level ?? 0) >= I32(values, 0), + 1003 => (host?.Automation.Character.Level ?? 0) <= I32(values, 0), + 1004 => CharacterBaseSkillRange(values, host), + 2000 => BuffedMedianDamage(item, properties) >= F64(values, 0), + 2001 => BuffedMissileDamage(item, properties) >= F64(values, 0), + 2003 => BuffedInt( + U32(values, 1), item, properties) >= F64(values, 0), + 2005 => (float)BuffedDouble( + U32(values, 1), item, properties) >= (float)F64(values, 0), + 2006 => BuffedTinkedDamage(item, properties) >= F64(values, 0), + 2007 => TotalRatings(item, properties) >= F64(values, 0), + 2008 => CanReachTarget(values, item, properties), + 9999 => !Bool(values, 0), + _ => false, + }; + } + + private static bool SpellMatch( + string[] values, + in PluginInventoryItem item, + IPluginHost? host) + { + Regex include = Rx(values, 0); + Regex exclude = Rx(values, 1); + bool excludeEmpty = Value(values, 1).Trim().Length == 0; + int required = I32(values, 2); + int count = 0; + foreach (string name in SpellNames(item, host)) + { + if (include.IsMatch(name) + && (excludeEmpty || !exclude.IsMatch(name)) + && ++count >= required) + { + return true; + } + } + return false; + } + + private static bool ColorMatch( + string[] values, + IReadOnlyList palettes) + { + for (int index = 0; index < palettes.Count; index++) + { + if (SimilarColor(values, palettes[index])) + return true; + } + return false; + } + + private static bool ArmorColorMatch( + string[] values, + IReadOnlyList palettes) + { + if (!ArmorColorSlots.TryGetValue(Value(values, 5), out int[]? slots)) + return false; + foreach (int slot in slots) + { + if (slot >= 0 && slot < palettes.Count + && SimilarColor(values, palettes[slot])) + { + return true; + } + } + return false; + } + + private static bool SlotColorMatch( + string[] values, + IReadOnlyList palettes) + { + int slot = I32(values, 5); + return slot >= 0 && slot < palettes.Count + && SimilarColor(values, palettes[slot]); + } + + private static bool ExactPalette( + string[] values, + IReadOnlyList palettes) + { + int slot = I32(values, 0); + uint expected = U32(values, 1) & 0x00FF_FFFFu; + return slot >= 0 && slot < palettes.Count + && (palettes[slot].PaletteId & 0x00FF_FFFFu) == expected; + } + + private static bool SimilarColor( + string[] values, + in PluginPaletteInfo palette) + { + Hsv( + checked((byte)I32(values, 0)), + checked((byte)I32(values, 1)), + checked((byte)I32(values, 2)), + out double targetHue, + out double targetSaturation, + out double targetValue); + Hsv( + palette.Red, + palette.Green, + palette.Blue, + out double hue, + out double saturation, + out double value); + if (Math.Abs(hue - targetHue) > F64(values, 3)) + return false; + double sd = saturation - targetSaturation; + double vd = value - targetValue; + return Math.Sqrt((sd * sd) + (vd * vd)) <= F64(values, 4); + } + + private static void Hsv( + byte red, + byte green, + byte blue, + out double hue, + out double saturation, + out double value) + { + int maximum = Math.Max(red, Math.Max(green, blue)); + int minimum = Math.Min(red, Math.Min(green, blue)); + int delta = maximum - minimum; + if (delta == 0) + { + hue = 0d; + } + else if (maximum == red) + { + hue = 60d * (green - blue) / delta; + if (hue < 0d) + hue += 360d; + } + else if (maximum == green) + { + hue = (60d * (blue - red) / delta) + 120d; + } + else + { + hue = (60d * (red - green) / delta) + 240d; + } + saturation = maximum == 0 ? 0d : 1d - ((double)minimum / maximum); + value = maximum / 255d; + } + + private static IEnumerable SpellNames( + PluginInventoryItem item, + IPluginHost? host) + { + if (host is null) + yield break; + foreach (uint spellId in item.AppraisedSpellIds) + { + if (host.Automation.Spells.TryGet(spellId, out PluginSpellInfo spell)) + yield return spell.Name; + } + } + + private static int CharacterSkill( + IPluginHost? host, + uint skillId, + bool buffed) + { + if (host?.Automation.Character.TryGetSkill( + skillId, + out PluginSkillInfo skill) != true) + { + return 0; + } + return checked((int)(buffed ? skill.Current : skill.Base)); + } + + private static bool CharacterBaseSkillRange( + string[] values, + IPluginHost? host) + { + int level = CharacterSkill(host, U32(values, 0), buffed: false); + return level >= I32(values, 1) && level <= I32(values, 2); + } + + private static string StringValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => key switch + { + 1 => item.Name, + _ => properties.Strings?.TryGetValue(key, out string? value) == true + ? value + : string.Empty, + }; + + private static int IntValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => key switch + { + 5 => item.Burden, + 19 => item.Value, + 105 => checked((int)item.Workmanship), + 107 => item.ItemCurrentMana, + 108 => item.ItemMaximumMana, + 131 => checked((int)item.MaterialType), + VtankIntBase + 0 => checked((int)item.WeenieClassId), + VtankIntBase + 2 => checked((int)item.ContainerObjectId), + VtankIntBase + 4 => item.ItemsCapacity, + VtankIntBase + 5 => item.ContainersCapacity, + VtankIntBase + 6 => item.StackSize, + VtankIntBase + 7 => item.MaximumStackSize, + VtankIntBase + 8 => checked((int)item.SpellId), + VtankIntBase + 9 => item.ContainerSlot, + VtankIntBase + 10 => checked((int)item.WielderObjectId), + VtankIntBase + 11 => checked((int)item.EquippedLocation), + VtankIntBase + 14 => checked((int)item.ValidLocations), + VtankIntBase + 18 => checked((int)item.Useability), + VtankIntBase + 23 => checked((int)item.PublicFlags), + VtankIntBase + 31 => item.CombatUse, + VtankIntBase + 32 => item.WeaponSkill, + VtankIntBase + 33 => item.DamageType, + VtankIntBase + 34 => item.Damage, + VtankIntBase + 38 => item.AppraisedSpellIds.Count, + _ => properties.Ints?.TryGetValue(key, out int value) == true + ? value + : 0, + }; + + private static double DoubleValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => key switch + { + VtankDoubleBase + 9 => item.Workmanship, + VtankDoubleBase + 11 => item.DamageVariance, + VtankDoubleBase + 12 => RawFloat(properties, 62), + VtankDoubleBase + 14 => RawFloat(properties, 63), + _ => RawFloat(properties, key), + }; + + private static double RawFloat(in PluginItemProperties properties, uint key) => + properties.Floats?.TryGetValue(key, out double value) == true ? value : 0d; + + private static int BuffedInt( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) + { + int value = IntValue(key, item, properties); + foreach (uint spellId in item.AppraisedSpellIds) + { + if (IntSpellBonuses.TryGetValue(spellId, out var bonus) + && bonus.Key == key) + { + value += bonus.Bonus; + } + } + return value; + } + + private static double BuffedDouble( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) + { + double value = DoubleValue(key, item, properties); + foreach (uint spellId in item.AppraisedSpellIds) + { + if (!DoubleSpellBonuses.TryGetValue(spellId, out var bonus) + || bonus.Key != key) + { + continue; + } + value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus; + } + return value; + } + + private static double MinimumDamage(in PluginInventoryItem item) => + item.Damage - (item.DamageVariance * item.Damage); + + private static double BuffedMedianDamage( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + int maximum = BuffedInt(VtankIntBase + 34, item, properties); + double minimum = maximum - (item.DamageVariance * maximum); + return (minimum + maximum) / 2d; + } + + private static double BuffedMissileDamage( + in PluginInventoryItem item, + in PluginItemProperties properties) => + BuffedInt(VtankIntBase + 34, item, properties) + + (((BuffedDouble(VtankDoubleBase + 14, item, properties) - 1d) + * 100d) / 3d) + + BuffedInt(204, item, properties); + + private static double BuffedTinkedDamage( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + double variance = item.DamageVariance; + int maximum = BuffedInt(VtankIntBase + 34, item, properties); + int tinks = Math.Max(10 - IntValue(171, item, properties), 0); + if (IntValue(179, item, properties) == 0) + tinks--; + if (IntValue(131, item, properties) == 0) + tinks = 0; + for (int index = 1; index <= tinks; index++) + { + double iron = DamageOverTime(maximum + 25, variance); + double granite = DamageOverTime(maximum + 24, variance * .8d); + if (iron >= granite) + maximum++; + else + variance *= .8d; + } + return DamageOverTime(maximum + 24, variance); + } + + private static int TotalRatings( + in PluginInventoryItem item, + in PluginItemProperties properties) => + item.GearDamage + item.GearDamageResistance + + item.GearCriticalChance + item.GearCriticalResistance + + item.GearCriticalDamage + item.GearCriticalDamageResistance + + IntValue(376, item, properties) + IntValue(379, item, properties); + + private static bool CanReachTarget( + string[] values, + in PluginInventoryItem item, + in PluginItemProperties properties) + { + double targetDamage = F64(values, 0); + double targetDefense = F64(values, 1); + double targetAttack = F64(values, 2); + double defense = BuffedDouble(29, item, properties); + double attack = BuffedDouble(VtankDoubleBase + 12, item, properties); + double variance = item.DamageVariance; + int maximum = BuffedInt(VtankIntBase + 34, item, properties); + int tinks = Math.Max(10 - IntValue(171, item, properties), 0); + if (IntValue(179, item, properties) == 0) + tinks--; + if (IntValue(131, item, properties) == 0) + tinks = 0; + for (int index = 1; index <= tinks; index++) + { + if (defense < targetDefense) + defense += .01d; + else if (attack < targetAttack) + attack += .01d; + else if (DamageOverTime(maximum + 25, variance) + >= DamageOverTime(maximum + 24, variance * .8d)) + maximum++; + else + variance *= .8d; + } + return DamageOverTime(maximum + 24, variance) >= targetDamage + && defense >= targetDefense + && attack >= targetAttack; + } + + private static double DamageOverTime(int maximum, double variance) => + maximum * ((.9d * (2d - variance) / 2d) + .2d); + + private static string[] Lines(string? payload) => + (payload ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + + private static string Value(string[] values, int index) => + index >= 0 && index < values.Length + ? values[index] + : throw new FormatException("A VTClassic loot requirement is truncated."); + + private static Regex Rx(string[] values, int index) => new( + Value(values, index), + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static int I32(string[] values, int index) => + int.TryParse(Value(values, index), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int parsed) + ? parsed + : throw new FormatException("A VTClassic integer is invalid."); + + private static uint U32(string[] values, int index) => + uint.TryParse(Value(values, index), NumberStyles.Integer, + CultureInfo.InvariantCulture, out uint parsed) + ? parsed + : throw new FormatException("A VTClassic key is invalid."); + + private static double F64(string[] values, int index) => + double.TryParse(Value(values, index).Replace(',', '.'), + NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? parsed + : throw new FormatException("A VTClassic number is invalid."); + + private static bool Bool(string[] values, int index) => + bool.TryParse(Value(values, index), out bool parsed) + ? parsed + : throw new FormatException("A VTClassic boolean is invalid."); +} diff --git a/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs new file mode 100644 index 00000000..faa3a5bb --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs @@ -0,0 +1,742 @@ +using System.Globalization; + +namespace AcDream.Plugins.MossTank; + +/// +/// Reads and writes VTank's exact line-encoded CondAct Meta database. +/// The format is the public interchange contract used by legacy .met +/// profiles; it is deliberately independent from MossTank's native JSON store. +/// +internal static class VtankMetaProfileSerializer +{ + private static readonly string[] Header = + [ + "1", "CondAct", "5", "CType", "AType", "CData", "AData", + "State", "n", "n", "n", "n", "n", + ]; + + private static readonly string[] TablePrefix = ["TABLE", "2", "k", "v", "n", "n"]; + private static readonly string[] RecursiveTablePrefix = ["TABLE", "2", "K", "V", "n", "n"]; + private const int MaximumRules = 100_000; + private const int MaximumNesting = 256; + + public static bool TryLoad(string source, out MetaProfile profile, out string error) + { + try + { + var reader = new LineReader(source); + reader.Expect(Header); + int count = reader.ReadInt(); + if (count is < 0 or > MaximumRules) + throw reader.Error("Invalid VTank Meta rule count."); + + var parsed = new MetaProfile(); + for (int index = 0; index < count; index++) + { + reader.Expect("i"); + int conditionType = reader.ReadInt(); + reader.Expect("i"); + int actionType = reader.ReadInt(); + MetaCondition condition = ReadCondition(reader, conditionType, 0); + MetaAction action = ReadAction(reader, actionType, 0); + reader.Expect("s"); + parsed.Rules.Add(new MetaRule + { + State = reader.Read(), + Condition = condition, + Action = action, + Enabled = true, + }); + } + reader.ExpectEnd(); + profile = parsed; + error = string.Empty; + return true; + } + catch (Exception exception) when (exception is FormatException + or OverflowException or ArgumentOutOfRangeException) + { + profile = new MetaProfile(); + error = exception.Message; + return false; + } + } + + public static string Save(MetaProfile source) + { + ArgumentNullException.ThrowIfNull(source); + var writer = new LineWriter(); + writer.Add(Header); + MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray(); + writer.Add(rules.Length); + foreach (MetaRule rule in rules) + { + writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind)); + WriteCondition(writer, rule.Condition, 0); + WriteAction(writer, rule.Action, 0); + writer.Add("s", rule.State ?? string.Empty); + } + return writer.Finish(); + } + + private static MetaCondition ReadCondition(LineReader reader, int type, int depth) + { + CheckDepth(reader, depth); + var value = new MetaCondition { Kind = ConditionKind(type) }; + switch (type) + { + case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20: + reader.Expect("i", "0"); + break; + case 2 or 3: + reader.Expect(RecursiveTablePrefix); + ReadConditions(reader, value, reader.ReadCount(), depth); + break; + case 4: + reader.Expect("s"); + value.Text = reader.Read(); + break; + case 5 or 6 or 17 or 18 or 22 or 24: + reader.Expect("i"); + value.Number = reader.ReadInt(); + break; + case 11 or 12: + reader.Expect(TablePrefix, "2", "s", "n", "s"); + value.Text = reader.Read(); + reader.Expect("s", "c", "i"); + value.Number = reader.ReadInt(); + break; + case 13: + reader.Expect(TablePrefix, "3", "s", "n", "s"); + value.Text = reader.Read(); + reader.Expect("s", "c", "i"); + value.Number = reader.ReadInt(); + reader.Expect("s", "r", "d"); + value.SecondaryNumber = reader.ReadDouble(); + break; + case 14: + reader.Expect(TablePrefix, "3", "s", "p", "i"); + value.TertiaryNumber = reader.ReadInt(); + reader.Expect("s", "c", "i"); + value.Number = reader.ReadInt(); + reader.Expect("s", "r", "d"); + value.SecondaryNumber = reader.ReadDouble(); + break; + case 16: + reader.Expect(TablePrefix, "1", "s", "r", "d"); + value.Number = reader.ReadDouble(); + break; + case 21: + reader.Expect(RecursiveTablePrefix); + if (reader.ReadCount() != 1) + throw reader.Error("VTank Meta Not requires exactly one condition."); + reader.Expect("i"); + value.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1)); + break; + case 23: + reader.Expect(TablePrefix, "2", "s", "sid", "i"); + value.Number = reader.ReadInt(); + reader.Expect("s", "sec", "i"); + value.SecondaryNumber = reader.ReadInt(); + break; + case 25: + reader.Expect(TablePrefix, "1", "s", "dist", "d"); + value.Number = reader.ReadDouble(); + break; + case 26: + reader.Expect(TablePrefix, "1", "s", "e", "s"); + value.Text = reader.Read(); + break; + case 28: + reader.Expect(TablePrefix, "2", "s", "p", "s"); + value.Text = reader.Read(); + reader.Expect("s", "c", "s"); + value.SecondaryText = reader.Read(); + break; + default: + throw reader.Error($"Unknown VTank Meta condition type {type}."); + } + return value; + } + + private static void ReadConditions( + LineReader reader, + MetaCondition target, + int count, + int depth) + { + for (int index = 0; index < count; index++) + { + reader.Expect("i"); + target.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1)); + } + } + + private static MetaAction ReadAction(LineReader reader, int type, int depth) + { + CheckDepth(reader, depth); + var value = new MetaAction { Kind = ActionKind(type) }; + switch (type) + { + case 0 or 6: + reader.Expect("i", "0"); + break; + case 1 or 2: + reader.Expect("s"); + value.Text = reader.Read(); + break; + case 3: + reader.Expect(RecursiveTablePrefix); + int count = reader.ReadCount(); + for (int index = 0; index < count; index++) + { + reader.Expect("i"); + value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1)); + } + break; + case 4: + ReadEmbeddedNavigation(reader, value); + break; + case 5: + reader.Expect(TablePrefix, "2", "s", "st", "s"); + value.Text = reader.Read(); + reader.Expect("s", "ret", "s"); + value.SecondaryText = reader.Read(); + break; + case 7 or 8: + reader.Expect(TablePrefix, "1", "s", "e", "s"); + value.Text = reader.Read(); + break; + case 9: + reader.Expect(TablePrefix, "3", "s", "s", "s"); + value.Text = reader.Read(); + reader.Expect("s", "r", "d"); + value.Number = reader.ReadDouble(); + reader.Expect("s", "t", "d"); + value.SecondaryNumber = reader.ReadDouble(); + break; + case 10 or 15: + reader.Expect(TablePrefix, "0"); + break; + case 11: + reader.Expect(TablePrefix, "2", "s", "o", "s"); + value.Text = reader.Read(); + reader.Expect("s", "v", "s"); + value.SecondaryText = reader.Read(); + break; + case 12: + reader.Expect(TablePrefix, "2", "s", "o", "s"); + value.Text = reader.Read(); + reader.Expect("s", "v", "s"); + value.SecondaryText = reader.Read(); + break; + case 13: + reader.Expect(TablePrefix, "2", "s", "n", "s"); + value.Text = reader.Read(); + reader.Expect("s", "x", "ba"); + int length = reader.ReadCount(); + value.SecondaryText = reader.ReadByteArray(length); + break; + case 14: + reader.Expect(TablePrefix, "1", "s", "n", "s"); + value.Text = reader.Read(); + break; + default: + throw reader.Error($"Unknown VTank Meta action type {type}."); + } + return value; + } + + private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target) + { + reader.Expect("ba"); + int serializedCharacters = reader.ReadCount(); + target.SecondaryText = reader.Read(); + int statedNodeCount = reader.ReadCount(); + if (serializedCharacters <= 5) + { + target.Text = EmptyNavigation(); + return; + } + + var lines = new List { reader.ReadExpected("uTank2 NAV 1.2") }; + int mode = reader.ReadInt(out string modeLine); + lines.Add(modeLine); + int actualNodeCount; + if (mode == 3) + { + lines.Add(reader.Read()); + lines.Add(reader.Read()); + actualNodeCount = 1; + } + else if (mode is 1 or 2 or 4) + { + actualNodeCount = reader.ReadCount(out string countLine); + lines.Add(countLine); + for (int index = 0; index < actualNodeCount; index++) + ReadNavigationNode(reader, lines); + } + else + { + throw reader.Error($"Unknown embedded VTank navigation type {mode}."); + } + if (actualNodeCount != statedNodeCount) + throw reader.Error("Embedded VTank navigation node counts do not match."); + target.Text = string.Join("\r\n", lines) + "\r\n"; + } + + private static void ReadNavigationNode(LineReader reader, List lines) + { + int type = reader.ReadInt(out string typeLine); + lines.Add(typeLine); + for (int index = 0; index < 4; index++) + lines.Add(reader.Read()); + int extra = type switch + { + 0 or 8 => 0, + 1 or 2 or 3 or 4 => 1, + 5 => 2, + 6 or 7 => 6, + 9 => 3, + _ => throw reader.Error($"Unknown embedded VTank waypoint type {type}."), + }; + for (int index = 0; index < extra; index++) + lines.Add(reader.Read()); + } + + private static void WriteCondition(LineWriter writer, MetaCondition value, int depth) + { + CheckDepth(depth); + int type = ConditionType(value.Kind); + switch (type) + { + case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20: + writer.Add("i", "0"); + break; + case 2 or 3: + writer.Add(RecursiveTablePrefix); + writer.Add(value.Children.Count); + foreach (MetaCondition child in value.Children) + { + writer.Add("i", ConditionType(child.Kind)); + WriteCondition(writer, child, depth + 1); + } + break; + case 4: + writer.Add("s", value.Text); + break; + case 5 or 6 or 17 or 18 or 22 or 24: + writer.Add("i", IntValue(value.Number)); + break; + case 11 or 12: + writer.Add(TablePrefix, "2", "s", "n", "s", value.Text, + "s", "c", "i", IntValue(value.Number)); + break; + case 13: + writer.Add(TablePrefix, "3", "s", "n", "s", value.Text, + "s", "c", "i", IntValue(value.Number), + "s", "r", "d", Number(value.SecondaryNumber)); + break; + case 14: + writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber), + "s", "c", "i", IntValue(value.Number), + "s", "r", "d", Number(value.SecondaryNumber)); + break; + case 16: + writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number)); + break; + case 21: + if (value.Children.Count != 1) + throw new InvalidOperationException("VTank Meta Not requires exactly one condition."); + writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind)); + WriteCondition(writer, value.Children[0], depth + 1); + break; + case 23: + writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number), + "s", "sec", "i", IntValue(value.SecondaryNumber)); + break; + case 25: + writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number)); + break; + case 26: + writer.Add(TablePrefix, "1", "s", "e", "s", value.Text); + break; + case 28: + writer.Add(TablePrefix, "2", "s", "p", "s", value.Text, + "s", "c", "s", value.SecondaryText); + break; + default: + throw new InvalidOperationException($"Unknown VTank Meta condition type {type}."); + } + } + + private static void WriteAction(LineWriter writer, MetaAction value, int depth) + { + CheckDepth(depth); + int type = ActionType(value.Kind); + switch (type) + { + case 0 or 6: + writer.Add("i", "0"); + break; + case 1 or 2: + writer.Add("s", value.Text); + break; + case 3: + writer.Add(RecursiveTablePrefix); + writer.Add(value.Children.Count); + foreach (MetaAction child in value.Children) + { + writer.Add("i", ActionType(child.Kind)); + WriteAction(writer, child, depth + 1); + } + break; + case 4: + WriteEmbeddedNavigation(writer, value); + break; + case 5: + writer.Add(TablePrefix, "2", "s", "st", "s", value.Text, + "s", "ret", "s", value.SecondaryText); + break; + case 7 or 8: + writer.Add(TablePrefix, "1", "s", "e", "s", value.Text); + break; + case 9: + writer.Add(TablePrefix, "3", "s", "s", "s", value.Text, + "s", "r", "d", Number(value.Number), + "s", "t", "d", Number(value.SecondaryNumber)); + break; + case 10 or 15: + writer.Add(TablePrefix, "0"); + break; + case 11 or 12: + writer.Add(TablePrefix, "2", "s", "o", "s", value.Text, + "s", "v", "s", value.SecondaryText); + break; + case 13: + writer.Add(TablePrefix, "2", "s", "n", "s", value.Text, + "s", "x", "ba", value.SecondaryText.Length); + writer.AddBuggedByteArray(value.SecondaryText); + break; + case 14: + writer.Add(TablePrefix, "1", "s", "n", "s", value.Text); + break; + default: + throw new InvalidOperationException($"Unknown VTank Meta action type {type}."); + } + } + + private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value) + { + string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text; + string normalized = NormalizeNewlines(nav); + string[] navLines = normalized.Split('\n', StringSplitOptions.None); + if (navLines.Length != 0 && navLines[^1].Length == 0) + navLines = navLines[..^1]; + int nodes = NavigationNodeCount(navLines); + string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText; + int characters = name.Length + 2 + + nodes.ToString(CultureInfo.InvariantCulture).Length + 2 + + navLines.Sum(static line => line.Length + 2); + writer.Add("ba", characters, name, nodes); + writer.Add(navLines); + } + + private static int NavigationNodeCount(string[] lines) + { + if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal)) + throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data."); + int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture); + if (mode == 3) + return 1; + if (mode is not (1 or 2 or 4) || lines.Length < 3) + throw new InvalidOperationException("Embedded Meta route has an invalid navigation type."); + return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n"; + + private static string NormalizeNewlines(string value) => value + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + private static int ConditionType(MetaConditionKind kind) => kind switch + { + MetaConditionKind.Never => 0, + MetaConditionKind.Always => 1, + MetaConditionKind.All => 2, + MetaConditionKind.Any => 3, + MetaConditionKind.ChatMessage => 4, + MetaConditionKind.PackSlotsLessThanOrEqual => 5, + MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6, + MetaConditionKind.NavigationRouteEmpty => 7, + MetaConditionKind.CharacterDeath => 8, + MetaConditionKind.AnyVendorOpen => 9, + MetaConditionKind.VendorClosed => 10, + MetaConditionKind.InventoryItemCountLessThanOrEqual => 11, + MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12, + MetaConditionKind.MonsterNameCountWithinDistance => 13, + MetaConditionKind.MonsterPriorityCountWithinDistance => 14, + MetaConditionKind.NeedToBuff => 15, + MetaConditionKind.NoMonstersWithinDistance => 16, + MetaConditionKind.LandblockEquals => 17, + MetaConditionKind.LandcellEquals => 18, + MetaConditionKind.PortalspaceEntered => 19, + MetaConditionKind.PortalspaceExited => 20, + MetaConditionKind.Not => 21, + MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22, + MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23, + MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24, + MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25, + MetaConditionKind.Expression => 26, + MetaConditionKind.ChatMessageCapture => 28, + _ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."), + }; + + private static MetaConditionKind ConditionKind(int type) => type switch + { + 0 => MetaConditionKind.Never, + 1 => MetaConditionKind.Always, + 2 => MetaConditionKind.All, + 3 => MetaConditionKind.Any, + 4 => MetaConditionKind.ChatMessage, + 5 => MetaConditionKind.PackSlotsLessThanOrEqual, + 6 => MetaConditionKind.SecondsInStateGreaterThanOrEqual, + 7 => MetaConditionKind.NavigationRouteEmpty, + 8 => MetaConditionKind.CharacterDeath, + 9 => MetaConditionKind.AnyVendorOpen, + 10 => MetaConditionKind.VendorClosed, + 11 => MetaConditionKind.InventoryItemCountLessThanOrEqual, + 12 => MetaConditionKind.InventoryItemCountGreaterThanOrEqual, + 13 => MetaConditionKind.MonsterNameCountWithinDistance, + 14 => MetaConditionKind.MonsterPriorityCountWithinDistance, + 15 => MetaConditionKind.NeedToBuff, + 16 => MetaConditionKind.NoMonstersWithinDistance, + 17 => MetaConditionKind.LandblockEquals, + 18 => MetaConditionKind.LandcellEquals, + 19 => MetaConditionKind.PortalspaceEntered, + 20 => MetaConditionKind.PortalspaceExited, + 21 => MetaConditionKind.Not, + 22 => MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual, + 23 => MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual, + 24 => MetaConditionKind.BurdenPercentGreaterThanOrEqual, + 25 => MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual, + 26 => MetaConditionKind.Expression, + 28 => MetaConditionKind.ChatMessageCapture, + _ => throw new FormatException($"Unknown VTank Meta condition type {type}."), + }; + + private static int ActionType(MetaActionKind kind) => kind switch + { + MetaActionKind.None => 0, + MetaActionKind.SetMetaState => 1, + MetaActionKind.ChatCommand => 2, + MetaActionKind.All => 3, + MetaActionKind.LoadEmbeddedNavigationRoute => 4, + MetaActionKind.CallMetaState => 5, + MetaActionKind.ReturnFromCall => 6, + MetaActionKind.ExpressionAction => 7, + MetaActionKind.ChatExpression => 8, + MetaActionKind.SetWatchdog => 9, + MetaActionKind.ClearWatchdog => 10, + MetaActionKind.GetVtankOption => 11, + MetaActionKind.SetVtankOption => 12, + MetaActionKind.CreateView => 13, + MetaActionKind.DestroyView => 14, + MetaActionKind.DestroyAllViews => 15, + _ => throw new InvalidOperationException($"Unsupported Meta action {kind}."), + }; + + private static MetaActionKind ActionKind(int type) => type switch + { + 0 => MetaActionKind.None, + 1 => MetaActionKind.SetMetaState, + 2 => MetaActionKind.ChatCommand, + 3 => MetaActionKind.All, + 4 => MetaActionKind.LoadEmbeddedNavigationRoute, + 5 => MetaActionKind.CallMetaState, + 6 => MetaActionKind.ReturnFromCall, + 7 => MetaActionKind.ExpressionAction, + 8 => MetaActionKind.ChatExpression, + 9 => MetaActionKind.SetWatchdog, + 10 => MetaActionKind.ClearWatchdog, + 11 => MetaActionKind.GetVtankOption, + 12 => MetaActionKind.SetVtankOption, + 13 => MetaActionKind.CreateView, + 14 => MetaActionKind.DestroyView, + 15 => MetaActionKind.DestroyAllViews, + _ => throw new FormatException($"Unknown VTank Meta action type {type}."), + }; + + private static string Number(double value) + { + if (!double.IsFinite(value)) + throw new InvalidOperationException("VTank Meta numbers must be finite."); + return value.ToString("R", CultureInfo.InvariantCulture); + } + + private static int IntValue(double value) + { + if (!double.IsFinite(value) || value != Math.Truncate(value)) + throw new InvalidOperationException("VTank Meta integer fields require whole numbers."); + return checked((int)value); + } + + private static void CheckDepth(LineReader reader, int depth) + { + if (depth > MaximumNesting) + throw reader.Error("VTank Meta nesting is too deep."); + } + + private static void CheckDepth(int depth) + { + if (depth > MaximumNesting) + throw new InvalidOperationException("VTank Meta nesting is too deep."); + } + + private sealed class LineReader + { + private readonly List _lines; + private int _index; + + public LineReader(string source) + { + string normalized = NormalizeNewlines(source ?? string.Empty); + _lines = normalized.Split('\n', StringSplitOptions.None).ToList(); + if (_lines.Count != 0 && _lines[^1].Length == 0) + _lines.RemoveAt(_lines.Count - 1); + } + + public string Read() + { + if (_index >= _lines.Count) + throw Error("Unexpected end of VTank Meta data."); + return _lines[_index++]; + } + + public string ReadExpected(string expected) + { + string actual = Read(); + if (!actual.Equals(expected, StringComparison.Ordinal)) + throw Error($"Expected '{expected}', found '{actual}'."); + return actual; + } + + public void Expect(params string[] expected) + { + foreach (string value in expected) + ReadExpected(value); + } + + public void Expect(string[] first, params string[] rest) + { + Expect(first); + Expect(rest); + } + + public int ReadInt() => int.Parse( + Read(), NumberStyles.Integer, CultureInfo.InvariantCulture); + + public int ReadInt(out string line) + { + line = Read(); + return int.Parse(line, NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + public int ReadCount() + { + int value = ReadInt(); + if (value is < 0 or > MaximumRules) + throw Error("Invalid VTank Meta collection count."); + return value; + } + + public int ReadCount(out string line) + { + int value = ReadInt(out line); + if (value is < 0 or > MaximumRules) + throw Error("Invalid VTank Meta collection count."); + return value; + } + + public double ReadDouble() => double.Parse( + Read(), NumberStyles.Float, CultureInfo.InvariantCulture); + + public string ReadByteArray(int length) + { + if (length < 0) + throw Error("Invalid VTank Meta byte-array length."); + string first = Read(); + if (first.Length >= length) + { + string value = first[..length]; + string remainder = first[length..]; + if (remainder.Length != 0) + _lines.Insert(_index, remainder); + return value; + } + + var valueBuilder = new System.Text.StringBuilder(first); + while (valueBuilder.Length < length && _index < _lines.Count) + { + valueBuilder.Append("\r\n"); + valueBuilder.Append(Read()); + } + if (valueBuilder.Length < length) + throw Error("Truncated VTank Meta byte array."); + string combined = valueBuilder.ToString(); + string result = combined[..length]; + string remaining = combined[length..]; + if (remaining.Length != 0) + _lines.Insert(_index, remaining.TrimStart('\r', '\n')); + return result; + } + + public void ExpectEnd() + { + while (_index < _lines.Count && _lines[_index].Length == 0) + _index++; + if (_index != _lines.Count) + throw Error($"Unexpected trailing VTank Meta data '{_lines[_index]}'."); + } + + public FormatException Error(string message) => + new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}"); + } + + private sealed class LineWriter + { + private readonly List _lines = []; + private readonly List _buggedByteArrays = []; + + public void Add(params object?[] values) + { + foreach (object? value in values) + _lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + } + + public void Add(string[] first, params object?[] rest) + { + Add(first.Cast().ToArray()); + Add(rest); + } + + public void AddBuggedByteArray(string value) + { + _buggedByteArrays.Add(_lines.Count); + _lines.Add(value ?? string.Empty); + } + + public string Finish() + { + foreach (int index in _buggedByteArrays.OrderDescending()) + { + if (index + 1 >= _lines.Count) + throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record."); + _lines[index] += _lines[index + 1]; + _lines.RemoveAt(index + 1); + } + return string.Join("\r\n", _lines) + "\r\n"; + } + } +} diff --git a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs new file mode 100644 index 00000000..88c8561a --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// Reader for VTank's verbatim uTank2 NAV 1.2 format. +internal static class VtankNavRouteSerializer +{ + private const string Header = "uTank2 NAV 1.2"; + + public static string Save(NavigationSettings source) + { + ArgumentNullException.ThrowIfNull(source); + var writer = new StringWriter(CultureInfo.InvariantCulture) + { + NewLine = "\r\n", + }; + writer.WriteLine(Header); + writer.WriteLine(source.Mode switch + { + RouteMode.Circular => 1, + RouteMode.Linear => 2, + RouteMode.Target => 3, + RouteMode.Once => 4, + _ => throw new InvalidOperationException("Unknown navigation type."), + }); + if (source.Mode == RouteMode.Target) + { + writer.WriteLine(source.FollowTargetName ?? string.Empty); + writer.WriteLine(unchecked((int)source.FollowTargetObjectId)); + return writer.ToString(); + } + + writer.WriteLine(source.Waypoints.Count); + foreach (RouteWaypoint waypoint in source.Waypoints) + WriteWaypoint(writer, waypoint); + return writer.ToString(); + } + + public static bool TryLoad( + string source, + NavigationSettings target, + ISpellCatalog spells, + out string error) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(spells); + try + { + string nav = UnwrapEmbedded(source); + using var reader = new StringReader(nav); + if (!ReadLine(reader).Equals(Header, StringComparison.Ordinal)) + throw new FormatException("Nav file version does not match uTank2 NAV 1.2."); + + var parsed = new NavigationSettings + { + Enabled = target.Enabled, + Priority = target.Priority, + MinimumDistanceMeters = target.MinimumDistanceMeters, + FollowAroundCorners = target.FollowAroundCorners, + OpenDoors = target.OpenDoors, + Mode = ReadInt(reader) switch + { + 1 => RouteMode.Circular, + 2 => RouteMode.Linear, + 3 => RouteMode.Target, + 4 => RouteMode.Once, + _ => throw new FormatException("Unknown VTank navigation type."), + }, + }; + + if (parsed.Mode == RouteMode.Target) + { + parsed.FollowTargetName = ReadLine(reader); + parsed.FollowTargetObjectId = unchecked((uint)ReadInt(reader)); + } + else + { + int count = ReadInt(reader); + if (count is < 0 or > 100_000) + throw new FormatException("Invalid VTank waypoint count."); + for (int index = 0; index < count; index++) + parsed.Waypoints.Add(ReadWaypoint(reader, spells)); + } + + Apply(parsed, target); + error = string.Empty; + return true; + } + catch (Exception exception) when (exception is FormatException + or OverflowException or EndOfStreamException) + { + error = exception.Message; + return false; + } + } + + private static RouteWaypoint ReadWaypoint( + TextReader reader, + ISpellCatalog spells) + { + int type = ReadInt(reader); + double eastWest = ReadDouble(reader); + double northSouth = ReadDouble(reader); + double elevation = ReadDouble(reader); + _ = ReadLine(reader); // historical unused coordinate component + var waypoint = new RouteWaypoint + { + Type = type switch + { + 0 => RouteWaypointType.Point, + 1 => RouteWaypointType.Portal, + 2 => RouteWaypointType.Recall, + 3 => RouteWaypointType.Pause, + 4 => RouteWaypointType.ChatCommand, + 5 => RouteWaypointType.OpenVendor, + 6 => RouteWaypointType.PortalByName, + 7 => RouteWaypointType.UseNpc, + 8 => RouteWaypointType.Checkpoint, + 9 => RouteWaypointType.Jump, + _ => throw new FormatException($"Unknown VTank waypoint type {type}."), + }, + Position = Position(eastWest, northSouth, elevation), + }; + + switch (type) + { + case 1: + waypoint.ObjectId = unchecked((uint)ReadInt(reader)); + break; + case 2: + waypoint.RecallSpellId = checked((uint)ReadInt(reader)); + if (spells.TryGet(waypoint.RecallSpellId, out PluginSpellInfo spell)) + waypoint.RecallSpellName = spell.Name; + break; + case 3: + waypoint.DurationMilliseconds = ReadInt(reader); + break; + case 4: + waypoint.Text = ReadLine(reader); + break; + case 5: + waypoint.ObjectId = unchecked((uint)ReadInt(reader)); + waypoint.ObjectName = ReadLine(reader); + break; + case 6: + case 7: + waypoint.ObjectName = ReadLine(reader); + waypoint.LegacyObjectClass = ReadInt(reader); + waypoint.LegacyReferenceValid = ReadBoolean(reader); + double referenceEastWest = ReadDouble(reader); + double referenceNorthSouth = ReadDouble(reader); + double referenceElevation = ReadDouble(reader); + waypoint.Position = Position( + referenceEastWest, + referenceNorthSouth, + referenceElevation); + break; + case 9: + waypoint.JumpHeadingDegrees = checked((float)ReadDouble(reader)); + waypoint.JumpRun = ReadBoolean(reader); + ParseJump(ReadLine(reader), waypoint); + break; + } + return waypoint; + } + + private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint) + { + int type = (int)waypoint.Type; + writer.WriteLine(type.ToString(CultureInfo.InvariantCulture)); + WriteDouble(writer, waypoint.Position.EastWest); + WriteDouble(writer, waypoint.Position.NorthSouth); + WriteDouble(writer, waypoint.Position.Elevation); + writer.WriteLine("0"); + switch (waypoint.Type) + { + case RouteWaypointType.Point: + case RouteWaypointType.Checkpoint: + break; + case RouteWaypointType.Portal: + writer.WriteLine(unchecked((int)waypoint.ObjectId) + .ToString(CultureInfo.InvariantCulture)); + break; + case RouteWaypointType.Recall: + writer.WriteLine(waypoint.RecallSpellId + .ToString(CultureInfo.InvariantCulture)); + break; + case RouteWaypointType.Pause: + writer.WriteLine(waypoint.DurationMilliseconds + .ToString(CultureInfo.InvariantCulture)); + break; + case RouteWaypointType.ChatCommand: + writer.WriteLine(waypoint.Text ?? string.Empty); + break; + case RouteWaypointType.OpenVendor: + writer.WriteLine(unchecked((int)waypoint.ObjectId) + .ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(waypoint.ObjectName ?? string.Empty); + break; + case RouteWaypointType.PortalByName: + case RouteWaypointType.UseNpc: + writer.WriteLine(waypoint.ObjectName ?? string.Empty); + int objectClass = waypoint.LegacyObjectClass != 0 + ? waypoint.LegacyObjectClass + : waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37; + writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(waypoint.LegacyReferenceValid + .ToString(CultureInfo.InvariantCulture)); + WriteDouble(writer, waypoint.Position.EastWest); + WriteDouble(writer, waypoint.Position.NorthSouth); + WriteDouble(writer, waypoint.Position.Elevation); + break; + case RouteWaypointType.Jump: + WriteDouble(writer, waypoint.JumpHeadingDegrees); + writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture)); + string suffix = waypoint.JumpDirection switch + { + RouteJumpDirection.StrafeLeft => "4", + RouteJumpDirection.StrafeRight => "5", + _ => "3", + }; + writer.WriteLine( + waypoint.JumpChargeMilliseconds.ToString( + "0.0000", + CultureInfo.InvariantCulture) + + suffix); + break; + default: + throw new InvalidOperationException( + $"Unknown waypoint type {waypoint.Type}."); + } + } + + private static void WriteDouble(TextWriter writer, double value) => + writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture)); + + private static void ParseJump(string source, RouteWaypoint target) + { + string value = source.Trim(); + char suffix = value.Length == 0 ? '\0' : value[^1]; + bool encoded = suffix is '3' or '4' or '5' + && value.Length >= 6 + && value[^6] == '.'; + string milliseconds = encoded ? value[..^1] : value; + target.JumpChargeMilliseconds = checked((int)Math.Round( + double.Parse(milliseconds, NumberStyles.Float, CultureInfo.InvariantCulture), + MidpointRounding.AwayFromZero)); + target.JumpDirection = suffix switch + { + '4' when encoded => RouteJumpDirection.StrafeLeft, + '5' when encoded => RouteJumpDirection.StrafeRight, + _ => RouteJumpDirection.Forward, + }; + } + + private static string UnwrapEmbedded(string source) + { + string normalized = source?.Replace("\r\n", "\n", StringComparison.Ordinal) + ?? string.Empty; + if (normalized.StartsWith(Header, StringComparison.Ordinal)) + return normalized; + using var reader = new StringReader(normalized); + _ = ReadLine(reader); // embedded route display name + _ = ReadInt(reader); // embedded point count + return reader.ReadToEnd(); + } + + private static PluginNavigationPosition Position( + double eastWest, + double northSouth, + double elevation) => new( + 0u, + eastWest, + northSouth, + elevation, + 0f, + IsOutdoor: true); + + private static void Apply(NavigationSettings source, NavigationSettings target) + { + target.Mode = source.Mode; + target.FollowTargetObjectId = source.FollowTargetObjectId; + target.FollowTargetName = source.FollowTargetName; + target.Waypoints.Clear(); + target.Waypoints.AddRange(source.Waypoints.Select(static value => value.Clone())); + } + + private static string ReadLine(TextReader reader) => + reader.ReadLine() ?? throw new EndOfStreamException("Unexpected end of VTank nav data."); + + private static int ReadInt(TextReader reader) => int.Parse( + ReadLine(reader), + NumberStyles.Integer, + CultureInfo.InvariantCulture); + + private static double ReadDouble(TextReader reader) => double.Parse( + ReadLine(reader), + NumberStyles.Float, + CultureInfo.InvariantCulture); + + private static bool ReadBoolean(TextReader reader) => bool.Parse(ReadLine(reader)); +} diff --git a/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs new file mode 100644 index 00000000..8947b9e5 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs @@ -0,0 +1,221 @@ +namespace AcDream.Plugins.MossTank; + +/// +/// Exact 137-row Settings table from VTank's shipped +/// uTank2.Resources.defaultsettings.usd. Order is retained because +/// /vt opt list presents the database order four entries per line. +/// +internal static class VtankOptionCatalog +{ + internal static readonly string[] Names = + [ + "EnableLooting", "EnableNav", "EnableBuffing", "EnableCombat", + "SpellDiffExcessThreshold-Hunt", "SpellDiffExcessThreshold-Buff", + "ArrowheadFletchDiffExcessThreshold", "Recharge-Norm-HitP", + "Recharge-Norm-Stam", "Recharge-Norm-Mana", "Recharge-NoTarg-HitP", + "Recharge-NoTarg-Stam", "Recharge-NoTarg-Mana", "Recharge-Helper-HitP", + "Recharge-Helper-Stam", "Recharge-Helper-Mana", "DoHelp", + "AttackDistance", "AttackMinimumDistance", "ApproachDistance", + "RingDistance", "CorpseApproachRange-Max", "CorpseApproachRange-Min", + "NavCloseStopRange", "NavFarStopRange", "UsePortalDistance", + "HelperDistanceHitP", "HelperDistanceStam", "HelperDistanceMana", + "MinimumRingTargets", "DefaultMeleeAttackHeight", "CastDispelSelf", + "UseDispelItems", "AutoCram", "AutoStack", "ReadUnknownScrolls", + "UseDispelDrum", "SwitchWandsToDebuff", "AutoCraftItems", + "UseHealersHeart", "JumpOutWandCasting", "LootAllCorpses", + "LootFellowCorpses", "DoJiggle", "RandomHelperBuffs", + "RandomHelperIntervalSeconds", "IdlePeaceMode", "TargetLock", + "StopMacroOnDeath", "UseArcs", "ArcRange", "TargetSelectMethod", + "TargetSelectAngleRange", "IdleBuffTopoff", "IdleBuffTopoffTimeSeconds", + "RebuffTimeRemainingSeconds", "RefillWornMana", + "RefillWornMana-Item-ManaPercent", "BuffProfile-Prots", + "BuffProfile-Banes", "BuffProfile_Prots", "BuffProfile_Banes", + "DebuffEachFirst", "AutoAttackPower", "LootPriorityBoost", + "CorpseCacheTimeoutMinutes", "CorpseItemAppearanceTimeoutSeconds", + "CorpseItemIDTimeoutSeconds", "DebuffSelectionMethod", + "ManaStoneLootCount", "ManaTankMinimumMana", "SplitPeas", + "SpellCompMin-Critical", "SpellCompMin-Normal", "SpellCompMin-Idle", + "RechargeBoostTimeSeconds", "RechargeBoostAmount", "UseSpecialAmmo", + "OpenDoors", "DoorIDRange", "DoorOpenRange", + "DoorLockpickDiffExcessThreshold", "ManaChargesWhenOff", + "AutoFellowManagement", "MinimumHealKitSuccessChance", + "UseKitsInMagicMode", "StaminaToHealthMultiplier", + "ManaToHealthMultiplier", "NavPriorityBoost", "DeleteGhostMonsters", + "GhostMonsterSpellAttemptCount", "WhoYouGonnaCall", + "BlacklistMonsterAttemptCount", "BlacklistMonsterTimeoutSeconds", + "CombineSalvage", "LootOnlyRareCorpses", + "DeleteGhostMonstersByHPTracker", "GhostDeleteHPTrackerSeconds", + "GoToPeaceModeToUseKits", "UseRecklessness", "DebuffPrecastSeconds", + "ClearLevelBoostFlagOnCast", "IdleCraftCount_HealthKits", + "IdleCraftCount_StamKits", "IdleCraftCount_ManaKits", + "IdleCraftCount_HealthFood", "IdleCraftCount_StamFood", + "IdleCraftCount_ManaFood", "BuffCastRecast_Seconds", + "BuffCastRecastReset_Seconds", "EnableMeta", "BlacklistedSpellComps", + "DropToPeaceModeRetryCount", "FollowAroundCorners", + "BlacklistCorpseOpenAttemptCount", "BlacklistCorpseOpenTimeoutSeconds", + "SummonPets", "PetRangeMode", "PetCustomRange", "PetRefillCount-Idle", + "PetRefillCount-Normal", "CorpseOpenTimeoutSeconds", + "PetMonsterDensity", "CorpseLootItemMaxAttempts", "FastCastBuffs", + "UseBreakableTurnTo", "UseProjectileAwareness", + "CollisionProjectileRadius", "CollisionStepDistance", + "ShowCollisionDebug", "MaximumCollisionChecksPerTick", "SpellRangeFudge", + "BuffWithUntrained-Item", "BuffWithUntrained-Creature", + "BuffWithUntrained-Life", "AllowDebuffFallback", "RechargeHandlerSet", + ]; + + // Scalar defaults are read verbatim from VTank's shipped Settings table. + // RechargeHandlerSet is the one non-scalar row and is represented by its + // table identity; the Vitals policy owns its live ordered handlers. + private static readonly IReadOnlyDictionary Defaults = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["EnableLooting"] = MonsterValue.FromBoolean(false), + ["EnableNav"] = MonsterValue.FromBoolean(false), + ["EnableBuffing"] = MonsterValue.FromBoolean(true), + ["EnableCombat"] = MonsterValue.FromBoolean(true), + ["SpellDiffExcessThreshold-Hunt"] = MonsterValue.FromNumber(25d), + ["SpellDiffExcessThreshold-Buff"] = MonsterValue.FromNumber(5d), + ["ArrowheadFletchDiffExcessThreshold"] = MonsterValue.FromNumber(10d), + ["Recharge-Norm-HitP"] = MonsterValue.FromNumber(75d), + ["Recharge-Norm-Stam"] = MonsterValue.FromNumber(50d), + ["Recharge-Norm-Mana"] = MonsterValue.FromNumber(50d), + ["Recharge-NoTarg-HitP"] = MonsterValue.FromNumber(1d), + ["Recharge-NoTarg-Stam"] = MonsterValue.FromNumber(1d), + ["Recharge-NoTarg-Mana"] = MonsterValue.FromNumber(1d), + ["Recharge-Helper-HitP"] = MonsterValue.FromNumber(20d), + ["Recharge-Helper-Stam"] = MonsterValue.FromNumber(1d), + ["Recharge-Helper-Mana"] = MonsterValue.FromNumber(1d), + ["DoHelp"] = MonsterValue.FromBoolean(true), + ["AttackDistance"] = MonsterValue.FromNumber(0.0208333333333333d), + ["AttackMinimumDistance"] = MonsterValue.FromNumber(0d), + ["ApproachDistance"] = MonsterValue.FromNumber(0d), + ["RingDistance"] = MonsterValue.FromNumber(0.0208333333333333d), + ["CorpseApproachRange-Max"] = MonsterValue.FromNumber(0d), + ["CorpseApproachRange-Min"] = MonsterValue.FromNumber(0.014d), + ["NavCloseStopRange"] = MonsterValue.FromNumber(0.00833333333333333d), + ["NavFarStopRange"] = MonsterValue.FromNumber(999999d), + ["UsePortalDistance"] = MonsterValue.FromNumber(0.0166666666666667d), + ["HelperDistanceHitP"] = MonsterValue.FromNumber(0.310416666666667d), + ["HelperDistanceStam"] = MonsterValue.FromNumber(0.310416666666667d), + ["HelperDistanceMana"] = MonsterValue.FromNumber(0.166666666666667d), + ["MinimumRingTargets"] = MonsterValue.FromNumber(4d), + ["DefaultMeleeAttackHeight"] = MonsterValue.FromNumber(2d), + ["CastDispelSelf"] = MonsterValue.FromBoolean(false), + ["UseDispelItems"] = MonsterValue.FromBoolean(false), + ["AutoCram"] = MonsterValue.FromBoolean(false), + ["AutoStack"] = MonsterValue.FromBoolean(true), + ["ReadUnknownScrolls"] = MonsterValue.FromBoolean(true), + ["UseDispelDrum"] = MonsterValue.FromBoolean(false), + ["SwitchWandsToDebuff"] = MonsterValue.FromBoolean(false), + ["AutoCraftItems"] = MonsterValue.FromBoolean(true), + ["UseHealersHeart"] = MonsterValue.FromBoolean(true), + ["JumpOutWandCasting"] = MonsterValue.FromBoolean(false), + ["LootAllCorpses"] = MonsterValue.FromBoolean(false), + ["LootFellowCorpses"] = MonsterValue.FromBoolean(false), + ["DoJiggle"] = MonsterValue.FromBoolean(false), + ["RandomHelperBuffs"] = MonsterValue.FromBoolean(false), + ["RandomHelperIntervalSeconds"] = MonsterValue.FromNumber(5d), + ["IdlePeaceMode"] = MonsterValue.FromBoolean(false), + ["TargetLock"] = MonsterValue.FromBoolean(false), + ["StopMacroOnDeath"] = MonsterValue.FromBoolean(true), + ["UseArcs"] = MonsterValue.FromNumber(1d), + ["ArcRange"] = MonsterValue.FromNumber(0.0208333333333333d), + ["TargetSelectMethod"] = MonsterValue.FromNumber(3d), + ["TargetSelectAngleRange"] = MonsterValue.FromNumber(0.0208333333333333d), + ["IdleBuffTopoff"] = MonsterValue.FromBoolean(false), + ["IdleBuffTopoffTimeSeconds"] = MonsterValue.FromNumber(1200d), + ["RebuffTimeRemainingSeconds"] = MonsterValue.FromNumber(300d), + ["RefillWornMana"] = MonsterValue.FromBoolean(true), + ["RefillWornMana-Item-ManaPercent"] = MonsterValue.FromNumber(33d), + ["BuffProfile-Prots"] = MonsterValue.FromText("ALFCBPS"), + ["BuffProfile-Banes"] = MonsterValue.FromText("ALFCBPS"), + ["BuffProfile_Prots"] = MonsterValue.FromNumber(2d), + ["BuffProfile_Banes"] = MonsterValue.FromNumber(2d), + ["DebuffEachFirst"] = MonsterValue.FromNumber(1d), + ["AutoAttackPower"] = MonsterValue.FromBoolean(true), + ["LootPriorityBoost"] = MonsterValue.FromBoolean(false), + ["CorpseCacheTimeoutMinutes"] = MonsterValue.FromNumber(60d), + ["CorpseItemAppearanceTimeoutSeconds"] = MonsterValue.FromNumber(6d), + ["CorpseItemIDTimeoutSeconds"] = MonsterValue.FromNumber(60d), + ["DebuffSelectionMethod"] = MonsterValue.FromNumber(2d), + ["ManaStoneLootCount"] = MonsterValue.FromNumber(4d), + ["ManaTankMinimumMana"] = MonsterValue.FromNumber(1000d), + ["SplitPeas"] = MonsterValue.FromBoolean(true), + ["SpellCompMin-Critical"] = MonsterValue.FromNumber(4d), + ["SpellCompMin-Normal"] = MonsterValue.FromNumber(20d), + ["SpellCompMin-Idle"] = MonsterValue.FromNumber(20d), + ["RechargeBoostTimeSeconds"] = MonsterValue.FromNumber(5d), + ["RechargeBoostAmount"] = MonsterValue.FromNumber(40d), + ["UseSpecialAmmo"] = MonsterValue.FromNumber(0d), + ["OpenDoors"] = MonsterValue.FromBoolean(false), + ["DoorIDRange"] = MonsterValue.FromNumber(0.0833333333333333d), + ["DoorOpenRange"] = MonsterValue.FromNumber(0.0166666666666667d), + ["DoorLockpickDiffExcessThreshold"] = MonsterValue.FromNumber(-50d), + ["ManaChargesWhenOff"] = MonsterValue.FromBoolean(true), + ["AutoFellowManagement"] = MonsterValue.FromBoolean(true), + ["MinimumHealKitSuccessChance"] = MonsterValue.FromNumber(95d), + ["UseKitsInMagicMode"] = MonsterValue.FromBoolean(true), + ["StaminaToHealthMultiplier"] = MonsterValue.FromNumber(1.9d), + ["ManaToHealthMultiplier"] = MonsterValue.FromNumber(2.8d), + ["NavPriorityBoost"] = MonsterValue.FromBoolean(false), + ["DeleteGhostMonsters"] = MonsterValue.FromBoolean(true), + ["GhostMonsterSpellAttemptCount"] = MonsterValue.FromNumber(200d), + ["WhoYouGonnaCall"] = MonsterValue.FromBoolean(true), + ["BlacklistMonsterAttemptCount"] = MonsterValue.FromNumber(4d), + ["BlacklistMonsterTimeoutSeconds"] = MonsterValue.FromNumber(120d), + ["CombineSalvage"] = MonsterValue.FromBoolean(true), + ["LootOnlyRareCorpses"] = MonsterValue.FromBoolean(false), + ["DeleteGhostMonstersByHPTracker"] = MonsterValue.FromBoolean(true), + ["GhostDeleteHPTrackerSeconds"] = MonsterValue.FromNumber(30d), + ["GoToPeaceModeToUseKits"] = MonsterValue.FromBoolean(false), + ["UseRecklessness"] = MonsterValue.FromBoolean(true), + ["DebuffPrecastSeconds"] = MonsterValue.FromNumber(5d), + ["ClearLevelBoostFlagOnCast"] = MonsterValue.FromBoolean(true), + ["IdleCraftCount_HealthKits"] = MonsterValue.FromNumber(2d), + ["IdleCraftCount_StamKits"] = MonsterValue.FromNumber(2d), + ["IdleCraftCount_ManaKits"] = MonsterValue.FromNumber(2d), + ["IdleCraftCount_HealthFood"] = MonsterValue.FromNumber(15d), + ["IdleCraftCount_StamFood"] = MonsterValue.FromNumber(15d), + ["IdleCraftCount_ManaFood"] = MonsterValue.FromNumber(15d), + ["BuffCastRecast_Seconds"] = MonsterValue.FromNumber(30d), + ["BuffCastRecastReset_Seconds"] = MonsterValue.FromNumber(30d), + ["EnableMeta"] = MonsterValue.FromBoolean(false), + ["BlacklistedSpellComps"] = MonsterValue.FromText(string.Empty), + ["DropToPeaceModeRetryCount"] = MonsterValue.FromNumber(34d), + ["FollowAroundCorners"] = MonsterValue.FromBoolean(true), + ["BlacklistCorpseOpenAttemptCount"] = MonsterValue.FromNumber(30d), + ["BlacklistCorpseOpenTimeoutSeconds"] = MonsterValue.FromNumber(200d), + ["SummonPets"] = MonsterValue.FromBoolean(true), + ["PetRangeMode"] = MonsterValue.FromNumber(0d), + ["PetCustomRange"] = MonsterValue.FromNumber(0.0208333333333333d), + ["PetRefillCount-Idle"] = MonsterValue.FromNumber(3d), + ["PetRefillCount-Normal"] = MonsterValue.FromNumber(1d), + ["CorpseOpenTimeoutSeconds"] = MonsterValue.FromNumber(1.5d), + ["PetMonsterDensity"] = MonsterValue.FromNumber(1d), + ["CorpseLootItemMaxAttempts"] = MonsterValue.FromNumber(20d), + ["FastCastBuffs"] = MonsterValue.FromBoolean(false), + ["UseBreakableTurnTo"] = MonsterValue.FromBoolean(true), + ["UseProjectileAwareness"] = MonsterValue.FromBoolean(true), + ["CollisionProjectileRadius"] = MonsterValue.FromNumber(0.4d), + ["CollisionStepDistance"] = MonsterValue.FromNumber(0.7d), + ["ShowCollisionDebug"] = MonsterValue.FromBoolean(false), + ["MaximumCollisionChecksPerTick"] = MonsterValue.FromNumber(500d), + ["SpellRangeFudge"] = MonsterValue.FromNumber(1d), + ["BuffWithUntrained-Item"] = MonsterValue.FromNumber(80d), + ["BuffWithUntrained-Creature"] = MonsterValue.FromNumber(80d), + ["BuffWithUntrained-Life"] = MonsterValue.FromNumber(80d), + ["AllowDebuffFallback"] = MonsterValue.FromBoolean(false), + ["RechargeHandlerSet"] = MonsterValue.FromText("RechargeHandlerSet"), + }; + + internal static bool IsKnown(string name) => + Names.Contains(name, StringComparer.OrdinalIgnoreCase); + + internal static string Canonical(string name) => + Names.First(value => value.Equals(name, StringComparison.OrdinalIgnoreCase)); + + internal static MonsterValue Default(string name) => + Defaults.TryGetValue(name, out MonsterValue value) + ? value + : MonsterValue.FromNumber(0d); +} diff --git a/src/AcDream.Plugins.MossTank/mosstank-settings.xml b/src/AcDream.Plugins.MossTank/mosstank-settings.xml deleted file mode 100644 index 47bf4a27..00000000 --- a/src/AcDream.Plugins.MossTank/mosstank-settings.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - void Publish(T command) where T : notnull; } + +/// +/// Optional local-command extension carried by a command bus. The chat router +/// checks it after retail client commands and before unknown commands are sent +/// to the server. +/// +public interface IPluginCommandBus : ICommandBus +{ + bool TryHandlePluginCommand(string commandLine); +} diff --git a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs index 9296a7c0..73160965 100644 --- a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs +++ b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs @@ -303,11 +303,17 @@ public sealed class LiveChatCommandRoute /// Stable host-owned bus over a replaceable generation route. A retained /// login-command runner never captures an obsolete transport. /// -public sealed class LiveChatCommandSurface : ICommandBus +public sealed class LiveChatCommandSurface : IPluginCommandBus { private readonly object _gate = new(); + private readonly Func? _tryHandlePluginCommand; private LiveChatCommandRoute? _active; + public LiveChatCommandSurface(Func? tryHandlePluginCommand = null) + { + _tryHandlePluginCommand = tryHandlePluginCommand; + } + public ILiveSessionCommandRouting Attach(LiveChatCommandRoute route) { ArgumentNullException.ThrowIfNull(route); @@ -331,6 +337,9 @@ public sealed class LiveChatCommandSurface : ICommandBus route?.Publish(command); } + public bool TryHandlePluginCommand(string commandLine) => + _tryHandlePluginCommand?.Invoke(commandLine) == true; + private void Release(LiveChatCommandRoute expected) { expected.Dispose(); diff --git a/src/AcDream.Runtime/GameRuntimeActionViews.cs b/src/AcDream.Runtime/GameRuntimeActionViews.cs index c8726b86..944295d7 100644 --- a/src/AcDream.Runtime/GameRuntimeActionViews.cs +++ b/src/AcDream.Runtime/GameRuntimeActionViews.cs @@ -11,7 +11,13 @@ public readonly record struct RuntimeCombatAttackSnapshot( bool BuildInProgress, bool RequestInProgress, float RequestedPower, - bool RepeatAttackInProgress = false); + bool RepeatAttackInProgress = false, + bool ServerResponsePending = false) +{ + public long CompletionRevision { get; init; } + public uint CompletionSequence { get; init; } + public uint CompletionWeenieError { get; init; } +} public readonly record struct RuntimeSpellCastSnapshot( long Revision, diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 487d70db..d28ed25d 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -17,7 +17,8 @@ public readonly record struct MovementInput( bool TurnRight = false, bool Run = false, float MouseDeltaX = 0f, - bool Jump = false); + bool Jump = false, + bool IsPersistentCommand = false); /// /// Typed construction policy for the local movement owner. Server-authoritative @@ -340,6 +341,26 @@ public sealed class PlayerMovementController public uint CellId { get; private set; } public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; + /// + /// Current local-player position for Runtime consumers. The physics body's + /// carried intentionally owns cell + /// identity and the cell-local origin only; its frame rotation is not + /// rewritten by animation root motion. Consumers that need the live facing + /// direction must therefore combine that carried translation with the + /// authoritative body orientation, just like the outbound movement path. + /// + internal AcDream.Core.Physics.Position CurrentCellPosition + { + get + { + AcDream.Core.Physics.Position carried = _body.CellPosition; + return new AcDream.Core.Physics.Position( + carried.ObjCellId, + carried.Frame.Origin, + _body.Orientation); + } + } + /// /// True only when the most recent visible or Hidden object update admitted /// at least one complete retail quantum. Presentation uses this to rebuild @@ -414,11 +435,7 @@ public sealed class PlayerMovementController out AcDream.Core.Physics.Position outboundPosition) { EnsurePublishedForRuntimeOperation(); - AcDream.Core.Physics.Position canonical = _body.CellPosition; - outboundPosition = new AcDream.Core.Physics.Position( - canonical.ObjCellId, - canonical.Frame.Origin, - _body.Orientation); + outboundPosition = CurrentCellPosition; return PositionFrameValidation.IsValid( outboundPosition.ObjCellId, outboundPosition.Frame.Origin, @@ -2512,6 +2529,27 @@ public sealed class PlayerMovementController bool movementEventRequested = externallyRequestedMovementEvent; { + // Plugin/headless movement is a persistent command level rather + // than a sampled physical key. A server-authored posture change + // (notably the Magic + Ready acknowledgement emitted while + // MossTank is facing a spell target) legitimately takes movement + // control, but it must not permanently erase a still-active + // command intent. Retake through the same retail + // CommandInterpreter boundary before edge detection; clearing the + // prior levels below makes this frame re-dispatch the held axes and + // publish one fresh autonomous movement event. Physical keyboard + // snapshots leave IsPersistentCommand false and retain their exact + // edge-driven behavior. + bool persistentMovementHeld = input.IsPersistentCommand + && (input.Forward + || input.Backward + || input.StrafeLeft + || input.StrafeRight + || input.TurnLeft + || input.TurnRight); + if (_controlledByServer && persistentMovementHeld) + TakeControlFromServer(); + bool userInputEdge = input.Run != _prevRunHeld || input.Forward != _prevForwardHeld || input.Backward != _prevBackwardHeld diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs index fcd76db7..204681c1 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs @@ -2,6 +2,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Selection; using AcDream.Core.Spells; +using System.Diagnostics; namespace AcDream.Runtime.Gameplay; @@ -53,6 +54,9 @@ public sealed class RuntimeActionState : IDisposable private long _interactionRevision; private long _combatIntentRevision; private long _magicIntentRevision; + private readonly Func _now; + private readonly Dictionary _healthActivity = []; + private long _healthActivityRevision; public RuntimeActionState( InventoryTransactionState inventoryTransactions, @@ -69,6 +73,8 @@ public sealed class RuntimeActionState : IDisposable ArgumentNullException.ThrowIfNull(combatTargetOperations); ArgumentNullException.ThrowIfNull(combatModeOperations); ArgumentNullException.ThrowIfNull(spellCastOperations); + _now = now ?? (() => + Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency); Selection = new SelectionState(); Combat = new CombatState(); Interaction = new InteractionState(); @@ -77,7 +83,7 @@ public sealed class RuntimeActionState : IDisposable CombatAttack = new RuntimeCombatAttackState( Combat, combatAttackOperations, - now); + _now); CombatTarget = new RuntimeCombatTargetState( Combat, Selection, @@ -111,6 +117,22 @@ public sealed class RuntimeActionState : IDisposable public IRuntimeActionView View { get; } public bool IsDisposed => _disposed; + public bool TryGetHealthActivity( + uint objectId, + out long revision, + out double secondsSinceUpdate) + { + if (!_healthActivity.TryGetValue(objectId, out HealthActivity activity)) + { + revision = 0; + secondsSinceUpdate = double.PositiveInfinity; + return false; + } + revision = activity.Revision; + secondsSinceUpdate = Math.Max(0d, _now() - activity.UpdatedAt); + return true; + } + internal event Action? CombatChanged; public RuntimeActionOwnershipSnapshot CaptureOwnership() => new( @@ -147,6 +169,7 @@ public sealed class RuntimeActionState : IDisposable Try(CombatAttack.ResetSession, ref failures); Try(() => Selection.Reset(), ref failures); Try(Combat.Clear, ref failures); + ClearHealthActivity(); if (failures is not null) { throw new AggregateException( @@ -169,6 +192,7 @@ public sealed class RuntimeActionState : IDisposable Try(CombatAttack.ResetSession, ref failures); Try(() => Selection.Reset(), ref failures); Try(Combat.Clear, ref failures); + ClearHealthActivity(); } finally { @@ -201,12 +225,20 @@ public sealed class RuntimeActionState : IDisposable CombatChanged?.Invoke(); } - private void OnHealthChanged(uint _, float __) + private void OnHealthChanged(uint objectId, float _) { + long revision = ++_healthActivityRevision; + _healthActivity[objectId] = new HealthActivity(revision, _now()); Interlocked.Increment(ref _combatRevision); CombatChanged?.Invoke(); } + private void ClearHealthActivity() + { + _healthActivity.Clear(); + _healthActivityRevision = 0; + } + private void OnInteractionChanged(InteractionModeTransition _) => Interlocked.Increment(ref _interactionRevision); @@ -254,7 +286,13 @@ public sealed class RuntimeActionState : IDisposable owner.CombatAttack.BuildInProgress, owner.CombatAttack.AttackRequestInProgress, owner.CombatAttack.RequestedAttackPower, - owner.CombatAttack.RepeatAttackInProgress), + owner.CombatAttack.RepeatAttackInProgress, + owner.CombatAttack.AttackServerResponsePending) + { + CompletionRevision = owner.CombatAttack.CompletionRevision, + CompletionSequence = owner.CombatAttack.CompletionSequence, + CompletionWeenieError = owner.CombatAttack.CompletionWeenieError, + }, new RuntimeSpellCastSnapshot( Interlocked.Read(ref owner._magicIntentRevision), owner.SpellCast.LastRequestedSpellId ?? 0u, @@ -272,4 +310,6 @@ public sealed class RuntimeActionState : IDisposable return true; } } + + private readonly record struct HealthActivity(long Revision, double UpdatedAt); } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs index 6c0eccb7..14753aa8 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs @@ -110,6 +110,7 @@ public sealed class RuntimeCombatAttackState : IDisposable private float _requestedAttackPower; private float _latestPowerBarLevel; private bool _disposed; + private long _completionRevision; public RuntimeCombatAttackState( CombatState combat, @@ -152,10 +153,19 @@ public sealed class RuntimeCombatAttackState : IDisposable public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium; public float DesiredPower { get; private set; } = InitialDesiredPower; public bool AttackRequestInProgress => _attackRequestInProgress; + /// + /// True after an attack request has been emitted and before the matching + /// server completion. Automation must not begin another request during + /// this interval. + /// + public bool AttackServerResponsePending => _attackServerResponsePending; public bool RepeatAttackInProgress => _repeatAttacking; public float RequestedAttackPower => _requestedAttackPower; public bool BuildInProgress => _buildInProgress; public bool IsDisposed => _disposed; + public long CompletionRevision => _completionRevision; + public uint CompletionSequence { get; private set; } + public uint CompletionWeenieError { get; private set; } /// The level retail publishes to the embedded combat meter. public float PowerBarLevel => _buildInProgress @@ -394,8 +404,11 @@ public sealed class RuntimeCombatAttackState : IDisposable StateChanged?.Invoke(); } - private void OnAttackDone(uint _, uint weenieError) + private void OnAttackDone(uint attackSequence, uint weenieError) { + CompletionSequence = attackSequence; + CompletionWeenieError = weenieError; + _completionRevision++; _attackServerResponsePending = false; if (weenieError != 0) _repeatAttacking = false; @@ -471,6 +484,9 @@ public sealed class RuntimeCombatAttackState : IDisposable _attackWhenResponseReceivedPower = 0f; _repeatAttacking = false; _requestedAttackPower = 0f; + _completionRevision = 0; + CompletionSequence = 0u; + CompletionWeenieError = 0u; ResetPowerBar(); } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs index fbf7e4aa..0d36f62e 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs @@ -91,4 +91,42 @@ public sealed class RuntimeCombatModeState RuntimeCombatModeRequestStatus.Sent, nextMode); } + + /// + /// The explicit-mode half of Decal's combat-state primitive used by + /// plugins such as VTank. Unlike , the caller already + /// chose the mode after its equipment policy ran. + /// + public RuntimeCombatModeRequestResult Request(CombatMode mode) + { + if (!_operations.IsInWorld) + { + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Inactive, + _combat.CurrentMode); + } + if (mode is not (CombatMode.NonCombat + or CombatMode.Melee + or CombatMode.Missile + or CombatMode.Magic)) + { + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Rejected, + _combat.CurrentMode, + "Invalid combat mode."); + } + if (_combat.CurrentMode == mode) + { + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Sent, + mode); + } + + _operations.NotifyExplicitCombatModeRequest(); + _operations.SendChangeCombatMode(mode); + _combat.SetCombatMode(mode); + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Sent, + mode); + } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs index 59e7d81e..1296a932 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs @@ -148,6 +148,38 @@ public static class RuntimeFriendlyTargetQuery : null; } + /// Horizontal live-world distance from the local player. + public static bool TryGetDistance( + GameRuntime runtime, + uint guid, + out float distance) + { + ArgumentNullException.ThrowIfNull(runtime); + uint playerGuid = runtime.PlayerIdentity.ServerGuid; + if (playerGuid == 0u + || !runtime.EntityObjects.Entities.TryGetActive( + playerGuid, + out RuntimeEntityRecord player) + || player.Snapshot.Position is not { } playerPosition + || !runtime.EntityObjects.Entities.TryGetActive( + guid, + out RuntimeEntityRecord target) + || target.Snapshot.Position is not { } targetPosition + || (target.FinalPhysicsState + & (PhysicsStateFlags.Hidden | PhysicsStateFlags.NoDraw)) != 0) + { + distance = float.PositiveInfinity; + return false; + } + + Vector3 from = AbsolutePosition(playerPosition); + Vector3 to = AbsolutePosition(targetPosition); + distance = Vector2.Distance( + new Vector2(from.X, from.Y), + new Vector2(to.X, to.Y)); + return true; + } + private static bool IsPlayer(RuntimeEntityRecord record) => EntityCollisionFlagsExt .FromPwdBitfield(record.Snapshot.ObjectDescriptionFlags ?? 0u) diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs index 12a2e420..3d10838e 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs @@ -3,10 +3,35 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.Properties; using AcDream.Runtime.Entities; namespace AcDream.Runtime.Gameplay; +/// +/// One hostile candidate projected from the canonical entity/object owners. +/// Distances are horizontal-world meters and relative angle uses retail's +/// compass convention: 0 straight ahead, negative left, positive right. +/// +public readonly record struct RuntimeHostileTargetSnapshot( + uint ObjectId, + string Name, + uint WeenieClassId, + float Distance, + float RelativeAngleDegrees, + bool IsHealthKnown, + float HealthFraction) +{ + public int SpeciesId { get; init; } + public int MaximumHealth { get; init; } + public bool HasShield { get; init; } + public ushort Incarnation { get; init; } + public long HealthRevision { get; init; } + public double SecondsSinceHealthUpdate { get; init; } = + double.PositiveInfinity; +} + /// /// Presentation-independent hostile-target query over the canonical Runtime /// directory and object table. Graphical hosts may retain their render-aware @@ -15,6 +40,117 @@ namespace AcDream.Runtime.Gameplay; /// public static class RuntimeHostileTargetQuery { + /// + /// Captures every live hostile within a bounded horizontal distance. The + /// returned array is immutable-by-convention and detached from the owner; + /// callers may retain it until their next decision tick. + /// + public static IReadOnlyList Capture( + GameRuntime runtime, + float maximumDistance) + { + ArgumentNullException.ThrowIfNull(runtime); + if (float.IsNaN(maximumDistance) || maximumDistance <= 0f) + return Array.Empty(); + + uint playerGuid = runtime.PlayerIdentity.ServerGuid; + if (playerGuid == 0u + || !runtime.EntityObjects.Entities.TryGetActive( + playerGuid, + out RuntimeEntityRecord playerRecord) + || playerRecord.Snapshot.Position is not { } playerPosition) + { + return Array.Empty(); + } + + Vector3 playerWorld = AbsolutePosition(playerPosition); + float playerHeading = MoveToMath.GetHeading(new Quaternion( + playerPosition.RotationX, + playerPosition.RotationY, + playerPosition.RotationZ, + playerPosition.RotationW)); + float maximumDistanceSquared = maximumDistance * maximumDistance; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + ClientObject? player = objects.Get(playerGuid); + var targets = new List(); + + foreach (RuntimeEntityRecord record + in runtime.EntityObjects.Entities.ActiveRecords) + { + if (record.ServerGuid == playerGuid + || record.Snapshot.Position is not { } position + || (record.FinalPhysicsState + & (PhysicsStateFlags.Hidden + | PhysicsStateFlags.NoDraw)) != 0) + { + continue; + } + + ClientObject? candidate = objects.Get(record.ServerGuid); + if (!CombatTargetPolicy.IsHostileMonster( + playerGuid, + player, + candidate)) + { + continue; + } + + bool hasHealth = runtime.ActionOwner.Combat.HasHealth( + record.ServerGuid); + float health = hasHealth + ? runtime.ActionOwner.Combat.GetHealthPercent(record.ServerGuid) + : 1f; + if (hasHealth && health <= 0f) + continue; + + Vector3 targetWorld = AbsolutePosition(position); + Vector2 delta = new( + targetWorld.X - playerWorld.X, + targetWorld.Y - playerWorld.Y); + float distanceSquared = delta.LengthSquared(); + if (distanceSquared > maximumDistanceSquared) + continue; + + float targetHeading = MoveToMath.PositionHeading( + playerWorld, + targetWorld); + float relativeAngle = NormalizeSignedDegrees( + targetHeading - playerHeading); + int speciesId = candidate?.Properties.GetInt( + (uint)PropertyInt.CreatureType) ?? 0; + bool hasShield = candidate is not null + && objects.GetEquippedBy(candidate.ObjectId).Any(static item => + (item.Type & ItemType.Armor) != 0); + runtime.ActionOwner.TryGetHealthActivity( + record.ServerGuid, + out long healthRevision, + out double healthAge); + targets.Add(new RuntimeHostileTargetSnapshot( + record.ServerGuid, + candidate?.Name ?? string.Empty, + candidate?.WeenieClassId ?? 0u, + MathF.Sqrt(distanceSquared), + relativeAngle, + hasHealth, + health) + { + SpeciesId = speciesId, + // CreatureProfile maximum HP is appraisal data and is not yet + // a Runtime owner. Zero truthfully means unknown; MossTank's + // maxhp expressions begin matching as soon as that owner lands. + MaximumHealth = 0, + HasShield = hasShield, + Incarnation = record.Incarnation, + HealthRevision = healthRevision, + SecondsSinceHealthUpdate = healthAge, + }); + } + + return targets.Count == 0 + ? Array.Empty() + : targets.ToArray(); + } + public static uint? FindClosest(GameRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); @@ -112,4 +248,14 @@ public static class RuntimeHostileTargetQuery position.PositionY + landblockY * 192f, position.PositionZ); } + + private static float NormalizeSignedDegrees(float degrees) + { + float normalized = degrees % 360f; + if (normalized > 180f) + normalized -= 360f; + else if (normalized < -180f) + normalized += 360f; + return normalized; + } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs index 37b11903..083ed01c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs @@ -52,6 +52,15 @@ public readonly record struct RuntimeAppraisalResponseAcceptance( bool Accepted, bool FirstResponse); +public readonly record struct RuntimeItemUseCompletion( + long Revision, + uint SourceObjectId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + public enum RuntimeInteractionDispatchResult { Rejected, @@ -76,7 +85,9 @@ public readonly record struct RuntimeInteractionTransactionSnapshot( // cancellation resolves it — it must reach zero at teardown exactly // like HasPendingPickup. bool HasPendingUse = false, - ulong PendingUseToken = 0u) + ulong PendingUseToken = 0u, + bool AwaitingItemUseCompletion = false, + RuntimeItemUseCompletion LastItemUseCompletion = default) { public bool IsConverged => IsDisposed @@ -86,7 +97,9 @@ public readonly record struct RuntimeInteractionTransactionSnapshot( && CurrentAppraisalId == 0u && OutboundCount == 0 && !HasPendingPickup - && !HasPendingUse; + && !HasPendingUse + && !AwaitingItemUseCompletion + && LastItemUseCompletion.Revision == 0; } /// @@ -131,6 +144,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable private uint _clearEpoch; private long _revision; private long _dispatchFailureCount; + private bool _awaitingItemUseCompletion; private bool _disposed; public RuntimeInteractionTransactionState( @@ -151,6 +165,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable public long DispatchFailureCount => Interlocked.Read(ref _dispatchFailureCount); public Exception? LastDispatchFailure { get; private set; } + public RuntimeItemUseCompletion LastItemUseCompletion { get; private set; } public RuntimeInteractionTransactionSnapshot CaptureOwnership() => new( _disposed, @@ -164,7 +179,9 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _pendingPickup?.Token ?? 0u, DispatchFailureCount, _pendingUse is not null, - _pendingUse?.Token ?? 0u); + _pendingUse?.Token ?? 0u, + _awaitingItemUseCompletion, + LastItemUseCompletion); public bool TryConsumeUseThrottle(long nowMs) { @@ -220,6 +237,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable reservation?.MarkDispatched(); _lastUseSourceId = serverGuid; _lastUseTargetId = 0u; + _awaitingItemUseCompletion = true; IncrementRevision(); verdict = RuntimeInteractionDispatchResult.Dispatched; } @@ -246,6 +264,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _lastUseSourceId = sourceObjectId; _lastUseTargetId = targetObjectId; + _awaitingItemUseCompletion = true; if (incrementBusy) _inventory.IncrementBusyCount(); IncrementRevision(); @@ -264,7 +283,17 @@ public sealed class RuntimeInteractionTransactionState : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); int before = _inventory.BusyCount; _inventory.CompleteUse(error); - if (_inventory.BusyCount != before) + if (_awaitingItemUseCompletion) + { + LastItemUseCompletion = new RuntimeItemUseCompletion( + LastItemUseCompletion.Revision + 1, + _lastUseSourceId, + _lastUseTargetId, + error); + _awaitingItemUseCompletion = false; + IncrementRevision(); + } + else if (_inventory.BusyCount != before) IncrementRevision(); } @@ -706,6 +735,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable || _pendingUse is not null || _lastUseSourceId != 0u || _lastUseTargetId != 0u + || _awaitingItemUseCompletion + || LastItemUseCompletion.Revision != 0 || _lastUseMs != long.MinValue / 2; // G3: an armed Use's reservation is a live busy-count reference — @@ -717,6 +748,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _lastUseSourceId = 0u; _lastUseTargetId = 0u; + _awaitingItemUseCompletion = false; + LastItemUseCompletion = default; _awaitingAppraisalId = 0u; _currentAppraisalId = 0u; _outbound.Clear(); diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index d354b974..6f0edef6 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -202,7 +202,7 @@ public sealed class RuntimeLocalPlayerMovementState : new RuntimeMovementSnapshot( true, controller.LocalEntityId, - controller.CellPosition, + controller.CurrentCellPosition, controller.BodyVelocity, controller.IsAirborne, controller.SimTimeSeconds, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs index 9c42309b..79010dff 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs @@ -4,6 +4,15 @@ using AcDream.Core.Spells; namespace AcDream.Runtime.Gameplay; +public readonly record struct RuntimeSpellCastCompletion( + long Revision, + uint SpellId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + public interface IRuntimeSpellCastOperations { uint LocalPlayerId { get; } @@ -48,6 +57,9 @@ public sealed class RuntimeSpellCastState public uint? LastRequestedSpellId { get; private set; } public uint? LastRequestedTargetId { get; private set; } + public uint? PendingSpellId { get; private set; } + public uint? PendingTargetId { get; private set; } + public RuntimeSpellCastCompletion LastCompletion { get; private set; } public event Action? StateChanged; public bool IsTargetReady(uint spellId) => @@ -130,12 +142,19 @@ public sealed class RuntimeSpellCastState _operations.DisplayMessage("You cannot cast a spell right now."); return CastRequestResult.Unavailable; } + if (PendingSpellId is not null) + { + _operations.DisplayMessage("You cannot cast a spell right now."); + return CastRequestResult.Unavailable; + } try { _operations.StopCompletely(); LastRequestedSpellId = spellId; LastRequestedTargetId = target; + PendingSpellId = spellId; + PendingTargetId = target; if (untargeted) _operations.SendUntargeted(spellId); else @@ -149,18 +168,48 @@ public sealed class RuntimeSpellCastState { LastRequestedSpellId = null; LastRequestedTargetId = null; + PendingSpellId = null; + PendingTargetId = null; throw; } StateChanged?.Invoke(); return CastRequestResult.Sent; } + /// + /// Resolve the one cast currently holding retail's shared UseDone busy + /// reference. Other item-use completions are ignored when no cast is + /// pending, so this owner cannot fabricate a cast receipt. + /// + public bool CompleteUse(uint weenieError) + { + if (PendingSpellId is not uint spellId) + return false; + + long revision = LastCompletion.Revision + 1; + LastCompletion = new RuntimeSpellCastCompletion( + revision, + spellId, + PendingTargetId ?? 0u, + weenieError); + PendingSpellId = null; + PendingTargetId = null; + StateChanged?.Invoke(); + return true; + } + public void Reset() { bool changed = LastRequestedSpellId is not null - || LastRequestedTargetId is not null; + || LastRequestedTargetId is not null + || PendingSpellId is not null + || PendingTargetId is not null + || LastCompletion.Revision != 0; LastRequestedSpellId = null; LastRequestedTargetId = null; + PendingSpellId = null; + PendingTargetId = null; + LastCompletion = default; if (changed) StateChanged?.Invoke(); } diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 81729bcf..9e452a49 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -455,6 +455,7 @@ public sealed class LiveSessionController /// private int _createsSinceCharacterList; private LiveSessionCharacterSelection? _activeSelection; + private uint _nextLoginCharacterId; private Action? _autoSaveTickHook; private Action? _preLogoffFlushHook; @@ -516,6 +517,47 @@ public sealed class LiveSessionController get { lock (_gate) return new RuntimeGenerationToken(_generation); } } + /// + /// UtilityBelt-compatible one-shot login choice. The id is retained across + /// the world-generation reset performed by character logoff, then consumed + /// only after the selected character successfully enters the world. + /// + public uint NextLoginCharacterId + { + get { lock (_gate) return _nextLoginCharacterId; } + } + + public bool TrySetNextLogin(uint characterId) + { + lock (_gate) + { + if (_disposed + || _disposeRequested + || _scope is null + || characterId == 0u + || !CharacterSelectionState.View.TryGet( + characterId, + out RuntimeCharacterSelectionEntry character) + || !character.CanEnter) + { + return false; + } + _nextLoginCharacterId = characterId; + return true; + } + } + + public bool ClearNextLogin() + { + lock (_gate) + { + if (_disposed) + return false; + _nextLoginCharacterId = 0u; + return true; + } + } + /// /// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11 — mechanism lens /// finding): reaches retail's CPlayerModule::UseTime (the 480 s @@ -1339,6 +1381,21 @@ public sealed class LiveSessionController if (_operations.GetServerInfo(session) is { } serverInfo) CharacterSelectionState.ApplyWorldName(serverInfo.WorldName); + // UtilityBelt LoaderLogin parity: after a character logout, the + // loader sees character select and immediately invokes retail's + // normal LogOnCharacter route for the remembered GUID. Reuse this + // controller's exact highlight/Enter transaction so host binding, + // command activation and lifecycle publication stay canonical. + uint nextLogin = _nextLoginCharacterId; + if (nextLogin != 0u + && CharacterSelectionState.TryHighlight(nextLogin)) + { + RuntimeCommandResult entered = EnterSelectedCore(); + if (entered.Status == RuntimeCommandStatus.Accepted) + _nextLoginCharacterId = 0u; + return entered; + } + Console.WriteLine( "live: character logoff complete — returned to character " + "select (session connected)"); diff --git a/src/AcDream.Runtime/packages.win-x64.lock.json b/src/AcDream.Runtime/packages.win-x64.lock.json index 0b1f001d..98098838 100644 --- a/src/AcDream.Runtime/packages.win-x64.lock.json +++ b/src/AcDream.Runtime/packages.win-x64.lock.json @@ -2,23 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "BCnEncoder.Net.ImageSharp": { - "type": "Direct", - "requested": "[1.1.2, )", - "resolved": "1.1.2", - "contentHash": "qUi8L+bNfHJii95BMBcV6MhBchkKU2VV6sd6D1yyzgm77YhMt+aFT0keh5uf70bvTsRrq/ZKQnE4UQScNU6XAA==", - "dependencies": { - "BCnEncoder.Net": "2.2.0", - "CommunityToolkit.HighPerformance": "8.4.0", - "SixLabors.ImageSharp": "3.1.7" - } - }, - "SixLabors.ImageSharp": { - "type": "Direct", - "requested": "[3.1.12, )", - "resolved": "3.1.12", - "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" - }, "Autofac": { "type": "Transitive", "resolved": "8.4.0", @@ -213,6 +196,14 @@ "resolved": "0.1.1", "contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg==" }, + "acdream.content": { + "type": "Project", + "dependencies": { + "AcDream.Core": "[1.0.0, )", + "BCnEncoder.Net.ImageSharp": "[1.1.2, )", + "SixLabors.ImageSharp": "[3.1.12, )" + } + }, "acdream.core": { "type": "Project", "dependencies": { @@ -224,6 +215,15 @@ "StbImageSharp": "[2.30.16, )" } }, + "acdream.core.net": { + "type": "Project", + "dependencies": { + "AcDream.Core": "[1.0.0, )" + } + }, + "acdream.platform": { + "type": "Project" + }, "acdream.plugin.abstractions": { "type": "Project" }, @@ -236,6 +236,17 @@ "CommunityToolkit.HighPerformance": "8.4.0" } }, + "BCnEncoder.Net.ImageSharp": { + "type": "CentralTransitive", + "requested": "[1.1.2, )", + "resolved": "1.1.2", + "contentHash": "qUi8L+bNfHJii95BMBcV6MhBchkKU2VV6sd6D1yyzgm77YhMt+aFT0keh5uf70bvTsRrq/ZKQnE4UQScNU6XAA==", + "dependencies": { + "BCnEncoder.Net": "2.2.0", + "CommunityToolkit.HighPerformance": "8.4.0", + "SixLabors.ImageSharp": "3.1.7" + } + }, "Chorizite.Core": { "type": "CentralTransitive", "requested": "[0.0.18, )", @@ -279,6 +290,12 @@ "resolved": "4.0.2", "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" }, + "SixLabors.ImageSharp": { + "type": "CentralTransitive", + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, "StbImageSharp": { "type": "CentralTransitive", "requested": "[2.30.16, )", diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs index e7047018..c41e2791 100644 --- a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs +++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs @@ -34,6 +34,21 @@ public sealed class DispatcherMovementInputSourceTests Assert.False(captured.Run); } + [Fact] + public void CommandInputIsMarkedPersistentWithoutChangingStoredSnapshot() + { + using var movement = new RuntimeLocalPlayerMovementState(); + var command = new MovementInput(TurnRight: true); + movement.SetCommandInput(command); + var source = new DispatcherMovementInputSource(movement); + + MovementInput captured = source.Capture(); + + Assert.True(captured.TurnRight); + Assert.True(captured.IsPersistentCommand); + Assert.Equal(command, movement.CommandInput); + } + [Fact] public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun() { diff --git a/tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs b/tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs new file mode 100644 index 00000000..58c8694e --- /dev/null +++ b/tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs @@ -0,0 +1,46 @@ +using AcDream.App.Platform; + +namespace AcDream.App.Tests.Platform; + +public sealed class Win32GlfwActiveWindowGuardTests +{ + [Fact] + public void CurrentProcessWindowRemainsVisibleToGlfw() + { + nint window = (nint)0x1234; + + Assert.Equal( + window, + Win32GlfwActiveWindowGuard.AcceptWindow( + window, + ownerProcessId: 47, + currentProcessId: 47)); + } + + [Fact] + public void ForeignProcessWindowBecomesGlfwsExistingNoWindowPath() + { + Assert.Equal( + 0, + Win32GlfwActiveWindowGuard.AcceptWindow( + (nint)0x1234, + ownerProcessId: 48, + currentProcessId: 47)); + } + + [Theory] + [InlineData(0, 47, 47)] + [InlineData(0x1234, 0, 47)] + public void MissingOrUnownedWindowIsRejected( + long window, + uint ownerProcessId, + uint currentProcessId) + { + Assert.Equal( + 0, + Win32GlfwActiveWindowGuard.AcceptWindow( + (nint)window, + ownerProcessId, + currentProcessId)); + } +} diff --git a/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs b/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs new file mode 100644 index 00000000..68d3cb5e --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs @@ -0,0 +1,277 @@ +using AcDream.App.Plugins; +using AcDream.Core.Chat; +using AcDream.Core.Items; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.Spells; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Gameplay; +using System.Numerics; + +namespace AcDream.App.Tests.Plugins; + +public sealed class AppAutomationSurfaceTests +{ + [Fact] + public void ProjectileDebugSamplesAreDetachedValidatedAndClearedOnUnbind() + { + using var surface = new AppAutomationSurface(); + PluginProjectileDebugSample[] source = + [ + new(new Vector3(1f, 2f, 3f), true, 0.4f), + new(new Vector3(float.NaN, 0f, 0f), false, 0.4f), + ]; + + surface.Projectiles.ShowDebugSamples(source); + source[0] = default; + + PluginProjectileDebugSample sample = Assert.Single( + surface.CaptureProjectileDebugSamples()); + Assert.Equal(new Vector3(1f, 2f, 3f), sample.WorldPosition); + Assert.True(sample.IsClear); + + surface.Unbind(); + Assert.Empty(surface.CaptureProjectileDebugSamples()); + } + + [Fact] + public void SelectionAutomationUsesTheBoundCanonicalActionRoute() + { + using var surface = new AppAutomationSurface(); + var actions = new List(); + surface.BindSelectionActions(action => + { + actions.Add(action); + return true; + }); + + Assert.True(surface.Selection.Execute( + PluginSelectionAction.PreviousSelection)); + Assert.True(surface.Selection.Execute( + PluginSelectionAction.NextPlayer)); + Assert.Equal( + [ + PluginSelectionAction.PreviousSelection, + PluginSelectionAction.NextPlayer, + ], + actions); + } + + [Theory] + [InlineData((uint)ItemType.MeleeWeapon, 0u, PluginObjectClass.MeleeWeapon)] + [InlineData((uint)ItemType.Armor, 0u, PluginObjectClass.Armor)] + [InlineData((uint)ItemType.Creature, 0x10u, PluginObjectClass.Monster)] + [InlineData((uint)ItemType.Creature, 0u, PluginObjectClass.Npc)] + [InlineData((uint)ItemType.Creature, 0x04000010u, PluginObjectClass.CombatPet)] + [InlineData((uint)ItemType.Creature, 0x8u, PluginObjectClass.Player)] + [InlineData((uint)ItemType.Misc, 0x200u, PluginObjectClass.Vendor)] + [InlineData((uint)ItemType.Misc, 0x1000u, PluginObjectClass.Door)] + public void ObjectClassProjectionMatchesVirindiPriority( + uint itemType, + uint publicFlags, + PluginObjectClass expected) + { + var item = new ClientObject + { + ObjectId = 1u, + Type = (ItemType)itemType, + PublicWeenieBitfield = publicFlags, + }; + + Assert.Equal(expected, AppAutomationSurface.ClassifyObject(item)); + } + + [Fact] + public void NavigationProjectionUsesVtankMapCoordinatesAndCompassHeading() + { + PluginNavigationPosition center = + AppAutomationSurface.ProjectNavigationPosition(new Position( + 0x7F7F0001u, + new Vector3(84f, 84f, 240f), + Quaternion.Identity)); + + Assert.Equal(0d, center.EastWest, 8); + Assert.Equal(0d, center.NorthSouth, 8); + Assert.Equal(1d, center.Elevation, 8); + Assert.Equal(0f, center.HeadingDegrees, 4); + Assert.True(center.IsOutdoor); + + PluginNavigationPosition nextBlock = + AppAutomationSurface.ProjectNavigationPosition(new Position( + 0x80800041u, + new Vector3(84f, 84f, 0f), + Quaternion.Identity)); + + Assert.Equal(0.8d, nextBlock.EastWest, 8); + Assert.Equal(0.8d, nextBlock.NorthSouth, 8); + Assert.False(nextBlock.IsOutdoor); + } + + [Fact] + public void ChatCapture_isOrderedCursorBasedAndDetachesAcrossSessions() + { + using var first = GameRuntimeTestFactory.Create(); + using var second = GameRuntimeTestFactory.Create(); + using var surface = new AppAutomationSurface(); + surface.Bind( + first, + first.CharacterOwner, + first.ActionOwner.SpellCast); + + first.CommunicationOwner.AddText( + "You cast Imperil Other VII on Olthoi.", + RetailLogTextType.Magic); + PluginChatMessage one = Assert.Single(surface.CaptureMessages(0)); + Assert.Equal("You cast Imperil Other VII on Olthoi.", one.Text); + Assert.Empty(surface.CaptureMessages(one.Sequence)); + + surface.Bind( + second, + second.CharacterOwner, + second.ActionOwner.SpellCast); + first.CommunicationOwner.AddText( + "stale first-session line", + RetailLogTextType.Magic); + second.CommunicationOwner.AddText( + "You cast Fester Other VII on Olthoi.", + RetailLogTextType.Magic); + + PluginChatMessage two = Assert.Single( + surface.CaptureMessages(one.Sequence)); + Assert.True(two.Sequence > one.Sequence); + Assert.Equal("You cast Fester Other VII on Olthoi.", two.Text); + Assert.Equal(1, second.CommunicationOwner.SubscriberCount); + + surface.Dispose(); + Assert.Equal(0, second.CommunicationOwner.SubscriberCount); + } + + [Fact] + public void InventoryCompletionProjectsTheCanonicalRequestReceipt() + { + using var runtime = GameRuntimeTestFactory.Create(); + using var surface = new AppAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + const uint itemId = 0x50000123u; + objects.AddOrUpdate(new ClientObject + { + ObjectId = itemId, + Name = "Stack", + StackSize = 10, + StackSizeMax = 100, + }); + + Assert.True(runtime.InventoryOwner.Transactions.TryDispatch( + InventoryRequestKind.Merge, + itemId, + static () => true)); + Assert.True(objects.UpdateStackSize(itemId, 9, 0)); + + PluginInventoryCompletion completion = + surface.Items.LastInventoryCompletion; + Assert.True(completion.Revision > 0); + Assert.Equal(PluginInventoryCommandKind.Merge, completion.Kind); + Assert.Equal(itemId, completion.SourceObjectId); + Assert.True(completion.IsSuccess); + } + + [Fact] + public void RecoveryClearsExactlyOneCanonicalBusyReference() + { + using var runtime = GameRuntimeTestFactory.Create(); + using var surface = new AppAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + runtime.InventoryOwner.Transactions.IncrementBusyCount(); + runtime.InventoryOwner.Transactions.IncrementBusyCount(); + + PluginRecoveryResult first = surface.Recovery.ClearOneBusyReference(); + PluginRecoveryResult second = surface.Recovery.ClearOneBusyReference(); + PluginRecoveryResult alreadyClear = + surface.Recovery.ClearOneBusyReference(); + + Assert.True(first.Accepted); + Assert.Equal((2, 1), (first.PreviousCount, first.CurrentCount)); + Assert.Equal((1, 0), (second.PreviousCount, second.CurrentCount)); + Assert.Equal((0, 0), + (alreadyClear.PreviousCount, alreadyClear.CurrentCount)); + Assert.Equal(0, runtime.InventoryOwner.Transactions.BusyCount); + } + + [Fact] + public void EnchantmentLedgerSharesReportedAndConfirmedLocalDurationCasts() + { + var operations = new SpellOperations(); + using var runtime = GameRuntimeTestFactory.Create(spellCast: operations); + runtime.CharacterOwner.InstallSpellMetadata(SpellTable.Create([DurationSpell()])); + runtime.CharacterOwner.Spellbook.OnSpellLearned(42u); + using var surface = new AppAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + + Assert.True(surface.Enchantments.ReportCast(100u, 42u, 30d)); + PluginTrackedEnchantment reported = Assert.Single( + surface.Enchantments.Capture(100u)); + Assert.Equal(7u, reported.Family); + Assert.Equal(350, reported.Quality); + Assert.InRange(reported.SecondsRemaining, 29d, 30d); + + runtime.ActionOwner.Selection.Select( + 200u, + SelectionChangeSource.Plugin); + Assert.Equal( + CastRequestResult.Sent, + runtime.ActionOwner.SpellCast.Cast(42u)); + Assert.True(runtime.ActionOwner.SpellCast.CompleteUse(0u)); + + Assert.True(surface.Magic.LastCompletion.IsSuccess); + PluginTrackedEnchantment local = Assert.Single( + surface.Enchantments.Capture(200u)); + Assert.Equal(42u, local.SpellId); + Assert.InRange(local.SecondsRemaining, 59d, 60d); + + surface.Unbind(); + Assert.Empty(surface.Enchantments.Capture(100u)); + Assert.Empty(surface.Enchantments.Capture(200u)); + } + + private static SpellMetadata DurationSpell() => new( + 42u, + "Fire Vulnerability Other VII", + "Life Magic", + 7u, + 0u, + string.Empty, + 60f, + 10, + true, + false, + string.Empty, + 0, + 350, + 0u, + 7, + false, + true, + false, + 0f, + 0u, + 0u, + 1u, + 0); + + private sealed class SpellOperations : IRuntimeSpellCastOperations + { + public uint LocalPlayerId => 1u; + public bool CanSend => true; + public bool HasRequiredComponents(uint spellId) => true; + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => true; + public void StopCompletely() { } + public void SendUntargeted(uint spellId) { } + public void SendTargeted(uint targetId, uint spellId) { } + public void DisplayMessage(string message) { } + public void IncrementBusy() { } + } +} diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs index 23e84896..fc471f43 100644 --- a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs @@ -1,5 +1,6 @@ using AcDream.App.Plugins; using AcDream.App.UI; +using AcDream.Plugin.Abstractions; namespace AcDream.App.Tests.Plugins; @@ -32,6 +33,8 @@ public class BufferedUiRegistryTests var element = new UiPanel(); root.AddChild(element); registry.CompleteMount(pending, root, element); + bool windowRemoved = false; + registry.CompleteWindowMount(pending, () => windowRemoved = true); Assert.Contains(element, root.Children); Assert.Equal(1, registry.RegistrationCount); @@ -40,5 +43,92 @@ public class BufferedUiRegistryTests Assert.DoesNotContain(element, root.Children); Assert.Equal(0, registry.RegistrationCount); + Assert.True(windowRemoved); + } + + [Fact] + public void FirstClassPanelCarriesManifestOwnerAndStableWindowIdentity() + { + var registry = new BufferedUiRegistry(); + var descriptor = new PluginPanelDescriptor("main", "MossTank") + { + IconText = "MT", + StartVisible = false, + }; + + registry.RegisterPanel( + new PluginUiOwner("acdream.mosstank", "MossTank"), + descriptor, + "mosstank.xml", + new object()); + + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + Assert.Equal("acdream.mosstank", pending.Owner.Id); + Assert.Same(descriptor, pending.Descriptor); + Assert.Equal("plugin:acdream.mosstank:main", pending.WindowName); + Assert.False(pending.Descriptor.StartVisible); + } + + [Fact] + public void InlinePanelContentHasAnIndependentlyRemovableLifetime() + { + var registry = new BufferedUiRegistry(); + IDisposable token = registry.RegisterPanelContent( + new PluginUiOwner("acdream.mosstank", "MossTank"), + new PluginPanelDescriptor("meta-status", "Status"), + "", + new object()); + + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + Assert.Equal("", pending.MarkupContent); + Assert.Equal("plugin:acdream.mosstank:meta-status", pending.WindowName); + + token.Dispose(); + Assert.Equal(0, registry.RegistrationCount); + } + + [Fact] + public void LateWindowPublicationCleansUpAfterConcurrentDisposal() + { + var registry = new BufferedUiRegistry(); + IDisposable token = registry.RegisterMarkupPanel("late.xml", new object()); + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + token.Dispose(); + bool cleaned = false; + + registry.CompleteWindowMount(pending, () => cleaned = true); + + Assert.True(cleaned); + Assert.Equal(0, registry.RegistrationCount); + } + + [Fact] + public void ScopedViewsExposeOnlyTheirOwnNamedControls() + { + var registry = new BufferedUiRegistry(); + var owner = new PluginUiOwner("acdream.mosstank", "MossTank"); + registry.RegisterPanelContent( + owner, + new PluginPanelDescriptor("meta", "Status View"), + "", + new object()); + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + var root = new UiRoot(); + var panel = new UiPanel(); + var button = new UiSimpleButton { Name = "Action", Text = "Old" }; + panel.AddChild(button); + root.AddChild(panel); + registry.CompleteMount(pending, root, panel); + + Assert.True(registry.ViewExists(owner, "Status View")); + Assert.True(registry.IsViewVisible(owner, "meta")); + Assert.True(registry.ControlExists(owner, "Status View", "Action")); + Assert.True(registry.SetControlLabel(owner, "Status View", "Action", "New")); + Assert.Equal("New", button.Text); + Assert.True(registry.SetControlVisible( + owner, "Status View", "Action", false)); + Assert.False(button.Visible); + Assert.False(registry.ViewExists( + new PluginUiOwner("another.plugin", "Other"), "Status View")); } } diff --git a/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs b/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs index 799c96f1..2e23874f 100644 --- a/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs +++ b/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs @@ -526,20 +526,29 @@ public sealed class ExternalRenderPackPackageLifecycleTests private static string FixtureAssemblyPath() { + const string projectName = "AcDream.Plugin.Tests.Fixtures.HostPlugin"; + string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll"); + if (File.Exists(colocated)) + return colocated; + string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent!.Name; return Path.Combine( FindRepoRoot(), "tests", - "AcDream.Plugin.Tests.Fixtures.HostPlugin", + projectName, "bin", configuration, "net10.0", - "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + projectName + ".dll"); } private static string FixtureAssemblyPath(string projectName) { + string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll"); + if (File.Exists(colocated)) + return colocated; + string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent!.Name; return Path.Combine( diff --git a/tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs b/tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs new file mode 100644 index 00000000..28e99a53 --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs @@ -0,0 +1,35 @@ +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public sealed class FilePluginStorageTests +{ + [Fact] + public void WriteReadReplaceAndDeleteStayUnderConfiguredRoot() + { + string root = Path.Combine( + Path.GetTempPath(), + $"acdream-plugin-storage-{Guid.NewGuid():N}"); + try + { + var storage = new FilePluginStorage(root); + storage.WriteText("plugin/profile.json", "one"); + storage.WriteText("plugin/imports/route.nav", "nav"); + Assert.Equal("one", storage.ReadText("plugin/profile.json")); + storage.WriteText("plugin/profile.json", "two"); + Assert.Equal("two", storage.ReadText("plugin/profile.json")); + Assert.Equal( + ["plugin/imports/route.nav"], + storage.List("plugin/imports")); + Assert.True(storage.Delete("plugin/profile.json")); + Assert.Null(storage.ReadText("plugin/profile.json")); + Assert.Throws(() => + storage.WriteText("../escape.json", "bad")); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs index 6e039746..451c8aa2 100644 --- a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -232,6 +232,10 @@ public sealed class GraphicalPluginSessionTests "fixture-panel.xml", panel.MarkupPath, StringComparison.Ordinal); + Assert.Equal(FixtureId, panel.Owner.Id); + Assert.Equal("Host fixture", panel.Owner.DisplayName); + Assert.Equal("fixture-panel", panel.Descriptor.WindowId); + Assert.Equal("Host fixture", panel.Descriptor.Title); Assert.Equal( "AcDream.Plugin.Tests.Fixtures.HostPlugin", panel.Binding.GetType().Assembly.GetName().Name); @@ -271,6 +275,11 @@ public sealed class GraphicalPluginSessionTests private static string FixtureAssemblyPath() { + string fileName = "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"; + string colocated = Path.Combine(AppContext.BaseDirectory, fileName); + if (File.Exists(colocated)) + return colocated; + string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent!.Name; string root = FindRepoRoot(AppContext.BaseDirectory); @@ -281,7 +290,7 @@ public sealed class GraphicalPluginSessionTests "bin", configuration, "net10.0", - "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + fileName); } private static string FindRepoRoot(string start) diff --git a/tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs new file mode 100644 index 00000000..63ff920e --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs @@ -0,0 +1,73 @@ +using AcDream.App.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Tests.Plugins; + +public sealed class LocalPluginPeerRegistryTests +{ + [Fact] + public void PublishesRemoteClientsIgnoresSelfAndExpiresStaleHeartbeat() + { + string root = Path.Combine( + Path.GetTempPath(), + $"acdream-plugin-peers-{Guid.NewGuid():N}"); + var time = new ManualTimeProvider( + new DateTimeOffset(2026, 8, 27, 12, 0, 0, TimeSpan.Zero)); + try + { + using var first = new LocalPluginPeerRegistry( + root, + time, + Guid.Parse("11111111-1111-1111-1111-111111111111")); + using var second = new LocalPluginPeerRegistry( + root, + time, + Guid.Parse("22222222-2222-2222-2222-222222222222")); + first.Publish(Client(first.ClientId, 10u, "Alpha", ["one"])); + second.Publish(Client(second.ClientId, 20u, "Beta", ["two"])); + + PluginNetworkClient remote = Assert.Single( + first.CaptureRemoteClients()); + Assert.Equal(second.ClientId, remote.ClientId); + Assert.Equal("Beta", remote.Name); + Assert.Equal(["two"], remote.Tags); + Assert.Equal(33.5d, remote.Position.EastWest); + + time.Advance(LocalPluginPeerRegistry.StaleAfter + + TimeSpan.FromMilliseconds(1)); + Assert.Empty(first.CaptureRemoteClients()); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static PluginNetworkClient Client( + uint clientId, + uint playerId, + string name, + IReadOnlyList tags) => new( + clientId, + playerId, + name, + "Coldeve", + new PluginNavigationPosition( + 0x7F7F0001u, 33.5d, -72.8d, 1d, 90f, true), + tags, + 90u, + 70u, + 80u, + 100u, + 100u, + 100u, + 90f); + + private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + public override DateTimeOffset GetUtcNow() => _utcNow; + public void Advance(TimeSpan elapsed) => _utcNow += elapsed; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs index 23547a96..f6f7cb74 100644 --- a/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs @@ -109,7 +109,7 @@ public sealed class LinuxPlatformBoundaryTests } [Fact] - public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths() + public void ShippedPluginCopiesUseResolvedTargetPathsForBuildAndPublish() { string project = File.ReadAllText(Path.Combine( AppSourceRoot(), @@ -119,8 +119,16 @@ public sealed class LinuxPlatformBoundaryTests Assert.Contains("$(RuntimeIdentifier)", project, StringComparison.Ordinal); Assert.Contains("$(OutputPath)plugins/", project, StringComparison.Ordinal); Assert.Contains("$(PublishDir)plugins/", project, StringComparison.Ordinal); + Assert.Equal( + 4, + project.Split("Targets=\"GetTargetPath\"", StringSplitOptions.None) + .Length - 1); + Assert.Contains( + "../AcDream.Plugins.MossTank/mosstank.xml", + project, + StringComparison.Ordinal); Assert.DoesNotContain( - @"bin\$(Configuration)\net10.0", + "/bin/$(Configuration)", project, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 10902a19..17477cf5 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -167,6 +167,22 @@ public sealed class RuntimeOptionsTests Assert.False(opts.ExactAutomationFramebuffer); Assert.False(opts.UiProbeEnabled); Assert.False(opts.HasLiveCredentials); + Assert.Empty(opts.PluginTags); + } + + [Fact] + public void PluginPeerTagsAreParsedOnceBoundedAndCaseInsensitive() + { + string oversized = new('x', 129); + RuntimeOptions options = RuntimeOptions.Parse( + AnyDatDir, + Env(new() + { + ["ACDREAM_PLUGIN_TAGS"] = + $" healer,Leader,HEALER,,{oversized}, scout ", + })); + + Assert.Equal(["healer", "Leader", "scout"], options.PluginTags); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index f03c7123..a0c25a99 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -40,6 +40,7 @@ public sealed class ItemInteractionControllerTests public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new(); public bool SendBuyAllSucceeds = true; public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new(); + public readonly List<(uint ToolGuid, IReadOnlyList ItemGuids)> Salvages = new(); public bool SendSellSucceeds = true; public readonly List Toasts = new(); public readonly List SystemMessages = new(); @@ -133,7 +134,12 @@ public sealed class ItemInteractionControllerTests }, interfaceText: (text, type) => InterfaceTexts.Add((text, type)), sendStackableMerge: (source, target, amount) => - Merges.Add((source, target, amount))); + Merges.Add((source, target, amount)), + sendSalvage: (tool, items) => + { + Salvages.Add((tool, items.ToArray())); + return true; + }); } public ItemInteractionController Controller { get; } @@ -171,6 +177,86 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.UseWithTarget); } + [Fact] + public void AutomationApply_dispatchesDirectlyWithoutInstallingTargetMode() + { + var h = new Harness(); + const uint source = 0x50000A21u; + h.AddContained(source, item => + { + item.Useability = HealthKitUseability; + item.TargetType = (uint)ItemType.Creature; + }); + + Assert.True(h.Controller.TryApplyItem(source, Player)); + + Assert.Equal(new[] { (source, Player) }, h.UseWithTarget); + Assert.False(h.Controller.IsAnyTargetModeActive); + Assert.Equal(1, h.Controller.BusyCount); + } + + [Fact] + public void AutomationApply_reportsRefusalWhenTargetIsIncompatible() + { + var h = new Harness(); + const uint source = 0x50000A21u; + const uint coat = 0x50000A22u; + h.AddContained(source, item => + { + item.Useability = HealthKitUseability; + item.TargetType = (uint)ItemType.Creature; + }); + h.AddContained(coat, item => item.Type = ItemType.Armor); + + Assert.False(h.Controller.TryApplyItem(source, coat)); + + Assert.Empty(h.UseWithTarget); + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void AutomationUse_dispatchesOnlyAnOrdinaryWireUse() + { + var h = new Harness(); + const uint item = 0x50000A23u; + h.AddContained(item, candidate => + candidate.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.TryUseItemForAutomation(item)); + + Assert.Equal(new[] { item }, h.Uses); + Assert.Equal(1, h.Controller.BusyCount); + } + + [Fact] + public void AutomationUse_refusesTargetedItemInsteadOfOpeningModalCursor() + { + var h = new Harness(); + const uint item = 0x50000A24u; + h.AddContained(item, candidate => + candidate.Useability = HealthKitUseability); + + Assert.False(h.Controller.TryUseItemForAutomation(item)); + + Assert.Empty(h.Uses); + Assert.False(h.Controller.IsAnyTargetModeActive); + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void AutomationAppraisalUsesCanonicalOwnerWithoutChangingSelectionMode() + { + var h = new Harness(); + const uint item = 0x50000A25u; + h.AddContained(item); + + Assert.True(h.Controller.TryAppraiseForAutomation(item)); + + Assert.Equal(new[] { item }, h.Examines); + Assert.False(h.Controller.IsAnyTargetModeActive); + Assert.Equal(1, h.Controller.BusyCount); + } + [Fact] public void ResetSession_RetryNotifiesEveryStateObserver() { @@ -2085,6 +2171,110 @@ public sealed class ItemInteractionControllerTests Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); } + [Fact] + public void AutomationMoveUsesWholeOrExactPartialRetailRequest() + { + var whole = new Harness(); + const uint wholeItem = 0x50000A32u; + whole.AddContained(wholeItem, item => item.StackSize = 10); + + Assert.True(whole.Controller.TryMoveItemForAutomation( + wholeItem, Player, amount: 0u, placement: 7)); + Assert.Equal(new[] { (wholeItem, Player, 7) }, whole.Puts); + Assert.True(whole.Controller.TryGetPendingInventoryRequest(out var put)); + Assert.Equal(InventoryRequestKind.PutInContainer, put.Kind); + + var partial = new Harness(); + const uint partialItem = 0x50000A33u; + partial.AddContained(partialItem, item => item.StackSize = 10); + + Assert.True(partial.Controller.TryMoveItemForAutomation( + partialItem, Player, amount: 2u, placement: 3)); + Assert.Equal( + new[] { (partialItem, Player, 3u, 2u) }, + partial.SplitPuts); + Assert.True(partial.Controller.TryGetPendingInventoryRequest(out var split)); + Assert.Equal(InventoryRequestKind.SplitToContainer, split.Kind); + } + + [Fact] + public void AutomationMergeUsesRetailPlannerAndSharedGate() + { + var h = new Harness(); + const uint source = 0x50000A34u; + const uint target = 0x50000A35u; + h.AddContained(source, item => + { + item.WeenieClassId = 77u; + item.StackSize = 8; + item.StackSizeMax = 10; + }); + h.AddContained(target, item => + { + item.WeenieClassId = 77u; + item.StackSize = 7; + item.StackSizeMax = 10; + }); + + Assert.True(h.Controller.TryMergeItemsForAutomation(source, target)); + + Assert.Equal(new[] { (source, target, 3u) }, h.Merges); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(InventoryRequestKind.Merge, pending.Kind); + Assert.False(h.Controller.TryMoveItemForAutomation(source, Player)); + Assert.Single(h.Merges); + } + + [Fact] + public void AutomationDropAndGivePreserveExactStackAmounts() + { + var drop = new Harness(); + const uint dropItem = 0x50000A36u; + drop.AddContained(dropItem, item => item.StackSize = 10); + + Assert.True(drop.Controller.TryDropItemForAutomation(dropItem, 2u)); + Assert.Equal(new[] { (dropItem, 2u) }, drop.SplitDrops); + Assert.Empty(drop.Drops); + + var give = new Harness(); + const uint giveItem = 0x50000A37u; + const uint recipient = 0x70000A38u; + give.AddContained(giveItem, item => item.StackSize = 10); + give.Objects.AddOrUpdate(new ClientObject + { + ObjectId = recipient, + Name = "Recipient", + Type = ItemType.Creature, + }); + + Assert.True(give.Controller.TryGiveItemForAutomation( + giveItem, recipient, 4u)); + Assert.Equal(new[] { (recipient, giveItem, 4u) }, give.Gives); + } + + [Fact] + public void AutomationSalvageRequiresRetailToolAndSuitableOwnedItems() + { + var h = new Harness(); + const uint tool = 0x50000A40u; + const uint source = 0x50000A41u; + h.AddContained(tool, item => item.Type = ItemType.TinkeringTool); + h.AddContained(source, item => + { + item.MaterialType = 12u; + item.Structure = 50; + }); + + Assert.True(h.Controller.TrySalvageItemsForAutomation(tool, [source])); + Assert.Single(h.Salvages); + Assert.Equal(tool, h.Salvages[0].ToolGuid); + Assert.Equal(new[] { source }, h.Salvages[0].ItemGuids); + + h.Objects.Get(source)!.Structure = 100; + Assert.False(h.Controller.TrySalvageItemsForAutomation(tool, [source])); + Assert.Single(h.Salvages); + } + [Fact] public void MatchingInventoryFailureReleasesGlobalRequest() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs new file mode 100644 index 00000000..087470ff --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs @@ -0,0 +1,51 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class ProjectileDebugOverlayControllerTests +{ + [Fact] + public void ProjectsTransientClearAndBlockedSamplesWithoutConsumingInput() + { + IReadOnlyList samples = + [ + new(new Vector3(0f, 0f, -10f), true, 0.4f), + new(new Vector3(1f, 0f, -10f), false, 0.4f), + ]; + var root = new UiRoot { Width = 800f, Height = 600f }; + Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 2f, + 4f / 3f, + 0.1f, + 100f); + ProjectileDebugOverlayController controller = + ProjectileDebugOverlayController.Mount( + root, + () => samples, + () => (Matrix4x4.Identity, projection, new Vector2(800f, 600f))); + + controller.Tick(); + + UiPanel overlay = Assert.IsType(Assert.Single(root.Children)); + Assert.True(overlay.Visible); + Assert.True(overlay.ClickThrough); + Assert.Equal(2, overlay.Children.Count); + UiPanel clear = Assert.IsType(overlay.Children[0]); + UiPanel blocked = Assert.IsType(overlay.Children[1]); + Assert.True(clear.Visible); + Assert.True(blocked.Visible); + Assert.Equal(new Vector4(0f, 1f, 0f, 0.95f), clear.BorderColor); + Assert.Equal(new Vector4(1f, 0f, 0f, 0.95f), blocked.BorderColor); + Assert.InRange(clear.Left, 380f, 400f); + Assert.InRange(clear.Top, 280f, 300f); + + samples = []; + controller.Tick(); + + Assert.False(overlay.Visible); + Assert.All(overlay.Children, static child => Assert.False(child.Visible)); + } +} diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index 659a0f90..bb3cb9b1 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -4,6 +4,79 @@ namespace AcDream.App.Tests.UI; public class MarkupDocumentTests { + private sealed class EditorBinding + { + public string Draft { get; private set; } = "initial"; + public string Submitted { get; private set; } = string.Empty; + public string Selected { get; private set; } = "First"; + public int SelectedIndex { get; private set; } + public IReadOnlyList Choices => ["First", "Second"]; + public IReadOnlyList ChoiceColors => [0xFF0000u, 0x00FF00u]; + public Action ChangeDraft => value => Draft = value; + public Action SubmitDraft => value => Submitted = value; + public Action SelectChoice => value => Selected = value; + public Action SelectIndex => value => SelectedIndex = value; + } + + [Fact] + public void FieldAndMenuBindEditablePluginState() + { + const string xml = """ + + + + + + """; + var binding = new EditorBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, + binding, + _ => (1u, 32, 32)); + + UiField field = Assert.IsType(panel.Children[0]); + UiMenu menu = Assert.IsType(panel.Children[1]); + UiMarkupList list = Assert.IsType(panel.Children[2]); + field.SetText("named profile"); + field.OnSubmit?.Invoke(field.Text); + menu.OnSelect?.Invoke("Second"); + list.OnEvent(new UiEvent + { + Type = UiEventType.MouseDown, + Data2 = 19, + }); + + Assert.Equal("named profile", binding.Draft); + Assert.Equal("named profile", binding.Submitted); + Assert.Equal("Second", binding.Selected); + Assert.Equal(1, binding.SelectedIndex); + Assert.Equal(2, menu.Items.Count); + Assert.Equal([0xFF0000u, 0x00FF00u], list.ItemColorsSource()); + Assert.False(menu.OpenUpward); + } + + [Fact] + public void ControlIdAndNameBecomeStablePluginControlNames() + { + const string xml = """ + +