DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 = "配置已保存。";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user