feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System.Text;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>Crash-safe filesystem implementation behind scoped plugin keys.</summary>
|
||||
internal sealed class FilePluginStorage : IPluginStorage
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
internal FilePluginStorage(string root)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(root);
|
||||
_root = Path.GetFullPath(root);
|
||||
}
|
||||
|
||||
public bool IsAvailable => true;
|
||||
|
||||
public string? ReadText(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
return File.Exists(path)
|
||||
? File.ReadAllText(path, Encoding.UTF8)
|
||||
: null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
{
|
||||
string directory = Resolve(prefix);
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
||||
.Select(path => Path.GetRelativePath(_root, path)
|
||||
.Replace(Path.DirectorySeparatorChar, '/'))
|
||||
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void WriteText(string key, string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
string path = Resolve(key);
|
||||
string directory = Path.GetDirectoryName(path)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporary = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, content, new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string Resolve(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
if (Path.IsPathRooted(key))
|
||||
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
|
||||
string path = Path.GetFullPath(Path.Combine(_root, key));
|
||||
string relative = Path.GetRelativePath(_root, path);
|
||||
if (Path.IsPathRooted(relative)
|
||||
|| relative.Equals("..", StringComparison.Ordinal)
|
||||
|| relative.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Small cross-process peer roster for plugins. UtilityBelt used a local TCP
|
||||
/// relay; acdream uses bounded heartbeat documents in the user's local app
|
||||
/// data, which provides the same machine-local discovery without a privileged
|
||||
/// daemon or a fixed port. The files carry data only—never commands.
|
||||
/// </summary>
|
||||
internal sealed class LocalPluginPeerRegistry : IDisposable
|
||||
{
|
||||
internal static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(15);
|
||||
private const long MaximumDocumentBytes = 64 * 1024;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly string _path;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Guid _instanceId;
|
||||
private bool _disposed;
|
||||
|
||||
public LocalPluginPeerRegistry(
|
||||
string directory,
|
||||
TimeProvider? timeProvider = null,
|
||||
Guid? instanceId = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
_directory = Path.GetFullPath(directory);
|
||||
_time = timeProvider ?? TimeProvider.System;
|
||||
_instanceId = instanceId ?? Guid.NewGuid();
|
||||
_path = Path.Combine(_directory, $"peer-{_instanceId:N}.json");
|
||||
ClientId = BitConverter.ToUInt32(_instanceId.ToByteArray(), 0);
|
||||
if (ClientId == 0u)
|
||||
ClientId = 1u;
|
||||
}
|
||||
|
||||
public uint ClientId { get; private set; }
|
||||
|
||||
public void Publish(in PluginNetworkClient client)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Directory.CreateDirectory(_directory);
|
||||
var document = PeerDocument.From(
|
||||
client with { ClientId = ClientId },
|
||||
_instanceId,
|
||||
_time.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(document, JsonOptions));
|
||||
File.Move(temporary, _path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginNetworkClient> CaptureRemoteClients()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!Directory.Exists(_directory))
|
||||
return Array.Empty<PluginNetworkClient>();
|
||||
long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter)
|
||||
.ToUnixTimeMilliseconds();
|
||||
var result = new List<PluginNetworkClient>();
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
_directory,
|
||||
"peer-*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (file.Equals(_path, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length is <= 0 or > MaximumDocumentBytes)
|
||||
continue;
|
||||
PeerDocument? document = JsonSerializer.Deserialize<PeerDocument>(
|
||||
File.ReadAllText(file),
|
||||
JsonOptions);
|
||||
if (document is null
|
||||
|| document.InstanceId == _instanceId
|
||||
|| document.UpdatedUnixMs < newestAllowed
|
||||
|| document.ClientId == 0u
|
||||
|| document.PlayerId == 0u
|
||||
|| string.IsNullOrWhiteSpace(document.Name)
|
||||
|| document.Name.Length > 128
|
||||
|| document.WorldName is null
|
||||
|| document.WorldName.Length > 128
|
||||
|| document.Tags is null
|
||||
|| document.Tags.Length > 128
|
||||
|| !double.IsFinite(document.EastWest)
|
||||
|| !double.IsFinite(document.NorthSouth)
|
||||
|| !double.IsFinite(document.Elevation)
|
||||
|| !float.IsFinite(document.Heading))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
result.Add(document.ToClient());
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A peer can atomically replace or remove its own heartbeat
|
||||
// between enumeration and read. It will reappear next scan.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return result
|
||||
.OrderBy(static client => client.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static client => client.ClientId)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Withdraw()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (File.Exists(_path))
|
||||
File.Delete(_path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
Withdraw();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private sealed class PeerDocument
|
||||
{
|
||||
public Guid InstanceId { get; set; }
|
||||
public long UpdatedUnixMs { get; set; }
|
||||
public uint ClientId { get; set; }
|
||||
public uint PlayerId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string WorldName { get; set; } = string.Empty;
|
||||
public string[] Tags { get; set; } = [];
|
||||
public uint CellId { get; set; }
|
||||
public double EastWest { get; set; }
|
||||
public double NorthSouth { get; set; }
|
||||
public double Elevation { get; set; }
|
||||
public bool IsOutdoor { get; set; }
|
||||
public float Heading { get; set; }
|
||||
public uint CurrentHealth { get; set; }
|
||||
public uint CurrentMana { get; set; }
|
||||
public uint CurrentStamina { get; set; }
|
||||
public uint MaxHealth { get; set; }
|
||||
public uint MaxMana { get; set; }
|
||||
public uint MaxStamina { get; set; }
|
||||
|
||||
public static PeerDocument From(
|
||||
in PluginNetworkClient client,
|
||||
Guid instanceId,
|
||||
long updatedUnixMs) => new()
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
UpdatedUnixMs = updatedUnixMs,
|
||||
ClientId = client.ClientId,
|
||||
PlayerId = client.PlayerId,
|
||||
Name = client.Name,
|
||||
WorldName = client.WorldName,
|
||||
Tags = client.Tags
|
||||
.Where(static tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(static tag => tag.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(128)
|
||||
.ToArray(),
|
||||
CellId = client.Position.CellId,
|
||||
EastWest = client.Position.EastWest,
|
||||
NorthSouth = client.Position.NorthSouth,
|
||||
Elevation = client.Position.Elevation,
|
||||
IsOutdoor = client.Position.IsOutdoor,
|
||||
Heading = client.Heading,
|
||||
CurrentHealth = client.CurrentHealth,
|
||||
CurrentMana = client.CurrentMana,
|
||||
CurrentStamina = client.CurrentStamina,
|
||||
MaxHealth = client.MaxHealth,
|
||||
MaxMana = client.MaxMana,
|
||||
MaxStamina = client.MaxStamina,
|
||||
};
|
||||
|
||||
public PluginNetworkClient ToClient() => new(
|
||||
ClientId,
|
||||
PlayerId,
|
||||
Name,
|
||||
WorldName,
|
||||
new PluginNavigationPosition(
|
||||
CellId,
|
||||
EastWest,
|
||||
NorthSouth,
|
||||
Elevation,
|
||||
Heading,
|
||||
IsOutdoor),
|
||||
Tags,
|
||||
CurrentHealth,
|
||||
CurrentMana,
|
||||
CurrentStamina,
|
||||
MaxHealth,
|
||||
MaxMana,
|
||||
MaxStamina,
|
||||
Heading);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue