DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
using System.Runtime.InteropServices;
namespace DNFLoadout;
internal sealed class HotkeyManager : IDisposable
{
private const int AltBit = 0x1;
private const int CtrlBit = 0x2;
private const int ShiftBit = 0x4;
private readonly System.Windows.Forms.Timer _timer = new() { Interval = 50 };
private readonly HashSet<int> _down = new();
private Config _config;
public event Action<Scheme>? Triggered;
public HotkeyManager(Config config)
{
_config = config;
_timer.Tick += (_, _) => Poll();
_timer.Start();
}
public void UpdateConfig(Config config) => _config = config;
private static bool Down(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0;
private static bool ModCtrl => Down((int)Keys.ControlKey);
private static bool ModAlt => Down((int)Keys.Menu);
private static bool ModShift => Down((int)Keys.ShiftKey);
private void Poll()
{
foreach (var scheme in _config.CurrentPage.Schemes)
{
if (scheme.KeyCode == 0) continue;
bool mods = ((scheme.Modifiers & CtrlBit) == 0 || ModCtrl)
&& ((scheme.Modifiers & AltBit) == 0 || ModAlt)
&& ((scheme.Modifiers & ShiftBit) == 0 || ModShift);
// Reject extra modifiers so Ctrl+F1 cannot fire plain F1.
if ((scheme.Modifiers & CtrlBit) == 0 && ModCtrl) mods = false;
if ((scheme.Modifiers & AltBit) == 0 && ModAlt) mods = false;
if ((scheme.Modifiers & ShiftBit) == 0 && ModShift) mods = false;
bool key = Down(scheme.KeyCode);
int id = scheme.KeyCode | (scheme.Modifiers << 16);
bool was = _down.Contains(id);
if (key && mods && !was)
{
_down.Add(id);
Triggered?.Invoke(scheme);
}
else if (!key || !mods)
{
_down.Remove(id);
}
}
}
public static (int Modifiers, int KeyCode) Capture(Keys keyData)
{
var modifiers = 0;
if ((keyData & Keys.Control) != 0) modifiers |= CtrlBit;
if ((keyData & Keys.Alt) != 0) modifiers |= AltBit;
if ((keyData & Keys.Shift) != 0) modifiers |= ShiftBit;
var keyCode = (int)(keyData & Keys.KeyCode);
if (keyCode is (int)Keys.ControlKey or (int)Keys.Menu or (int)Keys.ShiftKey
or (int)Keys.LControlKey or (int)Keys.RControlKey
or (int)Keys.LMenu or (int)Keys.RMenu
or (int)Keys.LShiftKey or (int)Keys.RShiftKey)
return (modifiers, 0);
return (modifiers, keyCode);
}
public void Dispose()
{
_timer.Stop();
_timer.Dispose();
}
[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<AssemblyName>DNFLoadout</AssemblyName>
<RootNamespace>DNFLoadout</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
+389
View File
@@ -0,0 +1,389 @@
namespace DNFLoadout;
internal sealed class TextPrompt : Form
{
private readonly TextBox _text = new() { Dock = DockStyle.Fill };
public string Value => _text.Text.Trim();
public TextPrompt(string title, string label, string initial = "")
{
Text = title;
Width = 360;
Height = 150;
StartPosition = FormStartPosition.CenterParent;
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(10), RowCount = 3 };
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 36));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
panel.Controls.Add(new Label { Text = label + "(按回车确认)", Dock = DockStyle.Fill }, 0, 0);
_text.Text = initial;
panel.Controls.Add(_text, 0, 1);
var ok = new Button { Text = "确定", DialogResult = DialogResult.OK, Dock = DockStyle.Right, Width = 90 };
var cancel = new Button { Text = "取消", DialogResult = DialogResult.Cancel, Dock = DockStyle.Right, Width = 90 };
var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft };
buttons.Controls.Add(ok);
buttons.Controls.Add(cancel);
panel.Controls.Add(buttons, 0, 2);
Controls.Add(panel);
AcceptButton = ok;
CancelButton = cancel;
}
}
internal sealed class HotkeyDialog : Form
{
private readonly TextBox _display = new() { ReadOnly = true, Dock = DockStyle.Top, Height = 28 };
private int _modifiers;
private int _keyCode;
public int Modifiers => _modifiers;
public int KeyCode => _keyCode;
public HotkeyDialog(int modifiers, int keyCode)
{
Text = "设置快捷键";
Width = 360;
Height = 190;
StartPosition = FormStartPosition.CenterParent;
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
_modifiers = modifiers;
_keyCode = keyCode;
_display.Text = Scheme.HotkeyTextOf(_modifiers, _keyCode);
_display.KeyDown += (_, e) =>
{
var (mods, code) = HotkeyManager.Capture(e.KeyData);
if (code == 0) return;
_modifiers = mods;
_keyCode = code;
_display.Text = Scheme.HotkeyTextOf(_modifiers, _keyCode);
e.SuppressKeyPress = true;
};
var hint = new Label
{
Text = "点击下面的输入框,然后按下想要的组合键。\n支持 F1~F12、字母、数字,可组合 Ctrl/Alt/Shift。",
Dock = DockStyle.Fill,
Padding = new Padding(8),
};
var ok = new Button { Text = "确定", DialogResult = DialogResult.OK, Dock = DockStyle.Right, Width = 90 };
var cancel = new Button { Text = "取消", DialogResult = DialogResult.Cancel, Dock = DockStyle.Right, Width = 90 };
var buttons = new FlowLayoutPanel { Dock = DockStyle.Bottom, Height = 40, FlowDirection = FlowDirection.RightToLeft };
buttons.Controls.Add(ok);
buttons.Controls.Add(cancel);
Controls.Add(hint);
Controls.Add(_display);
Controls.Add(buttons);
AcceptButton = ok;
CancelButton = cancel;
}
}
internal sealed class MainForm : Form
{
private readonly TextBox _server = new() { Width = 260 };
private readonly TextBox _token = new() { Width = 260, UseSystemPasswordChar = true };
private readonly ListBox _schemes = new() { Dock = DockStyle.Fill, IntegralHeight = false };
private readonly Label _status = new() { Dock = DockStyle.Bottom, Height = 64, TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(6) };
private readonly Button _add = new() { Text = "新增方案", Width = 100 };
private readonly Button _hotkey = new() { Text = "设置快捷键", Width = 100 };
private readonly Button _saveCurrent = new() { Text = "保存当前配装", Width = 120 };
private readonly Button _apply = new() { Text = "应用方案", Width = 100 };
private readonly Button _delete = new() { Text = "删除方案", Width = 100 };
private readonly Button _saveConfig = new() { Text = "保存配置", Width = 100 };
private readonly Button _addPage = new() { Text = "新增换装页", Width = 100 };
private readonly Button _renamePage = new() { Text = "重命名换装页", Width = 100 };
private readonly Button _deletePage = new() { Text = "删除换装页", Width = 100 };
private readonly TabControl _pageTabs = new()
{
Dock = DockStyle.Top,
Height = 34,
Appearance = TabAppearance.Buttons,
SizeMode = TabSizeMode.Fixed,
ItemSize = new Size(120, 28),
Multiline = false,
};
private bool _syncingPageTabs;
private readonly Config _config;
private readonly HotkeyManager _hotkeys;
private readonly Dictionary<string, DateTime> _cooldownUntil = new(StringComparer.OrdinalIgnoreCase);
public MainForm()
{
Text = "DNF 一键换装";
Width = 720;
Height = 520;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(680, 460);
_config = ConfigStore.Load();
_server.Text = _config.ServerUrl;
_token.Text = _config.AdminToken;
var top = new TableLayoutPanel { Dock = DockStyle.Top, Height = 46, ColumnCount = 4, Padding = new Padding(8) };
for (var i = 0; i < 4; i++) top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
top.Controls.Add(new Label { Text = "服务端", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0);
top.Controls.Add(_server, 1, 0);
top.Controls.Add(new Label { Text = "Admin Token", AutoSize = true, Anchor = AnchorStyles.Left }, 2, 0);
top.Controls.Add(_token, 3, 0);
var right = new FlowLayoutPanel { Dock = DockStyle.Right, Width = 130, FlowDirection = FlowDirection.TopDown, Padding = new Padding(6) };
foreach (var button in new[] { _addPage, _renamePage, _deletePage, _add, _hotkey, _saveCurrent, _apply, _delete, _saveConfig })
{
button.Width = 112;
right.Controls.Add(button);
}
Controls.Add(_status);
Controls.Add(_schemes);
Controls.Add(_pageTabs);
Controls.Add(right);
Controls.Add(top);
_hotkeys = new HotkeyManager(_config);
_hotkeys.Triggered += scheme => BeginInvoke(async () => await ApplySchemeAsync(scheme));
_pageTabs.SelectedIndexChanged += (_, _) => OnPageChanged();
_addPage.Click += (_, _) => AddPage();
_renamePage.Click += (_, _) => RenamePage();
_deletePage.Click += (_, _) => DeletePage();
_add.Click += (_, _) => AddScheme();
_hotkey.Click += (_, _) => SetHotkey();
_saveCurrent.Click += async (_, _) => await SaveCurrentAsync();
_apply.Click += async (_, _) => await ApplySelectedAsync();
_delete.Click += (_, _) => DeleteScheme();
_saveConfig.Click += (_, _) => SaveConfig(true);
_schemes.DoubleClick += async (_, _) => await ApplySelectedAsync();
FormClosing += (_, _) => SaveConfig(false);
RebuildPageTabs();
RefreshList();
}
private Scheme? Selected => _schemes.SelectedIndex >= 0 && _schemes.SelectedIndex < _config.CurrentPage.Schemes.Count
? _config.CurrentPage.Schemes[_schemes.SelectedIndex] : null;
private void RebuildPageTabs()
{
_syncingPageTabs = true;
_pageTabs.TabPages.Clear();
foreach (var page in _config.Pages)
{
_pageTabs.TabPages.Add(new TabPage(page.Name));
}
if (_pageTabs.TabPages.Count > 0)
_pageTabs.SelectedIndex = Math.Clamp(_config.ActivePage, 0, _pageTabs.TabPages.Count - 1);
_syncingPageTabs = false;
}
private void OnPageChanged()
{
if (_syncingPageTabs || _pageTabs.SelectedIndex < 0) return;
_config.ActivePage = _pageTabs.SelectedIndex;
_hotkeys.UpdateConfig(_config);
RefreshList();
SaveConfig(false);
}
private void AddPage()
{
using var prompt = new TextPrompt("新增换装页", "换装页名称", $"换装页{_config.Pages.Count + 1}");
if (prompt.ShowDialog(this) != DialogResult.OK || prompt.Value.Length == 0) return;
_config.Pages.Add(new LoadoutPage { Name = prompt.Value });
_config.ActivePage = _config.Pages.Count - 1;
RebuildPageTabs();
RefreshList();
SaveConfig(false);
}
private void RenamePage()
{
var page = _config.CurrentPage;
using var prompt = new TextPrompt("重命名换装页", "换装页名称", page.Name);
if (prompt.ShowDialog(this) != DialogResult.OK || prompt.Value.Length == 0) return;
page.Name = prompt.Value;
RebuildPageTabs();
SaveConfig(false);
}
private void DeletePage()
{
if (_config.Pages.Count <= 1)
{
_status.Text = "至少保留一个换装页。";
return;
}
var page = _config.CurrentPage;
if (MessageBox.Show(this, $"删除换装页“{page.Name}”及其中的全部方案?", "确认",
MessageBoxButtons.YesNo) != DialogResult.Yes) return;
_config.Pages.Remove(page);
_config.ActivePage = Math.Clamp(_config.ActivePage, 0, _config.Pages.Count - 1);
RebuildPageTabs();
RefreshList();
SaveConfig(false);
}
private void RefreshList()
{
var selected = _schemes.SelectedIndex;
_schemes.Items.Clear();
foreach (var scheme in _config.CurrentPage.Schemes) _schemes.Items.Add(scheme.Display);
if (_schemes.Items.Count > 0) _schemes.SelectedIndex = Math.Clamp(selected, 0, _schemes.Items.Count - 1);
}
private void AddScheme()
{
using var namePrompt = new TextPrompt("新增方案", "方案名称", $"方案{_config.CurrentPage.Schemes.Count + 1}");
if (namePrompt.ShowDialog(this) != DialogResult.OK || namePrompt.Value.Length == 0) return;
using var characterPrompt = new TextPrompt("绑定角色", "角色游戏内名称", "");
if (characterPrompt.ShowDialog(this) != DialogResult.OK || characterPrompt.Value.Length == 0) return;
var scheme = new Scheme
{
Name = namePrompt.Value,
CharacterName = characterPrompt.Value,
};
_config.CurrentPage.Schemes.Add(scheme);
RefreshList();
_schemes.SelectedIndex = _config.CurrentPage.Schemes.Count - 1;
SetHotkey();
}
private void SetHotkey()
{
var scheme = Selected;
if (scheme == null) return;
using var dialog = new HotkeyDialog(scheme.Modifiers, scheme.KeyCode);
if (dialog.ShowDialog(this) != DialogResult.OK || dialog.KeyCode == 0) return;
scheme.Modifiers = dialog.Modifiers;
scheme.KeyCode = dialog.KeyCode;
RefreshList();
SaveConfig(false);
}
private async Task SaveCurrentAsync()
{
var scheme = Selected;
if (scheme == null) { MessageBox.Show(this, "先选择一个方案"); return; }
if (!ValidateConnection()) return;
if (!EnsureCharacter(scheme)) return;
try
{
SaveConfig(false);
var client = new ServerClient(_config);
var slots = await client.GetEquipmentAsync(scheme.CharacterName);
scheme.Slots = slots;
RefreshList();
SaveConfig(false);
_status.Text = $"已保存方案“{scheme.Name}”:角色“{scheme.CharacterName}”,{slots.Count} 个部位。";
}
catch (Exception ex)
{
_status.Text = "保存失败:" + ex.Message;
}
}
private async Task ApplySelectedAsync()
{
var scheme = Selected;
if (scheme == null) { MessageBox.Show(this, "先选择一个方案"); return; }
await ApplySchemeAsync(scheme);
}
private async Task ApplySchemeAsync(Scheme scheme)
{
if (!ValidateConnection()) return;
if (!EnsureCharacter(scheme)) return;
if (!CheckCooldown(scheme)) return;
if (scheme.Slots.Count == 0)
{
_status.Text = $"方案“{scheme.Name}”还没有保存配装。";
return;
}
try
{
SaveConfig(false);
_status.Text = $"正在应用“{scheme.Name}”到角色“{scheme.CharacterName}”...";
var client = new ServerClient(_config);
var result = await client.ApplyAsync(scheme.CharacterName, scheme.Slots);
var applied = result.Applied ?? new List<SlotItem>();
var skippedList = result.Skipped ?? new List<ServerClient.SkipData>();
var skipped = skippedList.Count == 0 ? "无跳过"
: "跳过:" + string.Join("; ", skippedList.Select(s => $"槽{s.Slot}={s.ItemId} {s.Reason}"));
if (result.CooldownSeconds > 0)
{
_cooldownUntil[scheme.CharacterName] = DateTime.UtcNow.AddSeconds(result.CooldownSeconds);
_status.Text = $"应用“{scheme.Name}”完成:成功 {applied.Count} 件,{skipped};换装冷却 {result.CooldownSeconds} 秒"
+ (result.RefreshError is { Length: > 0 } ? " 刷新异常:" + result.RefreshError : "");
}
else
{
_status.Text = $"应用“{scheme.Name}”完成:成功 {applied.Count} 件,{skipped}"
+ (result.RefreshError is { Length: > 0 } ? " 刷新异常:" + result.RefreshError : "");
}
}
catch (Exception ex)
{
_status.Text = "应用失败:" + ex.Message;
}
}
private bool CheckCooldown(Scheme scheme)
{
if (_cooldownUntil.TryGetValue(scheme.CharacterName, out var until))
{
var remaining = (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds);
if (remaining > 0)
{
_status.Text = $"角色“{scheme.CharacterName}”换装冷却中,还需 {remaining} 秒。";
return false;
}
_cooldownUntil.Remove(scheme.CharacterName);
}
return true;
}
private void DeleteScheme()
{
var scheme = Selected;
if (scheme == null) return;
if (MessageBox.Show(this, $"删除方案“{scheme.Name}”?", "确认", MessageBoxButtons.YesNo) != DialogResult.Yes) return;
_config.CurrentPage.Schemes.Remove(scheme);
RefreshList();
SaveConfig(false);
}
private bool EnsureCharacter(Scheme scheme)
{
if (scheme.CharacterName.Trim().Length > 0) return true;
using var prompt = new TextPrompt("绑定角色", "角色游戏内名称", "");
if (prompt.ShowDialog(this) != DialogResult.OK || prompt.Value.Length == 0) return false;
scheme.CharacterName = prompt.Value;
RefreshList();
SaveConfig(false);
return true;
}
private bool ValidateConnection()
{
_config.ServerUrl = _server.Text.Trim();
_config.AdminToken = _token.Text.Trim();
if (_config.ServerUrl.Length == 0 || _config.AdminToken.Length == 0)
{
_status.Text = "请填写服务端地址和 Admin Token。";
return false;
}
return true;
}
private void SaveConfig(bool show)
{
_config.ServerUrl = _server.Text.Trim();
_config.AdminToken = _token.Text.Trim();
if (!_syncingPageTabs && _pageTabs.SelectedIndex >= 0)
_config.ActivePage = _pageTabs.SelectedIndex;
ConfigStore.Save(_config);
_hotkeys.UpdateConfig(_config);
if (show) _status.Text = "配置已保存。";
}
}
+122
View File
@@ -0,0 +1,122 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DNFLoadout;
internal sealed class Config
{
public string ServerUrl { get; set; } = "http://127.0.0.1:18111";
public string AdminToken { get; set; } = "";
public List<LoadoutPage> Pages { get; set; } = new();
public int ActivePage { get; set; }
// Legacy pre-page format: keep the old JSON field readable, but never write it.
[JsonPropertyName("Schemes")]
public List<Scheme>? LegacySchemes { get; set; }
[JsonIgnore]
public LoadoutPage CurrentPage
{
get
{
Normalize();
return Pages[ActivePage];
}
}
public void Normalize()
{
if (Pages == null) Pages = new();
if (Pages.Count == 0 && LegacySchemes is { Count: > 0 })
Pages.Add(new LoadoutPage { Name = "默认页", Schemes = LegacySchemes });
LegacySchemes = null;
if (Pages.Count == 0)
Pages.Add(new LoadoutPage { Name = "默认页" });
ActivePage = Math.Clamp(ActivePage, 0, Pages.Count - 1);
}
}
internal sealed class LoadoutPage
{
public string Name { get; set; } = "默认页";
public List<Scheme> Schemes { get; set; } = new();
}
internal sealed class Scheme
{
public string Name { get; set; } = "方案";
public string CharacterName { get; set; } = "";
public int Modifiers { get; set; }
public int KeyCode { get; set; }
public List<SlotItem> Slots { get; set; } = new();
[JsonIgnore]
public string HotkeyText => HotkeyTextOf(Modifiers, KeyCode);
[JsonIgnore]
public string Display => $"{Name} [{CharacterName}] [{HotkeyText}] ({Slots.Count} 件)";
public static string HotkeyTextOf(int modifiers, int keyCode)
{
if (keyCode == 0) return "未设置";
var parts = new List<string>();
if ((modifiers & 0x2) != 0) parts.Add("Ctrl");
if ((modifiers & 0x1) != 0) parts.Add("Alt");
if ((modifiers & 0x4) != 0) parts.Add("Shift");
var key = (Keys)keyCode;
parts.Add(key.ToString());
return string.Join("+", parts);
}
}
internal sealed class SlotItem
{
[JsonPropertyName("slot")]
public int Slot { get; set; }
[JsonPropertyName("item_id")]
public long ItemId { get; set; }
}
internal sealed class ConfigStore
{
private static string PathFile
{
get
{
var portable = Path.Combine(AppContext.BaseDirectory, "config.json");
if (File.Exists(portable)) return portable;
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"DNFLoadout", "config.json");
}
}
public static Config Load()
{
try
{
if (File.Exists(PathFile))
{
var json = File.ReadAllText(PathFile);
var config = JsonSerializer.Deserialize<Config>(json) ?? new Config();
config.Normalize();
return config;
}
}
catch { }
var fresh = new Config();
fresh.Normalize();
return fresh;
}
public static void Save(Config config)
{
config.Normalize();
Directory.CreateDirectory(Path.GetDirectoryName(PathFile)!);
File.WriteAllText(PathFile, JsonSerializer.Serialize(config, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
}));
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace DNFLoadout;
internal static class Program
{
private static readonly string LogPath = Path.Combine(AppContext.BaseDirectory, "startup.log");
private static void Log(string text)
{
try { File.AppendAllText(LogPath, $"{DateTime.Now:O} {text}\r\n"); } catch { }
}
[STAThread]
private static void Main()
{
AppDomain.CurrentDomain.UnhandledException += (_, e) => Log("UNHANDLED " + e.ExceptionObject);
Application.ThreadException += (_, e) => Log("THREAD " + e.Exception);
Log("start");
try
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
Log("exit");
}
catch (Exception ex)
{
Log("FATAL " + ex);
throw;
}
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DNFLoadout;
internal sealed class ServerClient
{
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
private readonly Config _config;
public ServerClient(Config config) => _config = config;
private HttpRequestMessage Request(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, _config.ServerUrl.TrimEnd('/') + path);
if (!string.IsNullOrWhiteSpace(_config.AdminToken))
req.Headers.TryAddWithoutValidation("X-Admin-Token", _config.AdminToken.Trim());
return req;
}
public async Task<List<SlotItem>> GetEquipmentAsync(string characterName)
{
using var req = Request(HttpMethod.Get,
"/local/dnf-gm/loadout/equipment?character_id=" + Uri.EscapeDataString(characterName.Trim()));
using var resp = await _http.SendAsync(req);
var text = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new Exception(text);
var envelope = JsonSerializer.Deserialize<Envelope<SnapshotData>>(text)
?? throw new Exception("empty response");
if (!envelope.Ok) throw new Exception(envelope.Error ?? "server returned failure");
return envelope.Data?.Slots ?? new List<SlotItem>();
}
public async Task<List<CharacterInfo>> GetCharactersAsync()
{
using var req = Request(HttpMethod.Get, "/local/dnf-gm/characters");
using var resp = await _http.SendAsync(req);
var text = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new Exception(text);
var envelope = JsonSerializer.Deserialize<Envelope<List<CharacterInfo>>>(text)
?? throw new Exception("empty response");
if (!envelope.Ok) throw new Exception(envelope.Error ?? "server returned failure");
return envelope.Data ?? new List<CharacterInfo>();
}
internal sealed class CharacterInfo
{
[JsonPropertyName("character_id")] public string CharacterId { get; set; } = "";
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("online")] public bool Online { get; set; }
}
public async Task<ApplyData> ApplyAsync(string characterName, IEnumerable<SlotItem> slots)
{
var body = new ApplyRequest
{
CharacterId = characterName.Trim(),
Slots = slots.ToList(),
};
using var req = Request(HttpMethod.Post, "/local/dnf-gm/loadout/apply");
req.Content = JsonContent.Create(body);
using var resp = await _http.SendAsync(req);
var text = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new Exception(text);
var envelope = JsonSerializer.Deserialize<Envelope<ApplyData>>(text)
?? throw new Exception("empty response");
if (!envelope.Ok) throw new Exception(envelope.Error ?? "server returned failure");
return envelope.Data ?? new ApplyData();
}
private sealed class Envelope<T>
{
[JsonPropertyName("ok")] public bool Ok { get; set; }
[JsonPropertyName("error")] public string? Error { get; set; }
[JsonPropertyName("data")] public T? Data { get; set; }
}
private sealed class SnapshotData
{
[JsonPropertyName("character_id")] public string CharacterId { get; set; } = "";
[JsonPropertyName("online")] public bool Online { get; set; }
[JsonPropertyName("slots")] public List<SlotItem> Slots { get; set; } = new();
}
private sealed class ApplyRequest
{
[JsonPropertyName("character_id")] public string CharacterId { get; set; } = "";
[JsonPropertyName("slots")] public List<SlotItem> Slots { get; set; } = new();
}
internal sealed class ApplyData
{
[JsonPropertyName("applied")] public List<SlotItem>? Applied { get; set; } = new();
[JsonPropertyName("skipped")] public List<SkipData>? Skipped { get; set; } = new();
[JsonPropertyName("refresh_sent")] public bool RefreshSent { get; set; }
[JsonPropertyName("refresh_error")] public string? RefreshError { get; set; }
[JsonPropertyName("cooldown_seconds")] public int CooldownSeconds { get; set; }
[JsonPropertyName("cooldown_remaining")] public int CooldownRemaining { get; set; }
}
internal sealed class SkipData
{
[JsonPropertyName("slot")] public int Slot { get; set; }
[JsonPropertyName("item_id")] public long ItemId { get; set; }
[JsonPropertyName("reason")] public string Reason { get; set; } = "";
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="DNFLoadout.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges></security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>