feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -24,6 +24,79 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #451 — GLFW can dereference another acdream process's private window pointer after cross-process activation
**Status:** IN-PROGRESS — exact root fixed; 100-switch/30-minute dual-client
stress passed 2026-08-27. Graceful exit of both sessions remains before closure.
**Component:** graphical host / GLFW Win32 event pump / multi-process stability.
**Severity:** HIGH for multi-account play; one of the two sessions is lost without
an orderly disconnect.
Running two copies of the exact isolated `app-release23` graphical artifact
against local ACE reproduced the same access violation three times. The
faulting process varied: secondary PID 13688 at 15:01:39, primary PID 15300 at
15:03:35, and fresh primary PID 31412 at 15:08:58. The last occurrence fired
while the fresh primary was still on character selection, before EnterWorld;
the already-in-world secondary survived. Windows Application Error reports
all three as `coreclr.dll` exception `0xC0000005`, fault offset `0x356d4f`.
The managed terminal stack is only:
```text
Silk.NET.Windowing.WindowExtensions...Run
Silk.NET.Windowing.Internals.ViewImplementationBase.Run
Silk.NET.Windowing.Glfw.GlfwWindow.Run
AcDream.App.Rendering.GameWindow.Run
```
This is not #422's rare `0xC0000374` heap corruption during graceful process
exit: #451 happens while both graphical clients are active and reproduces
quickly. It is also not a MossTank/plugin-API, CoreCLR, Vulkan, PAK, or world-
cache failure.
**Exact root (first-chance cdb proof):** the access violation is in packaged
GLFW's Win32 event pump at `glfw3+0x10681`, not in CoreCLR. During its modifier-
key repair pass `_glfwPollEventsWin32` calls `GetActiveWindow`, then
`GetPropW(hwnd, L"GLFW")`, and dereferences the returned value as this
process's `_GLFWwindow*`. Windows UI automation temporarily joins input queues,
so the primary process can receive the secondary process's HWND. Because every
GLFW process uses the same `GLFW` property name, `GetPropW` succeeds but returns
the secondary process's private pointer. The crashed primary had
`rbx=00000202a7180ab0`; a debugger breakpoint in the surviving secondary
reported its own valid `ACTIVE_GLFW_WINDOW=00000202a7180ab0` — exact pointer
identity across the process boundary.
**Fix:** `Win32GlfwActiveWindowGuard` patches only `glfw3.dll`'s import-address-
table slot for `USER32!GetActiveWindow`, after the GLFW library is loaded and
before `glfwInit`/window creation. The replacement returns the real HWND only
when `GetWindowThreadProcessId` says it belongs to the current process;
otherwise it returns zero, GLFW's existing safe "nothing to repair" branch.
There is no system-wide hook and no other module is changed. Four focused
tests cover local, foreign, null and unowned HWNDs.
The isolated `app-release24` live gate launched two graphical clients, both
logged `GLFW foreign-active-window guard installed (#451)`, entered the world,
and remained responsive through 100 rapid forced cross-process activation
switches — the exact prior trigger — plus a 30-minute combined in-world soak.
The local peer API then passed in both directions: the secondary evaluated the
primary heartbeat and returned `+Acdream`. A two-member fellowship gate also
passed with both canonical rosters populated and the secondary returning `2`
from `getfellowshipcount[]`; both processes remained alive and responsive.
Evidence:
- `artifacts/live-gates/mosstank-final23-secondary/`
- `artifacts/live-gates/mosstank-final23-secondary2/`
- `artifacts/live-gates/mosstank-final23-primary5/`
- `artifacts/live-gates/i451-cdb-primary-attach/cdb.log`
- `artifacts/live-gates/i451-cdb-secondary/cdb.log`
- `artifacts/live-gates/i451-guard-primary2/`
- `artifacts/live-gates/i451-guard-secondary/`
- Windows Application Error events at 2026-08-27 15:01:39, 15:03:35 and
15:08:58 (same module, exception and offset).
**Next:** close only after both `app-release24` sessions exit gracefully; the
activation and sustained in-world portions of the regression gate have passed.
## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0`
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.

View file

@ -137,6 +137,68 @@ loads none). The headless adapter projects entity snapshots on demand from the
canonical Runtime view, subscribes to Runtime's ordered events, and borrows the
exact Runtime selection owner; it does not mirror gameplay state.
Graphical plugin panels are first-class retained windows. A plugin calls
`IUiRegistry.AddPanel` with a BCL-only `PluginPanelDescriptor`; Core's scoped
host authenticates the owner from the loaded manifest, and App derives the
stable identity `plugin:{pluginId}:{windowId}`. The host, not the plugin, owns
window geometry, z-order, persisted visibility, minimize/restore chrome, and
the shared right-edge plugin shelf. Minimizing only hides the presentation:
the plugin session, event subscriptions, automation policy, and binding object
remain live. Legacy `AddMarkupPanel` registrations are enriched into the same
first-class path, so API-v1 plugins keep working without a second lifecycle.
The markup vocabulary includes nested groups plus retained tab, toggle,
slider, editable-field, and retail-menu controls. Fields bind live
`Action<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,
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
@ -420,6 +482,10 @@ src/
IGameState.cs -> done
IEvents.cs -> done
ISelectionService.cs -> done
IPluginStorage.cs -> manifest-scoped durable text profiles
Automation.cs -> character/spell/magic/chat automation groups
CombatAutomation.cs -> hostile snapshots + retail combat attempts
EnchantmentAutomation.cs -> shared confirmed duration-cast timer ledger
AcDream.App/ Layer 1 + Layer 4 wiring
Platform/

View file

@ -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_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_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_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`) |

View file

