feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -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.
|
- 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.
|
- 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`
|
## #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.
|
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
|
||||||
|
|
|
||||||
|
|
@ -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
|
canonical Runtime view, subscribes to Runtime's ordered events, and borrows the
|
||||||
exact Runtime selection owner; it does not mirror gameplay state.
|
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<string>` change/submit callbacks and menus bind an
|
||||||
|
`IEnumerable<string>` 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,
|
Core `SelectionState` is the sole selected-object owner for world,
|
||||||
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
||||||
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
||||||
|
|
@ -420,6 +482,10 @@ src/
|
||||||
IGameState.cs -> done
|
IGameState.cs -> done
|
||||||
IEvents.cs -> done
|
IEvents.cs -> done
|
||||||
ISelectionService.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
|
AcDream.App/ Layer 1 + Layer 4 wiring
|
||||||
Platform/
|
Platform/
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
|
||||||
| `ACDREAM_NEAR_RADIUS` | `=<int>` | 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_NEAR_RADIUS` | `=<int>` | 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_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` | `=<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 → `<datDir>/acdream.pak` | `RuntimeOptions.PreparedAssetPath` → `ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` |
|
| `ACDREAM_PAK_PATH` | `=<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 → `<datDir>/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` | `=<int 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_ALPHA_SCRATCH_MIB` | `=<int 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` | `=<int>` (`>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_ENTRIES` | `=<int>` (`>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` | `=<int 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`) |
|
| `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=<int 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`) |
|
||||||
|
|
|
||||||
|
|
@ -184,17 +184,28 @@ panel through `IPanelRenderer`.
|
||||||
|
|
||||||
## Plugin UI API
|
## Plugin UI API
|
||||||
|
|
||||||
The shipped plugin-facing gameplay UI contract is
|
The shipped plugin-facing gameplay UI contract is the additive BCL-only
|
||||||
`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel`: a plugin provides
|
`AcDream.Plugin.Abstractions.IUiRegistry.AddPanel`: a plugin provides a stable
|
||||||
KSML-style markup and a binding object; the host builds it into the retained
|
window id/title/icon descriptor, KSML-style markup, and a binding object; the
|
||||||
`UiRoot` tree. `IPanel`/`IPanelRenderer` remains a first-party developer-panel
|
host builds it into the retained `UiRoot` tree. The API-v1
|
||||||
contract and is intentionally not referenced by `Plugin.Abstractions`.
|
`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
|
This makes plugin gameplay panels presentation-assembly independent while
|
||||||
share the retained input, window, and DAT-sprite runtime. Registrations made
|
allowing them to share the retained input, window, and DAT-sprite runtime.
|
||||||
before the GL host exists are buffered. In builds where retail UI is disabled,
|
Registrations made before the graphical host exists are buffered. The host
|
||||||
they remain registered but have no gameplay surface; the long-term release
|
assigns `plugin:{pluginId}:{windowId}`, registers every panel with the common
|
||||||
configuration enables retained gameplay UI.
|
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
|
The following was the original pre-D.2b proposal and remains historical
|
||||||
context, not the shipped plugin contract:
|
context, not the shipped plugin contract:
|
||||||
|
|
@ -255,7 +266,8 @@ walk around / take damage / regen.
|
||||||
### Sprint 3 — Plugin API hardening (superseded shape)
|
### Sprint 3 — Plugin API hardening (superseded shape)
|
||||||
|
|
||||||
- Document the `IPanel` contract.
|
- 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.
|
`IPanel` implementations.
|
||||||
- Confirm plugins can subscribe to game events and expose retained markup
|
- Confirm plugins can subscribe to game events and expose retained markup
|
||||||
bindings without referencing App or ImGui assemblies.
|
bindings without referencing App or ImGui assemblies.
|
||||||
|
|
|
||||||
483
docs/plans/2026-08-26-mosstank-parity-campaign.md
Normal file
483
docs/plans/2026-08-26-mosstank-parity-campaign.md
Normal file
|
|
@ -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<string>`,
|
||||||
|
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.
|
||||||
447
docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md
Normal file
447
docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md
Normal file
|
|
@ -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<unsigned long>` (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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -112,13 +112,17 @@
|
||||||
AfterTargets="Build"
|
AfterTargets="Build"
|
||||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginSourceDir>
|
|
||||||
<_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier)</_SmokePluginSourceDir>
|
|
||||||
<_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke</_SmokePluginDestDir>
|
<_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke</_SmokePluginDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<MSBuild
|
||||||
|
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
|
||||||
|
Targets="GetTargetPath"
|
||||||
|
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||||
|
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginBuildTarget" />
|
||||||
|
</MSBuild>
|
||||||
<MakeDir Directories="$(_SmokePluginDestDir)" />
|
<MakeDir Directories="$(_SmokePluginDestDir)" />
|
||||||
<Copy
|
<Copy
|
||||||
SourceFiles="$(_SmokePluginSourceDir)/AcDream.Plugins.Smoke.dll"
|
SourceFiles="@(_SmokePluginBuildTarget)"
|
||||||
DestinationFolder="$(_SmokePluginDestDir)"
|
DestinationFolder="$(_SmokePluginDestDir)"
|
||||||
SkipUnchangedFiles="true" />
|
SkipUnchangedFiles="true" />
|
||||||
<WriteLinesToFile
|
<WriteLinesToFile
|
||||||
|
|
@ -132,13 +136,17 @@
|
||||||
AfterTargets="Publish"
|
AfterTargets="Publish"
|
||||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginPublishSourceDir>
|
|
||||||
<_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier)</_SmokePluginPublishSourceDir>
|
|
||||||
<_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke</_SmokePluginPublishDestDir>
|
<_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke</_SmokePluginPublishDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<MSBuild
|
||||||
|
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
|
||||||
|
Targets="GetTargetPath"
|
||||||
|
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||||
|
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginPublishTarget" />
|
||||||
|
</MSBuild>
|
||||||
<MakeDir Directories="$(_SmokePluginPublishDestDir)" />
|
<MakeDir Directories="$(_SmokePluginPublishDestDir)" />
|
||||||
<Copy
|
<Copy
|
||||||
SourceFiles="$(_SmokePluginPublishSourceDir)/AcDream.Plugins.Smoke.dll"
|
SourceFiles="@(_SmokePluginPublishTarget)"
|
||||||
DestinationFolder="$(_SmokePluginPublishDestDir)"
|
DestinationFolder="$(_SmokePluginPublishDestDir)"
|
||||||
SkipUnchangedFiles="true" />
|
SkipUnchangedFiles="true" />
|
||||||
<WriteLinesToFile
|
<WriteLinesToFile
|
||||||
|
|
@ -161,13 +169,17 @@
|
||||||
AfterTargets="Build"
|
AfterTargets="Build"
|
||||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankSourceDir>
|
|
||||||
<_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier)</_MossTankSourceDir>
|
|
||||||
<_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank</_MossTankDestDir>
|
<_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank</_MossTankDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<MSBuild
|
||||||
|
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj"
|
||||||
|
Targets="GetTargetPath"
|
||||||
|
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||||
|
<Output TaskParameter="TargetOutputs" ItemName="_MossTankPluginBuildTarget" />
|
||||||
|
</MSBuild>
|
||||||
<MakeDir Directories="$(_MossTankDestDir)" />
|
<MakeDir Directories="$(_MossTankDestDir)" />
|
||||||
<Copy
|
<Copy
|
||||||
SourceFiles="$(_MossTankSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankSourceDir)/mosstank.xml;$(_MossTankSourceDir)/mosstank-settings.xml"
|
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
|
||||||
DestinationFolder="$(_MossTankDestDir)"
|
DestinationFolder="$(_MossTankDestDir)"
|
||||||
SkipUnchangedFiles="true" />
|
SkipUnchangedFiles="true" />
|
||||||
<WriteLinesToFile
|
<WriteLinesToFile
|
||||||
|
|
@ -181,13 +193,17 @@
|
||||||
AfterTargets="Publish"
|
AfterTargets="Publish"
|
||||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankPublishSourceDir>
|
|
||||||
<_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier)</_MossTankPublishSourceDir>
|
|
||||||
<_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank</_MossTankPublishDestDir>
|
<_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank</_MossTankPublishDestDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<MSBuild
|
||||||
|
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj"
|
||||||
|
Targets="GetTargetPath"
|
||||||
|
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||||
|
<Output TaskParameter="TargetOutputs" ItemName="_MossTankPluginPublishTarget" />
|
||||||
|
</MSBuild>
|
||||||
<MakeDir Directories="$(_MossTankPublishDestDir)" />
|
<MakeDir Directories="$(_MossTankPublishDestDir)" />
|
||||||
<Copy
|
<Copy
|
||||||
SourceFiles="$(_MossTankPublishSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankPublishSourceDir)/mosstank.xml;$(_MossTankPublishSourceDir)/mosstank-settings.xml"
|
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
|
||||||
DestinationFolder="$(_MossTankPublishDestDir)"
|
DestinationFolder="$(_MossTankPublishDestDir)"
|
||||||
SkipUnchangedFiles="true" />
|
SkipUnchangedFiles="true" />
|
||||||
<WriteLinesToFile
|
<WriteLinesToFile
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,8 @@ internal sealed record InteractionRetainedUiDependencies(
|
||||||
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
|
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
|
||||||
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
|
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
|
||||||
RenderPackDiagnostics = null,
|
RenderPackDiagnostics = null,
|
||||||
string? ScreenshotsDirectory = null)
|
string? ScreenshotsDirectory = null,
|
||||||
|
AppAutomationSurface? Automation = null)
|
||||||
{
|
{
|
||||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||||
|
|
||||||
|
|
@ -429,6 +430,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
return false;
|
return false;
|
||||||
activeSession.SendSell(vendorGuid, items);
|
activeSession.SendSell(vendorGuid, items);
|
||||||
return true;
|
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}",
|
$"Screenshot failed: {error}",
|
||||||
RetailLogTextType.ClientLocal);
|
RetailLogTextType.ClientLocal);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
ProjectileDebugSamples: d.Automation is null
|
||||||
|
? null
|
||||||
|
: d.Automation.CaptureProjectileDebugSamples);
|
||||||
RetailUiRuntime runtime = lease.Mount(
|
RetailUiRuntime runtime = lease.Mount(
|
||||||
() => RetailUiRuntime.CreateUninitialized(bindings));
|
() => RetailUiRuntime.CreateUninitialized(bindings));
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ internal sealed record SessionPlayerDependencies(
|
||||||
CombatFeedbackSlot CombatFeedback,
|
CombatFeedbackSlot CombatFeedback,
|
||||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||||
Action<string> Log,
|
Action<string> Log,
|
||||||
|
Func<string, bool>? TryHandlePluginCommand,
|
||||||
/// <summary>Campaign LA slice LA1: the shared per-session status-event
|
/// <summary>Campaign LA slice LA1: the shared per-session status-event
|
||||||
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
|
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
|
||||||
/// not configured.</summary>
|
/// not configured.</summary>
|
||||||
|
|
@ -112,6 +113,7 @@ internal sealed record SessionPlayerResult(
|
||||||
DatSpawnClaimHydrationClassifier SpawnClaimHydration,
|
DatSpawnClaimHydrationClassifier SpawnClaimHydration,
|
||||||
LiveSessionController LiveSession,
|
LiveSessionController LiveSession,
|
||||||
LiveEntityHydrationController Hydration,
|
LiveEntityHydrationController Hydration,
|
||||||
|
LiveEntityDeletionController Deletion,
|
||||||
LiveEntityNetworkUpdateController NetworkUpdates,
|
LiveEntityNetworkUpdateController NetworkUpdates,
|
||||||
LiveEntityLivenessController Liveness,
|
LiveEntityLivenessController Liveness,
|
||||||
LiveEntitySessionController SessionEvents,
|
LiveEntitySessionController SessionEvents,
|
||||||
|
|
@ -357,7 +359,8 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
// LiveSessionCommandSurface has no dependencies of its own, so
|
// LiveSessionCommandSurface has no dependencies of its own, so
|
||||||
// hoisting its construction is inert; the later site now reuses
|
// hoisting its construction is inert; the later site now reuses
|
||||||
// this instance instead of constructing a second one.
|
// this instance instead of constructing a second one.
|
||||||
var liveSessionCommands = new LiveSessionCommandSurface();
|
var liveSessionCommands = new LiveSessionCommandSurface(
|
||||||
|
d.TryHandlePluginCommand);
|
||||||
var settingsTargets = new RuntimeSettingsTargets(
|
var settingsTargets = new RuntimeSettingsTargets(
|
||||||
new SilkRuntimeDisplayWindowTarget(d.Window),
|
new SilkRuntimeDisplayWindowTarget(d.Window),
|
||||||
live.DrawDispatcher,
|
live.DrawDispatcher,
|
||||||
|
|
@ -1332,6 +1335,7 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
spawnClaimClassifier,
|
spawnClaimClassifier,
|
||||||
liveSession,
|
liveSession,
|
||||||
hydration,
|
hydration,
|
||||||
|
deletion,
|
||||||
networkUpdates,
|
networkUpdates,
|
||||||
liveness,
|
liveness,
|
||||||
sessionEvents,
|
sessionEvents,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
if (_movement.HasCommandInput)
|
if (_movement.HasCommandInput)
|
||||||
return _movement.CommandInput;
|
return _movement.CommandInput with { IsPersistentCommand = true };
|
||||||
|
|
||||||
if (_dispatcher is not { } dispatcher)
|
if (_dispatcher is not { } dispatcher)
|
||||||
return default;
|
return default;
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,17 @@ internal sealed class LiveSessionAppSource
|
||||||
/// retained UI may keep this surface, while the displaced route itself becomes
|
/// retained UI may keep this surface, while the displaced route itself becomes
|
||||||
/// inert before inbound subscriptions detach.
|
/// inert before inbound subscriptions detach.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class LiveSessionCommandSurface : ICommandBus
|
internal sealed class LiveSessionCommandSurface : IPluginCommandBus
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
private readonly Func<string, bool>? _tryHandlePluginCommand;
|
||||||
private LiveSessionCommandRouter? _active;
|
private LiveSessionCommandRouter? _active;
|
||||||
|
|
||||||
|
public LiveSessionCommandSurface(Func<string, bool>? tryHandlePluginCommand = null)
|
||||||
|
{
|
||||||
|
_tryHandlePluginCommand = tryHandlePluginCommand;
|
||||||
|
}
|
||||||
|
|
||||||
public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route)
|
public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(route);
|
ArgumentNullException.ThrowIfNull(route);
|
||||||
|
|
@ -65,6 +71,9 @@ internal sealed class LiveSessionCommandSurface : ICommandBus
|
||||||
route?.Publish(command);
|
route?.Publish(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryHandlePluginCommand(string commandLine) =>
|
||||||
|
_tryHandlePluginCommand?.Invoke(commandLine) == true;
|
||||||
|
|
||||||
private void Release(LiveSessionCommandRouter expected)
|
private void Release(LiveSessionCommandRouter expected)
|
||||||
{
|
{
|
||||||
expected.Dispose();
|
expected.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,7 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
OnUseDone: error =>
|
OnUseDone: error =>
|
||||||
{
|
{
|
||||||
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
|
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
|
||||||
|
_domain.Actions.SpellCast.CompleteUse(error);
|
||||||
_domain.Actions.Transactions.CompleteUse(error);
|
_domain.Actions.Transactions.CompleteUse(error);
|
||||||
},
|
},
|
||||||
_domain.Inventory.ItemMana,
|
_domain.Inventory.ItemMana,
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,13 @@ internal static class GraphicalWindowBackendConfigurator
|
||||||
_ => throw new ArgumentOutOfRangeException(
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
nameof(requested)),
|
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;
|
_glfw = glfw;
|
||||||
_configuredProtocol = requested;
|
_configuredProtocol = requested;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
282
src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
Normal file
282
src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace AcDream.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prevents GLFW's Win32 modifier-key repair pass from accepting a window
|
||||||
|
/// owned by another process.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// GLFW 3.4's <c>_glfwPollEventsWin32</c> calls <c>GetActiveWindow</c>, then
|
||||||
|
/// reads that HWND's process-global <c>GLFW</c> property and dereferences the
|
||||||
|
/// result as a local <c>_GLFWwindow*</c>. Normally <c>GetActiveWindow</c> 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 <c>GetPropW</c>
|
||||||
|
/// then succeeds but returns a pointer meaningful only in the other process.
|
||||||
|
/// The next modifier-key read is an access violation (#451).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Patch only GLFW's import-address-table entry for <c>GetActiveWindow</c>.
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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]<nint>)
|
||||||
|
&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);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,10 @@ public sealed class AppPluginHost : IPluginHost
|
||||||
IEvents events,
|
IEvents events,
|
||||||
ISelectionService selection,
|
ISelectionService selection,
|
||||||
IUiRegistry ui,
|
IUiRegistry ui,
|
||||||
IAutomationSurface automation)
|
IAutomationSurface automation,
|
||||||
|
IPluginStorage? storage = null,
|
||||||
|
IPluginCommandRegistry? commands = null,
|
||||||
|
IPluginLootClassifierRegistry? lootClassifiers = null)
|
||||||
{
|
{
|
||||||
Log = log;
|
Log = log;
|
||||||
State = state;
|
State = state;
|
||||||
|
|
@ -18,6 +21,10 @@ public sealed class AppPluginHost : IPluginHost
|
||||||
Selection = selection;
|
Selection = selection;
|
||||||
Ui = ui;
|
Ui = ui;
|
||||||
Automation = automation;
|
Automation = automation;
|
||||||
|
Storage = storage ?? NoOpPluginStorage.Instance;
|
||||||
|
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||||
|
LootClassifiers = lootClassifiers
|
||||||
|
?? NoOpPluginLootClassifierRegistry.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasUi => true;
|
public bool HasUi => true;
|
||||||
|
|
@ -27,4 +34,7 @@ public sealed class AppPluginHost : IPluginHost
|
||||||
public ISelectionService Selection { get; }
|
public ISelectionService Selection { get; }
|
||||||
public IUiRegistry Ui { get; }
|
public IUiRegistry Ui { get; }
|
||||||
public IAutomationSurface Automation { get; }
|
public IAutomationSurface Automation { get; }
|
||||||
|
public IPluginStorage Storage { get; }
|
||||||
|
public IPluginCommandRegistry Commands { get; }
|
||||||
|
public IPluginLootClassifierRegistry LootClassifiers { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,36 @@ namespace AcDream.App.Plugins;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BufferedUiRegistry : IScopedUiRegistry
|
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 long RegistrationId { get; init; }
|
||||||
|
internal string? MarkupContent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Stable, manifest-scoped retained-window persistence key.</summary>
|
||||||
|
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 string MarkupPath { get; } = markupPath;
|
||||||
internal object Binding { get; } = binding;
|
internal object Binding { get; } = binding;
|
||||||
|
internal string? MarkupContent { get; } = markupContent;
|
||||||
internal bool Drained { get; set; }
|
internal bool Drained { get; set; }
|
||||||
internal UiRoot? Root { get; set; }
|
internal UiRoot? Root { get; set; }
|
||||||
internal UiElement? Element { get; set; }
|
internal UiElement? Element { get; set; }
|
||||||
|
internal Action? WindowCleanup { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
|
@ -32,15 +50,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
||||||
public void AddMarkupPanel(string markupPath, object binding)
|
public void AddMarkupPanel(string markupPath, object binding)
|
||||||
=> _ = RegisterMarkupPanel(markupPath, 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)
|
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);
|
ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
|
||||||
ArgumentNullException.ThrowIfNull(binding);
|
ArgumentNullException.ThrowIfNull(binding);
|
||||||
long id;
|
long id;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
id = checked(++_nextRegistrationId);
|
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,
|
||||||
|
$"<inline:{descriptor.WindowId}>",
|
||||||
|
binding,
|
||||||
|
markupContent));
|
||||||
}
|
}
|
||||||
return new RegistrationToken(this, id);
|
return new RegistrationToken(this, id);
|
||||||
}
|
}
|
||||||
|
|
@ -57,10 +174,13 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
||||||
continue;
|
continue;
|
||||||
registration.Drained = true;
|
registration.Drained = true;
|
||||||
pending.Add(new Pending(
|
pending.Add(new Pending(
|
||||||
|
registration.Owner,
|
||||||
|
registration.Descriptor,
|
||||||
registration.MarkupPath,
|
registration.MarkupPath,
|
||||||
registration.Binding)
|
registration.Binding)
|
||||||
{
|
{
|
||||||
RegistrationId = id,
|
RegistrationId = id,
|
||||||
|
MarkupContent = registration.MarkupContent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
|
|
@ -88,6 +208,27 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
||||||
root.RemoveChild(element);
|
root.RemoveChild(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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 void FailMount(Pending pending) => Remove(pending.RegistrationId);
|
||||||
|
|
||||||
internal int RegistrationCount
|
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)
|
private void Remove(long id)
|
||||||
{
|
{
|
||||||
UiRoot? root;
|
UiRoot? root;
|
||||||
UiElement? element;
|
UiElement? element;
|
||||||
|
Action? windowCleanup;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_registrations.Remove(id, out Registration? registration))
|
if (!_registrations.Remove(id, out Registration? registration))
|
||||||
return;
|
return;
|
||||||
root = registration.Root;
|
root = registration.Root;
|
||||||
element = registration.Element;
|
element = registration.Element;
|
||||||
|
windowCleanup = registration.WindowCleanup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
windowCleanup?.Invoke();
|
||||||
if (root is not null && element is not null)
|
if (root is not null && element is not null)
|
||||||
root.RemoveChild(element);
|
root.RemoveChild(element);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
using System.Text;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Plugins;
|
||||||
|
|
||||||
|
/// <summary>Crash-safe filesystem implementation behind scoped plugin keys.</summary>
|
||||||
|
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<string> List(string prefix)
|
||||||
|
{
|
||||||
|
string directory = Resolve(prefix);
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
return Array.Empty<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<PluginNetworkClient> CaptureRemoteClients()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (!Directory.Exists(_directory))
|
||||||
|
return Array.Empty<PluginNetworkClient>();
|
||||||
|
long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter)
|
||||||
|
.ToUnixTimeMilliseconds();
|
||||||
|
var result = new List<PluginNetworkClient>();
|
||||||
|
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<PeerDocument>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -160,7 +160,13 @@ using IDisposable atmosphericPackRegistration = renderPackRegistry.Register(
|
||||||
"spv")));
|
"spv")));
|
||||||
// Constructed here and handed to both sides: GameWindow binds it to the live
|
// Constructed here and handed to both sides: GameWindow binds it to the live
|
||||||
// session's Runtime owners, the plugin host exposes it to plugins.
|
// 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(
|
using var window = new GameWindow(
|
||||||
runtimeOptions,
|
runtimeOptions,
|
||||||
worldGameState,
|
worldGameState,
|
||||||
|
|
@ -175,7 +181,11 @@ var host = new AppPluginHost(
|
||||||
worldEvents,
|
worldEvents,
|
||||||
window.Selection,
|
window.Selection,
|
||||||
uiRegistry,
|
uiRegistry,
|
||||||
automation);
|
automation,
|
||||||
|
new FilePluginStorage(
|
||||||
|
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
|
||||||
|
automation.PluginCommands,
|
||||||
|
lootClassifiers);
|
||||||
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||||
applicationPaths,
|
applicationPaths,
|
||||||
runtimeOptions.Plugins,
|
runtimeOptions.Plugins,
|
||||||
|
|
|
||||||
|
|
@ -715,6 +715,7 @@ public sealed class GameWindow :
|
||||||
// reset across generations. Re-binding per session would be re-binding
|
// reset across generations. Re-binding per session would be re-binding
|
||||||
// the same two references.
|
// the same two references.
|
||||||
_automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
|
_automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
|
||||||
|
_automation?.BindProjectileCollision(_physicsEngine);
|
||||||
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
|
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
|
||||||
_runtime.PlayerIdentity);
|
_runtime.PlayerIdentity);
|
||||||
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
|
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
|
||||||
|
|
@ -960,6 +961,10 @@ public sealed class GameWindow :
|
||||||
// zero skills with no error to explain it.
|
// zero skills with no error to explain it.
|
||||||
if (_automation is null)
|
if (_automation is null)
|
||||||
return;
|
return;
|
||||||
|
_automation.BindSpeciesNameResolver(
|
||||||
|
AcDream.App.UI.Layout.CreatureDisplayNameResolver.Load(value).Resolve);
|
||||||
|
_automation.BindPaletteColorResolver(
|
||||||
|
new AcDream.Content.CharGen.ChargenAppearanceCatalog(value));
|
||||||
if (!value.TryGet<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
|
if (!value.TryGet<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
|
||||||
|| skillTable is null)
|
|| skillTable is null)
|
||||||
{
|
{
|
||||||
|
|
@ -983,8 +988,11 @@ public sealed class GameWindow :
|
||||||
"prepared asset source");
|
"prepared asset source");
|
||||||
|
|
||||||
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
|
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
|
||||||
MagicCatalog value) =>
|
MagicCatalog value)
|
||||||
|
{
|
||||||
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
|
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
|
||||||
|
_automation?.BindMagicCatalog(value);
|
||||||
|
}
|
||||||
|
|
||||||
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
|
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
|
||||||
AcDream.Core.Physics.IAnimationLoader value) =>
|
AcDream.Core.Physics.IAnimationLoader value) =>
|
||||||
|
|
@ -1144,6 +1152,25 @@ public sealed class GameWindow :
|
||||||
_combatAttackController = result.CombatAttack;
|
_combatAttackController = result.CombatAttack;
|
||||||
_externalContainerLifecycle = result.ExternalContainerLifecycle;
|
_externalContainerLifecycle = result.ExternalContainerLifecycle;
|
||||||
_itemInteractionController = result.ItemInteraction;
|
_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;
|
_interactionUiLateBindings = result.LateBindings;
|
||||||
_magicRuntime = result.Magic;
|
_magicRuntime = result.Magic;
|
||||||
if (result.RetainedUi is { } retained)
|
if (result.RetainedUi is { } retained)
|
||||||
|
|
@ -1219,6 +1246,17 @@ public sealed class GameWindow :
|
||||||
_retailSelectionScene = result.SelectionScene;
|
_retailSelectionScene = result.SelectionScene;
|
||||||
_worldSelectionQuery = result.SelectionQuery;
|
_worldSelectionQuery = result.SelectionQuery;
|
||||||
_selectionInteractions = result.SelectionInteractions;
|
_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;
|
_retainedUiGameplayBinding = result.RetainedGameplay;
|
||||||
_paperdollViewportRenderer = result.PaperdollRenderer;
|
_paperdollViewportRenderer = result.PaperdollRenderer;
|
||||||
_paperdollFramePresenter = result.PaperdollPresenter;
|
_paperdollFramePresenter = result.PaperdollPresenter;
|
||||||
|
|
@ -1272,6 +1310,7 @@ public sealed class GameWindow :
|
||||||
_worldReveal = result.WorldReveal;
|
_worldReveal = result.WorldReveal;
|
||||||
_spawnClaimHydration = result.SpawnClaimHydration;
|
_spawnClaimHydration = result.SpawnClaimHydration;
|
||||||
_liveEntityHydration = result.Hydration;
|
_liveEntityHydration = result.Hydration;
|
||||||
|
_automation?.BindGhostDeletion(result.Deletion.DeleteClientGhost);
|
||||||
_liveEntityNetworkUpdates = result.NetworkUpdates;
|
_liveEntityNetworkUpdates = result.NetworkUpdates;
|
||||||
_liveEntityLiveness = result.Liveness;
|
_liveEntityLiveness = result.Liveness;
|
||||||
_liveEntitySessionEvents = result.SessionEvents;
|
_liveEntitySessionEvents = result.SessionEvents;
|
||||||
|
|
@ -1282,6 +1321,7 @@ public sealed class GameWindow :
|
||||||
_playerModeAutoEntry = result.PlayerModeAutoEntry;
|
_playerModeAutoEntry = result.PlayerModeAutoEntry;
|
||||||
_localPlayerTeleport = result.LocalTeleport;
|
_localPlayerTeleport = result.LocalTeleport;
|
||||||
_liveSessionHost = result.SessionHost;
|
_liveSessionHost = result.SessionHost;
|
||||||
|
_automation?.BindSessionCommands(result.GameRuntime);
|
||||||
_gameplayInputActions = result.GameplayActions;
|
_gameplayInputActions = result.GameplayActions;
|
||||||
_sessionPlayerBindings = result.RuntimeBindings;
|
_sessionPlayerBindings = result.RuntimeBindings;
|
||||||
}
|
}
|
||||||
|
|
@ -1529,7 +1569,8 @@ public sealed class GameWindow :
|
||||||
() => WorldTime.CurrentCalendar,
|
() => WorldTime.CurrentCalendar,
|
||||||
settingsDevTools.RenderPacks,
|
settingsDevTools.RenderPacks,
|
||||||
_renderPackDiagnostics.CaptureDiagnostics,
|
_renderPackDiagnostics.CaptureDiagnostics,
|
||||||
_applicationPaths.ScreenshotsDirectory),
|
_applicationPaths.ScreenshotsDirectory,
|
||||||
|
_automation),
|
||||||
_retailUiLease,
|
_retailUiLease,
|
||||||
this).Compose(
|
this).Compose(
|
||||||
platformResult,
|
platformResult,
|
||||||
|
|
@ -1653,6 +1694,9 @@ public sealed class GameWindow :
|
||||||
_combatFeedback,
|
_combatFeedback,
|
||||||
_portalTunnelFallback,
|
_portalTunnelFallback,
|
||||||
Console.WriteLine,
|
Console.WriteLine,
|
||||||
|
_automation is null
|
||||||
|
? null
|
||||||
|
: _automation.TryHandlePluginCommand,
|
||||||
_statusWriter),
|
_statusWriter),
|
||||||
this).Compose(
|
this).Compose(
|
||||||
hostInputCamera,
|
hostInputCamera,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using AcDream.App.Interaction;
|
||||||
using AcDream.App.Net;
|
using AcDream.App.Net;
|
||||||
using AcDream.Core.CharGen;
|
using AcDream.Core.CharGen;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Chat;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.Runtime.World;
|
using AcDream.Runtime.World;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
|
|
@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
{
|
{
|
||||||
private readonly GameRuntime _runtime;
|
private readonly GameRuntime _runtime;
|
||||||
private readonly CurrentGameRuntimeCommandAdapter _commands;
|
private readonly CurrentGameRuntimeCommandAdapter _commands;
|
||||||
|
private readonly ICommandBus _commandBus;
|
||||||
private readonly CharacterSelectionProjection _characterSelection;
|
private readonly CharacterSelectionProjection _characterSelection;
|
||||||
private readonly CharacterCreationProjection _characterCreation;
|
private readonly CharacterCreationProjection _characterCreation;
|
||||||
private readonly IDisposable _hostLease;
|
private readonly IDisposable _hostLease;
|
||||||
|
|
@ -39,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
ArgumentNullException.ThrowIfNull(commands);
|
ArgumentNullException.ThrowIfNull(commands);
|
||||||
ArgumentNullException.ThrowIfNull(selection);
|
ArgumentNullException.ThrowIfNull(selection);
|
||||||
|
|
||||||
|
_commandBus = commands;
|
||||||
_hostLease = runtime.AcquireHostLease(
|
_hostLease = runtime.AcquireHostLease(
|
||||||
"graphical game-runtime command adapter");
|
"graphical game-runtime command adapter");
|
||||||
try
|
try
|
||||||
|
|
@ -137,6 +140,24 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
public IRuntimeAllegianceCommands AllegianceCommands => _commands;
|
public IRuntimeAllegianceCommands AllegianceCommands => _commands;
|
||||||
IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands;
|
IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Automation entrance to the same parser used by the retail chat field.
|
||||||
|
/// Plugins see this only through the BCL-only <c>IPluginChat</c> contract.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
public RuntimeStateCheckpoint CaptureCheckpoint()
|
||||||
{
|
{
|
||||||
RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint();
|
RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint();
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,11 @@ public sealed record RuntimeOptions(
|
||||||
|
|
||||||
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional machine-local peer tags advertised to other plugin
|
||||||
|
/// instances. Parsed once here so the live automation surface never reads
|
||||||
|
/// process configuration directly.</summary>
|
||||||
|
public IReadOnlyList<string> PluginTags { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build options from the process environment. Used by
|
/// Build options from the process environment. Used by
|
||||||
/// <c>Program.cs</c> at startup.
|
/// <c>Program.cs</c> at startup.
|
||||||
|
|
@ -247,7 +252,10 @@ public sealed record RuntimeOptions(
|
||||||
StatusFilePath: null,
|
StatusFilePath: null,
|
||||||
Plugins: null,
|
Plugins: null,
|
||||||
LoginCommands: [],
|
LoginCommands: [],
|
||||||
LoginCommandDelayMs: 500);
|
LoginCommandDelayMs: 500)
|
||||||
|
{
|
||||||
|
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -367,6 +375,15 @@ public sealed record RuntimeOptions(
|
||||||
private static string? NullIfEmpty(string? s)
|
private static string? NullIfEmpty(string? s)
|
||||||
=> string.IsNullOrEmpty(s) ? null : s;
|
=> string.IsNullOrEmpty(s) ? null : s;
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> 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)
|
private static int? TryParseInt(string? s)
|
||||||
=> int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
=> int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
|
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
|
||||||
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
|
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
|
||||||
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
|
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
|
||||||
|
private readonly Func<uint, IReadOnlyList<uint>, bool>? _sendSalvage;
|
||||||
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
|
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
|
||||||
private readonly InventoryTransactionState _transactions;
|
private readonly InventoryTransactionState _transactions;
|
||||||
|
|
||||||
|
|
@ -120,7 +121,8 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
|
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
|
||||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
|
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
|
||||||
Action<string, RetailLogTextType>? interfaceText = null,
|
Action<string, RetailLogTextType>? interfaceText = null,
|
||||||
Action<uint, uint, uint>? sendStackableMerge = null)
|
Action<uint, uint, uint>? sendStackableMerge = null,
|
||||||
|
Func<uint, IReadOnlyList<uint>, bool>? sendSalvage = null)
|
||||||
{
|
{
|
||||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
|
@ -155,6 +157,7 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_sendBuy = sendBuy;
|
_sendBuy = sendBuy;
|
||||||
_sendBuyAll = sendBuyAll;
|
_sendBuyAll = sendBuyAll;
|
||||||
_sendSell = sendSell;
|
_sendSell = sendSell;
|
||||||
|
_sendSalvage = sendSalvage;
|
||||||
_interactionState = interactionState
|
_interactionState = interactionState
|
||||||
?? throw new ArgumentNullException(nameof(interactionState));
|
?? throw new ArgumentNullException(nameof(interactionState));
|
||||||
_runtimeTransactions = runtimeTransactions
|
_runtimeTransactions = runtimeTransactions
|
||||||
|
|
@ -500,6 +503,197 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin-facing retail stack merge. The shared planner performs the same
|
||||||
|
/// WCID, maximum-size, staged-trade, and transfer-size checks as a drag.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Plugin-facing retail full-stack drop or split-to-world.</summary>
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Plugin-facing retail Give attempt with an exact stack amount.</summary>
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool TrySalvageItemsForAutomation(
|
||||||
|
uint toolId,
|
||||||
|
IReadOnlyList<uint> 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<uint>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
||||||
/// request issued by another retained controller has been sent. The
|
/// request issued by another retained controller has been sent. The
|
||||||
|
|
@ -655,6 +849,21 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
|
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin/automation appraisal through the one retail appraisal owner.
|
||||||
|
/// It does not mutate selection or open/raise the examination window.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryAppraiseForAutomation(uint objectId)
|
||||||
|
{
|
||||||
|
if (objectId == 0u
|
||||||
|
|| _sendExamine is null
|
||||||
|
|| _objects.Get(objectId) is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Accepts only the pending or current appraisal, matching
|
/// Accepts only the pending or current appraisal, matching
|
||||||
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
|
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
|
||||||
|
|
@ -750,6 +959,76 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
return ExecuteUseActions(decision.Actions);
|
return ExecuteUseActions(decision.Actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
|
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
|
||||||
/// publishes the waiting destination slot before issuing the move request,
|
/// publishes the waiting destination slot before issuing the move request,
|
||||||
|
|
@ -1095,6 +1374,24 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
return _autoWield.TryWield(item, targetMask);
|
return _autoWield.TryWield(item, targetMask);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin/automation entry into the exact same AutoWield transaction used
|
||||||
|
/// by inventory activation and paperdoll drops.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
|
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
|
||||||
public void NotifyExplicitCombatModeRequest()
|
public void NotifyExplicitCombatModeRequest()
|
||||||
=> _autoWield.NotifyExplicitCombatModeRequest();
|
=> _autoWield.NotifyExplicitCombatModeRequest();
|
||||||
|
|
|
||||||
137
src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs
Normal file
137
src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retained-view projection of VTank's <c>ShowCollisionDebug</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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<IReadOnlyList<PluginProjectileDebugSample>> _samples;
|
||||||
|
private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)>
|
||||||
|
_camera;
|
||||||
|
private readonly List<UiPanel> _markers = [];
|
||||||
|
|
||||||
|
private ProjectileDebugOverlayController(
|
||||||
|
UiPanel root,
|
||||||
|
Func<IReadOnlyList<PluginProjectileDebugSample>> samples,
|
||||||
|
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera)
|
||||||
|
{
|
||||||
|
_root = root;
|
||||||
|
_samples = samples;
|
||||||
|
_camera = camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ProjectileDebugOverlayController Mount(
|
||||||
|
UiRoot host,
|
||||||
|
Func<IReadOnlyList<PluginProjectileDebugSample>> 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<PluginProjectileDebugSample> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,12 @@ namespace AcDream.App.UI;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MarkupDocument
|
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;
|
||||||
|
|
||||||
/// <param name="xml">Raw XML markup for a single panel.</param>
|
/// <param name="xml">Raw XML markup for a single panel.</param>
|
||||||
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
|
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
|
||||||
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
|
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
|
||||||
|
|
@ -74,13 +80,47 @@ public static class MarkupDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var el in root.Elements())
|
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<uint, (uint, int, int)> resolve,
|
||||||
|
UiDatFont? datFont)
|
||||||
|
{
|
||||||
|
switch (el.Name.LocalName)
|
||||||
{
|
{
|
||||||
switch (el.Name.LocalName)
|
case "group":
|
||||||
{
|
var group = new UiPanel
|
||||||
case "meter":
|
{
|
||||||
|
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 cur = BindUint((string?)el.Attribute("cur"), binding);
|
||||||
var max = BindUint((string?)el.Attribute("max"), binding);
|
var max = BindUint((string?)el.Attribute("max"), binding);
|
||||||
panel.AddChild(new UiMeter
|
var meter = new UiMeter
|
||||||
{
|
{
|
||||||
Left = F(el, "x"),
|
Left = F(el, "x"),
|
||||||
Top = F(el, "y"),
|
Top = F(el, "y"),
|
||||||
|
|
@ -97,10 +137,12 @@ public static class MarkupDocument
|
||||||
FrontLeft = Hex((string?)el.Attribute("frontleft")),
|
FrontLeft = Hex((string?)el.Attribute("frontleft")),
|
||||||
FrontTile = Hex((string?)el.Attribute("fronttile")),
|
FrontTile = Hex((string?)el.Attribute("fronttile")),
|
||||||
FrontRight = Hex((string?)el.Attribute("frontright")),
|
FrontRight = Hex((string?)el.Attribute("frontright")),
|
||||||
});
|
};
|
||||||
|
ApplyCommon(meter, el, binding);
|
||||||
|
parent.AddChild(meter);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "label":
|
case "label":
|
||||||
// Text may be a literal or a {Binding}. Bound labels re-read
|
// Text may be a literal or a {Binding}. Bound labels re-read
|
||||||
// their property every frame through the Func, so a plugin
|
// their property every frame through the Func, so a plugin
|
||||||
// updates its status line by assigning a property rather
|
// updates its status line by assigning a property rather
|
||||||
|
|
@ -114,10 +156,11 @@ public static class MarkupDocument
|
||||||
};
|
};
|
||||||
if (el.Attribute("color") is not null)
|
if (el.Attribute("color") is not null)
|
||||||
label.TextColor = Color((string?)el.Attribute("color"));
|
label.TextColor = Color((string?)el.Attribute("color"));
|
||||||
panel.AddChild(label);
|
ApplyCommon(label, el, binding);
|
||||||
|
parent.AddChild(label);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "button":
|
case "button":
|
||||||
// onclick binds to an Action property on the binding
|
// onclick binds to an Action property on the binding
|
||||||
// object. Resolved once at build time: a button whose
|
// object. Resolved once at build time: a button whose
|
||||||
// handler silently failed to bind is a bug worth failing
|
// handler silently failed to bind is a bug worth failing
|
||||||
|
|
@ -151,13 +194,243 @@ public static class MarkupDocument
|
||||||
button.TextSource = BindString(caption, binding);
|
button.TextSource = BindString(caption, binding);
|
||||||
if (el.Attribute("color") is not null)
|
if (el.Attribute("color") is not null)
|
||||||
button.TextColor = Color((string?)el.Attribute("color"));
|
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)
|
if (onClick is not null)
|
||||||
button.Click += onClick;
|
button.Click += onClick;
|
||||||
panel.AddChild(button);
|
parent.AddChild(button);
|
||||||
break;
|
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(
|
||||||
|
$"<tab onclick=\"{tabClickName}\"> 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(
|
||||||
|
$"<toggle onclick=\"{toggleClickName}\"> 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<float>? changed = BindFloatAction(changeName, binding);
|
||||||
|
if (changeName is not null && changed is null)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"<slider onchange=\"{changeName}\"> did not resolve to an "
|
||||||
|
+ $"Action<float> 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<string>? fieldChanged = BindStringAction(
|
||||||
|
fieldChangeName,
|
||||||
|
binding);
|
||||||
|
if (fieldChangeName is not null && fieldChanged is null)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"<field onchange=\"{fieldChangeName}\"> did not resolve to an "
|
||||||
|
+ $"Action<string> property on {binding.GetType().Name}");
|
||||||
|
}
|
||||||
|
string? submitName = (string?)el.Attribute("onsubmit");
|
||||||
|
Action<string>? submitted = BindStringAction(submitName, binding);
|
||||||
|
if (submitName is not null && submitted is null)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"<field onsubmit=\"{submitName}\"> did not resolve to an "
|
||||||
|
+ $"Action<string> 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<string>? menuChanged = BindStringAction(
|
||||||
|
menuChangeName,
|
||||||
|
binding);
|
||||||
|
if (menuChangeName is not null && menuChanged is null)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"<menu onchange=\"{menuChangeName}\"> did not resolve to an "
|
||||||
|
+ $"Action<string> property on {binding.GetType().Name}");
|
||||||
|
}
|
||||||
|
Func<IReadOnlyList<string>> menuItems = BindStringList(
|
||||||
|
(string?)el.Attribute("items"),
|
||||||
|
binding,
|
||||||
|
"menu items");
|
||||||
|
Func<string?> 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<int>? listChanged = BindIntAction(listChangeName, binding);
|
||||||
|
if (listChangeName is not null && listChanged is null)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
|
||||||
|
+ $"Action<int> 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -197,13 +470,197 @@ public static class MarkupDocument
|
||||||
return () => (property.GetValue(binding) as Action)?.Invoke();
|
return () => (property.GetValue(binding) as Action)?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Action<float>? 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<float>).IsAssignableFrom(property.PropertyType))
|
||||||
|
return null;
|
||||||
|
return value => (property.GetValue(binding) as Action<float>)?.Invoke(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Action<string>? 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<string>).IsAssignableFrom(property.PropertyType))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value => (property.GetValue(binding) as Action<string>)?.Invoke(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Action<int>? 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<int>).IsAssignableFrom(property.PropertyType))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Func<IReadOnlyList<string>> 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<string>).IsAssignableFrom(property.PropertyType))
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"{expression} did not resolve to an IEnumerable<string> property on "
|
||||||
|
+ binding.GetType().Name);
|
||||||
|
}
|
||||||
|
return () => property.GetValue(binding) is IEnumerable<string> values
|
||||||
|
? values.ToArray()
|
||||||
|
: Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Func<IReadOnlyList<uint>> BindUintList(
|
||||||
|
string? expression,
|
||||||
|
object binding,
|
||||||
|
string context)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
|
return static () => Array.Empty<uint>();
|
||||||
|
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<uint>).IsAssignableFrom(property.PropertyType))
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"{expression} did not resolve to an IEnumerable<uint> property on "
|
||||||
|
+ binding.GetType().Name);
|
||||||
|
}
|
||||||
|
return () => property.GetValue(binding) is IEnumerable<uint> values
|
||||||
|
? values.ToArray()
|
||||||
|
: Array.Empty<uint>();
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsBinding(string value) =>
|
private static bool IsBinding(string value) =>
|
||||||
value.Length > 2 && value[0] == '{' && value[^1] == '}';
|
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<bool> setLiteral,
|
||||||
|
Action<Func<bool>> 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<bool> 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<int> 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)
|
private static float F(XElement e, string attr)
|
||||||
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
||||||
CultureInfo.InvariantCulture, out var v) ? v : 0f;
|
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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
|
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
|
||||||
/// controls.ini convention). Falls back to opaque white on bad input.
|
/// controls.ini convention). Falls back to opaque white on bad input.
|
||||||
|
|
|
||||||
355
src/AcDream.App/UI/PluginSidePanel.cs
Normal file
355
src/AcDream.App/UI/PluginSidePanel.cs
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
|
||||||
|
/// presentation visibility; it never touches plugin enable/session lifetime.
|
||||||
|
/// </summary>
|
||||||
|
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<uint, (uint tex, int width, int height)> _resolve;
|
||||||
|
private readonly UiDatFont? _font;
|
||||||
|
private readonly Dictionary<RetailWindowHandle, ShelfEntry> _entries = [];
|
||||||
|
private bool _disposed;
|
||||||
|
private float _lastLayoutHeight = -1f;
|
||||||
|
|
||||||
|
public PluginSidePanel(
|
||||||
|
RetailWindowManager windows,
|
||||||
|
Func<uint, (uint tex, int width, int height)> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of live plugin-window entries, exposed for gates.</summary>
|
||||||
|
public int EntryCount => _entries.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds one manifest-scoped plugin window and its minimize affordance.
|
||||||
|
/// Duplicate handles are idempotent.
|
||||||
|
/// </summary>
|
||||||
|
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<uint, (uint tex, int width, int height)> _resolve;
|
||||||
|
private readonly uint _iconSurfaceId;
|
||||||
|
private readonly string _tooltip;
|
||||||
|
|
||||||
|
internal PluginShelfButton(
|
||||||
|
PluginPanelDescriptor descriptor,
|
||||||
|
string ownerDisplayName,
|
||||||
|
RetailWindowHandle handle,
|
||||||
|
Func<uint, (uint tex, int width, int height)> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
using AcDream.UI.Abstractions.Panels.Settings;
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
using AcDream.UI.Abstractions.Input;
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using Silk.NET.Input;
|
using Silk.NET.Input;
|
||||||
|
|
||||||
|
|
@ -531,7 +532,9 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
CharacterSelectionRuntimeBindings? CharacterSelection = null,
|
CharacterSelectionRuntimeBindings? CharacterSelection = null,
|
||||||
// Campaign CC slice CC4: sibling of CharacterSelection above.
|
// Campaign CC slice CC4: sibling of CharacterSelection above.
|
||||||
CharacterCreationRuntimeBindings? CharacterCreation = null,
|
CharacterCreationRuntimeBindings? CharacterCreation = null,
|
||||||
Action? CaptureScreenshot = null);
|
Action? CaptureScreenshot = null,
|
||||||
|
Func<IReadOnlyList<PluginProjectileDebugSample>>?
|
||||||
|
ProjectileDebugSamples = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
||||||
|
|
@ -553,9 +556,11 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||||
private ItemCooldownUiController? _itemCooldownController;
|
private ItemCooldownUiController? _itemCooldownController;
|
||||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||||
|
private ProjectileDebugOverlayController? _projectileDebugOverlay;
|
||||||
private Layout.VitalsSideBySideController? _vitalsSideBySide;
|
private Layout.VitalsSideBySideController? _vitalsSideBySide;
|
||||||
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||||
private CharacterCreationUiMountCoordinator? _characterCreationMount;
|
private CharacterCreationUiMountCoordinator? _characterCreationMount;
|
||||||
|
private PluginSidePanel? _pluginSidePanel;
|
||||||
private IDisposable? _characterSheetSubscription;
|
private IDisposable? _characterSheetSubscription;
|
||||||
private Layout.CharacterTitlesController? _characterTitlesController;
|
private Layout.CharacterTitlesController? _characterTitlesController;
|
||||||
private ResourceShutdownTransaction? _shutdown;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
|
|
@ -602,6 +607,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
RetailUiRuntimeBindings bindings = _bindings;
|
RetailUiRuntimeBindings bindings = _bindings;
|
||||||
MountFpsDisplay();
|
MountFpsDisplay();
|
||||||
MountVividTargetIndicator();
|
MountVividTargetIndicator();
|
||||||
|
MountProjectileDebugOverlay();
|
||||||
MountVitals();
|
MountVitals();
|
||||||
MountRadar();
|
MountRadar();
|
||||||
MountChat();
|
MountChat();
|
||||||
|
|
@ -935,6 +941,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
Layout.UiMediaClock.Advance(deltaSeconds);
|
Layout.UiMediaClock.Advance(deltaSeconds);
|
||||||
FpsController?.Tick();
|
FpsController?.Tick();
|
||||||
_vividTargetIndicator?.Tick();
|
_vividTargetIndicator?.Tick();
|
||||||
|
_projectileDebugOverlay?.Tick();
|
||||||
_vitalsSideBySide?.Tick();
|
_vitalsSideBySide?.Tick();
|
||||||
SpellbookWindowController?.Tick();
|
SpellbookWindowController?.Tick();
|
||||||
AppraisalController?.Tick(deltaSeconds);
|
AppraisalController?.Tick(deltaSeconds);
|
||||||
|
|
@ -1655,6 +1662,18 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
: "[D.2b] vivid target indicator mounted from client-enum category 0x10000009.");
|
: "[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()
|
private void MountVitals()
|
||||||
{
|
{
|
||||||
ImportedLayout? layout = Import(0x2100006Cu);
|
ImportedLayout? layout = Import(0x2100006Cu);
|
||||||
|
|
@ -4608,16 +4627,64 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string xml = File.ReadAllText(panel.MarkupPath);
|
string xml = panel.MarkupContent
|
||||||
UiElement element = MarkupDocument.Build(
|
?? File.ReadAllText(panel.MarkupPath);
|
||||||
|
UiNineSlicePanel element = MarkupDocument.Build(
|
||||||
xml,
|
xml,
|
||||||
panel.Binding,
|
panel.Binding,
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.Controls,
|
_bindings.Assets.Controls,
|
||||||
_bindings.Assets.DefaultFont);
|
_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<bool>? availability = element.VisibleSource;
|
||||||
|
var visibility = new PluginWindowVisibilityController(
|
||||||
|
availability,
|
||||||
|
panel.Descriptor.StartVisible);
|
||||||
|
element.VisibleSource = visibility.ShouldBeVisible;
|
||||||
|
element.Visible = visibility.ShouldBeVisible();
|
||||||
|
|
||||||
Host.Root.AddChild(element);
|
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);
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -5312,6 +5379,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
_characterSheetSubscription?.Dispose();
|
_characterSheetSubscription?.Dispose();
|
||||||
_characterTitlesController?.Dispose();
|
_characterTitlesController?.Dispose();
|
||||||
|
_pluginSidePanel?.Dispose();
|
||||||
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
||||||
WindowLockPresentation.Dispose();
|
WindowLockPresentation.Dispose();
|
||||||
WindowOpacity.Dispose();
|
WindowOpacity.Dispose();
|
||||||
|
|
@ -5339,6 +5407,36 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_disposed = _shutdown.IsComplete;
|
_disposed = _shutdown.IsComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class PluginWindowVisibilityController(
|
||||||
|
Func<bool>? 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(
|
internal static ResourceShutdownTransaction CreateShutdownTransaction(
|
||||||
Action disposeAutomation,
|
Action disposeAutomation,
|
||||||
Action disposePersistence,
|
Action disposePersistence,
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ public abstract class UiElement
|
||||||
public uint DatElementId { get; internal set; }
|
public uint DatElementId { get; internal set; }
|
||||||
|
|
||||||
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
||||||
public string? Name { get; init; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
|
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
|
||||||
|
|
@ -274,6 +274,12 @@ public abstract class UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<bool>? VisibleSource { get; set; }
|
public Func<bool>? VisibleSource { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional live enabled reader. Declarative plugin controls use this to
|
||||||
|
/// expose unavailable/busy state without retaining presentation objects.
|
||||||
|
/// </summary>
|
||||||
|
public Func<bool>? EnabledSource { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If true, <see cref="UiRoot"/> will set focus here on click,
|
/// If true, <see cref="UiRoot"/> will set focus here on click,
|
||||||
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
|
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
|
||||||
|
|
@ -642,7 +648,19 @@ public abstract class UiElement
|
||||||
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
|
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
|
||||||
/// (vtable +0x88) to render the tooltip body.
|
/// (vtable +0x88) to render the tooltip body.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual string? GetTooltipText() => null;
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public Func<string?>? RuntimeTooltipTextSource { get; set; }
|
||||||
|
|
||||||
|
public virtual string? GetTooltipText()
|
||||||
|
{
|
||||||
|
string? text = RuntimeTooltipTextSource?.Invoke();
|
||||||
|
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Framework entry points (internal, called by UiRoot) ─────────────
|
// ── Framework entry points (internal, called by UiRoot) ─────────────
|
||||||
|
|
||||||
|
|
@ -763,6 +781,8 @@ public abstract class UiElement
|
||||||
if (VisibleSource is { } visibility)
|
if (VisibleSource is { } visibility)
|
||||||
Visible = visibility();
|
Visible = visibility();
|
||||||
if (!Visible) return;
|
if (!Visible) return;
|
||||||
|
if (EnabledSource is { } enabled)
|
||||||
|
Enabled = enabled();
|
||||||
OnTick(dt);
|
OnTick(dt);
|
||||||
for (int i = 0; i < _children.Count; i++)
|
for (int i = 0; i < _children.Count; i++)
|
||||||
_children[i].TickSelfAndChildren(dt);
|
_children[i].TickSelfAndChildren(dt);
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,13 @@ public sealed class UiField : UiElement
|
||||||
public Action<string>? OnSubmit { get; set; }
|
public Action<string>? OnSubmit { get; set; }
|
||||||
public Action? OnFocusGained { get; set; }
|
public Action? OnFocusGained { get; set; }
|
||||||
public Action<string>? OnFocusLost { get; set; }
|
public Action<string>? OnFocusLost { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Action<string>? OnTextChanged { get; set; }
|
||||||
|
|
||||||
private string _textValue = "";
|
private string _textValue = "";
|
||||||
|
|
||||||
|
|
@ -127,8 +134,11 @@ public sealed class UiField : UiElement
|
||||||
get => _textValue;
|
get => _textValue;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
|
if (string.Equals(_textValue, value, StringComparison.Ordinal))
|
||||||
|
return;
|
||||||
_textValue = value;
|
_textValue = value;
|
||||||
_textVersion++;
|
_textVersion++;
|
||||||
|
OnTextChanged?.Invoke(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
96
src/AcDream.App/UI/UiMarkupList.cs
Normal file
96
src/AcDream.App/UI/UiMarkupList.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiMarkupList : UiElement
|
||||||
|
{
|
||||||
|
public Func<IReadOnlyList<string>> ItemsSource { get; set; } =
|
||||||
|
static () => Array.Empty<string>();
|
||||||
|
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
|
||||||
|
static () => Array.Empty<uint>();
|
||||||
|
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
|
||||||
|
public Action<int>? 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<string> items = ItemsSource();
|
||||||
|
IReadOnlyList<uint> 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<string> 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);
|
||||||
|
}
|
||||||
47
src/AcDream.App/UI/UiMarkupTabButton.cs
Normal file
47
src/AcDream.App/UI/UiMarkupTabButton.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<bool>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/AcDream.App/UI/UiMarkupToggle.cs
Normal file
75
src/AcDream.App/UI/UiMarkupToggle.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// KSML boolean toggle with the compact lamp-and-caption presentation used by
|
||||||
|
/// VTank. State and action remain reflected BCL bindings owned by the plugin.
|
||||||
|
/// </summary>
|
||||||
|
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<string?>? TextSource { get; set; }
|
||||||
|
public Func<bool>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,7 +74,9 @@ public sealed class UiMenu : UiElement
|
||||||
string? live = TooltipTextProvider?.Invoke();
|
string? live = TooltipTextProvider?.Invoke();
|
||||||
if (!string.IsNullOrWhiteSpace(live))
|
if (!string.IsNullOrWhiteSpace(live))
|
||||||
return 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);
|
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ public sealed class UiScrollbar : UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float ScalarPosition { get; private set; }
|
public float ScalarPosition { get; private set; }
|
||||||
public Action<float>? ScalarChanged { get; set; }
|
public Action<float>? ScalarChanged { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Func<float?>? ScalarPositionSource { get; set; }
|
||||||
public bool Horizontal { get; set; }
|
public bool Horizontal { get; set; }
|
||||||
|
|
||||||
/// <summary>True while a thumb drag is in progress (between a thumb-hit
|
/// <summary>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)
|
public void SetScalarPosition(float position)
|
||||||
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
|
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
|
||||||
|
|
||||||
|
protected override void OnTick(double deltaSeconds)
|
||||||
|
{
|
||||||
|
base.OnTick(deltaSeconds);
|
||||||
|
if (!_draggingThumb && ScalarPositionSource?.Invoke() is { } value)
|
||||||
|
SetScalarPosition(value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Settable tooltip, surfaced through the shared
|
/// <summary>Settable tooltip, surfaced through the shared
|
||||||
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
|
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
|
||||||
/// pattern <see cref="UiButton.TooltipText"/> already established
|
/// pattern <see cref="UiButton.TooltipText"/> already established
|
||||||
|
|
@ -105,7 +118,9 @@ public sealed class UiScrollbar : UiElement
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override string? GetTooltipText() =>
|
public override string? GetTooltipText() =>
|
||||||
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
string.IsNullOrWhiteSpace(TooltipText)
|
||||||
|
? base.GetTooltipText()
|
||||||
|
: TooltipText;
|
||||||
|
|
||||||
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
||||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,21 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
|
||||||
return removed || removedDormant;
|
return removed || removedDormant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VTank ghost cleanup: synthesize the exact current incarnation delete,
|
||||||
|
/// then use the same complete teardown transaction as a server DeleteObject.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
public bool Prune(LiveEntityPruneCandidate candidate)
|
||||||
{
|
{
|
||||||
if (!_runtime.TryGetRecord(
|
if (!_runtime.TryGetRecord(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,15 @@ public sealed record SpellComponentDescriptor(
|
||||||
uint WeenieClassId,
|
uint WeenieClassId,
|
||||||
string Name,
|
string Name,
|
||||||
uint Category,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Process-shareable projection of retail's spell, component, and
|
/// Process-shareable projection of retail's spell, component, and
|
||||||
|
|
@ -142,7 +150,15 @@ public sealed class MagicCatalog
|
||||||
wcid,
|
wcid,
|
||||||
pair.Value.Name.Value,
|
pair.Value.Name.Value,
|
||||||
pair.Value.Category,
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1004,9 +1004,14 @@ public static class GameEventWiring
|
||||||
{
|
{
|
||||||
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
|
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
|
||||||
if (p is null) return;
|
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)
|
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)
|
if (p.Value.CreatureProfile is { HealthMax: > 0u } creature)
|
||||||
combat.OnUpdateHealth(
|
combat.OnUpdateHealth(
|
||||||
p.Value.Guid,
|
p.Value.Guid,
|
||||||
|
|
@ -1020,8 +1025,8 @@ public static class GameEventWiring
|
||||||
// spellbook arrives via PlayerDescription (0x0013), which uses
|
// spellbook arrives via PlayerDescription (0x0013), which uses
|
||||||
// a different wire format (see WorldSession + LocalPlayerState
|
// a different wire format (see WorldSession + LocalPlayerState
|
||||||
// — feeds vitals from PrivateUpdateVital instead).
|
// — feeds vitals from PrivateUpdateVital instead).
|
||||||
// The appraised spellbook belongs to that item. The local player's
|
// The appraised spellbook now belongs to that item. The local
|
||||||
// learned spell manifest arrives only in PlayerDescription.
|
// player's learned manifest arrives only in PlayerDescription.
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Player ────────────────────────────────────────────────
|
// ── Player ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ public static class InventoryActions
|
||||||
public const uint DropItemOpcode = 0x001Bu;
|
public const uint DropItemOpcode = 0x001Bu;
|
||||||
public const uint NoLongerViewingContentsOpcode = 0x0195u;
|
public const uint NoLongerViewingContentsOpcode = 0x0195u;
|
||||||
public const uint SetInscriptionOpcode = 0x00BFu;
|
public const uint SetInscriptionOpcode = 0x00BFu;
|
||||||
|
public const uint CreateTinkeringToolOpcode = 0x027Du;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Merge stack A into stack B of the same item type. Server validates
|
/// 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);
|
text.CopyTo(body, 18);
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Salvage one or more carried items with an owned salvage tool. Retail
|
||||||
|
/// <c>CM_Inventory::Event_CreateTinkeringTool @ 0x006AB830</c> writes the
|
||||||
|
/// tool id followed by <c>PackableList<unsigned long></c>: u32 count,
|
||||||
|
/// then the object ids in list order. The server replies with GameEvent
|
||||||
|
/// <c>0x02B4 SalvageOperationsResult</c> and removes accepted source items.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] BuildCreateTinkeringTool(
|
||||||
|
uint seq,
|
||||||
|
uint toolGuid,
|
||||||
|
IReadOnlyList<uint> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3268,6 +3268,19 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount));
|
SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send retail CreateTinkeringTool (0x027D), the salvage operation used by
|
||||||
|
/// gmSalvageUI and VTank.
|
||||||
|
/// </summary>
|
||||||
|
public void SendSalvage(uint toolGuid, IReadOnlyList<uint> itemGuids)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(InventoryActions.BuildCreateTinkeringTool(
|
||||||
|
seq,
|
||||||
|
toolGuid,
|
||||||
|
itemGuids));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
||||||
|
|
|
||||||
|
|
@ -2,55 +2,6 @@
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"net10.0": {
|
"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": {
|
"Autofac": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.4.0",
|
"resolved": "8.4.0",
|
||||||
|
|
@ -245,9 +196,57 @@
|
||||||
"resolved": "0.1.1",
|
"resolved": "0.1.1",
|
||||||
"contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg=="
|
"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": {
|
"acdream.plugin.abstractions": {
|
||||||
"type": "Project"
|
"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": {
|
"Microsoft.Extensions.Logging.Abstractions": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[9.0.9, )",
|
"requested": "[9.0.9, )",
|
||||||
|
|
@ -257,12 +256,24 @@
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9"
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[4.0.2, )",
|
||||||
|
"resolved": "4.0.2",
|
||||||
|
"contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA=="
|
||||||
|
},
|
||||||
"SixLabors.ImageSharp": {
|
"SixLabors.ImageSharp": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[3.1.12, )",
|
"requested": "[3.1.12, )",
|
||||||
"resolved": "3.1.11",
|
"resolved": "3.1.11",
|
||||||
"contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw=="
|
"contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw=="
|
||||||
},
|
},
|
||||||
|
"StbImageSharp": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.30.16, )",
|
||||||
|
"resolved": "2.30.16",
|
||||||
|
"contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw=="
|
||||||
|
},
|
||||||
"StbTrueTypeSharp": {
|
"StbTrueTypeSharp": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[1.26.12, )",
|
"requested": "[1.26.12, )",
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,21 @@ public sealed class ClientObject
|
||||||
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
|
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
|
||||||
public uint? SpellId { get; set; }
|
public uint? SpellId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Spell ids retained from this item's latest successful
|
||||||
|
/// <c>IdentifyObjectResponse</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<uint> AppraisedSpellIds { get; internal set; } =
|
||||||
|
Array.Empty<uint>();
|
||||||
|
/// <summary>
|
||||||
|
/// Monotonic millisecond tick at which the latest successful identify
|
||||||
|
/// response was received. This is Decal's per-world-object
|
||||||
|
/// <c>LastIdTime</c>, retained on the canonical object so it disappears
|
||||||
|
/// with that exact object lifetime.
|
||||||
|
/// </summary>
|
||||||
|
public int LastAppraisalTimeMs { get; internal set; }
|
||||||
|
/// <summary>
|
||||||
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
|
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
|
||||||
/// shared item-cooldown group whose player enchantment id is
|
/// shared item-cooldown group whose player enchantment id is
|
||||||
/// <c>CooldownId + 0x8000</c>.
|
/// <c>CooldownId + 0x8000</c>.
|
||||||
|
|
|
||||||
|
|
@ -774,6 +774,45 @@ public sealed class ClientObjectTable
|
||||||
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
|
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
|
||||||
{
|
{
|
||||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||||
|
MergeProperties(item, incoming);
|
||||||
|
ApplyCooldownProperties(item, incoming);
|
||||||
|
ObjectUpdated?.Invoke(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public bool UpdateAppraisal(
|
||||||
|
uint itemId,
|
||||||
|
PropertyBundle incoming,
|
||||||
|
IReadOnlyList<uint> 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<uint>()
|
||||||
|
: 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.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.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
|
||||||
foreach (var kv in incoming.Bools) item.Properties.Bools[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.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.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
|
||||||
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -2383,7 +2383,11 @@ public sealed class PhysicsEngine
|
||||||
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
|
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveResult;
|
return resolveResult with
|
||||||
|
{
|
||||||
|
LastCollidedObjectId = ci.LastCollidedObjectGuid ?? 0u,
|
||||||
|
CollidedWithEnvironment = ci.CollidedWithEnvironment,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -58,4 +58,16 @@ public readonly record struct ResolveResult(
|
||||||
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
|
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
|
||||||
uint ContactPlaneCellId = 0,
|
uint ContactPlaneCellId = 0,
|
||||||
/// <summary>Whether the accepted contact plane is water.</summary>
|
/// <summary>Whether the accepted contact plane is water.</summary>
|
||||||
bool ContactPlaneIsWater = false);
|
bool ContactPlaneIsWater = false)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public uint LastCollidedObjectId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether resident environment geometry blocked the sweep.</summary>
|
||||||
|
public bool CollidedWithEnvironment { get; init; }
|
||||||
|
}
|
||||||
|
|
|
||||||
142
src/AcDream.Core/Plugins/PluginCommandRegistry.cs
Normal file
142
src/AcDream.Core/Plugins/PluginCommandRegistry.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginCommandRegistry : IPluginCommandRegistry
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<string, Registration> _registrations =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Action<string, Exception>? _onFailure;
|
||||||
|
|
||||||
|
public PluginCommandRegistry(Action<string, Exception>? onFailure = null)
|
||||||
|
{
|
||||||
|
_onFailure = onFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable Register(string verb, Action<PluginCommand> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Try to consume one complete command-shaped line.</summary>
|
||||||
|
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<PluginCommand> handler) : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private PluginCommandRegistry? _owner = owner;
|
||||||
|
private Action<PluginCommand>? _handler = handler;
|
||||||
|
|
||||||
|
internal string Verb { get; } = verb;
|
||||||
|
|
||||||
|
internal void Invoke(PluginCommand command)
|
||||||
|
{
|
||||||
|
Action<PluginCommand>? callback;
|
||||||
|
lock (_gate)
|
||||||
|
callback = _handler;
|
||||||
|
callback?.Invoke(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
PluginCommandRegistry? currentOwner;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
currentOwner = _owner;
|
||||||
|
_owner = null;
|
||||||
|
_handler = null;
|
||||||
|
}
|
||||||
|
currentOwner?.Remove(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs
Normal file
151
src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
/// <summary>Process-local transactional registry for external loot plugins.</summary>
|
||||||
|
public sealed class PluginLootClassifierRegistry : IPluginLootClassifierRegistry
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<string, Entry> _entries =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginLootClassifierInfo> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -258,7 +258,10 @@ public sealed class PluginSession : IDisposable
|
||||||
{
|
{
|
||||||
foreach (PluginDiscoveryResult candidate in available)
|
foreach (PluginDiscoveryResult candidate in available)
|
||||||
{
|
{
|
||||||
var scope = new ScopedPluginHost(_host);
|
var scope = new ScopedPluginHost(
|
||||||
|
_host,
|
||||||
|
candidate.Manifest!.Id,
|
||||||
|
candidate.Manifest.DisplayName);
|
||||||
ScopedRenderPackRegistry? renderPackScope =
|
ScopedRenderPackRegistry? renderPackScope =
|
||||||
candidate.Manifest!.Declares(PluginKind.RenderPack)
|
candidate.Manifest!.Declares(PluginKind.RenderPack)
|
||||||
&& _renderPacks is not null
|
&& _renderPacks is not null
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,30 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
private readonly ScopedEvents _events;
|
private readonly ScopedEvents _events;
|
||||||
private readonly ScopedSelectionService _selection;
|
private readonly ScopedSelectionService _selection;
|
||||||
private readonly ScopedUiRegistry _ui;
|
private readonly ScopedUiRegistry _ui;
|
||||||
|
private readonly ScopedPluginStorage _storage;
|
||||||
|
private readonly ScopedPluginCommandRegistry _commands;
|
||||||
|
private readonly ScopedLootClassifierRegistry _lootClassifiers;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
internal ScopedPluginHost(IPluginHost inner)
|
internal ScopedPluginHost(
|
||||||
|
IPluginHost inner,
|
||||||
|
string pluginId,
|
||||||
|
string pluginDisplayName)
|
||||||
{
|
{
|
||||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName);
|
||||||
_events = new ScopedEvents(inner.Events);
|
_events = new ScopedEvents(inner.Events);
|
||||||
_selection = new ScopedSelectionService(inner.Selection);
|
_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;
|
public bool HasUi => _inner.HasUi;
|
||||||
|
|
@ -29,6 +45,9 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
public IEvents Events => _events;
|
public IEvents Events => _events;
|
||||||
public ISelectionService Selection => _selection;
|
public ISelectionService Selection => _selection;
|
||||||
public IUiRegistry Ui => _ui;
|
public IUiRegistry Ui => _ui;
|
||||||
|
public IPluginStorage Storage => _storage;
|
||||||
|
public IPluginCommandRegistry Commands => _commands;
|
||||||
|
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delegated rather than scoped, unlike <see cref="Events"/>,
|
/// Delegated rather than scoped, unlike <see cref="Events"/>,
|
||||||
|
|
@ -45,6 +64,48 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public IAutomationSurface Automation => _inner.Automation;
|
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<string> 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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|
@ -53,6 +114,127 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
_events.Dispose();
|
_events.Dispose();
|
||||||
_selection.Dispose();
|
_selection.Dispose();
|
||||||
_ui.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<IDisposable> _registrations = [];
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginLootClassifierInfo> 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<IDisposable> _registrations = [];
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public IDisposable Register(string verb, Action<PluginCommand> 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)
|
private sealed class ScopedSelectionService(ISelectionService inner)
|
||||||
|
|
@ -297,22 +479,92 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
|
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IScopedUiRegistry _inner;
|
private readonly IScopedUiRegistry _inner;
|
||||||
|
private readonly PluginUiOwner _owner;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly List<IDisposable> _registrations = [];
|
private readonly List<IDisposable> _registrations = [];
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
internal ScopedUiRegistry(IUiRegistry inner)
|
internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner)
|
||||||
{
|
{
|
||||||
_inner = inner as IScopedUiRegistry
|
_inner = inner as IScopedUiRegistry
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
|
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
|
||||||
|
_owner = owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddMarkupPanel(string markupPath, object binding)
|
public void AddMarkupPanel(string markupPath, object binding)
|
||||||
{
|
{
|
||||||
IDisposable registration = _inner.RegisterMarkupPanel(
|
AddRegistration(_inner.RegisterPanel(
|
||||||
|
_owner,
|
||||||
|
new PluginPanelDescriptor(
|
||||||
|
Path.GetFileNameWithoutExtension(markupPath),
|
||||||
|
_owner.DisplayName),
|
||||||
markupPath,
|
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)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_disposed)
|
if (!_disposed)
|
||||||
|
|
@ -326,6 +578,31 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
IDisposable[] registrations;
|
IDisposable[] registrations;
|
||||||
|
|
@ -344,5 +621,15 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class IndividualRegistration(
|
||||||
|
ScopedUiRegistry owner,
|
||||||
|
IDisposable registration) : IDisposable
|
||||||
|
{
|
||||||
|
private ScopedUiRegistry? _owner = owner;
|
||||||
|
|
||||||
|
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
|
||||||
|
.RemoveRegistration(registration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ internal sealed class HeadlessMovementInputSource(
|
||||||
public MovementInput Capture()
|
public MovementInput Capture()
|
||||||
{
|
{
|
||||||
if (_movement.HasCommandInput)
|
if (_movement.HasCommandInput)
|
||||||
return _movement.CommandInput;
|
return _movement.CommandInput with { IsPersistentCommand = true };
|
||||||
return new MovementInput(
|
return new MovementInput(
|
||||||
Forward: _movement.AutoRunActive,
|
Forward: _movement.AutoRunActive,
|
||||||
Run: true);
|
Run: true);
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
// descriptor.StatusFile is unset — every call site below stays
|
// descriptor.StatusFile is unset — every call site below stays
|
||||||
// unconditional.
|
// unconditional.
|
||||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
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(
|
var loginCommands = new LoginCommandSequence(
|
||||||
descriptor.LoginCommands,
|
descriptor.LoginCommands,
|
||||||
TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs),
|
TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs),
|
||||||
|
|
@ -359,7 +365,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
statusWriter,
|
statusWriter,
|
||||||
descriptor.Id,
|
descriptor.Id,
|
||||||
pluginRoots ?? [],
|
pluginRoots ?? [],
|
||||||
descriptor.Plugins);
|
descriptor.Plugins,
|
||||||
|
pluginCommands);
|
||||||
var liveSession = new LiveSessionHost(
|
var liveSession = new LiveSessionHost(
|
||||||
runtime.Session,
|
runtime.Session,
|
||||||
new LiveSessionHostBindings(
|
new LiveSessionHostBindings(
|
||||||
|
|
@ -1201,6 +1208,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
{
|
{
|
||||||
Runtime.InventoryOwner.ExternalContainers
|
Runtime.InventoryOwner.ExternalContainers
|
||||||
.ApplyUseDone(error);
|
.ApplyUseDone(error);
|
||||||
|
Runtime.ActionOwner.SpellCast.CompleteUse(error);
|
||||||
Runtime.ActionOwner.Transactions.CompleteUse(error);
|
Runtime.ActionOwner.Transactions.CompleteUse(error);
|
||||||
},
|
},
|
||||||
Runtime.InventoryOwner.ItemMana,
|
Runtime.InventoryOwner.ItemMana,
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,18 @@ internal sealed class HeadlessPluginHost
|
||||||
|
|
||||||
internal HeadlessPluginHost(
|
internal HeadlessPluginHost(
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
IPluginLogger logger)
|
IPluginLogger logger,
|
||||||
|
IPluginCommandRegistry? commands = null)
|
||||||
{
|
{
|
||||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||||
_eventSubscription = runtime.Subscribe(this);
|
_eventSubscription = runtime.Subscribe(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasUi => false;
|
public bool HasUi => false;
|
||||||
public IPluginLogger Log { get; }
|
public IPluginLogger Log { get; }
|
||||||
|
public IPluginCommandRegistry Commands { get; }
|
||||||
public IGameState State => this;
|
public IGameState State => this;
|
||||||
public IEvents Events => this;
|
public IEvents Events => this;
|
||||||
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ internal sealed class HeadlessPluginSession : IDisposable
|
||||||
SessionStatusWriter statusWriter,
|
SessionStatusWriter statusWriter,
|
||||||
string sessionId,
|
string sessionId,
|
||||||
IEnumerable<string> roots,
|
IEnumerable<string> roots,
|
||||||
IReadOnlyList<string>? allowList)
|
IReadOnlyList<string>? allowList,
|
||||||
|
IPluginCommandRegistry? commands = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(runtime);
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
ArgumentNullException.ThrowIfNull(diagnostics);
|
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||||
|
|
@ -57,7 +58,8 @@ internal sealed class HeadlessPluginSession : IDisposable
|
||||||
new HeadlessPluginLogger(
|
new HeadlessPluginLogger(
|
||||||
diagnostics,
|
diagnostics,
|
||||||
sessionId,
|
sessionId,
|
||||||
() => runtime.Generation.Value));
|
() => runtime.Generation.Value),
|
||||||
|
commands);
|
||||||
var plugins = new PluginSession(
|
var plugins = new PluginSession(
|
||||||
host,
|
host,
|
||||||
status => Report(statusWriter, sessionId, status),
|
status => Report(statusWriter, sessionId, status),
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,38 @@ public readonly record struct PluginSpellInfo(
|
||||||
uint School,
|
uint School,
|
||||||
string Description,
|
string Description,
|
||||||
bool IsSelfTargeted,
|
bool IsSelfTargeted,
|
||||||
bool IsBeneficial);
|
bool IsBeneficial)
|
||||||
|
{
|
||||||
|
/// <summary>Retail spell-table classification, projected without policy.</summary>
|
||||||
|
public bool IsDebuff { get; init; }
|
||||||
|
public bool IsOffensive { get; init; }
|
||||||
|
public bool IsFellowship { get; init; }
|
||||||
|
public bool IsUntargeted { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// VTank's spell-facing rule: targeted spells require facing except the
|
||||||
|
/// authored family range 222..235.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>
|
||||||
|
/// Retail formula component ids in authored order. Plugins can inspect
|
||||||
|
/// requirements without importing client/Core spell types.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<uint> FormulaComponentIds { get; init; } =
|
||||||
|
Array.Empty<uint>();
|
||||||
|
/// <summary>
|
||||||
|
/// VTank's spell quality. It is the portal spell difficulty unless its
|
||||||
|
/// official GameInfoDB override supplies a replacement.
|
||||||
|
/// </summary>
|
||||||
|
public int? QualityOverride { get; init; }
|
||||||
|
public int Quality => QualityOverride ?? Difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>One enchantment currently in force on the local player.</summary>
|
/// <summary>One enchantment currently in force on the local player.</summary>
|
||||||
public readonly record struct PluginActiveEnchantment(
|
public readonly record struct PluginActiveEnchantment(
|
||||||
|
|
@ -62,18 +93,39 @@ public readonly record struct PluginActiveEnchantment(
|
||||||
int Tier,
|
int Tier,
|
||||||
double SecondsRemaining);
|
double SecondsRemaining);
|
||||||
|
|
||||||
|
/// <summary>One immutable entry from retail SpellComponentTable 0x0E00000F.</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
/// <summary>One of the character's skills, named from the retail skill table.</summary>
|
/// <summary>One of the character's skills, named from the retail skill table.</summary>
|
||||||
public readonly record struct PluginSkillInfo(
|
public readonly record struct PluginSkillInfo(
|
||||||
uint SkillId,
|
uint SkillId,
|
||||||
string Name,
|
string Name,
|
||||||
PluginSkillTraining Training,
|
PluginSkillTraining Training,
|
||||||
uint Current);
|
uint Current)
|
||||||
|
{
|
||||||
|
/// <summary>Unenchanted retail skill level before vitae and spell mods.</summary>
|
||||||
|
public uint Base { get; init; } = Current;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>One primary attribute. <paramref name="Kind"/> is 0..5.</summary>
|
/// <summary>One primary attribute. <paramref name="Kind"/> is 0..5.</summary>
|
||||||
public readonly record struct PluginAttributeInfo(
|
public readonly record struct PluginAttributeInfo(
|
||||||
int Kind,
|
int Kind,
|
||||||
string Name,
|
string Name,
|
||||||
uint Current);
|
uint Current)
|
||||||
|
{
|
||||||
|
/// <summary>Unenchanted primary-attribute value.</summary>
|
||||||
|
public uint Base { get; init; } = Current;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Why a cast would or would not be accepted right now.</summary>
|
/// <summary>Why a cast would or would not be accepted right now.</summary>
|
||||||
public enum PluginCastGate
|
public enum PluginCastGate
|
||||||
|
|
@ -93,6 +145,28 @@ public interface ICharacterInfo
|
||||||
{
|
{
|
||||||
bool IsInWorld { get; }
|
bool IsInWorld { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
string Name => string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Server-advertised world name used to scope global variables.</summary>
|
||||||
|
string WorldName => string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Authenticated account name; expression surfaces expose only its hash.</summary>
|
||||||
|
string AccountName => string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Retail roster slot for this character, or -1 when unavailable.</summary>
|
||||||
|
int CharacterIndex => -1;
|
||||||
|
|
||||||
|
/// <summary>Current character level.</summary>
|
||||||
|
int Level => 0;
|
||||||
|
|
||||||
|
/// <summary>Unused ordinary slots in the main pack.</summary>
|
||||||
|
int MainPackFreeSlots => 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The local player's own object id, or 0 when not in world. Needed to
|
/// 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
|
/// target yourself: retail's banes are Item Enchantments whose description
|
||||||
|
|
@ -108,6 +182,12 @@ public interface ICharacterInfo
|
||||||
uint CurrentMana { get; }
|
uint CurrentMana { get; }
|
||||||
uint MaxMana { get; }
|
uint MaxMana { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail PropertyInt.SummoningMastery: 0 undef/geomancer, 1 primalist,
|
||||||
|
/// 2 necromancer, 3 naturalist.
|
||||||
|
/// </summary>
|
||||||
|
int SummoningMastery => 0;
|
||||||
|
|
||||||
/// <summary>Skills the character has, with training state and current level.</summary>
|
/// <summary>Skills the character has, with training state and current level.</summary>
|
||||||
IReadOnlyList<PluginSkillInfo> Skills { get; }
|
IReadOnlyList<PluginSkillInfo> Skills { get; }
|
||||||
|
|
||||||
|
|
@ -141,18 +221,75 @@ public interface ISpellCatalog
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; }
|
IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Learned direct offensive spells. Debuffs and beneficial spells are
|
||||||
|
/// excluded; the plugin owns which attack spell to choose.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<PluginSpellInfo> KnownAttackSpells =>
|
||||||
|
Array.Empty<PluginSpellInfo>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<PluginSpellInfo> KnownCombatSpells =>
|
||||||
|
Array.Empty<PluginSpellInfo>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the character has learned this exact spell id. <see cref="TryGet"/>
|
||||||
|
/// answers a different question: it can resolve metadata for spells that
|
||||||
|
/// are not in the character's spellbook, such as a scroll being appraised.
|
||||||
|
/// </summary>
|
||||||
|
bool IsKnown(uint spellId) => false;
|
||||||
|
|
||||||
bool TryGet(uint spellId, out PluginSpellInfo info);
|
bool TryGet(uint spellId, out PluginSpellInfo info);
|
||||||
|
|
||||||
|
/// <summary>Resolve retail's spell-component id, not its inventory WCID.</summary>
|
||||||
|
bool TryGetComponent(uint componentId, out PluginSpellComponentInfo info)
|
||||||
|
{
|
||||||
|
info = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seconds remaining for one retail shared cooldown id.</summary>
|
||||||
|
double GetCooldownRemaining(uint cooldownId) => 0d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writing to the player's chat window.</summary>
|
/// <summary>Writing to the player's chat window.</summary>
|
||||||
|
public readonly record struct PluginChatMessage(
|
||||||
|
ulong Sequence,
|
||||||
|
uint SenderObjectId,
|
||||||
|
int Kind,
|
||||||
|
string Sender,
|
||||||
|
string Text,
|
||||||
|
string ChannelName);
|
||||||
|
|
||||||
|
/// <summary>Reading confirmed chat and writing client-local notices.</summary>
|
||||||
public interface IPluginChat
|
public interface IPluginChat
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered transcript messages newer than <paramref name="afterSequence"/>.
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence) =>
|
||||||
|
Array.Empty<PluginChatMessage>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Post a client-local system line, the channel retail uses for the
|
/// 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
|
/// client's own notices. It is local to this client: nothing is sent to the
|
||||||
/// server and no other player sees it.
|
/// server and no other player sees it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void PostSystemMessage(string text);
|
void PostSystemMessage(string text);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
bool Submit(string text) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Casting, with a preflight so a plugin need not guess.</summary>
|
/// <summary>Casting, with a preflight so a plugin need not guess.</summary>
|
||||||
|
|
@ -160,6 +297,13 @@ public interface IMagicCommands
|
||||||
{
|
{
|
||||||
bool IsCasting { get; }
|
bool IsCasting { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
PluginCastCompletion LastCompletion => default;
|
||||||
|
|
||||||
PluginCastGate EvaluateGate(uint spellId);
|
PluginCastGate EvaluateGate(uint spellId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -167,6 +311,13 @@ public interface IMagicCommands
|
||||||
/// not whether the spell ultimately lands, which the server decides.
|
/// not whether the spell ultimately lands, which the server decides.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool Cast(uint spellId);
|
bool Cast(uint spellId);
|
||||||
|
|
||||||
|
/// <summary>Evaluate a cast against an explicit target atomically.</summary>
|
||||||
|
PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) =>
|
||||||
|
PluginCastGate.Refused;
|
||||||
|
|
||||||
|
/// <summary>Select and cast on one explicit target in the same host call.</summary>
|
||||||
|
bool Cast(uint spellId, uint targetObjectId) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -187,6 +338,52 @@ public interface IAutomationSurface
|
||||||
ISpellCatalog Spells { get; }
|
ISpellCatalog Spells { get; }
|
||||||
IMagicCommands Magic { get; }
|
IMagicCommands Magic { get; }
|
||||||
IPluginChat Chat { get; }
|
IPluginChat Chat { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
ICombatAutomation Combat => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Owned equipment reads and confirmed AutoWield attempts.</summary>
|
||||||
|
IEquipmentAutomation Equipment => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Carried-item reads and canonical use/apply attempts.</summary>
|
||||||
|
IItemAutomation Items => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>External-container discovery and canonical corpse looting.</summary>
|
||||||
|
ILootAutomation Loot => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Authoritative fellowship vitals for helper spell policy.</summary>
|
||||||
|
IFellowshipAutomation Fellowship => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Shared confirmed duration-spell observations by target.</summary>
|
||||||
|
IEnchantmentAutomation Enchantments => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Canonical position reads and command-interpreter movement.</summary>
|
||||||
|
INavigationAutomation Navigation => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>General canonical object discovery and raw property access.</summary>
|
||||||
|
IWorldObjectAutomation Objects => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Runtime-owned Dereth calendar and day/night projection.</summary>
|
||||||
|
IWorldTimeAutomation WorldTime => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Account roster and one-shot post-logout character entry.</summary>
|
||||||
|
ILoginAutomation Login => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Other local acdream clients discovered by the host.</summary>
|
||||||
|
INetworkAutomation Network => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Explicit VTank-compatible stuck-action recovery.</summary>
|
||||||
|
IRecoveryAutomation Recovery => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Bounded projectile collision probes over the live world.</summary>
|
||||||
|
IProjectileAutomation Projectiles => NoOpAutomationSurface.Instance;
|
||||||
|
|
||||||
|
/// <summary>Canonical retail previous/next selection actions.</summary>
|
||||||
|
ISelectionAutomation Selection => NoOpAutomationSurface.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -194,7 +391,13 @@ public interface IAutomationSurface
|
||||||
/// and every command refuses, so a plugin can keep one code path.
|
/// and every command refuses, so a plugin can keep one code path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NoOpAutomationSurface
|
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();
|
public static NoOpAutomationSurface Instance { get; } = new();
|
||||||
|
|
||||||
|
|
@ -207,11 +410,41 @@ public sealed class NoOpAutomationSurface
|
||||||
public ISpellCatalog Spells => this;
|
public ISpellCatalog Spells => this;
|
||||||
public IMagicCommands Magic => this;
|
public IMagicCommands Magic => this;
|
||||||
public IPluginChat Chat => 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 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 bool IsInWorld => false;
|
||||||
public uint ObjectId => 0;
|
public uint ObjectId => 0;
|
||||||
public uint CurrentHealth => 0;
|
public uint CurrentHealth => 0;
|
||||||
|
|
@ -220,6 +453,7 @@ public sealed class NoOpAutomationSurface
|
||||||
public uint MaxStamina => 0;
|
public uint MaxStamina => 0;
|
||||||
public uint CurrentMana => 0;
|
public uint CurrentMana => 0;
|
||||||
public uint MaxMana => 0;
|
public uint MaxMana => 0;
|
||||||
|
public int SummoningMastery => 0;
|
||||||
|
|
||||||
public IReadOnlyList<PluginSkillInfo> Skills { get; } = Array.Empty<PluginSkillInfo>();
|
public IReadOnlyList<PluginSkillInfo> Skills { get; } = Array.Empty<PluginSkillInfo>();
|
||||||
public IReadOnlyList<PluginAttributeInfo> Attributes { get; } =
|
public IReadOnlyList<PluginAttributeInfo> Attributes { get; } =
|
||||||
|
|
@ -228,6 +462,10 @@ public sealed class NoOpAutomationSurface
|
||||||
Array.Empty<PluginActiveEnchantment>();
|
Array.Empty<PluginActiveEnchantment>();
|
||||||
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; } =
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; } =
|
||||||
Array.Empty<PluginSpellInfo>();
|
Array.Empty<PluginSpellInfo>();
|
||||||
|
public IReadOnlyList<PluginSpellInfo> KnownAttackSpells { get; } =
|
||||||
|
Array.Empty<PluginSpellInfo>();
|
||||||
|
public IReadOnlyList<PluginSpellInfo> KnownCombatSpells { get; } =
|
||||||
|
Array.Empty<PluginSpellInfo>();
|
||||||
|
|
||||||
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
|
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
|
||||||
{
|
{
|
||||||
|
|
@ -242,6 +480,64 @@ public sealed class NoOpAutomationSurface
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsCasting => false;
|
public bool IsCasting => false;
|
||||||
|
public PluginCastCompletion LastCompletion => default;
|
||||||
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Unavailable;
|
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Unavailable;
|
||||||
public bool Cast(uint spellId) => false;
|
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<PluginCombatTarget> CaptureHostileTargets(
|
||||||
|
float maximumDistance) => Array.Empty<PluginCombatTarget>();
|
||||||
|
public PluginCombatCommandResult EnterDefaultMode() => new(
|
||||||
|
PluginCombatCommandStatus.Unavailable);
|
||||||
|
bool IEquipmentAutomation.IsAvailable => false;
|
||||||
|
bool IEquipmentAutomation.IsBusy => false;
|
||||||
|
public IReadOnlyList<PluginEquipmentItem> CaptureOwnedEquipment() =>
|
||||||
|
Array.Empty<PluginEquipmentItem>();
|
||||||
|
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<PluginInventoryItem> CaptureOwnedItems() =>
|
||||||
|
Array.Empty<PluginInventoryItem>();
|
||||||
|
public PluginItemCommandResult Use(uint objectId) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
public PluginItemCommandResult Apply(uint objectId, uint targetObjectId) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
public IReadOnlyList<PluginLootContainer> CaptureCorpses(
|
||||||
|
float maximumDistance) => Array.Empty<PluginLootContainer>();
|
||||||
|
public IReadOnlyList<PluginInventoryItem> CaptureCurrentContents() =>
|
||||||
|
Array.Empty<PluginInventoryItem>();
|
||||||
|
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<PluginFellowMember> CaptureMembers() =>
|
||||||
|
Array.Empty<PluginFellowMember>();
|
||||||
|
public IReadOnlyList<PluginTrackedEnchantment> Capture(
|
||||||
|
uint targetObjectId) => Array.Empty<PluginTrackedEnchantment>();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
152
src/AcDream.Plugin.Abstractions/CombatAutomation.cs
Normal file
152
src/AcDream.Plugin.Abstractions/CombatAutomation.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Presentation-independent combat mode projected to a plugin.</summary>
|
||||||
|
public enum PluginCombatMode
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Peace,
|
||||||
|
Melee,
|
||||||
|
Missile,
|
||||||
|
Magic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail's three physical attack heights.</summary>
|
||||||
|
public enum PluginAttackHeight
|
||||||
|
{
|
||||||
|
High = 1,
|
||||||
|
Medium = 2,
|
||||||
|
Low = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One canonical hostile candidate at the instant it was captured.</summary>
|
||||||
|
public readonly record struct PluginCombatTarget(
|
||||||
|
uint ObjectId,
|
||||||
|
string Name,
|
||||||
|
uint WeenieClassId,
|
||||||
|
float Distance,
|
||||||
|
float RelativeAngleDegrees,
|
||||||
|
bool IsHealthKnown,
|
||||||
|
float HealthFraction)
|
||||||
|
{
|
||||||
|
/// <summary>Retail PropertyInt CreatureType (2), or zero when unknown.</summary>
|
||||||
|
public int SpeciesId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Retail creature-enum display name used by VTank's species variable.</summary>
|
||||||
|
public string SpeciesName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Spawn/appraisal maximum HP, or zero until the host knows it.</summary>
|
||||||
|
public int MaximumHealth { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VTank's dynamic hasshield value: true when the target currently has an
|
||||||
|
/// equipped object whose object class is Armor.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasShield { get; init; }
|
||||||
|
public ushort Incarnation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Monotonic revision of the last server health update.</summary>
|
||||||
|
public long HealthRevision { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seconds since the last server health update at capture time, or
|
||||||
|
/// positive infinity when health has never been reported.
|
||||||
|
/// </summary>
|
||||||
|
public double SecondsSinceHealthUpdate { get; init; } =
|
||||||
|
double.PositiveInfinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The canonical local combat/attack state visible to a plugin.</summary>
|
||||||
|
public readonly record struct PluginCombatSnapshot(
|
||||||
|
uint SelectedObjectId,
|
||||||
|
PluginCombatMode Mode,
|
||||||
|
PluginAttackHeight AttackHeight,
|
||||||
|
float DesiredPower,
|
||||||
|
float PowerBarLevel,
|
||||||
|
bool BuildInProgress,
|
||||||
|
bool RequestInProgress,
|
||||||
|
bool ServerResponsePending,
|
||||||
|
bool RepeatAttackInProgress)
|
||||||
|
{
|
||||||
|
/// <summary>Revision of the last physical AttackDone receipt.</summary>
|
||||||
|
public long CompletionRevision { get; init; }
|
||||||
|
public uint CompletionSequence { get; init; }
|
||||||
|
public uint CompletionWeenieError { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why an automation combat command did or did not proceed.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICombatAutomation
|
||||||
|
{
|
||||||
|
PluginCombatSnapshot Snapshot { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Capture currently valid hostile creatures no farther than
|
||||||
|
/// <paramref name="maximumDistance"/> meters from the local player.
|
||||||
|
/// Snapshot semantics: the returned list is never mutated in place.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<PluginCombatTarget> CaptureHostileTargets(float maximumDistance);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enter the combat mode implied by currently equipped items. If already
|
||||||
|
/// in any combat mode this reports <see cref="PluginCombatCommandStatus.AlreadyReady"/>.
|
||||||
|
/// </summary>
|
||||||
|
PluginCombatCommandResult EnterDefaultMode();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
PluginCombatCommandResult EnterMode(PluginCombatMode mode) =>
|
||||||
|
new(PluginCombatCommandStatus.Unavailable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Select <paramref name="targetObjectId"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
PluginCombatCommandResult BeginPhysicalAttack(
|
||||||
|
uint targetObjectId,
|
||||||
|
PluginAttackHeight height,
|
||||||
|
float power);
|
||||||
|
|
||||||
|
PluginCombatCommandResult ReleasePhysicalAttack();
|
||||||
|
PluginCombatCommandResult AbortPhysicalAttack();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
PluginCombatCommandResult DismissGhostTarget(uint targetObjectId) =>
|
||||||
|
new(PluginCombatCommandStatus.Unavailable);
|
||||||
|
}
|
||||||
35
src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs
Normal file
35
src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginTrackedEnchantment(
|
||||||
|
uint TargetObjectId,
|
||||||
|
uint SpellId,
|
||||||
|
uint Family,
|
||||||
|
int Quality,
|
||||||
|
bool IsUntargeted,
|
||||||
|
double SecondsRemaining);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>LogSpellCast(target, spell, duration)</c> capability.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEnchantmentAutomation
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginTrackedEnchantment> Capture(uint targetObjectId) =>
|
||||||
|
Array.Empty<PluginTrackedEnchantment>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Report a confirmed duration spell. Dispatch attempts must not be
|
||||||
|
/// reported; <paramref name="durationSeconds"/> is the effective duration.
|
||||||
|
/// </summary>
|
||||||
|
bool ReportCast(
|
||||||
|
uint targetObjectId,
|
||||||
|
uint spellId,
|
||||||
|
double durationSeconds) => false;
|
||||||
|
}
|
||||||
64
src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs
Normal file
64
src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>One owned item that can participate in VTank equipment policy.</summary>
|
||||||
|
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;
|
||||||
|
/// <summary>Retail AMMO_TYPE bit from PublicWeenieDesc.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Borrowed inventory equipment view and one request through the client's
|
||||||
|
/// canonical confirmed AutoWield transaction.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEquipmentAutomation
|
||||||
|
{
|
||||||
|
bool IsAvailable => false;
|
||||||
|
bool IsBusy => false;
|
||||||
|
|
||||||
|
IReadOnlyList<PluginEquipmentItem> CaptureOwnedEquipment() =>
|
||||||
|
Array.Empty<PluginEquipmentItem>();
|
||||||
|
|
||||||
|
/// <param name="requestedLocation">
|
||||||
|
/// Zero asks retail AutoWield to choose; otherwise this is the exact
|
||||||
|
/// retail INVENTORY_LOC bit requested by a profile.
|
||||||
|
/// </param>
|
||||||
|
PluginEquipmentCommandResult Equip(
|
||||||
|
uint objectId,
|
||||||
|
uint requestedLocation = 0u) =>
|
||||||
|
new(PluginEquipmentCommandStatus.Unavailable);
|
||||||
|
}
|
||||||
72
src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs
Normal file
72
src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>One authoritative fellowship-roster entry plus live range.</summary>
|
||||||
|
public readonly record struct PluginFellowMember(
|
||||||
|
uint ObjectId,
|
||||||
|
string Name,
|
||||||
|
uint CurrentHealth,
|
||||||
|
uint MaxHealth,
|
||||||
|
uint CurrentStamina,
|
||||||
|
uint MaxStamina,
|
||||||
|
uint CurrentMana,
|
||||||
|
uint MaxMana,
|
||||||
|
float Distance)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFellowshipAutomation
|
||||||
|
{
|
||||||
|
bool IsInFellowship => false;
|
||||||
|
string Name => string.Empty;
|
||||||
|
uint LeaderObjectId => 0u;
|
||||||
|
bool IsOpen => false;
|
||||||
|
bool IsLocked => false;
|
||||||
|
int MemberCount => 0;
|
||||||
|
IReadOnlyList<PluginFellowMember> CaptureMembers() =>
|
||||||
|
Array.Empty<PluginFellowMember>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Complete authoritative roster in server insertion order, including the
|
||||||
|
/// local player. Use <see cref="CaptureMembers"/> for helper/healer policy
|
||||||
|
/// that intentionally excludes self.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<PluginFellowMember> 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);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,19 @@ public interface IPluginHost
|
||||||
IEvents Events { get; }
|
IEvents Events { get; }
|
||||||
ISelectionService Selection { get; }
|
ISelectionService Selection { get; }
|
||||||
IUiRegistry Ui { get; }
|
IUiRegistry Ui { get; }
|
||||||
|
/// <summary>
|
||||||
|
/// Locally handled slash/at commands. Hosts without command routing expose
|
||||||
|
/// an inert registry so an API-v1 plugin can retain one code path.
|
||||||
|
/// </summary>
|
||||||
|
IPluginCommandRegistry Commands => NoOpPluginCommandRegistry.Instance;
|
||||||
|
/// <summary>
|
||||||
|
/// Durable storage scoped by the host to this plugin's manifest id.
|
||||||
|
/// No-window/test hosts may explicitly expose the inert implementation.
|
||||||
|
/// </summary>
|
||||||
|
IPluginStorage Storage => NoOpPluginStorage.Instance;
|
||||||
|
/// <summary>Unload-safe external VTank-style loot classifiers.</summary>
|
||||||
|
IPluginLootClassifierRegistry LootClassifiers =>
|
||||||
|
NoOpPluginLootClassifierRegistry.Instance;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Character reads, spell data and casting. Hosts with no live session
|
/// Character reads, spell data and casting. Hosts with no live session
|
||||||
|
|
|
||||||
22
src/AcDream.Plugin.Abstractions/IPluginStorage.cs
Normal file
22
src/AcDream.Plugin.Abstractions/IPluginStorage.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-plugin durable text storage. The host scopes keys to the authenticated
|
||||||
|
/// manifest id, so a plugin cannot collide with another plugin's profile.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginStorage
|
||||||
|
{
|
||||||
|
bool IsAvailable => false;
|
||||||
|
string? ReadText(string key) => null;
|
||||||
|
/// <summary>Relative file keys beneath one relative prefix.</summary>
|
||||||
|
IReadOnlyList<string> List(string prefix) => Array.Empty<string>();
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,47 @@
|
||||||
namespace AcDream.Plugin.Abstractions;
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="WindowId">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Title">User-facing window title.</param>
|
||||||
|
public sealed record PluginPanelDescriptor(string WindowId, string Title)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Optional one-to-three-character fallback drawn in the sidepanel button
|
||||||
|
/// when no DAT icon is supplied. The host derives initials from
|
||||||
|
/// <see cref="Title"/> when this is empty.
|
||||||
|
/// </summary>
|
||||||
|
public string? IconText { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional installed-client RenderSurface DID. Zero asks the host to draw
|
||||||
|
/// <see cref="IconText"/> instead. Plugins never receive the resulting GPU
|
||||||
|
/// resource and remain BCL-only.
|
||||||
|
/// </summary>
|
||||||
|
public uint IconSurfaceId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initial visibility used only when no per-character persisted layout is
|
||||||
|
/// available. Hiding the window never disables the plugin.
|
||||||
|
/// </summary>
|
||||||
|
public bool StartVisible { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>Whether this window receives a button in the shared sidepanel.</summary>
|
||||||
|
public bool ShowInSidePanel { get; init; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host-authenticated plugin identity attached to registrations by the scoped
|
||||||
|
/// plugin lifetime. Plugins cannot choose or spoof this value.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginUiOwner(string Id, string DisplayName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
||||||
/// a binding object exposing the data properties the markup binds to, and
|
/// a binding object exposing the data properties the markup binds to, and
|
||||||
|
|
@ -13,6 +55,52 @@ public interface IUiRegistry
|
||||||
/// <param name="markupPath">Absolute path to the plugin's panel markup file.</param>
|
/// <param name="markupPath">Absolute path to the plugin's panel markup file.</param>
|
||||||
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
||||||
void AddMarkupPanel(string markupPath, object binding);
|
void AddMarkupPanel(string markupPath, object binding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a first-class plugin window. The host keeps the plugin lifetime
|
||||||
|
/// independent from the window's visible/minimized state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
void AddPanel(
|
||||||
|
PluginPanelDescriptor descriptor,
|
||||||
|
string markupPath,
|
||||||
|
object binding)
|
||||||
|
=> AddMarkupPanel(markupPath, binding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
IDisposable RegisterPanel(
|
||||||
|
PluginPanelDescriptor descriptor,
|
||||||
|
string markupPath,
|
||||||
|
object binding)
|
||||||
|
{
|
||||||
|
AddPanel(descriptor, markupPath, binding);
|
||||||
|
return NoOpUiRegistration.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
IDisposable RegisterPanelContent(
|
||||||
|
PluginPanelDescriptor descriptor,
|
||||||
|
string markupContent,
|
||||||
|
object binding) => NoOpUiRegistration.Instance;
|
||||||
|
|
||||||
|
/// <summary>Queries this plugin's own registered view by title or stable id.</summary>
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -25,6 +113,55 @@ public interface IUiRegistry
|
||||||
public interface IScopedUiRegistry : IUiRegistry
|
public interface IScopedUiRegistry : IUiRegistry
|
||||||
{
|
{
|
||||||
IDisposable RegisterMarkupPanel(string markupPath, object binding);
|
IDisposable RegisterMarkupPanel(string markupPath, object binding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host-only scoped registration carrying the manifest-derived owner.
|
||||||
|
/// Disposal removes both the retained window and its sidepanel entry.
|
||||||
|
/// </summary>
|
||||||
|
IDisposable RegisterPanel(
|
||||||
|
PluginUiOwner owner,
|
||||||
|
PluginPanelDescriptor descriptor,
|
||||||
|
string markupPath,
|
||||||
|
object binding)
|
||||||
|
=> RegisterMarkupPanel(markupPath, binding);
|
||||||
|
|
||||||
|
/// <summary>Host-owned registration for in-memory plugin markup.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared empty registration returned by UI-less/legacy hosts.</summary>
|
||||||
|
public sealed class NoOpUiRegistration : IDisposable
|
||||||
|
{
|
||||||
|
public static NoOpUiRegistration Instance { get; } = new();
|
||||||
|
|
||||||
|
private NoOpUiRegistration()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -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) =>
|
public IDisposable RegisterMarkupPanel(string markupPath, object binding) =>
|
||||||
NoOpRegistration.Instance;
|
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
|
private sealed class NoOpRegistration : IDisposable
|
||||||
{
|
{
|
||||||
internal static NoOpRegistration Instance { get; } = new();
|
internal static NoOpRegistration Instance { get; } = new();
|
||||||
|
|
|
||||||
245
src/AcDream.Plugin.Abstractions/ItemAutomation.cs
Normal file
245
src/AcDream.Plugin.Abstractions/ItemAutomation.cs
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One ordered VTClassic-compatible subpalette sample from an object's model
|
||||||
|
/// description. RGB is sampled at retail/VTank's representative index.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginPaletteInfo(
|
||||||
|
uint PaletteId,
|
||||||
|
byte Offset,
|
||||||
|
byte Length,
|
||||||
|
byte Red,
|
||||||
|
byte Green,
|
||||||
|
byte Blue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>PropertyInt.BoosterEnum</c>: current Health/Stamina/Mana are
|
||||||
|
/// 2/4/6. VTank uses this to classify both kits and food without relying
|
||||||
|
/// on localized item names.
|
||||||
|
/// </summary>
|
||||||
|
public int BoosterVital { get; init; }
|
||||||
|
public int BoostValue { get; init; }
|
||||||
|
public double HealKitModifier { get; init; }
|
||||||
|
public IReadOnlyList<uint> AppraisedSpellIds { get; init; } =
|
||||||
|
Array.Empty<uint>();
|
||||||
|
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; }
|
||||||
|
/// <summary>Retail PublicWeenieDesc maximum stack size.</summary>
|
||||||
|
public int MaximumStackSize { get; init; } = 1;
|
||||||
|
/// <summary>Current zero-based slot inside <see cref="ContainerObjectId"/>.</summary>
|
||||||
|
public int ContainerSlot { get; init; } = -1;
|
||||||
|
/// <summary>Number of ordinary item slots when this object is a container.</summary>
|
||||||
|
public int ItemsCapacity { get; init; }
|
||||||
|
/// <summary>Number of nested-container slots when this object is a container.</summary>
|
||||||
|
public int ContainersCapacity { get; init; }
|
||||||
|
/// <summary>Current total burden of this object or stack.</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>Virindi/Decal's stable object class, not ItemType flags.</summary>
|
||||||
|
public PluginObjectClass ObjectClass { get; init; }
|
||||||
|
public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } =
|
||||||
|
Array.Empty<PluginPaletteInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginItemProperties(
|
||||||
|
IReadOnlyDictionary<uint, int> Ints,
|
||||||
|
IReadOnlyDictionary<uint, long> Int64s,
|
||||||
|
IReadOnlyDictionary<uint, bool> Bools,
|
||||||
|
IReadOnlyDictionary<uint, double> Floats,
|
||||||
|
IReadOnlyDictionary<uint, string> Strings,
|
||||||
|
IReadOnlyDictionary<uint, uint> DataIds,
|
||||||
|
IReadOnlyDictionary<uint, uint> InstanceIds);
|
||||||
|
|
||||||
|
/// <summary>One server <c>UseDone</c> for a plugin-issued item action.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The retail inventory request that produced a completion receipt.</summary>
|
||||||
|
public enum PluginInventoryCommandKind
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Pickup,
|
||||||
|
PutInContainer,
|
||||||
|
SplitToContainer,
|
||||||
|
Merge,
|
||||||
|
Move,
|
||||||
|
DropToWorld,
|
||||||
|
SplitToWorld,
|
||||||
|
Wield,
|
||||||
|
Give,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative completion of one plugin or UI inventory transaction. A
|
||||||
|
/// started command is not success until this revision advances for its source.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginInventoryCompletion(
|
||||||
|
long Revision,
|
||||||
|
PluginInventoryCommandKind Kind,
|
||||||
|
uint SourceObjectId,
|
||||||
|
uint WeenieError)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => Revision != 0 && WeenieError == 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Borrowed inventory view and item actions through the client's one retail
|
||||||
|
/// item-interaction transaction. A successful command means only that the
|
||||||
|
/// request started; <see cref="LastCompletion"/> is the server result.
|
||||||
|
/// </summary>
|
||||||
|
public interface IItemAutomation
|
||||||
|
{
|
||||||
|
bool IsAvailable => false;
|
||||||
|
bool IsBusy => false;
|
||||||
|
int ActiveOwnedPetCount => 0;
|
||||||
|
uint ActiveVendorObjectId => 0u;
|
||||||
|
PluginItemUseCompletion LastCompletion => default;
|
||||||
|
PluginInventoryCompletion LastInventoryCompletion => default;
|
||||||
|
|
||||||
|
IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() =>
|
||||||
|
Array.Empty<PluginInventoryItem>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move all or an exact partial quantity into a carried container. An
|
||||||
|
/// amount of zero means the whole current stack.
|
||||||
|
/// </summary>
|
||||||
|
PluginItemCommandResult MoveToContainer(
|
||||||
|
uint objectId,
|
||||||
|
uint containerObjectId,
|
||||||
|
uint amount = 0u,
|
||||||
|
int placement = 0) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merge up to <paramref name="amount"/> units from source into target.
|
||||||
|
/// Zero means as much as retail permits.
|
||||||
|
/// </summary>
|
||||||
|
PluginItemCommandResult Merge(
|
||||||
|
uint sourceObjectId,
|
||||||
|
uint targetObjectId,
|
||||||
|
uint amount = 0u) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
|
||||||
|
/// <summary>Drop all or an exact partial stack on the ground.</summary>
|
||||||
|
PluginItemCommandResult Drop(uint objectId, uint amount = 0u) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
|
||||||
|
/// <summary>Give all or an exact partial stack to a world target.</summary>
|
||||||
|
PluginItemCommandResult Give(
|
||||||
|
uint objectId,
|
||||||
|
uint targetObjectId,
|
||||||
|
uint amount = 0u) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
PluginItemCommandResult Salvage(
|
||||||
|
uint toolObjectId,
|
||||||
|
IReadOnlyList<uint> itemObjectIds) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sell an owned item through the currently-open authoritative vendor.
|
||||||
|
/// Zero amount means the complete current stack.
|
||||||
|
/// </summary>
|
||||||
|
PluginItemCommandResult Sell(uint objectId, uint amount = 0u) =>
|
||||||
|
new(PluginItemCommandStatus.Unavailable);
|
||||||
|
}
|
||||||
25
src/AcDream.Plugin.Abstractions/LoginAutomation.cs
Normal file
25
src/AcDream.Plugin.Abstractions/LoginAutomation.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>One character in the account's authoritative login roster.</summary>
|
||||||
|
public readonly record struct PluginLoginCharacter(
|
||||||
|
uint ObjectId,
|
||||||
|
string Name,
|
||||||
|
int ActiveIndex,
|
||||||
|
bool IsPendingDelete);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILoginAutomation
|
||||||
|
{
|
||||||
|
bool IsAvailable => false;
|
||||||
|
uint NextLoginObjectId => 0u;
|
||||||
|
|
||||||
|
IReadOnlyList<PluginLoginCharacter> CaptureRoster() =>
|
||||||
|
Array.Empty<PluginLoginCharacter>();
|
||||||
|
|
||||||
|
bool SetNextLogin(uint characterObjectId) => false;
|
||||||
|
bool ClearNextLogin() => false;
|
||||||
|
}
|
||||||
69
src/AcDream.Plugin.Abstractions/LootAutomation.cs
Normal file
69
src/AcDream.Plugin.Abstractions/LootAutomation.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One live external container that an automation plugin may approach and use.
|
||||||
|
/// The host classifies corpses; plugins decide whether and when to loot them.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only corpse/container discovery plus canonical open and pickup commands.
|
||||||
|
/// Successful commands mean the request started; completion is reported through
|
||||||
|
/// <see cref="LastItemUseCompletion"/> or <see cref="LastInventoryCompletion"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<PluginLootContainer> CaptureCorpses(float maximumDistance) =>
|
||||||
|
Array.Empty<PluginLootContainer>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures the complete currently viewed external-container tree. Entries
|
||||||
|
/// are ordered depth-first in retail container-slot order.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<PluginInventoryItem> CaptureCurrentContents() =>
|
||||||
|
Array.Empty<PluginInventoryItem>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
93
src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs
Normal file
93
src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>VTank's public loot-plugin action vocabulary.</summary>
|
||||||
|
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<PluginInventoryItem> OwnedItems);
|
||||||
|
|
||||||
|
/// <summary>A classifier's detached decision. Matched=false means no rule.</summary>
|
||||||
|
public readonly record struct PluginLootClassification(
|
||||||
|
bool Matched,
|
||||||
|
PluginLootAction Action,
|
||||||
|
string RuleName = "",
|
||||||
|
int Priority = 0,
|
||||||
|
int KeepCount = 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A classified item after the server-confirmed move into owned inventory.
|
||||||
|
/// This is VTank's custom-action item ledger boundary.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginLootClassifierRegistry
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginLootClassifierInfo> Available =>
|
||||||
|
Array.Empty<PluginLootClassifierInfo>();
|
||||||
|
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
11
src/AcDream.Plugin.Abstractions/MagicAutomation.cs
Normal file
11
src/AcDream.Plugin.Abstractions/MagicAutomation.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>One authoritative completion of a spell request.</summary>
|
||||||
|
public readonly record struct PluginCastCompletion(
|
||||||
|
long Revision,
|
||||||
|
uint SpellId,
|
||||||
|
uint TargetObjectId,
|
||||||
|
uint WeenieError)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => Revision != 0 && WeenieError == 0u;
|
||||||
|
}
|
||||||
115
src/AcDream.Plugin.Abstractions/NavigationAutomation.cs
Normal file
115
src/AcDream.Plugin.Abstractions/NavigationAutomation.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One live object's canonical identity, name, and position.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The local movement state sampled atomically by a plugin tick.</summary>
|
||||||
|
public readonly record struct PluginNavigationSnapshot(
|
||||||
|
bool IsAvailable,
|
||||||
|
bool IsPortalSpace,
|
||||||
|
uint LocalObjectId,
|
||||||
|
PluginNavigationPosition Position,
|
||||||
|
bool IsMoving,
|
||||||
|
bool IsAirborne)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public PluginNavigationPosition ConfirmedPosition { get; init; }
|
||||||
|
public ulong ConfirmedPositionRevision { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface INavigationAutomation
|
||||||
|
{
|
||||||
|
PluginNavigationSnapshot Snapshot { get; }
|
||||||
|
|
||||||
|
bool TryGetObject(uint objectId, out PluginNavigationObject value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
bool TryFindObject(
|
||||||
|
string name,
|
||||||
|
in PluginNavigationPosition near,
|
||||||
|
double maximumDistanceMeters,
|
||||||
|
out PluginNavigationObject value)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detached live world-object projection used by plugin-owned proximity
|
||||||
|
/// policies such as VTank's door opener. Hosts may return an empty list.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<PluginNavigationObject> CaptureObjects() =>
|
||||||
|
Array.Empty<PluginNavigationObject>();
|
||||||
|
|
||||||
|
PluginNavigationCommandStatus SetMovementIntent(
|
||||||
|
in PluginMovementIntent intent);
|
||||||
|
|
||||||
|
PluginNavigationCommandStatus ClearMovementIntent();
|
||||||
|
}
|
||||||
28
src/AcDream.Plugin.Abstractions/NetworkAutomation.cs
Normal file
28
src/AcDream.Plugin.Abstractions/NetworkAutomation.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One other live acdream client discovered by the host's local peer service.
|
||||||
|
/// The shape mirrors UtilityBelt's ClientData expression contract.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginNetworkClient(
|
||||||
|
uint ClientId,
|
||||||
|
uint PlayerId,
|
||||||
|
string Name,
|
||||||
|
string WorldName,
|
||||||
|
PluginNavigationPosition Position,
|
||||||
|
IReadOnlyList<string> Tags,
|
||||||
|
uint CurrentHealth,
|
||||||
|
uint CurrentMana,
|
||||||
|
uint CurrentStamina,
|
||||||
|
uint MaxHealth,
|
||||||
|
uint MaxMana,
|
||||||
|
uint MaxStamina,
|
||||||
|
float Heading);
|
||||||
|
|
||||||
|
/// <summary>Read-only discovery of other local acdream client processes.</summary>
|
||||||
|
public interface INetworkAutomation
|
||||||
|
{
|
||||||
|
bool IsAvailable => false;
|
||||||
|
IReadOnlyList<PluginNetworkClient> CaptureClients() =>
|
||||||
|
Array.Empty<PluginNetworkClient>();
|
||||||
|
}
|
||||||
48
src/AcDream.Plugin.Abstractions/PluginCommands.cs
Normal file
48
src/AcDream.Plugin.Abstractions/PluginCommands.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>One locally handled slash/at command submitted by the player.</summary>
|
||||||
|
public readonly record struct PluginCommand(
|
||||||
|
string Verb,
|
||||||
|
string Arguments,
|
||||||
|
string RawText);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginCommandRegistry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Register one bare verb (for example <c>vt</c>, without a leading slash).
|
||||||
|
/// Matching is case-insensitive and accepts both retail command prefixes.
|
||||||
|
/// The returned lease removes only this exact registration.
|
||||||
|
/// </summary>
|
||||||
|
IDisposable Register(string verb, Action<PluginCommand> handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inert command surface for hosts that cannot route local commands.</summary>
|
||||||
|
public sealed class NoOpPluginCommandRegistry : IPluginCommandRegistry
|
||||||
|
{
|
||||||
|
public static NoOpPluginCommandRegistry Instance { get; } = new();
|
||||||
|
|
||||||
|
private NoOpPluginCommandRegistry()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable Register(string verb, Action<PluginCommand> handler)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(verb);
|
||||||
|
ArgumentNullException.ThrowIfNull(handler);
|
||||||
|
return NoOpLease.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoOpLease : IDisposable
|
||||||
|
{
|
||||||
|
public static NoOpLease Instance { get; } = new();
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs
Normal file
91
src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>The trajectory family VTank asks the client to validate.</summary>
|
||||||
|
public enum PluginProjectilePathKind
|
||||||
|
{
|
||||||
|
Straight = 0,
|
||||||
|
Arc,
|
||||||
|
Missile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why a projectile-path query did or did not admit the shot.</summary>
|
||||||
|
public enum PluginProjectilePathStatus
|
||||||
|
{
|
||||||
|
Unavailable = 0,
|
||||||
|
Clear,
|
||||||
|
Blocked,
|
||||||
|
InvalidTarget,
|
||||||
|
BudgetExceeded,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One VTank collision-debug marker in client world coordinates.</summary>
|
||||||
|
public readonly record struct PluginProjectileDebugSample(
|
||||||
|
Vector3 WorldPosition,
|
||||||
|
bool IsClear,
|
||||||
|
float Radius);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detached result of one bounded collision probe. The host reports geometry;
|
||||||
|
/// the plugin still decides whether to cast, fire, or choose a fallback.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginProjectilePathResult(
|
||||||
|
PluginProjectilePathStatus Status,
|
||||||
|
int CollisionChecks = 0,
|
||||||
|
uint BlockingObjectId = 0u,
|
||||||
|
string? Notice = null)
|
||||||
|
{
|
||||||
|
public bool IsClear => Status == PluginProjectilePathStatus.Clear;
|
||||||
|
public IReadOnlyList<PluginProjectileDebugSample> DebugSamples
|
||||||
|
{ get; init; } = Array.Empty<PluginProjectileDebugSample>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IProjectileAutomation
|
||||||
|
{
|
||||||
|
bool IsAvailable => false;
|
||||||
|
|
||||||
|
PluginProjectilePathResult EvaluatePath(
|
||||||
|
uint targetObjectId,
|
||||||
|
PluginProjectilePathKind kind,
|
||||||
|
PluginAttackHeight targetHeight,
|
||||||
|
float projectileRadius,
|
||||||
|
float stepDistance,
|
||||||
|
int maximumCollisionChecks) =>
|
||||||
|
new(PluginProjectilePathStatus.Unavailable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same bounded query with VTank's optional per-quantum debug markers.
|
||||||
|
/// Older hosts safely fall back to the ordinary result.
|
||||||
|
/// </summary>
|
||||||
|
PluginProjectilePathResult EvaluatePathWithDiagnostics(
|
||||||
|
uint targetObjectId,
|
||||||
|
PluginProjectilePathKind kind,
|
||||||
|
PluginAttackHeight targetHeight,
|
||||||
|
float projectileRadius,
|
||||||
|
float stepDistance,
|
||||||
|
int maximumCollisionChecks) =>
|
||||||
|
EvaluatePath(
|
||||||
|
targetObjectId,
|
||||||
|
kind,
|
||||||
|
targetHeight,
|
||||||
|
projectileRadius,
|
||||||
|
stepDistance,
|
||||||
|
maximumCollisionChecks);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
void ShowDebugSamples(
|
||||||
|
IReadOnlyList<PluginProjectileDebugSample> samples)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs
Normal file
21
src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Result of one explicit operator recovery operation.</summary>
|
||||||
|
public readonly record struct PluginRecoveryResult(
|
||||||
|
bool Accepted,
|
||||||
|
int PreviousCount = 0,
|
||||||
|
int CurrentCount = 0,
|
||||||
|
string Message = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRecoveryAutomation
|
||||||
|
{
|
||||||
|
PluginRecoveryResult ClearOneBusyReference() => new(
|
||||||
|
Accepted: false,
|
||||||
|
Message: "Action recovery is unavailable on this host.");
|
||||||
|
}
|
||||||
18
src/AcDream.Plugin.Abstractions/SelectionAutomation.cs
Normal file
18
src/AcDream.Plugin.Abstractions/SelectionAutomation.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public enum PluginSelectionAction
|
||||||
|
{
|
||||||
|
PreviousSelection = 0,
|
||||||
|
PreviousPlayer,
|
||||||
|
NextPlayer,
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISelectionAutomation
|
||||||
|
{
|
||||||
|
bool Execute(PluginSelectionAction action) => false;
|
||||||
|
}
|
||||||
117
src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs
Normal file
117
src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detached canonical world-object projection for general plugins and
|
||||||
|
/// expression engines. The host reports facts; filtering and automation
|
||||||
|
/// policy remain in the plugin.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>
|
||||||
|
/// Decal-compatible monotonic millisecond tick of the latest successful
|
||||||
|
/// identify response for this exact object lifetime.
|
||||||
|
/// </summary>
|
||||||
|
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<uint> SpellIds { get; init; } = Array.Empty<uint>();
|
||||||
|
public IReadOnlyList<uint> ActiveSpellIds { get; init; } = Array.Empty<uint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IWorldObjectAutomation
|
||||||
|
{
|
||||||
|
bool IsAvailable => false;
|
||||||
|
uint OpenContainerObjectId => 0u;
|
||||||
|
|
||||||
|
IReadOnlyList<PluginWorldObject> CaptureObjects() =>
|
||||||
|
Array.Empty<PluginWorldObject>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
20
src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs
Normal file
20
src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Authoritative Dereth calendar facts projected from Runtime.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -22,8 +22,7 @@
|
||||||
<None Update="mosstank.xml">
|
<None Update="mosstank.xml">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<None Update="mosstank-settings.xml">
|
<EmbeddedResource Include="VtankCraftRecipes.tsv" />
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<EmbeddedResource Include="VtankAmmunitionOptions.tsv" />
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
408
src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs
Normal file
408
src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs
Normal file
|
|
@ -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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AttackSpellCatalog
|
||||||
|
{
|
||||||
|
private const uint TuskerFistsSpellId = 0x0B76u;
|
||||||
|
private readonly AttackSpellChoice[] _choices;
|
||||||
|
|
||||||
|
private AttackSpellCatalog(AttackSpellChoice[] choices) =>
|
||||||
|
_choices = choices;
|
||||||
|
|
||||||
|
public static AttackSpellCatalog Build(
|
||||||
|
IReadOnlyList<PluginSpellInfo> spells)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(spells);
|
||||||
|
var choices = new List<AttackSpellChoice>();
|
||||||
|
foreach (PluginSpellInfo spell in spells)
|
||||||
|
{
|
||||||
|
if (TryClassify(spell, out AttackSpellChoice choice))
|
||||||
|
choices.Add(choice);
|
||||||
|
}
|
||||||
|
return new AttackSpellCatalog([.. choices]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AttackSpellChoice> 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<AttackSpellChoice>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/AcDream.Plugins.MossTank/AutoAttackPower.cs
Normal file
173
src/AcDream.Plugins.MossTank/AutoAttackPower.cs
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verbatim decision tree from official VTank <c>hi.cs</c> immediately before
|
||||||
|
/// its call to <c>bo.a(target,power,spell)</c>. This odd-looking table is
|
||||||
|
/// intentional: slash/pierce hybrid weapons use different charge points for
|
||||||
|
/// single, triple-strike, dual-wield and shield arrangements.
|
||||||
|
/// </summary>
|
||||||
|
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<PluginInventoryItem> 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<PluginInventoryItem> 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<PluginInventoryItem> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,18 +5,32 @@ namespace AcDream.Plugins.MossTank;
|
||||||
/// <summary>Settings that shape a buff pass. Defaults follow Virindi Tank's.</summary>
|
/// <summary>Settings that shape a buff pass. Defaults follow Virindi Tank's.</summary>
|
||||||
public sealed class BuffSettings
|
public sealed class BuffSettings
|
||||||
{
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
/// <summary>
|
||||||
|
/// VTank's separate idle top-off rule. The ordinary rebuff rule always
|
||||||
|
/// uses <see cref="RebuffWhenUnderSeconds"/>; this wider window is only
|
||||||
|
/// considered after combat, loot, and navigation have found no work.
|
||||||
|
/// </summary>
|
||||||
|
public bool IdleBuffTopoff { get; set; }
|
||||||
|
public double IdleBuffTopoffSeconds { get; set; } = 1200.0;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// VTank recasts buffs once they drop below five minutes remaining
|
/// VTank recasts buffs once they drop below five minutes remaining
|
||||||
/// ("all buff spells are recast when they go below 5 minutes").
|
/// ("all buff spells are recast when they go below 5 minutes").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double RebuffWhenUnderSeconds { get; set; } = 300.0;
|
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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How far the casting skill must exceed a spell's difficulty before the
|
/// How far the casting skill must exceed a spell's difficulty before the
|
||||||
/// tier is considered reliable — VTank's
|
/// tier is considered reliable — VTank's
|
||||||
/// <c>SpellDiffExcessThreshold-Buff</c>.
|
/// <c>SpellDiffExcessThreshold-Buff</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SkillExcessOverDifficulty { get; set; } = 10;
|
public int SkillExcessOverDifficulty { get; set; } = 5;
|
||||||
|
|
||||||
/// <summary>Buff every attribute (VTank's default).</summary>
|
/// <summary>Buff every attribute (VTank's default).</summary>
|
||||||
public bool BuffAttributes { get; set; } = true;
|
public bool BuffAttributes { get; set; } = true;
|
||||||
|
|
@ -26,6 +40,8 @@ public sealed class BuffSettings
|
||||||
/// their own profile (<c>BuffProfile_Prots</c>) and casts them by default.
|
/// their own profile (<c>BuffProfile_Prots</c>) and casts them by default.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool BuffProtections { get; set; } = true;
|
public bool BuffProtections { get; set; } = true;
|
||||||
|
public string ProtectionElements { get; set; } = "ALFCBPS";
|
||||||
|
public int ProtectionProfileMode { get; set; } = 2;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift
|
/// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift
|
||||||
|
|
@ -38,6 +54,8 @@ public sealed class BuffSettings
|
||||||
/// in their own profile (<c>BuffProfile_Banes</c>) and casts them by default.
|
/// in their own profile (<c>BuffProfile_Banes</c>) and casts them by default.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool BuffBanes { get; set; } = true;
|
public bool BuffBanes { get; set; } = true;
|
||||||
|
public string BaneElements { get; set; } = "ALFCBPS";
|
||||||
|
public int BaneProfileMode { get; set; } = 2;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The vital regeneration rates — Regeneration (health), Rejuvenation
|
/// The vital regeneration rates — Regeneration (health), Rejuvenation
|
||||||
|
|
@ -57,6 +75,15 @@ public sealed class BuffSettings
|
||||||
/// "automatically buffs every Attribute and Skill you have trained".
|
/// "automatically buffs every Attribute and Skill you have trained".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool BuffTrainedSkillsOnly { get; set; } = true;
|
public bool BuffTrainedSkillsOnly { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int BuffWithUntrainedItemSkill { get; set; } = 80;
|
||||||
|
public int BuffWithUntrainedCreatureSkill { get; set; } = 80;
|
||||||
|
public int BuffWithUntrainedLifeSkill { get; set; } = 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -80,8 +107,12 @@ public static class BuffPlan
|
||||||
IReadOnlyList<PluginAttributeInfo> attributes,
|
IReadOnlyList<PluginAttributeInfo> attributes,
|
||||||
IReadOnlyList<PluginActiveEnchantment> active,
|
IReadOnlyList<PluginActiveEnchantment> active,
|
||||||
BuffSettings settings,
|
BuffSettings settings,
|
||||||
bool force = false)
|
bool force = false,
|
||||||
|
double? rebuffWhenUnderSeconds = null,
|
||||||
|
int characterLevel = 0)
|
||||||
{
|
{
|
||||||
|
if (!settings.Enabled && !force)
|
||||||
|
return [];
|
||||||
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
|
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (PluginSkillInfo skill in skills)
|
foreach (PluginSkillInfo skill in skills)
|
||||||
|
|
@ -120,16 +151,29 @@ public static class BuffPlan
|
||||||
|
|
||||||
foreach (BuffLine line in lines)
|
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
|
bool wanted = line.Kind switch
|
||||||
{
|
{
|
||||||
BuffTargetKind.Attribute =>
|
BuffTargetKind.Attribute =>
|
||||||
settings.BuffAttributes && attributeNames.Contains(line.TargetName),
|
schoolAvailable && settings.BuffAttributes
|
||||||
BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName),
|
&& attributeNames.Contains(line.TargetName),
|
||||||
BuffTargetKind.Protection => settings.BuffProtections,
|
BuffTargetKind.Skill => schoolAvailable
|
||||||
BuffTargetKind.Aura => settings.BuffAuras,
|
&& (trainedSkills.ContainsKey(line.TargetName)
|
||||||
BuffTargetKind.Bane => settings.BuffBanes,
|
|| IsMagicSchoolName(line.TargetName)),
|
||||||
BuffTargetKind.Regeneration => settings.BuffRegeneration,
|
BuffTargetKind.Protection =>
|
||||||
BuffTargetKind.Other => settings.BuffOther,
|
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,
|
_ => false,
|
||||||
};
|
};
|
||||||
if (!wanted)
|
if (!wanted)
|
||||||
|
|
@ -141,7 +185,8 @@ public static class BuffPlan
|
||||||
if (!force
|
if (!force
|
||||||
&& inForce.TryGetValue(line.Family, out var held)
|
&& inForce.TryGetValue(line.Family, out var held)
|
||||||
&& held.Tier >= pick.Tier
|
&& held.Tier >= pick.Tier
|
||||||
&& held.Seconds >= settings.RebuffWhenUnderSeconds)
|
&& held.Seconds >= (rebuffWhenUnderSeconds
|
||||||
|
?? settings.RebuffWhenUnderSeconds))
|
||||||
{
|
{
|
||||||
continue; // already covered at this strength, and not expiring
|
continue; // already covered at this strength, and not expiring
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +212,91 @@ public static class BuffPlan
|
||||||
return ordered;
|
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<PluginSkillInfo> 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Skill ids of the three schools that carry self-buffs.</summary>
|
/// <summary>Skill ids of the three schools that carry self-buffs.</summary>
|
||||||
private const uint CreatureEnchantmentSkill = 31;
|
private const uint CreatureEnchantmentSkill = 31;
|
||||||
private const uint ItemEnchantmentSkill = 32;
|
private const uint ItemEnchantmentSkill = 32;
|
||||||
|
|
|
||||||
2059
src/AcDream.Plugins.MossTank/CombatController.cs
Normal file
2059
src/AcDream.Plugins.MossTank/CombatController.cs
Normal file
File diff suppressed because it is too large
Load diff
173
src/AcDream.Plugins.MossTank/CombatFailureTracker.cs
Normal file
173
src/AcDream.Plugins.MossTank/CombatFailureTracker.cs
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank;
|
||||||
|
|
||||||
|
internal enum CombatSuppressionReason
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Blacklisted,
|
||||||
|
Ghost,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implements VTank's three distinct unhittable-target guards. “Ghost” is
|
||||||
|
/// session-persistent until the object disappears; a normal blacklist expires
|
||||||
|
/// after the configured timeout.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CombatFailureTracker
|
||||||
|
{
|
||||||
|
private readonly Dictionary<uint, Entry> _entries = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<uint> ObserveTargets(
|
||||||
|
IReadOnlyList<PluginCombatTarget> targets,
|
||||||
|
double now,
|
||||||
|
CombatSettings settings)
|
||||||
|
{
|
||||||
|
var live = new HashSet<uint>();
|
||||||
|
List<uint>? 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<uint>)Array.Empty<uint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
224
src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs
Normal file
224
src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of official VTank <c>dz.b.CompareTo</c> plus <c>dz.a(MySpell,f7)</c>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<CombatDebuffSource> Candidates(
|
||||||
|
MonsterRuleActions actions,
|
||||||
|
CombatSettings settings,
|
||||||
|
ICharacterInfo character,
|
||||||
|
ISpellCatalog spells,
|
||||||
|
IReadOnlyList<PluginInventoryItem> items,
|
||||||
|
Func<DebuffIdentity, PluginSpellInfo, bool> isDue)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actions);
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
ArgumentNullException.ThrowIfNull(character);
|
||||||
|
ArgumentNullException.ThrowIfNull(spells);
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
ArgumentNullException.ThrowIfNull(isDue);
|
||||||
|
|
||||||
|
HashSet<DebuffIdentity> required = DebuffSpellCatalog.Required(actions);
|
||||||
|
if (required.Count == 0)
|
||||||
|
return Array.Empty<CombatDebuffSource>();
|
||||||
|
|
||||||
|
var result = new List<CombatDebuffSource>();
|
||||||
|
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<CombatDebuffSource> result,
|
||||||
|
IReadOnlySet<DebuffIdentity> required,
|
||||||
|
PluginInventoryItem item,
|
||||||
|
ISpellCatalog spells,
|
||||||
|
Func<DebuffIdentity, PluginSpellInfo, bool> 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<CombatDebuffSource> result,
|
||||||
|
IReadOnlySet<DebuffIdentity> required,
|
||||||
|
PluginInventoryItem item,
|
||||||
|
ICharacterInfo character,
|
||||||
|
ISpellCatalog spells,
|
||||||
|
Func<DebuffIdentity, PluginSpellInfo, bool> 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<CombatDebuffSource> result,
|
||||||
|
IReadOnlySet<DebuffIdentity> required,
|
||||||
|
PluginSpellInfo spell,
|
||||||
|
CombatDebuffSourceKind kind,
|
||||||
|
uint itemObjectId,
|
||||||
|
int sourceSkill,
|
||||||
|
Func<DebuffIdentity, PluginSpellInfo, bool> 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;
|
||||||
|
}
|
||||||
151
src/AcDream.Plugins.MossTank/CombatSettings.cs
Normal file
151
src/AcDream.Plugins.MossTank/CombatSettings.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
/// <summary>VTank's hunt-cast skill margin.</summary>
|
||||||
|
public int HuntSkillExcessOverDifficulty { get; set; } = 25;
|
||||||
|
public float MaximumRange { get; set; } = 5f;
|
||||||
|
/// <summary>
|
||||||
|
/// Monsters nearer than this are not valid attack targets. VTank applies
|
||||||
|
/// this before priority and angle/range ranking.
|
||||||
|
/// </summary>
|
||||||
|
public float MinimumRange { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// VTank's Approach Distance. Zero disables monster approach; otherwise
|
||||||
|
/// navigation may close a selected target from this range down to
|
||||||
|
/// <see cref="MaximumRange"/>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime object ids resolved from VTank's Items profile. Debuff lenses
|
||||||
|
/// and cast-on-strike weapons are never taken from arbitrary inventory.
|
||||||
|
/// </summary>
|
||||||
|
public ISet<uint> CombatItemObjectIds { get; } = new HashSet<uint>();
|
||||||
|
public ISet<string> CombatItemNames { get; } =
|
||||||
|
new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
/// <summary>Exact names enabled in VTank's Consumables profile.</summary>
|
||||||
|
public ISet<string> ConsumableNames { get; } =
|
||||||
|
new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
public IDictionary<string, ConsumableCategory> ConsumableCategories { get; } =
|
||||||
|
new Dictionary<string, ConsumableCategory>(StringComparer.Ordinal);
|
||||||
|
public IList<MonsterRule> Rules { get; } =
|
||||||
|
new List<MonsterRule> { 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<string, MonsterValue> DynamicSettings { get; } =
|
||||||
|
new Dictionary<string, MonsterValue>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private MonsterValue? ResolveSetting(string name) =>
|
||||||
|
DynamicSettings.TryGetValue(name, out MonsterValue value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
649
src/AcDream.Plugins.MossTank/Crafting.cs
Normal file
649
src/AcDream.Plugins.MossTank/Crafting.cs
Normal file
|
|
@ -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<PluginInventoryItem> inventory,
|
||||||
|
IEnumerable<string> 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<string>(StringComparer.OrdinalIgnoreCase),
|
||||||
|
arrowheadFletchDifficultyExcess);
|
||||||
|
if (plan is not null)
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CraftingPlan? PlanPeaSplit(
|
||||||
|
IReadOnlyList<PluginInventoryItem> inventory,
|
||||||
|
ISet<string> 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<PluginInventoryItem> inventory,
|
||||||
|
ICharacterInfo character,
|
||||||
|
IReadOnlyDictionary<string, int> counts,
|
||||||
|
HashSet<string> 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<PluginInventoryItem> 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";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<PluginInventoryItem> 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<PluginInventoryItem> 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<PluginInventoryItem> 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<PluginInventoryItem> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
src/AcDream.Plugins.MossTank/DebuffScheduler.cs
Normal file
400
src/AcDream.Plugins.MossTank/DebuffScheduler.cs
Normal file
|
|
@ -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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<PluginSpellInfo> spells)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(spells);
|
||||||
|
var choices = new List<DebuffChoice>();
|
||||||
|
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<DebuffChoice> Candidates(
|
||||||
|
MonsterRuleActions actions,
|
||||||
|
DebuffSelectionMethod selection,
|
||||||
|
ICharacterInfo character,
|
||||||
|
Func<DebuffIdentity, PluginSpellInfo, bool> isDue)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actions);
|
||||||
|
ArgumentNullException.ThrowIfNull(character);
|
||||||
|
ArgumentNullException.ThrowIfNull(isDue);
|
||||||
|
|
||||||
|
HashSet<DebuffIdentity> required = Required(actions);
|
||||||
|
if (required.Count == 0 || _choices.Length == 0)
|
||||||
|
return Array.Empty<DebuffChoice>();
|
||||||
|
|
||||||
|
var candidates = new List<DebuffChoice>();
|
||||||
|
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<DebuffIdentity> 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<DebuffIdentity> Required(MonsterRuleActions actions)
|
||||||
|
{
|
||||||
|
var required = new HashSet<DebuffIdentity>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's levels I-VII vulnerability line uses the older * Lure names.
|
||||||
|
/// Do not confuse it with the distinct Lure Blade item-enchantment line.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session-local VTank spell tracker. A debuff becomes active only after the
|
||||||
|
/// host publishes its matching server UseDone receipt.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VTank's <c>/vt fakeimp</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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<uint> 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);
|
||||||
420
src/AcDream.Plugins.MossTank/DispelController.cs
Normal file
420
src/AcDream.Plugins.MossTank/DispelController.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<PluginInventoryItem> 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<PluginInventoryItem> inventory,
|
||||||
|
IEnumerable<string> 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<PluginTrackedEnchantment> tracked =
|
||||||
|
automation.Enchantments.Capture(member.ObjectId);
|
||||||
|
if (tracked.Any(static enchantment =>
|
||||||
|
enchantment.SpellId == DispelProtectionSpell
|
||||||
|
&& enchantment.SecondsRemaining > 0d))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qualities = new Dictionary<MonsterDamageType, int>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,653 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank.Expressions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CoreExpressionFunctions
|
||||||
|
{
|
||||||
|
private static readonly Regex CoordinatePattern = new(
|
||||||
|
@"^\s*(?<ns>[-+]?\d+(?:\.\d+)?)\s*(?<nsdir>[NS])\s*,\s*"
|
||||||
|
+ @"(?<ew>[-+]?\d+(?:\.\d+)?)\s*(?<ewdir>[EW])"
|
||||||
|
+ @"(?:\s*,\s*(?<z>[-+]?\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<double, double> 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<object>(
|
||||||
|
ReferenceEqualityComparer.Instance)))
|
||||||
|
{
|
||||||
|
throw new ExpressionEvaluationException(
|
||||||
|
$"{operation} cannot create a cyclic collection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsReference(
|
||||||
|
in ExpressionValue value,
|
||||||
|
object destination,
|
||||||
|
HashSet<object> 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<ExpressionValue>
|
||||||
|
{
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs
Normal file
84
src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank.Expressions;
|
||||||
|
|
||||||
|
/// <summary>UtilityBelt-compatible session XP/luminance accumulator.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
789
src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs
Normal file
789
src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs
Normal file
|
|
@ -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<int, bool> 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<Node>();
|
||||||
|
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<Node>();
|
||||||
|
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<Node> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs
Normal file
204
src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
namespace AcDream.Plugins.MossTank.Expressions;
|
||||||
|
|
||||||
|
internal enum ExpressionVariableScope
|
||||||
|
{
|
||||||
|
Session,
|
||||||
|
Persistent,
|
||||||
|
Global,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ExpressionState
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ExpressionValue> _session =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, ExpressionValue> _persistent =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, ExpressionValue> _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<string, ExpressionValue> Capture(
|
||||||
|
ExpressionVariableScope scope) =>
|
||||||
|
new Dictionary<string, ExpressionValue>(Table(scope),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public void Replace(
|
||||||
|
ExpressionVariableScope scope,
|
||||||
|
IEnumerable<KeyValuePair<string, ExpressionValue>> values)
|
||||||
|
{
|
||||||
|
Dictionary<string, ExpressionValue> target = Table(scope);
|
||||||
|
target.Clear();
|
||||||
|
foreach ((string name, ExpressionValue value) in values)
|
||||||
|
target[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, ExpressionValue> 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<ExpressionValue> arguments);
|
||||||
|
|
||||||
|
internal sealed record ExpressionFunction(
|
||||||
|
string Name,
|
||||||
|
int MinimumArguments,
|
||||||
|
int MaximumArguments,
|
||||||
|
ExpressionFunctionHandler Handler,
|
||||||
|
string Signature,
|
||||||
|
string Description);
|
||||||
|
|
||||||
|
internal sealed class ExpressionFunctionRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ExpressionFunction> _functions =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public IReadOnlyCollection<ExpressionFunction> 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<ExpressionValue> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs
Normal file
240
src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs
Normal file
|
|
@ -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<ExpressionValue> Items { get; } = [];
|
||||||
|
|
||||||
|
public ExpressionList()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpressionList(IEnumerable<ExpressionValue> values) =>
|
||||||
|
Items.AddRange(values);
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[{string.Join(",", Items.Select(static item => item.ToDisplayString()))}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ExpressionDictionary
|
||||||
|
{
|
||||||
|
public Dictionary<string, ExpressionValue> 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<ExpressionValue>
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
}
|
||||||
1276
src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs
Normal file
1276
src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<DelayedExpression> _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<ExpressionFunction> 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<string, StoredValue>? document = JsonSerializer.Deserialize<
|
||||||
|
Dictionary<string, StoredValue>>(json, JsonOptions);
|
||||||
|
if (document is not null)
|
||||||
|
{
|
||||||
|
_state.Replace(scope, document.Select(static pair =>
|
||||||
|
new KeyValuePair<string, ExpressionValue>(
|
||||||
|
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<string, StoredValue> 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<string, StoredValue>? 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<StoredValue>? List { get; set; }
|
||||||
|
public Dictionary<string, StoredValue>? Dictionary { get; set; }
|
||||||
|
public double[]? Coordinates { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct DelayedExpression(
|
||||||
|
int Id,
|
||||||
|
double RemainingMilliseconds,
|
||||||
|
string Source);
|
||||||
|
}
|
||||||
152
src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs
Normal file
152
src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank.Expressions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UtilityBelt-compatible /myquests cache. The server remains authoritative;
|
||||||
|
/// this owner only parses the same lines UB consumes and never invents flags.
|
||||||
|
/// </summary>
|
||||||
|
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<string, QuestFlag> _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(
|
||||||
|
"(?<key>\\S+) \\- (?<solves>\\d+) solves \\((?<completedOn>\\d{0,11})\\)\"?((?<description>.*)\" (?<maxSolves>.*) (?<repeatTime>\\d{0,11}))?.*$",
|
||||||
|
RegexOptions.CultureInvariant,
|
||||||
|
100)]
|
||||||
|
private static partial Regex MyQuestLine();
|
||||||
|
|
||||||
|
private readonly record struct QuestFlag(
|
||||||
|
int Solves,
|
||||||
|
int MaxSolves,
|
||||||
|
DateTimeOffset CompletedOn,
|
||||||
|
long RepeatSeconds);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank.Expressions;
|
||||||
|
|
||||||
|
/// <summary>UtilityBelt UST expression staging over the canonical salvage command.</summary>
|
||||||
|
internal sealed class SalvageStagingManager(IPluginHost host)
|
||||||
|
{
|
||||||
|
private readonly HashSet<uint> _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<PluginInventoryItem?>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
return ust is { } found
|
||||||
|
&& host.Automation.Items.Use(found.ObjectId).Accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Salvage()
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginInventoryItem> inventory =
|
||||||
|
host.Automation.Items.CaptureOwnedItems();
|
||||||
|
PluginInventoryItem? ust = inventory
|
||||||
|
.Where(static item => item.Name.Equals("Ust", StringComparison.Ordinal))
|
||||||
|
.OrderBy(static item => item.ObjectId)
|
||||||
|
.Cast<PluginInventoryItem?>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
68
src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs
Normal file
68
src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank.Expressions;
|
||||||
|
|
||||||
|
/// <summary>VTank Meta status HUD backed by one shelf-managed plugin window.</summary>
|
||||||
|
internal sealed class StatusHudManager(IPluginHost host)
|
||||||
|
{
|
||||||
|
private const uint DefaultColor = 0xE8DEC3u;
|
||||||
|
private const string Markup = """
|
||||||
|
<panel x="520" y="42" w="340" h="220" title="VTank Meta Status"
|
||||||
|
visible="{WindowAvailable}" resize="none">
|
||||||
|
<label x="8" y="25" text="VTank Meta" color="#FFE8DEC3" />
|
||||||
|
<list x="8" y="45" w="324" h="166" rowheight="18"
|
||||||
|
items="{Rows}" colors="{RowColors}" selected="{SelectedRow}" />
|
||||||
|
</panel>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly Dictionary<string, StatusEntry> _entries =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
private readonly StatusBinding _binding = new();
|
||||||
|
private IDisposable? _registration;
|
||||||
|
|
||||||
|
public int Count => _entries.Count;
|
||||||
|
internal IReadOnlyList<string> Rows => _binding.Rows;
|
||||||
|
internal IReadOnlyList<uint> 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<string> Rows { get; internal set; } = [];
|
||||||
|
public IReadOnlyList<uint> RowColors { get; internal set; } = [];
|
||||||
|
public int SelectedRow => -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
523
src/AcDream.Plugins.MossTank/FellowshipManager.cs
Normal file
523
src/AcDream.Plugins.MossTank/FellowshipManager.cs
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<WaitingPlayer> _waiting = [];
|
||||||
|
private readonly HashSet<string> _banned =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, double> _voteCooldowns =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly List<FellowVote> _votes = [];
|
||||||
|
private readonly Dictionary<string, Queue<double>> _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<string> WaitingNames =>
|
||||||
|
_waiting.Select(static value => value.Name).ToArray();
|
||||||
|
|
||||||
|
public void Tick(double elapsedSeconds, bool enabled)
|
||||||
|
{
|
||||||
|
_now += Math.Max(0d, elapsedSeconds);
|
||||||
|
IReadOnlyList<PluginChatMessage> 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<PluginFellowMember> 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<PluginFellowMember> 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<PluginFellowMember> 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<PluginFellowMember> 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<double>? times))
|
||||||
|
{
|
||||||
|
times = new Queue<double>();
|
||||||
|
_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<string, bool> Ballots { get; } =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public string Description => $"'{Kind} {Parameter}' (ID {Id})";
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/AcDream.Plugins.MossTank/GrenadeCatalog.cs
Normal file
79
src/AcDream.Plugins.MossTank/GrenadeCatalog.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
namespace AcDream.Plugins.MossTank;
|
||||||
|
|
||||||
|
internal readonly record struct GrenadeDefinition(
|
||||||
|
string Name,
|
||||||
|
uint SpellId,
|
||||||
|
int Spellcraft,
|
||||||
|
int RequiredAlchemy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VTank's exact 72-entry GameInfoDB GrenadeOptions table. The source is the
|
||||||
|
/// official Virindi update feed (DB version 9), not an inferred name pattern.
|
||||||
|
/// </summary>
|
||||||
|
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<GrenadeDefinition> Entries = Build();
|
||||||
|
private static readonly IReadOnlyDictionary<string, GrenadeDefinition> ByName =
|
||||||
|
Entries.ToDictionary(entry => entry.Name, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public static IReadOnlyList<GrenadeDefinition> All => Entries;
|
||||||
|
|
||||||
|
public static bool TryGet(string exactName, out GrenadeDefinition definition) =>
|
||||||
|
ByName.TryGetValue(exactName, out definition);
|
||||||
|
|
||||||
|
private static IReadOnlyList<GrenadeDefinition> Build()
|
||||||
|
{
|
||||||
|
var result = new List<GrenadeDefinition>(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<GrenadeDefinition> result,
|
||||||
|
Tier tier,
|
||||||
|
string effect,
|
||||||
|
uint spellId) =>
|
||||||
|
result.Add(new GrenadeDefinition(
|
||||||
|
$"{tier.Name} Phial of {effect}",
|
||||||
|
spellId,
|
||||||
|
tier.Spellcraft,
|
||||||
|
tier.RequiredAlchemy));
|
||||||
|
}
|
||||||
299
src/AcDream.Plugins.MossTank/InventoryMaintenance.cs
Normal file
299
src/AcDream.Plugins.MossTank/InventoryMaintenance.cs
Normal file
|
|
@ -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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class InventoryMaintenancePlanner
|
||||||
|
{
|
||||||
|
private const uint PublicWeenieFoci = 0x00800000u;
|
||||||
|
private static readonly ISet<uint> EmptyIgnored = new HashSet<uint>();
|
||||||
|
|
||||||
|
public static InventoryMaintenancePlan? Plan(
|
||||||
|
IReadOnlyList<PluginInventoryItem> items,
|
||||||
|
uint playerObjectId,
|
||||||
|
InventorySettings settings,
|
||||||
|
ISet<uint>? 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<PluginInventoryItem> items,
|
||||||
|
ISet<uint> ignored)
|
||||||
|
{
|
||||||
|
Dictionary<uint, PluginInventoryItem> byId = items.ToDictionary(
|
||||||
|
static item => item.ObjectId);
|
||||||
|
foreach (IGrouping<uint, PluginInventoryItem> 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<PluginInventoryItem> items,
|
||||||
|
uint playerObjectId,
|
||||||
|
ISet<uint> 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<uint, int> 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<uint, PluginInventoryItem> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes one StackCram operation at a time and waits for the host's
|
||||||
|
/// authoritative inventory receipt before planning the next one.
|
||||||
|
/// </summary>
|
||||||
|
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<uint> _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";
|
||||||
|
|
||||||
|
/// <summary>Returns true only when StackCram owns this scheduler tick.</summary>
|
||||||
|
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<PluginInventoryItem> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/AcDream.Plugins.MossTank/ItemManaRecharge.cs
Normal file
140
src/AcDream.Plugins.MossTank/ItemManaRecharge.cs
Normal file
|
|
@ -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<PluginInventoryItem> inventory,
|
||||||
|
ISet<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue