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

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