@ -184,17 +184,28 @@ panel through `IPanelRenderer`.
## Plugin UI API
The shipped plugin-facing gameplay UI contract is
`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel`: a plugin provides
KSML-style markup and a binding object; the host builds it into the retained
`UiRoot` tree. `IPanel`/`IPanelRenderer` remains a first-party developer-panel
contract and is intentionally not referenced by `Plugin.Abstractions`.
The shipped plugin-facing gameplay UI contract is the additive BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry.AddPanel`: a plugin provides a stable
window id/title/icon descriptor, KSML-style markup, and a binding object; the
host builds it into the retained `UiRoot` tree. The API-v1
`AddMarkupPanel` member remains source/binary compatible and is enriched into
the same first-class window route by the scoped host. `IPanel`/
`IPanelRenderer` remains a historical first-party developer-panel contract and
is intentionally not referenced by `Plugin.Abstractions`.
This makes plugin gameplay panels independent of ImGui while allowing them to
share the retained input, window, and DAT-sprite runtime. Registrations made
before the GL host exists are buffered. In builds where retail UI is disabled,
they remain registered but have no gameplay surface; the long-term release
configuration enables retained gameplay UI.
This makes plugin gameplay panels presentation-assembly independent while
allowing them to share the retained input, window, and DAT-sprite runtime.
Registrations made before the graphical host exists are buffered. The host
assigns `plugin:{pluginId}:{windowId}`, registers every panel with the common
window manager, persists its geometry/visibility, and exposes it through the
shared plugin sidepanel. Hiding/minimizing a panel does not dispose or pause the
plugin. No-window hosts retain the plugin session but expose the no-op UI
capability.
The retained markup vocabulary includes panels, nested groups, labels,
buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to
BCL-visible properties/actions on the plugin binding object; visible controls
must correspond to real behavior, never placeholders that report success.
The following was the original pre-D.2b proposal and remains historical
context, not the shipped plugin contract:
@ -255,7 +266,8 @@ walk around / take damage / regen.
### Sprint 3 — Plugin API hardening (superseded shape)
- Document the `IPanel` contract.
- The shipped route is `IUiRegistry.AddMarkupPanel`, not plugin-owned
- The shipped route is `IUiRegistry.AddPanel` (with `AddMarkupPanel` as the
compatible legacy entry), not plugin-owned
`IPanel` implementations.
- Confirm plugins can subscribe to game events and expose retained markup
bindings without referencing App or ImGui assemblies.

View file

@ -0,0 +1,483 @@
# MossTank — VTank parity campaign
Date: 2026-08-26
Status: ACTIVE — MT1 USER-PASSED; MTUIMT9 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 User1User5;
- [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.
MT7MT9 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/User1User5 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 | MT2MT9 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.

View 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, User1User5 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.

View file

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

View file

@ -112,13 +112,17 @@
AfterTargets="Build"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginSourceDir>
<_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier)</_SmokePluginSourceDir>
<_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke</_SmokePluginDestDir>
</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)" />
<Copy
SourceFiles="$(_SmokePluginSourceDir)/AcDream.Plugins.Smoke.dll"
SourceFiles="@(_SmokePluginBuildTarget)"
DestinationFolder="$(_SmokePluginDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
@ -132,13 +136,17 @@
AfterTargets="Publish"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginPublishSourceDir>
<_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier)</_SmokePluginPublishSourceDir>
<_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke</_SmokePluginPublishDestDir>
</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)" />
<Copy
SourceFiles="$(_SmokePluginPublishSourceDir)/AcDream.Plugins.Smoke.dll"
SourceFiles="@(_SmokePluginPublishTarget)"
DestinationFolder="$(_SmokePluginPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
@ -161,13 +169,17 @@
AfterTargets="Build"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankSourceDir>
<_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier)</_MossTankSourceDir>
<_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank</_MossTankDestDir>
</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)" />
<Copy
SourceFiles="$(_MossTankSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankSourceDir)/mosstank.xml;$(_MossTankSourceDir)/mosstank-settings.xml"
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
DestinationFolder="$(_MossTankDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
@ -181,13 +193,17 @@
AfterTargets="Publish"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankPublishSourceDir>
<_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier)</_MossTankPublishSourceDir>
<_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank</_MossTankPublishDestDir>
</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)" />
<Copy
SourceFiles="$(_MossTankPublishSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankPublishSourceDir)/mosstank.xml;$(_MossTankPublishSourceDir)/mosstank-settings.xml"
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
DestinationFolder="$(_MossTankPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile

View file

@ -97,7 +97,8 @@ internal sealed record InteractionRetainedUiDependencies(
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
RenderPackDiagnostics = null,
string? ScreenshotsDirectory = null)
string? ScreenshotsDirectory = null,
AppAutomationSurface? Automation = null)
{
public RuntimeActionState Actions => Runtime.ActionOwner;
@ -429,6 +430,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
return false;
activeSession.SendSell(vendorGuid, items);
return true;
},
sendSalvage: (toolGuid, itemGuids) =>
{
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
return false;
activeSession.SendSalvage(toolGuid, itemGuids);
return true;
});
}
@ -1230,7 +1238,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
$"Screenshot failed: {error}",
RetailLogTextType.ClientLocal);
}
});
},
ProjectileDebugSamples: d.Automation is null
? null
: d.Automation.CaptureProjectileDebugSamples);
RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);

View file

@ -83,6 +83,7 @@ internal sealed record SessionPlayerDependencies(
CombatFeedbackSlot CombatFeedback,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log,
Func<string, bool>? TryHandlePluginCommand,
/// <summary>Campaign LA slice LA1: the shared per-session status-event
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
/// not configured.</summary>
@ -112,6 +113,7 @@ internal sealed record SessionPlayerResult(
DatSpawnClaimHydrationClassifier SpawnClaimHydration,
LiveSessionController LiveSession,
LiveEntityHydrationController Hydration,
LiveEntityDeletionController Deletion,
LiveEntityNetworkUpdateController NetworkUpdates,
LiveEntityLivenessController Liveness,
LiveEntitySessionController SessionEvents,
@ -357,7 +359,8 @@ internal sealed class SessionPlayerCompositionPhase
// LiveSessionCommandSurface has no dependencies of its own, so
// hoisting its construction is inert; the later site now reuses
// this instance instead of constructing a second one.
var liveSessionCommands = new LiveSessionCommandSurface();
var liveSessionCommands = new LiveSessionCommandSurface(
d.TryHandlePluginCommand);
var settingsTargets = new RuntimeSettingsTargets(
new SilkRuntimeDisplayWindowTarget(d.Window),
live.DrawDispatcher,
@ -1332,6 +1335,7 @@ internal sealed class SessionPlayerCompositionPhase
spawnClaimClassifier,
liveSession,
hydration,
deletion,
networkUpdates,
liveness,
sessionEvents,

View file

@ -63,7 +63,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
return default;
if (_movement.HasCommandInput)
return _movement.CommandInput;
return _movement.CommandInput with { IsPersistentCommand = true };
if (_dispatcher is not { } dispatcher)
return default;

View file

@ -36,11 +36,17 @@ internal sealed class LiveSessionAppSource
/// retained UI may keep this surface, while the displaced route itself becomes
/// inert before inbound subscriptions detach.
/// </summary>
internal sealed class LiveSessionCommandSurface : ICommandBus
internal sealed class LiveSessionCommandSurface : IPluginCommandBus
{
private readonly object _gate = new();
private readonly Func<string, bool>? _tryHandlePluginCommand;
private LiveSessionCommandRouter? _active;
public LiveSessionCommandSurface(Func<string, bool>? tryHandlePluginCommand = null)
{
_tryHandlePluginCommand = tryHandlePluginCommand;
}
public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route)
{
ArgumentNullException.ThrowIfNull(route);
@ -65,6 +71,9 @@ internal sealed class LiveSessionCommandSurface : ICommandBus
route?.Publish(command);
}
public bool TryHandlePluginCommand(string commandLine) =>
_tryHandlePluginCommand?.Invoke(commandLine) == true;
private void Release(LiveSessionCommandRouter expected)
{
expected.Dispose();

View file

@ -455,6 +455,7 @@ internal sealed class LiveSessionRuntimeFactory
OnUseDone: error =>
{
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
_domain.Actions.SpellCast.CompleteUse(error);
_domain.Actions.Transactions.CompleteUse(error);
},
_domain.Inventory.ItemMana,

View file

@ -151,6 +151,13 @@ internal static class GraphicalWindowBackendConfigurator
_ => throw new ArgumentOutOfRangeException(
nameof(requested)),
});
// #451: InitHint proves the packaged glfw3.dll is loaded but runs
// before glfwInit creates any window or begins polling. This is
// the one safe point to narrow GLFW's GetActiveWindow import so a
// temporarily joined Win32 input queue cannot hand it another
// acdream process's private GLFWwindow pointer.
if (platform.OperatingSystem == GraphicalHostOperatingSystem.Windows)
Win32GlfwActiveWindowGuard.Install();
_glfw = glfw;
_configuredProtocol = requested;
}

View 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

View file

@ -10,7 +10,10 @@ public sealed class AppPluginHost : IPluginHost
IEvents events,
ISelectionService selection,
IUiRegistry ui,
IAutomationSurface automation)
IAutomationSurface automation,
IPluginStorage? storage = null,
IPluginCommandRegistry? commands = null,
IPluginLootClassifierRegistry? lootClassifiers = null)
{
Log = log;
State = state;
@ -18,6 +21,10 @@ public sealed class AppPluginHost : IPluginHost
Selection = selection;
Ui = ui;
Automation = automation;
Storage = storage ?? NoOpPluginStorage.Instance;
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
LootClassifiers = lootClassifiers
?? NoOpPluginLootClassifierRegistry.Instance;
}
public bool HasUi => true;
@ -27,4 +34,7 @@ public sealed class AppPluginHost : IPluginHost
public ISelectionService Selection { get; }
public IUiRegistry Ui { get; }
public IAutomationSurface Automation { get; }
public IPluginStorage Storage { get; }
public IPluginCommandRegistry Commands { get; }
public IPluginLootClassifierRegistry LootClassifiers { get; }
}

View file

@ -11,18 +11,36 @@ namespace AcDream.App.Plugins;
/// </summary>
public sealed class BufferedUiRegistry : IScopedUiRegistry
{
public readonly record struct Pending(string MarkupPath, object Binding)
public readonly record struct Pending(
PluginUiOwner Owner,
PluginPanelDescriptor Descriptor,
string MarkupPath,
object Binding)
{
internal long RegistrationId { get; init; }
internal string? MarkupContent { get; init; }
/// <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 object Binding { get; } = binding;
internal string? MarkupContent { get; } = markupContent;
internal bool Drained { get; set; }
internal UiRoot? Root { get; set; }
internal UiElement? Element { get; set; }
internal Action? WindowCleanup { get; set; }
}
private readonly object _gate = new();
@ -32,15 +50,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
public void AddMarkupPanel(string markupPath, object binding)
=> _ = RegisterMarkupPanel(markupPath, binding);
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
=> _ = RegisterPanel(
new PluginUiOwner("unscoped", descriptor.Title),
descriptor,
markupPath,
binding);
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding) => RegisterPanel(
new PluginUiOwner("unscoped", descriptor.Title),
descriptor,
markupPath,
binding);
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding) => RegisterPanelContent(
new PluginUiOwner("unscoped", descriptor.Title),
descriptor,
markupContent,
binding);
public bool ViewExists(string viewName) =>
ViewExists(new PluginUiOwner("unscoped", "Plugin"), viewName);
public bool IsViewVisible(string viewName) =>
IsViewVisible(new PluginUiOwner("unscoped", "Plugin"), viewName);
public bool ControlExists(string viewName, string controlName) =>
ControlExists(
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName);
public bool SetControlLabel(
string viewName,
string controlName,
string label) => SetControlLabel(
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, label);
public bool SetControlVisible(
string viewName,
string controlName,
bool visible) => SetControlVisible(
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, visible);
public IDisposable RegisterMarkupPanel(string markupPath, object binding)
=> RegisterPanel(
new PluginUiOwner("legacy", "Plugin"),
new PluginPanelDescriptor(
Path.GetFileNameWithoutExtension(markupPath),
Path.GetFileNameWithoutExtension(markupPath)),
markupPath,
binding);
public IDisposable RegisterPanel(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
ArgumentNullException.ThrowIfNull(binding);
long id;
lock (_gate)
{
id = checked(++_nextRegistrationId);
_registrations.Add(id, new Registration(markupPath, binding));
_registrations.Add(
id,
new Registration(owner, descriptor, markupPath, binding));
}
return new RegistrationToken(this, id);
}
public IDisposable RegisterPanelContent(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
string markupContent,
object binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
ArgumentException.ThrowIfNullOrWhiteSpace(markupContent);
ArgumentNullException.ThrowIfNull(binding);
long id;
lock (_gate)
{
id = checked(++_nextRegistrationId);
_registrations.Add(
id,
new Registration(
owner,
descriptor,
$"<inline:{descriptor.WindowId}>",
binding,
markupContent));
}
return new RegistrationToken(this, id);
}
@ -57,10 +174,13 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
continue;
registration.Drained = true;
pending.Add(new Pending(
registration.Owner,
registration.Descriptor,
registration.MarkupPath,
registration.Binding)
{
RegistrationId = id,
MarkupContent = registration.MarkupContent,
});
}
return pending;
@ -88,6 +208,27 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
root.RemoveChild(element);
}
/// <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 int RegistrationCount
@ -99,18 +240,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
}
}
public bool ViewExists(PluginUiOwner owner, string viewName)
{
lock (_gate)
return FindRegistrationLocked(owner, viewName) is not null;
}
public bool IsViewVisible(PluginUiOwner owner, string viewName)
{
UiElement? view;
lock (_gate)
view = FindRegistrationLocked(owner, viewName)?.Element;
return view?.Visible == true;
}
public bool ControlExists(
PluginUiOwner owner,
string viewName,
string controlName) =>
FindControl(owner, viewName, controlName) is not null;
public bool SetControlLabel(
PluginUiOwner owner,
string viewName,
string controlName,
string label)
{
UiElement? control = FindControl(owner, viewName, controlName);
switch (control)
{
case UiSimpleButton button:
button.TextSource = null;
button.Text = label;
return true;
case UiMarkupToggle toggle:
toggle.TextSource = null;
toggle.Text = label;
return true;
case UiLabel text:
text.TextSource = null;
text.Text = label;
return true;
default:
return false;
}
}
public bool SetControlVisible(
PluginUiOwner owner,
string viewName,
string controlName,
bool visible)
{
UiElement? control = FindControl(owner, viewName, controlName);
if (control is null)
return false;
control.VisibleSource = null;
control.Visible = visible;
return true;
}
private UiElement? FindControl(
PluginUiOwner owner,
string viewName,
string controlName)
{
UiElement? view;
lock (_gate)
view = FindRegistrationLocked(owner, viewName)?.Element;
return view is null ? null : FindByName(view, controlName);
}
private Registration? FindRegistrationLocked(
PluginUiOwner owner,
string viewName) => _registrations.Values.FirstOrDefault(registration =>
registration.Owner == owner
&& (registration.Descriptor.WindowId.Equals(
viewName, StringComparison.Ordinal)
|| registration.Descriptor.Title.Equals(
viewName, StringComparison.Ordinal)));
private static UiElement? FindByName(UiElement root, string name)
{
if (root.Name?.Equals(name, StringComparison.Ordinal) == true)
return root;
foreach (UiElement child in root.Children)
{
UiElement? found = FindByName(child, name);
if (found is not null)
return found;
}
return null;
}
private void Remove(long id)
{
UiRoot? root;
UiElement? element;
Action? windowCleanup;
lock (_gate)
{
if (!_registrations.Remove(id, out Registration? registration))
return;
root = registration.Root;
element = registration.Element;
windowCleanup = registration.WindowCleanup;
}
windowCleanup?.Invoke();
if (root is not null && element is not null)
root.RemoveChild(element);
}

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

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

View file

@ -160,7 +160,13 @@ using IDisposable atmosphericPackRegistration = renderPackRegistry.Register(
"spv")));
// Constructed here and handed to both sides: GameWindow binds it to the live
// session's Runtime owners, the plugin host exposes it to plugins.
using var automation = new AcDream.App.Plugins.AppAutomationSurface();
using var automation = new AcDream.App.Plugins.AppAutomationSurface(
worldEvents,
new AcDream.App.Plugins.LocalPluginPeerRegistry(Path.Combine(
applicationPaths.DataDirectory,
"plugin-peers")),
runtimeOptions.PluginTags);
var lootClassifiers = new AcDream.Core.Plugins.PluginLootClassifierRegistry();
using var window = new GameWindow(
runtimeOptions,
worldGameState,
@ -175,7 +181,11 @@ var host = new AppPluginHost(
worldEvents,
window.Selection,
uiRegistry,
automation);
automation,
new FilePluginStorage(
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
automation.PluginCommands,
lootClassifiers);
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
applicationPaths,
runtimeOptions.Plugins,

View file

@ -715,6 +715,7 @@ public sealed class GameWindow :
// reset across generations. Re-binding per session would be re-binding
// the same two references.
_automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
_automation?.BindProjectileCollision(_physicsEngine);
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
_runtime.PlayerIdentity);
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
@ -960,6 +961,10 @@ public sealed class GameWindow :
// zero skills with no error to explain it.
if (_automation is null)
return;
_automation.BindSpeciesNameResolver(
AcDream.App.UI.Layout.CreatureDisplayNameResolver.Load(value).Resolve);
_automation.BindPaletteColorResolver(
new AcDream.Content.CharGen.ChargenAppearanceCatalog(value));
if (!value.TryGet<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
|| skillTable is null)
{
@ -983,8 +988,11 @@ public sealed class GameWindow :
"prepared asset source");
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
MagicCatalog value) =>
MagicCatalog value)
{
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
_automation?.BindMagicCatalog(value);
}
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
AcDream.Core.Physics.IAnimationLoader value) =>
@ -1144,6 +1152,25 @@ public sealed class GameWindow :
_combatAttackController = result.CombatAttack;
_externalContainerLifecycle = result.ExternalContainerLifecycle;
_itemInteractionController = result.ItemInteraction;
_automation?.BindEquipment(
(itemId, requestedLocation) =>
result.ItemInteraction.TryWieldItem(
itemId,
(AcDream.Core.Items.EquipMask)requestedLocation),
() => result.ItemInteraction.IsAutoWieldBusy);
_automation?.BindItems(
result.ItemInteraction.TryUseItemForAutomation,
result.ItemInteraction.TryApplyItem,
result.ItemInteraction.TryMoveItemForAutomation,
result.ItemInteraction.TryMergeItemsForAutomation,
result.ItemInteraction.TryDropItemForAutomation,
result.ItemInteraction.TryGiveItemForAutomation,
result.ItemInteraction.PlaceWorldItemInBackpack,
result.ItemInteraction.TryAppraiseForAutomation,
result.ItemInteraction.TrySalvageItemsForAutomation,
(vendorId, itemId, amount) => result.ItemInteraction.TrySell(
vendorId,
[(amount, itemId)]));
_interactionUiLateBindings = result.LateBindings;
_magicRuntime = result.Magic;
if (result.RetainedUi is { } retained)
@ -1219,6 +1246,17 @@ public sealed class GameWindow :
_retailSelectionScene = result.SelectionScene;
_worldSelectionQuery = result.SelectionQuery;
_selectionInteractions = result.SelectionInteractions;
_automation?.BindSelectionActions(action =>
result.SelectionInteractions.HandleInputAction(action switch
{
AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousSelection =>
InputAction.SelectionPreviousSelection,
AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousPlayer =>
InputAction.SelectionPreviousPlayer,
AcDream.Plugin.Abstractions.PluginSelectionAction.NextPlayer =>
InputAction.SelectionNextPlayer,
_ => InputAction.None,
}));
_retainedUiGameplayBinding = result.RetainedGameplay;
_paperdollViewportRenderer = result.PaperdollRenderer;
_paperdollFramePresenter = result.PaperdollPresenter;
@ -1272,6 +1310,7 @@ public sealed class GameWindow :
_worldReveal = result.WorldReveal;
_spawnClaimHydration = result.SpawnClaimHydration;
_liveEntityHydration = result.Hydration;
_automation?.BindGhostDeletion(result.Deletion.DeleteClientGhost);
_liveEntityNetworkUpdates = result.NetworkUpdates;
_liveEntityLiveness = result.Liveness;
_liveEntitySessionEvents = result.SessionEvents;
@ -1282,6 +1321,7 @@ public sealed class GameWindow :
_playerModeAutoEntry = result.PlayerModeAutoEntry;
_localPlayerTeleport = result.LocalTeleport;
_liveSessionHost = result.SessionHost;
_automation?.BindSessionCommands(result.GameRuntime);
_gameplayInputActions = result.GameplayActions;
_sessionPlayerBindings = result.RuntimeBindings;
}
@ -1529,7 +1569,8 @@ public sealed class GameWindow :
() => WorldTime.CurrentCalendar,
settingsDevTools.RenderPacks,
_renderPackDiagnostics.CaptureDiagnostics,
_applicationPaths.ScreenshotsDirectory),
_applicationPaths.ScreenshotsDirectory,
_automation),
_retailUiLease,
this).Compose(
platformResult,
@ -1653,6 +1694,9 @@ public sealed class GameWindow :
_combatFeedback,
_portalTunnelFallback,
Console.WriteLine,
_automation is null
? null
: _automation.TryHandlePluginCommand,
_statusWriter),
this).Compose(
hostInputCamera,

View file

@ -2,6 +2,7 @@ using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Chat;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
using AcDream.UI.Abstractions;
@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter
{
private readonly GameRuntime _runtime;
private readonly CurrentGameRuntimeCommandAdapter _commands;
private readonly ICommandBus _commandBus;
private readonly CharacterSelectionProjection _characterSelection;
private readonly CharacterCreationProjection _characterCreation;
private readonly IDisposable _hostLease;
@ -39,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter
ArgumentNullException.ThrowIfNull(commands);
ArgumentNullException.ThrowIfNull(selection);
_commandBus = commands;
_hostLease = runtime.AcquireHostLease(
"graphical game-runtime command adapter");
try
@ -137,6 +140,24 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeAllegianceCommands AllegianceCommands => _commands;
IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands;
/// <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()
{
RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint();

View file

@ -122,6 +122,11 @@ public sealed record RuntimeOptions(
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>
/// Build options from the process environment. Used by
/// <c>Program.cs</c> at startup.
@ -247,7 +252,10 @@ public sealed record RuntimeOptions(
StatusFilePath: null,
Plugins: null,
LoginCommands: [],
LoginCommandDelayMs: 500);
LoginCommandDelayMs: 500)
{
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
};
}
/// <summary>
@ -367,6 +375,15 @@ public sealed record RuntimeOptions(
private static string? NullIfEmpty(string? 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)
=> int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;

View file

@ -75,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable
// 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)>, bool>? _sendSell;
private readonly Func<uint, IReadOnlyList<uint>, bool>? _sendSalvage;
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
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)>, bool>? sendSell = 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));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -155,6 +157,7 @@ public sealed class ItemInteractionController : IDisposable
_sendBuy = sendBuy;
_sendBuyAll = sendBuyAll;
_sendSell = sendSell;
_sendSalvage = sendSalvage;
_interactionState = interactionState
?? throw new ArgumentNullException(nameof(interactionState));
_runtimeTransactions = runtimeTransactions
@ -500,6 +503,197 @@ public sealed class ItemInteractionController : IDisposable
});
}
/// <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>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
@ -655,6 +849,21 @@ public sealed class ItemInteractionController : IDisposable
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
}
/// <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>
/// Accepts only the pending or current appraisal, matching
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
@ -750,6 +959,76 @@ public sealed class ItemInteractionController : IDisposable
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>
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
/// publishes the waiting destination slot before issuing the move request,
@ -1095,6 +1374,24 @@ public sealed class ItemInteractionController : IDisposable
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>
public void NotifyExplicitCombatModeRequest()
=> _autoWield.NotifyExplicitCombatModeRequest();

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

View file

@ -14,6 +14,12 @@ namespace AcDream.App.UI;
/// </summary>
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="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>
@ -74,13 +80,47 @@ public static class MarkupDocument
}
foreach (var el in root.Elements())
AddElement(panel, el, binding, resolve, datFont);
return panel;
}
private static void AddElement(
UiElement parent,
XElement el,
object binding,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
switch (el.Name.LocalName)
{
switch (el.Name.LocalName)
{
case "meter":
case "group":
var group = new UiPanel
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BackgroundColor = el.Attribute("background") is null
? Vector4.Zero
: Color((string?)el.Attribute("background")),
BorderColor = el.Attribute("border") is null
? Vector4.Zero
: Color((string?)el.Attribute("border")),
BorderThickness = el.Attribute("border") is null ? 0f : 1f,
// Transparent layout groups do not claim empty space, while
// their interactive descendants remain hittable.
ClickThrough = true,
};
ApplyCommon(group, el, binding);
parent.AddChild(group);
foreach (XElement child in el.Elements())
AddElement(group, child, binding, resolve, datFont);
break;
case "meter":
var cur = BindUint((string?)el.Attribute("cur"), binding);
var max = BindUint((string?)el.Attribute("max"), binding);
panel.AddChild(new UiMeter
var meter = new UiMeter
{
Left = F(el, "x"),
Top = F(el, "y"),
@ -97,10 +137,12 @@ public static class MarkupDocument
FrontLeft = Hex((string?)el.Attribute("frontleft")),
FrontTile = Hex((string?)el.Attribute("fronttile")),
FrontRight = Hex((string?)el.Attribute("frontright")),
});
};
ApplyCommon(meter, el, binding);
parent.AddChild(meter);
break;
case "label":
case "label":
// Text may be a literal or a {Binding}. Bound labels re-read
// their property every frame through the Func, so a plugin
// updates its status line by assigning a property rather
@ -114,10 +156,11 @@ public static class MarkupDocument
};
if (el.Attribute("color") is not null)
label.TextColor = Color((string?)el.Attribute("color"));
panel.AddChild(label);
ApplyCommon(label, el, binding);
parent.AddChild(label);
break;
case "button":
case "button":
// onclick binds to an Action property on the binding
// object. Resolved once at build time: a button whose
// handler silently failed to bind is a bug worth failing
@ -151,13 +194,243 @@ public static class MarkupDocument
button.TextSource = BindString(caption, binding);
if (el.Attribute("color") is not null)
button.TextColor = Color((string?)el.Attribute("color"));
if (el.Attribute("background") is not null)
button.BackgroundColor = Color(
(string?)el.Attribute("background"));
if (el.Attribute("border") is not null)
button.BorderColor = Color(
(string?)el.Attribute("border"));
ApplyCommon(button, el, binding);
if (onClick is not null)
button.Click += onClick;
panel.AddChild(button);
parent.AddChild(button);
break;
}
case "tab":
string? tabClickName = (string?)el.Attribute("onclick");
Action? tabClick = BindAction(tabClickName, binding);
if (tabClickName is not null && tabClick is null)
{
throw new FormatException(
$"<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>
@ -197,13 +470,197 @@ public static class MarkupDocument
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) =>
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)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out var v) ? v : 0f;
private static float FOr(XElement e, string attr, float fallback)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out float value) ? value : fallback;
private static int I(XElement e, string attr, int fallback)
=> int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer,
CultureInfo.InvariantCulture, out int value) ? value : fallback;
private static bool B(XElement e, string attr, bool fallback)
=> bool.TryParse((string?)e.Attribute(attr), out bool value)
? value
: fallback;
/// <summary>
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
/// controls.ini convention). Falls back to opaque white on bad input.

View 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";
}
}

View file

@ -25,6 +25,7 @@ using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Panels.Vitals;
using AcDream.UI.Abstractions.Input;
using AcDream.Plugin.Abstractions;
using DatReaderWriter;
using Silk.NET.Input;
@ -531,7 +532,9 @@ public sealed record RetailUiRuntimeBindings(
CharacterSelectionRuntimeBindings? CharacterSelection = null,
// Campaign CC slice CC4: sibling of CharacterSelection above.
CharacterCreationRuntimeBindings? CharacterCreation = null,
Action? CaptureScreenshot = null);
Action? CaptureScreenshot = null,
Func<IReadOnlyList<PluginProjectileDebugSample>>?
ProjectileDebugSamples = null);
/// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -553,9 +556,11 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private ProjectileDebugOverlayController? _projectileDebugOverlay;
private Layout.VitalsSideBySideController? _vitalsSideBySide;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private PluginSidePanel? _pluginSidePanel;
private IDisposable? _characterSheetSubscription;
private Layout.CharacterTitlesController? _characterTitlesController;
private ResourceShutdownTransaction? _shutdown;
@ -602,6 +607,7 @@ public sealed class RetailUiRuntime : IDisposable
RetailUiRuntimeBindings bindings = _bindings;
MountFpsDisplay();
MountVividTargetIndicator();
MountProjectileDebugOverlay();
MountVitals();
MountRadar();
MountChat();
@ -935,6 +941,7 @@ public sealed class RetailUiRuntime : IDisposable
Layout.UiMediaClock.Advance(deltaSeconds);
FpsController?.Tick();
_vividTargetIndicator?.Tick();
_projectileDebugOverlay?.Tick();
_vitalsSideBySide?.Tick();
SpellbookWindowController?.Tick();
AppraisalController?.Tick(deltaSeconds);
@ -1655,6 +1662,18 @@ public sealed class RetailUiRuntime : IDisposable
: "[D.2b] vivid target indicator mounted from client-enum category 0x10000009.");
}
private void MountProjectileDebugOverlay()
{
if (_bindings.ProjectileDebugSamples is not { } samples)
return;
_projectileDebugOverlay = ProjectileDebugOverlayController.Mount(
Host.Root,
samples,
_bindings.VividTarget.Camera);
Console.WriteLine(
"[PluginUI] projectile collision debug overlay mounted.");
}
private void MountVitals()
{
ImportedLayout? layout = Import(0x2100006Cu);
@ -4608,16 +4627,64 @@ public sealed class RetailUiRuntime : IDisposable
{
try
{
string xml = File.ReadAllText(panel.MarkupPath);
UiElement element = MarkupDocument.Build(
string xml = panel.MarkupContent
?? File.ReadAllText(panel.MarkupPath);
UiNineSlicePanel element = MarkupDocument.Build(
xml,
panel.Binding,
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls,
_bindings.Assets.DefaultFont);
if (Host.WindowManager.TryGet(panel.WindowName, out _))
{
throw new InvalidOperationException(
$"Plugin window '{panel.WindowName}' is already registered. "
+ "Window ids must be unique within one plugin.");
}
// Markup's root visibility is an availability gate (for example,
// a world-only panel), while the descriptor/persisted state is the
// user's minimize choice. Keep those two axes independent so an
// availability transition never disables the running plugin or
// forgets that the user wanted its window open.
Func<bool>? availability = element.VisibleSource;
var visibility = new PluginWindowVisibilityController(
availability,
panel.Descriptor.StartVisible);
element.VisibleSource = visibility.ShouldBeVisible;
element.Visible = visibility.ShouldBeVisible();
Host.Root.AddChild(element);
// Publish ownership immediately after the tree mutation. Any
// later registration/sidepanel failure then rolls the mounted
// subtree back through FailMount instead of leaking it.
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
RetailWindowHandle handle = Host.WindowManager.Register(
panel.WindowName,
element,
element,
visibility);
_bindings.Plugins.CompleteWindowMount(
panel,
() => Host.WindowManager.Unregister(panel.WindowName));
if (panel.Descriptor.ShowInSidePanel)
{
if (_pluginSidePanel is null)
{
_pluginSidePanel = new PluginSidePanel(
Host.WindowManager,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont);
Host.Root.AddChild(_pluginSidePanel);
}
_pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle);
}
Console.WriteLine(
$"[D.2b] plugin UI window loaded: {panel.WindowName} "
+ $"({panel.MarkupPath})");
}
catch (Exception ex)
{
@ -5312,6 +5379,7 @@ public sealed class RetailUiRuntime : IDisposable
{
_characterSheetSubscription?.Dispose();
_characterTitlesController?.Dispose();
_pluginSidePanel?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
WindowLockPresentation.Dispose();
WindowOpacity.Dispose();
@ -5339,6 +5407,36 @@ public sealed class RetailUiRuntime : IDisposable
_disposed = _shutdown.IsComplete;
}
/// <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(
Action disposeAutomation,
Action disposePersistence,

View file

@ -55,7 +55,7 @@ public abstract class UiElement
public uint DatElementId { get; internal set; }
/// <summary>Human-readable name for debugging / FindByName.</summary>
public string? Name { get; init; }
public string? Name { get; set; }
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
@ -274,6 +274,12 @@ public abstract class UiElement
/// </summary>
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>
/// If true, <see cref="UiRoot"/> will set focus here on click,
/// 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"
/// (vtable +0x88) to render the tooltip body.
/// </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) ─────────────
@ -763,6 +781,8 @@ public abstract class UiElement
if (VisibleSource is { } visibility)
Visible = visibility();
if (!Visible) return;
if (EnabledSource is { } enabled)
Enabled = enabled();
OnTick(dt);
for (int i = 0; i < _children.Count; i++)
_children[i].TickSelfAndChildren(dt);

View file

@ -110,6 +110,13 @@ public sealed class UiField : UiElement
public Action<string>? OnSubmit { get; set; }
public Action? OnFocusGained { 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 = "";
@ -127,8 +134,11 @@ public sealed class UiField : UiElement
get => _textValue;
set
{
if (string.Equals(_textValue, value, StringComparison.Ordinal))
return;
_textValue = value;
_textVersion++;
OnTextChanged?.Invoke(value);
}
}

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

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

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

View file

@ -74,7 +74,9 @@ public sealed class UiMenu : UiElement
string? live = TooltipTextProvider?.Invoke();
if (!string.IsNullOrWhiteSpace(live))
return live;
return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
return string.IsNullOrWhiteSpace(TooltipText)
? base.GetTooltipText()
: TooltipText;
}
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);

View file

@ -37,6 +37,12 @@ public sealed class UiScrollbar : UiElement
/// </summary>
public float ScalarPosition { get; private 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; }
/// <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)
=> 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
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
/// pattern <see cref="UiButton.TooltipText"/> already established
@ -105,7 +118,9 @@ public sealed class UiScrollbar : UiElement
/// <inheritdoc />
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>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }

View file

@ -65,6 +65,21 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
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)
{
if (!_runtime.TryGetRecord(

View file

@ -15,7 +15,15 @@ public sealed record SpellComponentDescriptor(
uint WeenieClassId,
string Name,
uint Category,
uint IconId);
uint IconId)
{
public uint SpellComponentId { get; init; }
public double BurnRate { get; init; }
public uint GestureId { get; init; }
public double GestureSpeed { get; init; }
public string Type { get; init; } = string.Empty;
public string Word { get; init; } = string.Empty;
}
/// <summary>
/// Process-shareable projection of retail's spell, component, and
@ -142,7 +150,15 @@ public sealed class MagicCatalog
wcid,
pair.Value.Name.Value,
pair.Value.Category,
pair.Value.Icon.DataId);
pair.Value.Icon.DataId)
{
SpellComponentId = pair.Key,
BurnRate = pair.Value.CDM,
GestureId = pair.Value.Gesture,
GestureSpeed = pair.Value.Time,
Type = pair.Value.Type.ToString(),
Word = pair.Value.Text.Value,
};
}
}

View file

@ -1004,9 +1004,14 @@ public static class GameEventWiring
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null) return;
// Merge parsed properties into the item if we know about it.
// Retain the property tables and the item's own spell manifest as
// one projection. VTank consults the latter for proc weapons.
if (p.Value.Success && items.Get(p.Value.Guid) is not null)
items.UpdateProperties(p.Value.Guid, p.Value.Properties);
items.UpdateAppraisal(
p.Value.Guid,
p.Value.Properties,
p.Value.SpellBook,
clientTime());
if (p.Value.CreatureProfile is { HealthMax: > 0u } creature)
combat.OnUpdateHealth(
p.Value.Guid,
@ -1020,8 +1025,8 @@ public static class GameEventWiring
// spellbook arrives via PlayerDescription (0x0013), which uses
// a different wire format (see WorldSession + LocalPlayerState
// — feeds vitals from PrivateUpdateVital instead).
// The appraised spellbook belongs to that item. The local player's
// learned spell manifest arrives only in PlayerDescription.
// The appraised spellbook now belongs to that item. The local
// player's learned manifest arrives only in PlayerDescription.
});
// ── Player ────────────────────────────────────────────────

View file

@ -27,6 +27,7 @@ public static class InventoryActions
public const uint DropItemOpcode = 0x001Bu;
public const uint NoLongerViewingContentsOpcode = 0x0195u;
public const uint SetInscriptionOpcode = 0x00BFu;
public const uint CreateTinkeringToolOpcode = 0x027Du;
/// <summary>
/// Merge stack A into stack B of the same item type. Server validates
@ -205,4 +206,48 @@ public static class InventoryActions
text.CopyTo(body, 18);
return body;
}
/// <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&lt;unsigned long&gt;</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;
}
}

View file

@ -3268,6 +3268,19 @@ public sealed class WorldSession : IDisposable
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>
/// <remarks>
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>

View file

@ -2,55 +2,6 @@
"version": 2,
"dependencies": {
"net10.0": {
"BCnEncoder.Net": {
"type": "Direct",
"requested": "[2.2.1, )",
"resolved": "2.2.1",
"contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==",
"dependencies": {
"CommunityToolkit.HighPerformance": "8.4.0"
}
},
"Chorizite.Core": {
"type": "Direct",
"requested": "[0.0.18, )",
"resolved": "0.0.18",
"contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==",
"dependencies": {
"Autofac": "8.4.0",
"Chorizite.ACProtocol": "1.0.1",
"Chorizite.Common": "1.0.3",
"Chorizite.DatReaderWriter": "1.0.0",
"FontStashSharp": "1.3.10",
"Microsoft.Diagnostics.Runtime": "3.1.512801",
"Microsoft.Extensions.Logging.Abstractions": "9.0.9",
"NJsonSchema": "11.5.1",
"SixLabors.ImageSharp": "3.1.11",
"SixLabors.ImageSharp.Drawing": "2.1.7"
}
},
"Chorizite.DatReaderWriter": {
"type": "Direct",
"requested": "[2.1.7, )",
"resolved": "2.1.7",
"contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==",
"dependencies": {
"DotNet.Standard.Common": "2.0.1",
"ZLibDotNet": "0.1.1"
}
},
"Serilog": {
"type": "Direct",
"requested": "[4.0.2, )",
"resolved": "4.0.2",
"contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA=="
},
"StbImageSharp": {
"type": "Direct",
"requested": "[2.30.16, )",
"resolved": "2.30.16",
"contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw=="
},
"Autofac": {
"type": "Transitive",
"resolved": "8.4.0",
@ -245,9 +196,57 @@
"resolved": "0.1.1",
"contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg=="
},
"acdream.core": {
"type": "Project",
"dependencies": {
"AcDream.Plugin.Abstractions": "[1.0.0, )",
"BCnEncoder.Net": "[2.2.1, )",
"Chorizite.Core": "[0.0.18, )",
"Chorizite.DatReaderWriter": "[2.1.7, )",
"Serilog": "[4.0.2, )",
"StbImageSharp": "[2.30.16, )"
}
},
"acdream.plugin.abstractions": {
"type": "Project"
},
"BCnEncoder.Net": {
"type": "CentralTransitive",
"requested": "[2.2.1, )",
"resolved": "2.2.1",
"contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==",
"dependencies": {
"CommunityToolkit.HighPerformance": "8.4.0"
}
},
"Chorizite.Core": {
"type": "CentralTransitive",
"requested": "[0.0.18, )",
"resolved": "0.0.18",
"contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==",
"dependencies": {
"Autofac": "8.4.0",
"Chorizite.ACProtocol": "1.0.1",
"Chorizite.Common": "1.0.3",
"Chorizite.DatReaderWriter": "1.0.0",
"FontStashSharp": "1.3.10",
"Microsoft.Diagnostics.Runtime": "3.1.512801",
"Microsoft.Extensions.Logging.Abstractions": "9.0.9",
"NJsonSchema": "11.5.1",
"SixLabors.ImageSharp": "3.1.11",
"SixLabors.ImageSharp.Drawing": "2.1.7"
}
},
"Chorizite.DatReaderWriter": {
"type": "CentralTransitive",
"requested": "[2.1.7, )",
"resolved": "2.1.7",
"contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==",
"dependencies": {
"DotNet.Standard.Common": "2.0.1",
"ZLibDotNet": "0.1.1"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[9.0.9, )",
@ -257,12 +256,24 @@
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9"
}
},
"Serilog": {
"type": "CentralTransitive",
"requested": "[4.0.2, )",
"resolved": "4.0.2",
"contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA=="
},
"SixLabors.ImageSharp": {
"type": "CentralTransitive",
"requested": "[3.1.12, )",
"resolved": "3.1.11",
"contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw=="
},
"StbImageSharp": {
"type": "CentralTransitive",
"requested": "[2.30.16, )",
"resolved": "2.30.16",
"contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw=="
},
"StbTrueTypeSharp": {
"type": "CentralTransitive",
"requested": "[1.26.12, )",

View file

@ -251,6 +251,21 @@ public sealed class ClientObject
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
public uint? SpellId { get; set; }
/// <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
/// shared item-cooldown group whose player enchantment id is
/// <c>CooldownId + 0x8000</c>.

View file

@ -774,6 +774,45 @@ public sealed class ClientObjectTable
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
MergeProperties(item, incoming);
ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item);
return true;
}
/// <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.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
@ -781,9 +820,6 @@ public sealed class ClientObjectTable
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>

View file

@ -2383,7 +2383,11 @@ public sealed class PhysicsEngine
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
}
return resolveResult;
return resolveResult with
{
LastCollidedObjectId = ci.LastCollidedObjectGuid ?? 0u,
CollidedWithEnvironment = ci.CollidedWithEnvironment,
};
}
finally
{

View file

@ -58,4 +58,16 @@ public readonly record struct ResolveResult(
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
uint ContactPlaneCellId = 0,
/// <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; }
}

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

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

View file

@ -258,7 +258,10 @@ public sealed class PluginSession : IDisposable
{
foreach (PluginDiscoveryResult candidate in available)
{
var scope = new ScopedPluginHost(_host);
var scope = new ScopedPluginHost(
_host,
candidate.Manifest!.Id,
candidate.Manifest.DisplayName);
ScopedRenderPackRegistry? renderPackScope =
candidate.Manifest!.Declares(PluginKind.RenderPack)
&& _renderPacks is not null

View file

@ -13,14 +13,30 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
private readonly ScopedEvents _events;
private readonly ScopedSelectionService _selection;
private readonly ScopedUiRegistry _ui;
private readonly ScopedPluginStorage _storage;
private readonly ScopedPluginCommandRegistry _commands;
private readonly ScopedLootClassifierRegistry _lootClassifiers;
private bool _disposed;
internal ScopedPluginHost(IPluginHost inner)
internal ScopedPluginHost(
IPluginHost inner,
string pluginId,
string pluginDisplayName)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName);
_events = new ScopedEvents(inner.Events);
_selection = new ScopedSelectionService(inner.Selection);
_ui = new ScopedUiRegistry(inner.Ui);
_ui = new ScopedUiRegistry(
inner.Ui,
new PluginUiOwner(pluginId, pluginDisplayName));
_storage = new ScopedPluginStorage(inner.Storage, pluginId);
_commands = new ScopedPluginCommandRegistry(inner.Commands);
_lootClassifiers = new ScopedLootClassifierRegistry(
inner.LootClassifiers,
pluginId,
pluginDisplayName);
}
public bool HasUi => _inner.HasUi;
@ -29,6 +45,9 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public IEvents Events => _events;
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public IPluginStorage Storage => _storage;
public IPluginCommandRegistry Commands => _commands;
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;
/// <summary>
/// Delegated rather than scoped, unlike <see cref="Events"/>,
@ -45,6 +64,48 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
/// </remarks>
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()
{
if (_disposed)
@ -53,6 +114,127 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
_events.Dispose();
_selection.Dispose();
_ui.Dispose();
_commands.Dispose();
_lootClassifiers.Dispose();
}
private sealed class ScopedLootClassifierRegistry(
IPluginLootClassifierRegistry inner,
string pluginId,
string pluginDisplayName)
: IPluginLootClassifierRegistry,
IDisposable
{
private readonly object _gate = new();
private readonly List<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)
@ -297,22 +479,92 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
{
private readonly IScopedUiRegistry _inner;
private readonly PluginUiOwner _owner;
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
internal ScopedUiRegistry(IUiRegistry inner)
internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner)
{
_inner = inner as IScopedUiRegistry
?? throw new InvalidOperationException(
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
_owner = owner;
}
public void AddMarkupPanel(string markupPath, object binding)
{
IDisposable registration = _inner.RegisterMarkupPanel(
AddRegistration(_inner.RegisterPanel(
_owner,
new PluginPanelDescriptor(
Path.GetFileNameWithoutExtension(markupPath),
_owner.DisplayName),
markupPath,
binding);
binding));
}
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
AddRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanelContent(
_owner,
descriptor,
markupContent,
binding));
}
public bool ViewExists(string viewName) =>
_inner.ViewExists(_owner, viewName);
public bool IsViewVisible(string viewName) =>
_inner.IsViewVisible(_owner, viewName);
public bool ControlExists(string viewName, string controlName) =>
_inner.ControlExists(_owner, viewName, controlName);
public bool SetControlLabel(
string viewName,
string controlName,
string label) =>
_inner.SetControlLabel(_owner, viewName, controlName, label);
public bool SetControlVisible(
string viewName,
string controlName,
bool visible) =>
_inner.SetControlVisible(_owner, viewName, controlName, visible);
private void AddRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
@ -326,6 +578,31 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private IDisposable TrackRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return new IndividualRegistration(this, registration);
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private void RemoveRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_registrations.Remove(registration))
return;
}
registration.Dispose();
}
public void Dispose()
{
IDisposable[] registrations;
@ -344,5 +621,15 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
catch { }
}
}
private sealed class IndividualRegistration(
ScopedUiRegistry owner,
IDisposable registration) : IDisposable
{
private ScopedUiRegistry? _owner = owner;
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
.RemoveRegistration(registration);
}
}
}

View file

@ -15,7 +15,7 @@ internal sealed class HeadlessMovementInputSource(
public MovementInput Capture()
{
if (_movement.HasCommandInput)
return _movement.CommandInput;
return _movement.CommandInput with { IsPersistentCommand = true };
return new MovementInput(
Forward: _movement.AutoRunActive,
Run: true);

View file

@ -341,7 +341,13 @@ internal sealed class HeadlessSessionHost : IDisposable
// descriptor.StatusFile is unset — every call site below stays
// unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
var chatCommandSurface = new LiveChatCommandSurface();
var pluginCommands = new AcDream.Core.Plugins.PluginCommandRegistry(
(verb, error) => diagnostics.Failure(
descriptor.Id,
$"plugin-command-{verb}",
error));
var chatCommandSurface = new LiveChatCommandSurface(
pluginCommands.TryHandle);
var loginCommands = new LoginCommandSequence(
descriptor.LoginCommands,
TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs),
@ -359,7 +365,8 @@ internal sealed class HeadlessSessionHost : IDisposable
statusWriter,
descriptor.Id,
pluginRoots ?? [],
descriptor.Plugins);
descriptor.Plugins,
pluginCommands);
var liveSession = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(
@ -1201,6 +1208,7 @@ internal sealed class HeadlessSessionHost : IDisposable
{
Runtime.InventoryOwner.ExternalContainers
.ApplyUseDone(error);
Runtime.ActionOwner.SpellCast.CompleteUse(error);
Runtime.ActionOwner.Transactions.CompleteUse(error);
},
Runtime.InventoryOwner.ItemMana,

View file

@ -38,15 +38,18 @@ internal sealed class HeadlessPluginHost
internal HeadlessPluginHost(
GameRuntime runtime,
IPluginLogger logger)
IPluginLogger logger,
IPluginCommandRegistry? commands = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
Log = logger ?? throw new ArgumentNullException(nameof(logger));
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
_eventSubscription = runtime.Subscribe(this);
}
public bool HasUi => false;
public IPluginLogger Log { get; }
public IPluginCommandRegistry Commands { get; }
public IGameState State => this;
public IEvents Events => this;
public ISelectionService Selection => _runtime.ActionOwner.Selection;

View file

@ -44,7 +44,8 @@ internal sealed class HeadlessPluginSession : IDisposable
SessionStatusWriter statusWriter,
string sessionId,
IEnumerable<string> roots,
IReadOnlyList<string>? allowList)
IReadOnlyList<string>? allowList,
IPluginCommandRegistry? commands = null)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(diagnostics);
@ -57,7 +58,8 @@ internal sealed class HeadlessPluginSession : IDisposable
new HeadlessPluginLogger(
diagnostics,
sessionId,
() => runtime.Generation.Value));
() => runtime.Generation.Value),
commands);
var plugins = new PluginSession(
host,
status => Report(statusWriter, sessionId, status),

View file

@ -53,7 +53,38 @@ public readonly record struct PluginSpellInfo(
uint School,
string Description,
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>
public readonly record struct PluginActiveEnchantment(
@ -62,18 +93,39 @@ public readonly record struct PluginActiveEnchantment(
int Tier,
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>
public readonly record struct PluginSkillInfo(
uint SkillId,
string Name,
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>
public readonly record struct PluginAttributeInfo(
int Kind,
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>
public enum PluginCastGate
@ -93,6 +145,28 @@ public interface ICharacterInfo
{
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>
/// The local player's own object id, or 0 when not in world. Needed to
/// target yourself: retail's banes are Item Enchantments whose description
@ -108,6 +182,12 @@ public interface ICharacterInfo
uint CurrentMana { get; }
uint MaxMana { get; }
/// <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>
IReadOnlyList<PluginSkillInfo> Skills { get; }
@ -141,18 +221,75 @@ public interface ISpellCatalog
/// </remarks>
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);
/// <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>
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
{
/// <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>
/// Post a client-local system line, the channel retail uses for the
/// client's own notices. It is local to this client: nothing is sent to the
/// server and no other player sees it.
/// </summary>
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>
@ -160,6 +297,13 @@ public interface IMagicCommands
{
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);
/// <summary>
@ -167,6 +311,13 @@ public interface IMagicCommands
/// not whether the spell ultimately lands, which the server decides.
/// </summary>
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>
@ -187,6 +338,52 @@ public interface IAutomationSurface
ISpellCatalog Spells { get; }
IMagicCommands Magic { 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>
@ -194,7 +391,13 @@ public interface IAutomationSurface
/// and every command refuses, so a plugin can keep one code path.
/// </summary>
public sealed class NoOpAutomationSurface
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands,
IPluginChat, ICombatAutomation
, IEquipmentAutomation, IItemAutomation, ILootAutomation,
IFellowshipAutomation, IEnchantmentAutomation, INavigationAutomation
, IWorldObjectAutomation, IWorldTimeAutomation, ILoginAutomation,
INetworkAutomation, IRecoveryAutomation, IProjectileAutomation
, ISelectionAutomation
{
public static NoOpAutomationSurface Instance { get; } = new();
@ -207,11 +410,41 @@ public sealed class NoOpAutomationSurface
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
public IPluginChat Chat => this;
public ICombatAutomation Combat => this;
public IEquipmentAutomation Equipment => this;
public IItemAutomation Items => this;
public ILootAutomation Loot => this;
public IFellowshipAutomation Fellowship => this;
public IEnchantmentAutomation Enchantments => this;
public INavigationAutomation Navigation => this;
public IWorldObjectAutomation Objects => this;
public IWorldTimeAutomation WorldTime => this;
public ILoginAutomation Login => this;
public INetworkAutomation Network => this;
public IRecoveryAutomation Recovery => this;
public IProjectileAutomation Projectiles => this;
public ISelectionAutomation Selection => this;
public void PostSystemMessage(string text)
{
}
public bool Submit(string text) => false;
PluginNavigationSnapshot INavigationAutomation.Snapshot => default;
public bool TryGetObject(
uint objectId,
out PluginNavigationObject value)
{
value = default;
return false;
}
public PluginNavigationCommandStatus SetMovementIntent(
in PluginMovementIntent intent) =>
PluginNavigationCommandStatus.Unavailable;
public PluginNavigationCommandStatus ClearMovementIntent() =>
PluginNavigationCommandStatus.Unavailable;
public bool IsInWorld => false;
public uint ObjectId => 0;
public uint CurrentHealth => 0;
@ -220,6 +453,7 @@ public sealed class NoOpAutomationSurface
public uint MaxStamina => 0;
public uint CurrentMana => 0;
public uint MaxMana => 0;
public int SummoningMastery => 0;
public IReadOnlyList<PluginSkillInfo> Skills { get; } = Array.Empty<PluginSkillInfo>();
public IReadOnlyList<PluginAttributeInfo> Attributes { get; } =
@ -228,6 +462,10 @@ public sealed class NoOpAutomationSurface
Array.Empty<PluginActiveEnchantment>();
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; } =
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)
{
@ -242,6 +480,64 @@ public sealed class NoOpAutomationSurface
}
public bool IsCasting => false;
public PluginCastCompletion LastCompletion => default;
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Unavailable;
public bool Cast(uint spellId) => false;
public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) =>
PluginCastGate.Unavailable;
public bool Cast(uint spellId, uint targetObjectId) => false;
public PluginCombatSnapshot Snapshot => default;
public IReadOnlyList<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);
}

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

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

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

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

View file

@ -20,6 +20,19 @@ public interface IPluginHost
IEvents Events { get; }
ISelectionService Selection { 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>
/// Character reads, spell data and casting. Hosts with no live session

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

View file

@ -1,5 +1,47 @@
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>
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
/// a binding object exposing the data properties the markup binds to, and
@ -13,6 +55,52 @@ public interface IUiRegistry
/// <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>
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>
@ -25,6 +113,55 @@ public interface IUiRegistry
public interface IScopedUiRegistry : IUiRegistry
{
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>
@ -44,9 +181,38 @@ public sealed class NoOpUiRegistry : IScopedUiRegistry
{
}
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
}
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding) => NoOpUiRegistration.Instance;
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding) => NoOpUiRegistration.Instance;
public IDisposable RegisterMarkupPanel(string markupPath, object binding) =>
NoOpRegistration.Instance;
public IDisposable RegisterPanel(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
string markupPath,
object binding) => NoOpRegistration.Instance;
public IDisposable RegisterPanelContent(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
string markupContent,
object binding) => NoOpUiRegistration.Instance;
private sealed class NoOpRegistration : IDisposable
{
internal static NoOpRegistration Instance { get; } = new();

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

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

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

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

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

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

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

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

View 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)
{
}
}

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

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

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

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

View file

@ -22,8 +22,7 @@
<None Update="mosstank.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="mosstank-settings.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="VtankCraftRecipes.tsv" />
<EmbeddedResource Include="VtankAmmunitionOptions.tsv" />
</ItemGroup>
</Project>

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

View 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,
};
}

View file

@ -5,18 +5,32 @@ namespace AcDream.Plugins.MossTank;
/// <summary>Settings that shape a buff pass. Defaults follow Virindi Tank's.</summary>
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>
/// VTank recasts buffs once they drop below five minutes remaining
/// ("all buff spells are recast when they go below 5 minutes").
/// </summary>
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>
/// How far the casting skill must exceed a spell's difficulty before the
/// tier is considered reliable — VTank's
/// <c>SpellDiffExcessThreshold-Buff</c>.
/// </summary>
public int SkillExcessOverDifficulty { get; set; } = 10;
public int SkillExcessOverDifficulty { get; set; } = 5;
/// <summary>Buff every attribute (VTank's default).</summary>
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.
/// </summary>
public bool BuffProtections { get; set; } = true;
public string ProtectionElements { get; set; } = "ALFCBPS";
public int ProtectionProfileMode { get; set; } = 2;
/// <summary>
/// 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.
/// </summary>
public bool BuffBanes { get; set; } = true;
public string BaneElements { get; set; } = "ALFCBPS";
public int BaneProfileMode { get; set; } = 2;
/// <summary>
/// The vital regeneration rates — Regeneration (health), Rejuvenation
@ -57,6 +75,15 @@ public sealed class BuffSettings
/// "automatically buffs every Attribute and Skill you have trained".
/// </summary>
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>
@ -80,8 +107,12 @@ public static class BuffPlan
IReadOnlyList<PluginAttributeInfo> attributes,
IReadOnlyList<PluginActiveEnchantment> active,
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>(
StringComparer.OrdinalIgnoreCase);
foreach (PluginSkillInfo skill in skills)
@ -120,16 +151,29 @@ public static class BuffPlan
foreach (BuffLine line in lines)
{
uint school = line.Tiers.Count == 0 ? 0u : line.Tiers[0].School;
bool schoolAvailable = IsSchoolAvailable(
school,
skills,
settings,
characterLevel);
bool wanted = line.Kind switch
{
BuffTargetKind.Attribute =>
settings.BuffAttributes && attributeNames.Contains(line.TargetName),
BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName),
BuffTargetKind.Protection => settings.BuffProtections,
BuffTargetKind.Aura => settings.BuffAuras,
BuffTargetKind.Bane => settings.BuffBanes,
BuffTargetKind.Regeneration => settings.BuffRegeneration,
BuffTargetKind.Other => settings.BuffOther,
schoolAvailable && settings.BuffAttributes
&& attributeNames.Contains(line.TargetName),
BuffTargetKind.Skill => schoolAvailable
&& (trainedSkills.ContainsKey(line.TargetName)
|| IsMagicSchoolName(line.TargetName)),
BuffTargetKind.Protection =>
schoolAvailable && settings.BuffProtections
&& ProfileAllows(line, settings, bane: false),
BuffTargetKind.Aura => schoolAvailable && settings.BuffAuras,
BuffTargetKind.Bane => schoolAvailable && settings.BuffBanes
&& ProfileAllows(line, settings, bane: true),
BuffTargetKind.Regeneration =>
schoolAvailable && settings.BuffRegeneration,
BuffTargetKind.Other => schoolAvailable && settings.BuffOther,
_ => false,
};
if (!wanted)
@ -141,7 +185,8 @@ public static class BuffPlan
if (!force
&& inForce.TryGetValue(line.Family, out var held)
&& held.Tier >= pick.Tier
&& held.Seconds >= settings.RebuffWhenUnderSeconds)
&& held.Seconds >= (rebuffWhenUnderSeconds
?? settings.RebuffWhenUnderSeconds))
{
continue; // already covered at this strength, and not expiring
}
@ -167,6 +212,91 @@ public static class BuffPlan
return ordered;
}
private static bool ProfileAllows(
BuffLine line,
BuffSettings settings,
bool bane)
{
int mode = bane
? settings.BaneProfileMode
: settings.ProtectionProfileMode;
string enabled = mode switch
{
1 => bane ? settings.BaneElements : settings.ProtectionElements,
2 => "ALFCBPS",
3 => string.Empty,
4 => "B",
5 => "BPS",
6 => "BPSA",
7 => "ALFC",
8 => "BPSAC",
_ => "ALFCBPS",
};
char element = ElementCode(line);
return element == '\0' || enabled.IndexOf(element) >= 0;
}
private static char ElementCode(BuffLine line)
{
string text = line.TargetName + " "
+ (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Name)
+ " "
+ (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Description);
if (text.Contains("acid", StringComparison.OrdinalIgnoreCase))
return 'A';
if (text.Contains("lightning", StringComparison.OrdinalIgnoreCase)
|| text.Contains("electric", StringComparison.OrdinalIgnoreCase))
return 'L';
if (text.Contains("fire", StringComparison.OrdinalIgnoreCase))
return 'F';
if (text.Contains("cold", StringComparison.OrdinalIgnoreCase)
|| text.Contains("frost", StringComparison.OrdinalIgnoreCase))
return 'C';
if (text.Contains("bludgeon", StringComparison.OrdinalIgnoreCase))
return 'B';
if (text.Contains("pierc", StringComparison.OrdinalIgnoreCase))
return 'P';
if (text.Contains("slash", StringComparison.OrdinalIgnoreCase))
return 'S';
return '\0';
}
private static bool IsMagicSchoolName(string name) =>
name.Equals("Item Enchantment", StringComparison.OrdinalIgnoreCase)
|| name.Equals("Creature Enchantment", StringComparison.OrdinalIgnoreCase)
|| name.Equals("Life Magic", StringComparison.OrdinalIgnoreCase);
private static bool IsSchoolAvailable(
uint school,
IReadOnlyList<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>
private const uint CreatureEnchantmentSkill = 31;
private const uint ItemEnchantmentSkill = 32;

File diff suppressed because it is too large Load diff

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

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

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

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

View 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);

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

View file

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

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

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

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

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

File diff suppressed because it is too large Load diff

View file

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

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

View file

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

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

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

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

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

View 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