diff --git a/client-patch/90CN.cpp b/client-patch/90CN.cpp index bc937ee..853f040 100644 --- a/client-patch/90CN.cpp +++ b/client-patch/90CN.cpp @@ -38,6 +38,7 @@ #include extern "C" void InstallRaidProbe(); +extern "C" void InstallWebRedirect(); namespace { @@ -15486,6 +15487,9 @@ DWORD WINAPI InstallTransportWorkerInner(void*) InstallRaidProbe(); LogLine("raid probe integrated normal/single mode compatibility"); + // 内嵌网页窗口(小助手/小井盖)跳到本地 GM 页。只起一个轮询线程,不碰游戏逻辑。 + InstallWebRedirect(); + LogLine("transport DLL installed codec=1 decode=1 route=%d dproto=%d party_ui=%d town_copresence=%d dungeon_pickup=%d creature_rename=%d rare_avatar_ticket=%d premium_state=%d contract_use=%d wait_ms=%d", enableSceneRouteCompat ? 1 : 0, enableDprotoCompat ? 1 : 0, @@ -15512,6 +15516,18 @@ DWORD WINAPI InstallWorker(void*) } } // namespace +// 其它翻译单元(如 90CN_web_redirect.cpp)要写同一条 90CN_trace.log,但 LogLine +// 在匿名 namespace 里只有内部链接,所以这里开一个 C 链接的转发出口。 +extern "C" void __cdecl CnmLogLine(const char* format, ...) +{ + char buffer[1024]; + va_list args; + va_start(args, format); + _vsnprintf_s(buffer, sizeof(buffer), _TRUNCATE, format, args); + va_end(args); + LogLine("%s", buffer); +} + #pragma comment(linker, "/EXPORT:Queue90CNClientNotice=_Queue90CNClientNotice@8") #pragma comment(linker, "/EXPORT:Dequeue90CNClientEvent=_Dequeue90CNClientEvent@8") #pragma comment(linker, "/EXPORT:Query90CNCharacterStatSnapshot=_Query90CNCharacterStatSnapshot@8") diff --git a/client-patch/90CN_web.ini b/client-patch/90CN_web.ini new file mode 100644 index 0000000..633a336 --- /dev/null +++ b/client-patch/90CN_web.ini @@ -0,0 +1,9 @@ +[web] +; 1 = redirect the embedded web window to the page below, 0 = leave it alone +enabled=1 +; target page. Must be plain http on 127.0.0.1 to avoid IE certificate prompts. +url=http://127.0.0.1:18112/ +; 1 = if the document navigates away (native tabs), pull it back to url +force=0 +; ms to wait after the IE_Server window appears, before navigating +delay=1200 diff --git a/client-patch/90CN_web_redirect.cpp b/client-patch/90CN_web_redirect.cpp new file mode 100644 index 0000000..3eb2425 --- /dev/null +++ b/client-patch/90CN_web_redirect.cpp @@ -0,0 +1,292 @@ +// ============================================================================ +// 90CN_web_redirect.cpp -- 把客户端内嵌网页窗口跳到本地 GM 页面 +// +// 背景:DNF 客户端里那个「小井盖 / 小助手」窗口是 IE 内核(窗口类链为 +// Browser Example -> Shell DocObject View -> Internet Explorer_Server), +// 地址在运行时拼接,客户端二进制与 PVF 里都没有明文域名,硬改字符串 +// 不可行。所以这里不碰原地址,而是等控件创建出来后,直接把文档导航 +// 到我们自己的页面。 +// +// 做法:起一个后台线程轮询本进程内的 Internet Explorer_Server 窗口;一旦 +// 发现新窗口,等原生导航落定,再用 WM_HTML_GETOBJECT + ObjectFromLresult +// 取到 IHTMLDocument2,经 IHTMLWindow2::execScript 执行 +// location.replace(...) 完成跳转。同一窗口只跳一次(force=1 时若发现 +// 文档又跑到别的域名,会再次拉回)。 +// +// 配置:DLL 同目录的 90CN_web.ini +// [web] +// enabled=1 +// url=http://127.0.0.1:18112/ +// force=0 +// delay=1200 +// 没有配置文件时用上面的默认值(enabled=1)。 +// +// 失败不影响游戏:任何一步取不到对象都只写日志,不会终止进程。 +// ============================================================================ + +#include +#include +#include +#include +#include + +// 90CN.cpp 里的 LogLine 在匿名 namespace 内(内部链接),所以走它导出的 +// C 链接转发口,保证本模块的日志和安装流程写进同一个 90CN_trace.log。 +extern "C" void __cdecl CnmLogLine(const char* format, ...); +#define LogLine CnmLogLine + +namespace { + +const wchar_t kIniName[] = L"90CN_web.ini"; +const wchar_t kIeServerClass[] = L"Internet Explorer_Server"; + +struct WebConfig { + bool enabled; + bool force; + int delayMs; + std::wstring url; +}; + +WebConfig g_cfg = { true, false, 1200, L"http://127.0.0.1:18112/" }; + +DWORD g_threadId = 0; +volatile LONG g_stop = 0; + +std::wstring WideFromAnsi(const char* s) +{ + if (!s || !*s) return std::wstring(); + int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0); + if (need <= 1) { + need = MultiByteToWideChar(CP_ACP, 0, s, -1, nullptr, 0); + if (need <= 1) return std::wstring(); + std::wstring out(static_cast(need - 1), L'\0'); + MultiByteToWideChar(CP_ACP, 0, s, -1, &out[0], need); + return out; + } + std::wstring out(static_cast(need - 1), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need); + return out; +} + +std::wstring ModuleDirectory() +{ + wchar_t path[MAX_PATH] = {}; + HMODULE self = nullptr; + // 用数据地址反查自身模块:函数地址在某些加载方式下解析不到。 + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&g_cfg), &self) || + !self) { + return std::wstring(); + } + if (!GetModuleFileNameW(self, path, MAX_PATH)) return std::wstring(); + std::wstring full(path); + const size_t slash = full.find_last_of(L"\\/"); + return slash == std::wstring::npos ? std::wstring() : full.substr(0, slash + 1); +} + +void LoadConfig() +{ + const std::wstring dir = ModuleDirectory(); + if (dir.empty()) { + LogLine("web redirect: module dir unresolved, using built-in defaults"); + return; + } + const std::wstring ini = dir + kIniName; + if (GetFileAttributesW(ini.c_str()) == INVALID_FILE_ATTRIBUTES) { + LogLine("web redirect: %ls not found, defaults enabled=1 url=%ls", ini.c_str(), g_cfg.url.c_str()); + return; + } + + const UINT enabled = GetPrivateProfileIntW(L"web", L"enabled", 1, ini.c_str()); + const UINT force = GetPrivateProfileIntW(L"web", L"force", 0, ini.c_str()); + const UINT delay = GetPrivateProfileIntW(L"web", L"delay", 1200, ini.c_str()); + + wchar_t buf[1024] = {}; + GetPrivateProfileStringW(L"web", L"url", L"http://127.0.0.1:18112/", buf, + static_cast(sizeof(buf) / sizeof(buf[0])), ini.c_str()); + + g_cfg.enabled = enabled != 0; + g_cfg.force = force != 0; + g_cfg.delayMs = delay > 200 ? static_cast(delay) : 200; + if (buf[0]) g_cfg.url = buf; + + LogLine("web redirect: config enabled=%d force=%d delay=%d url=%ls", + g_cfg.enabled ? 1 : 0, g_cfg.force ? 1 : 0, g_cfg.delayMs, g_cfg.url.c_str()); +} + +bool IsLocalUrl(const std::wstring& url) +{ + // 只认 "http://127.0.0.1:/" 前缀,用来判断当前文档是否已经在我们的页面上。 + if (g_cfg.url.empty() || url.size() < g_cfg.url.size()) return false; + return _wcsnicmp(url.c_str(), g_cfg.url.c_str(), g_cfg.url.size()) == 0; +} + +struct FindContext { + DWORD pid; + HWND found; +}; + +BOOL CALLBACK OnChildWindow(HWND hwnd, LPARAM param) +{ + FindContext* ctx = reinterpret_cast(param); + wchar_t cls[128] = {}; + if (GetClassNameW(hwnd, cls, 128) && _wcsicmp(cls, kIeServerClass) == 0) { + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid == ctx->pid) { + ctx->found = hwnd; + return FALSE; + } + } + return TRUE; +} + +BOOL CALLBACK OnTopWindow(HWND hwnd, LPARAM param) +{ + FindContext* ctx = reinterpret_cast(param); + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid != ctx->pid) return TRUE; + EnumChildWindows(hwnd, OnChildWindow, param); + return ctx->found ? FALSE : TRUE; +} + +HWND FindIeServerWindow() +{ + FindContext ctx = { GetCurrentProcessId(), nullptr }; + EnumWindows(OnTopWindow, reinterpret_cast(&ctx)); + return ctx.found; +} + +// 取文档对象。WM_HTML_GETOBJECT 的返回值经 ObjectFromLresult 还原成接口指针。 +IHTMLDocument2* GetDocument(HWND hwnd) +{ + static const UINT msg = RegisterWindowMessageW(L"WM_HTML_GETOBJECT"); + if (!msg) return nullptr; + + DWORD_PTR result = 0; + const LRESULT ok = SendMessageTimeoutW(hwnd, msg, 0, 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 4000, &result); + if (!ok || !result) return nullptr; + + IHTMLDocument2* doc = nullptr; + if (FAILED(ObjectFromLresult(static_cast(result), IID_IHTMLDocument2, 0, + reinterpret_cast(&doc)))) { + return nullptr; + } + return doc; +} + +std::wstring DocumentUrl(IHTMLDocument2* doc) +{ + BSTR url = nullptr; + std::wstring out; + if (doc && SUCCEEDED(doc->get_URL(&url)) && url) { + out.assign(url, SysStringLen(url)); + } + if (url) SysFreeString(url); + return out; +} + +bool NavigateDocument(IHTMLDocument2* doc) +{ + IHTMLWindow2* win = nullptr; + if (FAILED(doc->get_parentWindow(&win)) || !win) return false; + + // 用 location.replace 而不是 href:不往历史里塞记录,避免用户按返回键回到原页。 + std::wstring script = L"location.replace('"; + script += g_cfg.url; + script += L"')"; + + BSTR code = SysAllocString(script.c_str()); + BSTR lang = SysAllocString(L"JavaScript"); + VARIANT ret; + VariantInit(&ret); + HRESULT hr = win->execScript(code, lang, &ret); + VariantClear(&ret); + SysFreeString(code); + SysFreeString(lang); + win->Release(); + return SUCCEEDED(hr); +} + +DWORD WINAPI WorkerThread(void*) +{ + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + HWND lastHandled = nullptr; + for (;;) { + if (InterlockedCompareExchange(&g_stop, 0, 0) != 0) break; + + HWND hwnd = FindIeServerWindow(); + if (hwnd && hwnd != lastHandled) { + LogLine("web redirect: IE_Server window found hwnd=%p, waiting %dms", (void*)hwnd, g_cfg.delayMs); + // 等原生导航把文档对象建好,太早拿会拿到 about:blank。 + for (int waited = 0; waited < g_cfg.delayMs && !g_stop; waited += 100) { + Sleep(100); + } + + IHTMLDocument2* doc = GetDocument(hwnd); + if (!doc) { + LogLine("web redirect: document object unavailable hwnd=%p", (void*)hwnd); + lastHandled = hwnd; // 失败也不无限重试同一窗口 + } else { + const std::wstring current = DocumentUrl(doc); + LogLine("web redirect: current url=%ls", current.c_str()); + if (IsLocalUrl(current)) { + LogLine("web redirect: already on local page, skip"); + lastHandled = hwnd; + } else if (NavigateDocument(doc)) { + LogLine("web redirect: navigated to %ls", g_cfg.url.c_str()); + lastHandled = hwnd; + } else { + LogLine("web redirect: execScript failed hwnd=%p", (void*)hwnd); + lastHandled = hwnd; + } + doc->Release(); + } + } else if (hwnd && hwnd == lastHandled && g_cfg.force) { + // force 模式下防止用户点原生标签页跳走。 + IHTMLDocument2* doc = GetDocument(hwnd); + if (doc) { + if (!IsLocalUrl(DocumentUrl(doc))) { + if (NavigateDocument(doc)) LogLine("web redirect: pulled back to %ls", g_cfg.url.c_str()); + } + doc->Release(); + } + } + + if (!hwnd) lastHandled = nullptr; // 窗口被销毁后允许下次重新接管 + for (int waited = 0; waited < 700 && !g_stop; waited += 100) { + Sleep(100); + } + } + + CoUninitialize(); + return 0; +} + +} // namespace + +extern "C" void InstallWebRedirect() +{ + LoadConfig(); + if (!g_cfg.enabled) { + LogLine("web redirect: disabled by config"); + return; + } + if (g_cfg.url.find(L'\'') != std::wstring::npos) { + LogLine("web redirect: url contains a quote, refused"); + return; + } + + InterlockedExchange(&g_stop, 0); + HANDLE thread = CreateThread(nullptr, 0, WorkerThread, nullptr, 0, &g_threadId); + if (!thread) { + LogLine("web redirect: CreateThread failed err=%u", GetLastError()); + return; + } + CloseHandle(thread); + LogLine("web redirect: worker started tid=%u target=%ls", g_threadId, g_cfg.url.c_str()); +} diff --git a/gmweb/app.js b/gmweb/app.js new file mode 100644 index 0000000..0a51049 --- /dev/null +++ b/gmweb/app.js @@ -0,0 +1,402 @@ +/* GM 控制台前端 —— 严格 ES5 + XMLHttpRequest,兼容 IE11 内嵌浏览器。 + 所有请求走同源 /api/*,由本地 serve.py 代理到服务端 admin 口并注入 token。 */ +(function () { + 'use strict'; + + var S = { + charId: '', charName: '', chars: [], + itemId: 0, itemName: '', picked: null, items: [], + page: 1, pageSize: 20, total: 0 + }; + + /* ---------------------------------------------------------------- 基础 */ + + function $(id) { return document.getElementById(id); } + + function log(msg, cls) { + var box = $('log'); + var d = document.createElement('div'); + if (cls) { d.className = cls; } + var t = new Date(); + var p = function (n) { return (n < 10 ? '0' : '') + n; }; + d.innerHTML = '[' + p(t.getHours()) + ':' + p(t.getMinutes()) + ':' + p(t.getSeconds()) + '] ' + esc(msg); + box.appendChild(d); + box.scrollTop = box.scrollHeight; + while (box.childNodes.length > 120) { box.removeChild(box.firstChild); } + } + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); + } + + function api(path, method, body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open(method || 'GET', '/api' + path, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) { return; } + if (xhr.status === 0) { log('连不上本地代理(serve.py 没启动?)', 'err'); cb(null); return; } + var data = null; + try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; } + if (!data) { log('响应不是 JSON:' + xhr.responseText.substring(0, 120), 'err'); cb(null); return; } + if (data.success === false || data.ok === false) { + log('失败:' + (data.message || data.error || '未知错误'), 'err'); + cb(null); + return; + } + cb(data); + }; + xhr.send(body ? JSON.stringify(body) : null); + } + + function setOnline(on, text) { + $('dot').className = 'dot ' + (on ? 'on' : 'off'); + $('stat').innerHTML = esc(text); + } + + /* ---------------------------------------------------------------- 标签页 */ + + function switchTab(name) { + var tabs = document.getElementsByClassName('tab'); + for (var i = 0; i < tabs.length; i++) { + tabs[i].className = 'tab' + (tabs[i].getAttribute('data-tab') === name ? ' on' : ''); + } + var panes = document.getElementsByClassName('pane'); + for (var j = 0; j < panes.length; j++) { + panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : ''); + } + } + + function bindTabs() { + var tabs = document.getElementsByClassName('tab'); + for (var i = 0; i < tabs.length; i++) { + tabs[i].onclick = function () { switchTab(this.getAttribute('data-tab')); }; + } + } + + /* ---------------------------------------------------------------- 状态页 */ + + function loadStatus() { + api('/dnf/gm/status', 'GET', null, function (d) { + if (!d) { setOnline(false, '服务端未响应'); return; } + var v = d.data || {}; + $('v-online').innerHTML = esc(v.online_characters == null ? '-' : v.online_characters); + $('v-repo').innerHTML = v.repository_ready ? '就绪' : '未就绪'; + $('v-items').innerHTML = v.item_catalog_ready ? '就绪' : '未就绪'; + $('v-cap').innerHTML = esc(v.level_cap == null ? '-' : v.level_cap); + setOnline(true, '已连接 127.0.0.1:18111'); + }); + } + + /* ---------------------------------------------------------------- 角色 */ + + function renderChars() { + var box = $('char-list'); + if (!S.chars.length) { box.innerHTML = '
没有角色
'; return; } + var h = []; + for (var i = 0; i < S.chars.length; i++) { + var c = S.chars[i]; + h.push('
' + esc(c.name) + + 'Lv.' + esc(c.level) + ' · 金币 ' + esc(c.gold) + + (c.online ? ' · 在线' : '') + '
'); + } + box.innerHTML = h.join(''); + var lis = box.getElementsByClassName('li'); + for (var k = 0; k < lis.length; k++) { + lis[k].onclick = function () { pickChar(this); }; + } + } + + function pickChar(el) { + S.charId = el.getAttribute('data-id'); + S.charName = el.getAttribute('data-name'); + $('in-gold').value = el.getAttribute('data-gold'); + $('in-level').value = el.getAttribute('data-level'); + $('cur').innerHTML = esc(S.charName) + ' (id=' + esc(S.charId) + ')'; + renderChars(); + log('已选角色:' + S.charName, 'tip'); + } + + function loadChars() { + var q = $('q-char').value; + var path = '/local/dnf-gm/characters' + (q ? '?character_name=' + encodeURIComponent(q) : ''); + api(path, 'GET', null, function (d) { + if (!d) { return; } + S.chars = d.data || []; + renderChars(); + log('角色 ' + S.chars.length + ' 个'); + if (S.chars.length === 1) { pickChar($('char-list').getElementsByClassName('li')[0]); } + }); + } + + function setGold() { + if (!requireChar()) { return; } + var v = parseInt($('in-gold').value, 10); + if (isNaN(v) || v < 0) { log('金币要填非负整数', 'err'); return; } + api('/dnf/gm/characters/' + S.charId + '/gold', 'POST', { value: v }, function (d) { + if (d) { log('金币已改为 ' + v, 'ok'); loadChars(); } + }); + } + + function setLevel() { + if (!requireChar()) { return; } + var v = parseInt($('in-level').value, 10); + if (isNaN(v) || v < 1) { log('等级要填正整数', 'err'); return; } + api('/dnf/gm/characters/' + S.charId + '/level', 'POST', { level: v }, function (d) { + if (d) { log('等级已改为 ' + v, 'ok'); loadChars(); } + }); + } + + function requireChar() { + if (!S.charId) { log('先在「角色」页选一个角色', 'err'); return false; } + return true; + } + + /* ---------------------------------------------------------------- 物品 */ + + /* 装备类型标签:PVF 里是 "[weapon]" 这种,去掉括号后映射成中文。 */ + var EQUIP_LABEL = { + 'weapon': '武器', 'coat': '上衣', 'shoulder': '头肩', 'pants': '下装', + 'shoes': '鞋', 'waist': '腰带', 'amulet': '项链', 'wrist': '手镯', + 'ring': '戒指', 'support': '辅助装备', 'magic stone': '魔法石', + 'earring': '耳环', 'support weapon': '辅助武器', 'avatar': '时装', + 'creature': '宠物', 'artifact': '神器' + }; + + /* 与服务器 gmItemSupportsEquipmentAttributes 保持一致:只有这些类型吃得下强化/增幅。 */ + var UPGRADABLE = { + 'weapon': 1, 'coat': 1, 'shoulder': 1, 'pants': 1, 'shoes': 1, 'waist': 1, + 'amulet': 1, 'wrist': 1, 'ring': 1, 'support': 1, 'magic stone': 1, + 'earring': 1, 'support weapon': 1 + }; + + function equipTag(value) { + return String(value == null ? '' : value) + .replace(/[\[\]`]/g, '').replace(/^\s+|\s+$/g, '').toLowerCase(); + } + + function kindText(it) { + if (!it) { return ''; } + if (it.kind !== 'equipment') { return '道具'; } + var tag = equipTag(it.equipment_type); + return '装备 · ' + (EQUIP_LABEL[tag] || tag || '未知'); + } + + function canUpgrade(it) { + if (!it || it.kind !== 'equipment') { return false; } + return UPGRADABLE[equipTag(it.equipment_type)] === 1; + } + + function clamp(v, lo, hi) { + if (isNaN(v)) { return lo; } + return v < lo ? lo : (v > hi ? hi : v); + } + + function renderItems() { + var box = $('item-list'); + var pages = S.total > 0 ? Math.ceil(S.total / S.pageSize) : 1; + $('page-info').innerHTML = '第 ' + S.page + ' / ' + pages + ' 页 · 共 ' + S.total + ' 条'; + $('btn-prev').disabled = S.page <= 1; + $('btn-next').disabled = S.page >= pages; + + if (!S.items.length) { + box.innerHTML = '
没有匹配的物品
'; + return; + } + var h = []; + for (var i = 0; i < S.items.length; i++) { + var it = S.items[i]; + h.push('
' + + '' + esc(it.name) + '' + + 'id ' + esc(it.item_id) + ' · ' + esc(kindText(it)) + + (canUpgrade(it) ? '' : '') + '
'); + } + box.innerHTML = h.join(''); + var lis = box.getElementsByClassName('li'); + for (var k = 0; k < lis.length; k++) { + lis[k].onclick = function () { + var index = parseInt(this.getAttribute('data-i'), 10); + pickItem(S.items[index]); + }; + } + } + + function renderPicked() { + var box = $('picked'); + if (!S.itemId) { + box.innerHTML = '未选择物品 —— 先搜索,再点一行'; + } else { + var it = S.picked; + var hint = ''; + if (it && it.kind === 'equipment') { + hint = canUpgrade(it) ? '可强化 / 增幅' : '该类型不支持强化(时装类)'; + } else { + hint = '普通道具,属性值会被忽略'; + } + box.innerHTML = esc(S.itemName) + ' (id ' + esc(S.itemId) + ') · ' + esc(hint); + } + var off = !canUpgrade(S.picked); + $('in-reinforce').disabled = off; + $('in-amplify').disabled = off; + $('in-amplify-level').disabled = off; + $('in-forging').disabled = off; + $('in-quality').disabled = off; + } + + function pickItem(it) { + if (!it) { return; } + S.itemId = it.item_id; + S.itemName = it.name; + S.picked = it; + renderItems(); + renderPicked(); + log('已选物品:' + it.name + '(id ' + it.item_id + ')', 'tip'); + } + + function loadItems(page) { + var q = $('q-item').value; + var size = parseInt($('in-page-size').value, 10) || 20; + var p = page || 1; + if (p < 1) { p = 1; } + S.pageSize = size; + var offset = (p - 1) * size; + api('/dnf/gm/items/page?limit=' + size + '&offset=' + offset + + '&q=' + encodeURIComponent(q || ''), 'GET', null, function (d) { + if (!d) { return; } + var pageData = d.data || {}; + S.items = pageData.items || []; + S.total = pageData.total || 0; + S.page = p; + renderItems(); + log('物品 ' + S.items.length + ' 条 / 共 ' + S.total + ' 条'); + }); + } + + /* 组装强化/增幅/锻造/品质:增幅优先,红字装不再叠加白字强化。 */ + function readOptions() { + var out = { reinforce: 0, amplify_type: 0, amplify_level: 0, forging: 0, equipment_quality: '' }; + if (!canUpgrade(S.picked)) { return out; } + var amp = parseInt($('in-amplify').value, 10) || 0; + var ampLevel = parseInt($('in-amplify-level').value, 10) || 0; + var rein = parseInt($('in-reinforce').value, 10) || 0; + var forge = parseInt($('in-forging').value, 10) || 0; + if (amp > 0) { + out.amplify_type = clamp(amp, 1, 4); + out.amplify_level = clamp(ampLevel, 0, 31); + out.forging = 0; + } else { + out.reinforce = clamp(rein, 0, 31); + out.forging = clamp(forge, 0, 8); + } + out.equipment_quality = $('in-quality').value || ''; + return out; + } + + function optText(o) { + var parts = []; + if (o.amplify_type > 0) { + parts.push('增幅' + o.amplify_type + ' +' + o.amplify_level); + } else if (o.reinforce > 0) { + parts.push('强化 +' + o.reinforce); + } + if (o.forging > 0) { parts.push('锻造 +' + o.forging); } + if (o.equipment_quality) { + parts.push('品质' + (o.equipment_quality === 'top' ? '最上级' : '随机')); + } + return parts.length ? '(' + parts.join(',') + ')' : ''; + } + + function grantItem() { + if (!requireChar()) { return; } + if (!S.itemId) { log('先搜一个物品并选中', 'err'); return; } + var n = parseInt($('in-count').value, 10) || 1; + var o = readOptions(); + api('/local/dnf-gm/item/grant-options', 'POST', { + character_id: String(S.charId), item_id: S.itemId, count: n, + reinforce: o.reinforce, amplify_type: o.amplify_type, amplify_level: o.amplify_level, + forging: o.forging, equipment_quality: o.equipment_quality + }, function (d) { + if (!d) { return; } + var data = d.data || {}; + log('已发 ' + S.itemName + ' ×' + n + optText(o) + + (data.slot != null ? ',格位 ' + data.list_type + ':' + data.slot : ''), 'ok'); + }); + } + + function mailItem() { + if (!requireChar()) { return; } + if (!S.itemId) { log('先搜一个物品并选中', 'err'); return; } + var n = parseInt($('in-count').value, 10) || 1; + var o = readOptions(); + api('/dnf/gm/characters/' + S.charId + '/mail', 'POST', { + item_id: S.itemId, count: n, title: 'GM 物品', body: 'GM 发放', + expire_days: 7, reinforce: o.reinforce, amplify_type: o.amplify_type, + amplify_level: o.amplify_level, equipment_quality: o.equipment_quality, + forging: o.forging, emblem_item_ids: [], enchant_bead_item_id: 0 + }, function (d) { + if (d) { log('已邮寄 ' + S.itemName + ' ×' + n + optText(o), 'ok'); } + }); + } + + /* ---------------------------------------------------------------- 邮件 */ + + function sendMail() { + if (!requireChar()) { return; } + var gold = parseInt($('in-goldmail').value, 10) || 0; + api('/local/dnf-gm/mail', 'POST', { + character_name: S.charName, + title: $('in-title').value || 'GM 邮件', + body: $('in-body').value || '', + gold: gold, + attachments: [] + }, function (d) { + if (d) { log('邮件已发送给 ' + S.charName, 'ok'); } + }); + } + + /* ---------------------------------------------------------------- 启动 */ + + function bind(id, fn) { + var el = $(id); + if (el) { el.onclick = fn; } + } + + function init() { + bindTabs(); + + bind('btn-refresh', function () { loadStatus(); log('刷新状态'); }); + bind('btn-resync', function () { + if (!requireChar()) { return; } + api('/local/quest-resync', 'POST', { character_id: parseInt(S.charId, 10) }, function (d) { + if (d) { log('角色数据已同步', 'ok'); } + }); + }); + bind('btn-search-char', loadChars); + bind('btn-gold', setGold); + bind('btn-level', setLevel); + bind('btn-search-item', function () { loadItems(1); }); + bind('btn-prev', function () { if (S.page > 1) { loadItems(S.page - 1); } }); + bind('btn-next', function () { loadItems(S.page + 1); }); + bind('btn-grant', grantItem); + bind('btn-mailitem', mailItem); + bind('btn-mail', sendMail); + + $('in-page-size').onchange = function () { loadItems(1); }; + + $('q-char').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadChars(); } }; + $('q-item').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadItems(1); } }; + + renderPicked(); + log('GM 控制台已加载', 'tip'); + loadStatus(); + loadChars(); + setInterval(loadStatus, 15000); + } + + if (window.addEventListener) { window.addEventListener('load', init, false); } + else { window.attachEvent('onload', init); } +})(); diff --git a/gmweb/index.html b/gmweb/index.html new file mode 100644 index 0000000..714044b --- /dev/null +++ b/gmweb/index.html @@ -0,0 +1,122 @@ + + + + + +GM 控制台 + + + +
+
GM 控制台
+
连接中…
+
+ + + +
+ +
+ + + + + +
在线角色-
角色库-
物品库-
等级上限-
+
当前角色
+
未选择 —— 到「角色」页选一个
+
快捷操作
+
+ + +
+
+ +
+
+ + +
+
+
改数值
+
+ + +
+
+ + +
+
+ +
+
+ + + +
+
+
+ + - + +
+ +
未选择物品 —— 先搜索,再点一行
+ +
装备属性 (武器 / 防具 / 首饰)
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。
+
+ +
+
+
+
+
+ +
+
收件人取「角色」页选中的角色;未选中会提示。
+
+ +
+ +
+ + + + diff --git a/gmweb/serve.py b/gmweb/serve.py new file mode 100644 index 0000000..7acdd9a --- /dev/null +++ b/gmweb/serve.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""90CN GM 页本地托管 + 接口代理。 + +为什么需要这一层: + 1. 客户端里那个窗口是 IE11 内核,页面要同源才能发 XHR,跨域到 18111 会被拦; + 2. admin token 不应该写进页面 JS(会被任何人读到),由本进程在服务端注入。 + +用法: + python serve.py # 默认 127.0.0.1:18112 + python serve.py --port 18112 + python serve.py --runtime Z:\\90server\\Server\\runtime + +页面访问:http://127.0.0.1:18112/ +接口转发:/api/ -> http://127.0.0.1:18111/ +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib import error, request + +HERE = Path(__file__).resolve().parent +MIME = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".png": "image/png", + ".ico": "image/x-icon", +} + +CONFIG = {"admin": "127.0.0.1:18111", "token": ""} + +# 关键:本机回环不能走系统/环境代理。环境里存在 http_proxy 时 urllib 会照单全收, +# 结果连 127.0.0.1:18111 都被转发到代理,报 "upstream connect failed"。 +OPENER = request.build_opener(request.ProxyHandler({})) + + +# --------------------------------------------------------------------- 发现 + +def _candidate_runtimes() -> list[Path]: + out: list[Path] = [] + env = os.environ.get("DNF90_RUNTIME") + if env: + out.append(Path(env)) + out += [ + Path(r"Z:\90server\Server\runtime"), + HERE.parent / "server" / "runtime", + HERE.parent / "runtime", + ] + return out + + +def discover_admin() -> tuple[str, str]: + """从 instance.json / dnfbridge.toml 里读 adminListen 与 adminToken。""" + token_env = os.environ.get("DNF90_ADMIN_TOKEN") + for root in _candidate_runtimes(): + inst = root / "config" / "instance.json" + if inst.is_file(): + try: + cfg = json.loads(inst.read_text(encoding="utf-8")) + srv = cfg.get("server", {}) + listen = srv.get("adminListen") or CONFIG["admin"] + token = token_env or srv.get("adminToken") or "" + if token: + return listen, token + except (OSError, ValueError): + pass + toml = root / "configs" / "dnfbridge.toml" + if toml.is_file(): + try: + txt = toml.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + m = re.search(r'^\s*listen\s*=\s*"([^"]+)"', txt, re.M) + listen = m.group(1) if m else CONFIG["admin"] + m = re.search(r'^\s*token\s*=\s*"([^"]+)"', txt, re.M) + token = token_env or (m.group(1) if m else "") + if token: + return listen, token + return CONFIG["admin"], token_env or "" + + +# --------------------------------------------------------------------- 代理 + +def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]: + url = "http://%s%s" % (CONFIG["admin"], path) + headers = {"X-Admin-Token": CONFIG["token"]} + if method == "POST": + headers["Content-Type"] = "application/json" + # 服务端对写操作要求确认头 + 幂等键 + headers["X-Admin-Confirm"] = "gmweb" + headers["X-Admin-Idempotency-Key"] = str(uuid.uuid4()) + req = request.Request(url, data=body, headers=headers, method=method) + try: + with OPENER.open(req, timeout=20) as resp: + return resp.status, resp.read(), resp.headers.get("Content-Type", "application/json") + except error.HTTPError as exc: + return exc.code, exc.read(), exc.headers.get("Content-Type", "application/json") + except error.URLError as exc: + msg = json.dumps({"success": False, "message": "服务端未响应:%s" % exc.reason}, + ensure_ascii=False).encode() + return 502, msg, "application/json; charset=utf-8" + + +# --------------------------------------------------------------------- HTTP + +class Handler(BaseHTTPRequestHandler): + server_version = "90CN-GMWeb/1.0" + + def log_message(self, fmt, *args): # 静默,避免刷屏 + pass + + def _send(self, code: int, payload: bytes, ctype: str) -> None: + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + try: + self.wfile.write(payload) + except OSError: + pass + + def _static(self, path: str) -> None: + rel = "index.html" if path in ("/", "") else path.lstrip("/") + target = (HERE / rel).resolve() + if HERE not in target.parents and target != HERE: + self._send(403, b"forbidden", "text/plain") + return + if not target.is_file(): + self._send(404, b"not found", "text/plain") + return + self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream")) + + def do_GET(self) -> None: + if self.path.startswith("/api/"): + code, data, ctype = proxy("GET", self.path[4:], None) + self._send(code, data, ctype) + return + self._static(self.path.split("?")[0]) + + def do_POST(self) -> None: + if not self.path.startswith("/api/"): + self._send(404, b"not found", "text/plain") + return + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"{}" + code, data, ctype = proxy("POST", self.path[4:], body) + self._send(code, data, ctype) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="90CN GM 页本地托管 + 接口代理") + ap.add_argument("--port", type=int, default=18112) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args(argv) + + listen, token = discover_admin() + CONFIG["admin"], CONFIG["token"] = listen, token + if not token: + print("警告:没找到 adminToken,接口会 401。可用 DNF90_ADMIN_TOKEN 环境变量指定。", file=sys.stderr) + + srv = ThreadingHTTPServer((args.host, args.port), Handler) + print("GM 页: http://%s:%d/" % (args.host, args.port)) + print("代理到: http://%s/api/ -> http://%s/" % (args.host + ":" + str(args.port), listen)) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/gmweb/start_gmweb.bat b/gmweb/start_gmweb.bat new file mode 100644 index 0000000..de9cfd1 --- /dev/null +++ b/gmweb/start_gmweb.bat @@ -0,0 +1,10 @@ +@echo off +rem 启动 GM 页本地服务(127.0.0.1:18112)。pythonw 不留黑窗口。 +rem token 与 admin 端口由 serve.py 自动从 runtime\config\instance.json 读取。 +setlocal +cd /d "%~dp0" +set PYW=pythonw.exe +where pythonw.exe >nul 2>nul || set PYW=python.exe +start "" %PYW% "%~dp0serve.py" --port 18112 +echo 已在后台启动:http://127.0.0.1:18112/ +timeout /t 2 >nul diff --git a/gmweb/style.css b/gmweb/style.css new file mode 100644 index 0000000..af54179 --- /dev/null +++ b/gmweb/style.css @@ -0,0 +1,98 @@ +/* IE11 兼容:不用 CSS 变量、不用 flex、不用 grid。深色底 + 高对比文字。 */ +html, body { + margin: 0; padding: 0; height: 100%; + background: #14161c; color: #d8dbe2; + font: 12px/1.5 "Microsoft YaHei", "SimSun", Tahoma, sans-serif; + overflow: hidden; +} +#hd { + height: 30px; padding: 0 10px; + background: #1d2029; border-bottom: 1px solid #2b3040; + line-height: 30px; +} +#hd-t { float: left; font-weight: bold; color: #e8c979; letter-spacing: 1px; } +#hd-s { float: right; color: #8d94a6; } +.dot { display: inline-block; width: 7px; height: 7px; border-radius: 4px; margin-right: 6px; } +.dot.on { background: #4ec46a; } +.dot.off { background: #b04a4a; } + +#tabs { height: 28px; background: #191c24; border-bottom: 1px solid #2b3040; padding-left: 4px; } +.tab { + float: left; padding: 0 14px; line-height: 28px; color: #98a0b3; + text-decoration: none; border-bottom: 2px solid transparent; +} +.tab:hover { color: #d8dbe2; } +.tab.on { color: #e8c979; border-bottom-color: #e8c979; background: #20242e; } + +#body { position: absolute; top: 59px; bottom: 62px; left: 0; right: 0; overflow: auto; } +.pane { display: none; padding: 10px; } +.pane.on { display: block; } + +table.kv { width: 100%; border-collapse: collapse; } +table.kv th { + text-align: left; width: 84px; color: #8d94a6; font-weight: normal; + padding: 4px 0; border-bottom: 1px solid #232734; +} +table.kv td { padding: 4px 0; border-bottom: 1px solid #232734; color: #dfe3ea; } + +.sec { + margin: 12px 0 6px; color: #e8c979; font-size: 11px; + border-left: 3px solid #e8c979; padding-left: 6px; letter-spacing: 1px; +} +.cur { + padding: 8px; background: #1c2029; border: 1px solid #2b3040; + color: #9fd8a8; +} +.row { margin: 6px 0; } +.inp { + background: #0f1116; border: 1px solid #2f3547; color: #dfe3ea; + padding: 4px 6px; font: 12px "Microsoft YaHei", Tahoma, sans-serif; + width: 240px; outline: none; +} +.inp:focus { border-color: #e8c979; } +.inp.sm { width: 110px; } +.inp.xs { width: 60px; } +.inp.xs2 { width: 84px; } +select.inp { padding: 3px 2px; } +.inp[disabled] { opacity: .4; } +.lb { display: inline-block; width: 56px; color: #8d94a6; } +.btn { + background: #262c3a; border: 1px solid #39415a; color: #d8dbe2; + padding: 5px 12px; cursor: pointer; font: 12px "Microsoft YaHei", Tahoma, sans-serif; + margin-right: 6px; +} +.btn:hover { background: #303849; border-color: #4d5877; } +.btn.pri { background: #6b4f18; border-color: #a3781f; color: #ffe6ae; } +.btn.pri:hover { background: #7d5d1e; } +.btn[disabled] { opacity: .45; cursor: default; } + +.list { + margin-top: 8px; max-height: 190px; overflow: auto; + border: 1px solid #2b3040; background: #101319; +} +/* 物品列表固定 6 行高度:行数再多也只占这一块,靠滚动 + 翻页看更多 */ +.list.fixed { height: 156px; max-height: 156px; } +.li { + padding: 3px 8px; line-height: 20px; border-bottom: 1px solid #1c212b; cursor: pointer; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.li:hover { background: #1b2130; } +.li.on { background: #2a3550; color: #ffe6ae; } +.li .nm { color: #dfe3ea; } +.li .meta { color: #7c8496; font-size: 11px; margin-left: 6px; } +.empty { padding: 12px; color: #666e80; text-align: center; } +.dim { color: #7c8496; } +.pager { margin: 6px 0 2px; } +.pager .btn.sm { padding: 3px 10px; } +.pg { color: #8d94a6; font-size: 11px; margin: 0 8px; } + +#log { + position: absolute; bottom: 0; left: 0; right: 0; height: 62px; + background: #0d0f14; border-top: 1px solid #2b3040; + padding: 4px 8px; overflow: auto; font: 11px/1.45 Consolas, monospace; + color: #8d94a6; +} +#log .ok { color: #6fcf87; } +#log .err { color: #e07070; } +#log .tip { color: #e8c979; } +.hint { color: #666e80; font-size: 11px; margin-top: 6px; } diff --git a/launcher/server.py b/launcher/server.py index c657aad..5dbb294 100644 --- a/launcher/server.py +++ b/launcher/server.py @@ -101,6 +101,9 @@ CONTROL = os.path.join(SERVER_ROOT, "Server", "runtime", "bin", "DNF90Control.ex CLIENT_DIR = os.path.join(SERVER_ROOT, "Client") CLIENT_LAUNCHER = os.path.join(SERVER_ROOT, "Server", "runtime", "bin", "DNF90Launcher.exe") +# 游戏内嵌网页窗口(小助手)重定向到的本地 GM 页端口,与 gmweb/serve.py 默认值一致 +GM_WEB_PORT = 18112 + # GM 配置目录(物品名称表 + pvfexport 工具,均放这里,PVF 更新后自动重导出) GM_CONFIG_DIR = os.path.join(SERVER_ROOT, "GM配置") PVF_PATH = os.path.join(CLIENT_DIR, "Script.pvf") @@ -155,6 +158,31 @@ def port_open(port, host="127.0.0.1"): s.close() +def start_gmweb(): + """拉起本地 GM 页服务(127.0.0.1:18112),供游戏内嵌网页窗口显示。 + + 页面与代理脚本在 <90server>/gmweb/,它把 /api/* 转发到 admin 口并注入 token, + 这样 IE 内核不用处理跨域,页面里也不会出现明文 token。失败只提示,不阻断启动。 + """ + script = os.path.join(SERVER_ROOT, "gmweb", "serve.py") + if not os.path.exists(script): + return + if port_open(GM_WEB_PORT): + print("GM 页服务已在运行:http://127.0.0.1:%d/" % GM_WEB_PORT) + return + try: + kwargs = {"cwd": os.path.dirname(script), + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL} + if os.name == "nt": + # CREATE_NO_WINDOW:不要给用户弹一个黑框 + kwargs["creationflags"] = 0x08000000 + subprocess.Popen([sys.executable, script, "--port", str(GM_WEB_PORT)], **kwargs) + print("GM 页服务:http://127.0.0.1:%d/" % GM_WEB_PORT) + except Exception as exc: # noqa: BLE001 - 启动器不能因此退出 + print("[提示] GM 页服务启动失败:%s" % exc) + + def _read_json_file(path, default): try: if path and os.path.exists(path): @@ -2102,6 +2130,7 @@ def main(): threading.Thread(target=_serve, daemon=True).start() start_loadout_hotkeys() + start_gmweb() # 用内嵌窗口(pywebview)显示界面,替代打开浏览器 show_window(url) diff --git a/server/internal/app/dnf90/gm_tool_admin.go b/server/internal/app/dnf90/gm_tool_admin.go index dbc2985..40990a2 100644 --- a/server/internal/app/dnf90/gm_tool_admin.go +++ b/server/internal/app/dnf90/gm_tool_admin.go @@ -825,6 +825,7 @@ func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) { ExpireDays int `json:"expire_days"` Reinforce int `json:"reinforce"` AmplifyType int `json:"amplify_type"` + AmplifyLevel int `json:"amplify_level"` EquipmentQuality string `json:"equipment_quality"` Forging int `json:"forging"` EmblemItemIDs []uint32 `json:"emblem_item_ids"` @@ -845,6 +846,7 @@ func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) { ExpireDays: request.ExpireDays, Reinforce: request.Reinforce, AmplifyType: request.AmplifyType, + AmplifyLevel: request.AmplifyLevel, EquipmentQuality: request.EquipmentQuality, Forging: request.Forging, EmblemItemIDs: request.EmblemItemIDs, diff --git a/server/internal/services/dnfbridge/client_build_id.go b/server/internal/services/dnfbridge/client_build_id.go index 0233b23..77760e9 100644 --- a/server/internal/services/dnfbridge/client_build_id.go +++ b/server/internal/services/dnfbridge/client_build_id.go @@ -10,8 +10,8 @@ const ( boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1" boundMovieBytes = int64(22846532) - boundDllSHA256 = "C7BE377CC2795612074CAE70F071523E19E32964CED355762A2B59F48247071A" - boundDllBytes = int64(1215488) + boundDllSHA256 = "E947A0C2E36EA24C3F2F557E7D3207C76F9B6E63192E2555B6D4B73593EE17F1" + boundDllBytes = int64(1223168) ) diff --git a/server/internal/services/dnfbridge/gm_tool.go b/server/internal/services/dnfbridge/gm_tool.go index 2762525..64635e5 100644 --- a/server/internal/services/dnfbridge/gm_tool.go +++ b/server/internal/services/dnfbridge/gm_tool.go @@ -324,6 +324,7 @@ type GMMailOptions struct { ExpireDays int `json:"expire_days,omitempty"` Reinforce int `json:"reinforce,omitempty"` AmplifyType int `json:"amplify_type,omitempty"` + AmplifyLevel int `json:"amplify_level,omitempty"` EquipmentQuality string `json:"equipment_quality,omitempty"` Forging int `json:"forging,omitempty"` EmblemItemIDs []uint32 `json:"emblem_item_ids,omitempty"` @@ -2288,9 +2289,15 @@ func (s *Service) GMSendItemMailWithOptions( return GMMailDeliveryResult{}, fmt.Errorf("%w: equipment mail count must be 1", ErrGMInvalidRequest) } quality := strings.ToLower(strings.TrimSpace(options.EquipmentQuality)) - if options.ExpireDays < 0 || options.ExpireDays > 3650 || options.Reinforce < 0 || options.Reinforce > 31 || options.AmplifyType < 0 || options.AmplifyType > 4 || options.Forging < 0 || options.Forging > 8 { + if options.ExpireDays < 0 || options.ExpireDays > 3650 || options.Reinforce < 0 || options.Reinforce > 31 || options.AmplifyType < 0 || options.AmplifyType > 4 || options.AmplifyLevel < 0 || options.AmplifyLevel > 31 || options.Forging < 0 || options.Forging > 8 { return GMMailDeliveryResult{}, fmt.Errorf("%w: expire_days, reinforce, amplify_type or forging is out of range", ErrGMInvalidRequest) } + if options.AmplifyType == 0 && options.AmplifyLevel != 0 { + return GMMailDeliveryResult{}, fmt.Errorf("%w: amplify_level requires amplify_type", ErrGMInvalidRequest) + } + if options.AmplifyType > 0 && options.Reinforce != 0 { + return GMMailDeliveryResult{}, fmt.Errorf("%w: reinforce and amplify are mutually exclusive", ErrGMInvalidRequest) + } if quality != "" && quality != "default" && quality != "random" && quality != "top" { return GMMailDeliveryResult{}, fmt.Errorf("%w: equipment_quality must be default, random or top", ErrGMInvalidRequest) } @@ -2345,10 +2352,17 @@ func (s *Service) GMSendItemMailWithOptions( stack.Extra["durability"] = strconv.FormatUint(uint64(definition.Durability), 10) stack.Extra["max_durability"] = strconv.FormatUint(uint64(definition.Durability), 10) } - if options.Reinforce > 0 { - value := strconv.Itoa(options.Reinforce) - stack.Extra["reinforce"] = value - stack.Extra["ext_data0"] = value + // 等级字节只写一次:带增幅时它表示增幅等级,否则是强化等级。 + level := options.Reinforce + if options.AmplifyType > 0 { + level = options.AmplifyLevel + } + if level > 0 { + levelText := strconv.Itoa(level) + stack.Extra["ext_data0"] = levelText + stack.Extra["packed_flag_byte"] = levelText + stack.Extra["reinforce"] = levelText + stack.Extra["upgrade_level"] = levelText } if options.AmplifyType > 0 { // An amplification option is only real to the game once the row diff --git a/server/internal/services/dnfbridge/gm_tool_items_extra.go b/server/internal/services/dnfbridge/gm_tool_items_extra.go index bbd562d..87604af 100644 --- a/server/internal/services/dnfbridge/gm_tool_items_extra.go +++ b/server/internal/services/dnfbridge/gm_tool_items_extra.go @@ -38,6 +38,7 @@ type GMItemGrantOptionsRequest struct { Count uint32 `json:"count,omitempty"` Reinforce int `json:"reinforce,omitempty"` AmplifyType int `json:"amplify_type,omitempty"` + AmplifyLevel int `json:"amplify_level,omitempty"` Forging int `json:"forging,omitempty"` EquipmentQuality string `json:"equipment_quality,omitempty"` Durability *int `json:"durability,omitempty"` @@ -92,10 +93,18 @@ func (s *Service) GMGrantItemWithOptions( // 与邮件发放相同的取值范围校验,避免写出客户端读不了的装备。 if request.Reinforce < 0 || request.Reinforce > 31 || request.AmplifyType < 0 || request.AmplifyType > 4 || + request.AmplifyLevel < 0 || request.AmplifyLevel > 31 || request.Forging < 0 || request.Forging > 8 || request.ExpireDays < 0 || request.ExpireDays > 3650 { return result, fmt.Errorf("%w: reinforce/amplify/forging/expire_days out of range", ErrGMRequestInvalid) } + // 红字装不能同时是白字强化:两者写进同一个等级字节,静默取一个比报错更难查。 + if request.AmplifyType == 0 && request.AmplifyLevel != 0 { + return result, fmt.Errorf("%w: amplify_level requires amplify_type", ErrGMRequestInvalid) + } + if request.AmplifyType != 0 && request.Reinforce != 0 { + return result, fmt.Errorf("%w: reinforce and amplify are mutually exclusive", ErrGMRequestInvalid) + } catalog, err := s.currentPVFItemCatalog() if err != nil { @@ -119,6 +128,7 @@ func (s *Service) GMGrantItemWithOptions( stack, buildErr := s.buildGMGrantItemStack(ctx, catalog, definition, count, gmGrantItemOptions{ Reinforce: request.Reinforce, AmplifyType: request.AmplifyType, + AmplifyLevel: request.AmplifyLevel, Forging: request.Forging, EquipmentQuality: request.EquipmentQuality, Durability: request.Durability, @@ -181,6 +191,7 @@ func (s *Service) GMGrantItemWithOptions( type gmGrantItemOptions struct { Reinforce int AmplifyType int + AmplifyLevel int Forging int EquipmentQuality string Durability *int @@ -262,10 +273,19 @@ func (s *Service) buildGMGrantItemStack( stack.Extra["max_durability"] = strconv.FormatInt(int64(definition.Durability), 10) } - if options.Reinforce > 0 { - value := strconv.Itoa(options.Reinforce) - stack.Extra["reinforce"] = value - stack.Extra["ext_data0"] = value + // 强化等级与增幅等级共用 raw[0x0A] 这一个字节:带增幅时它就是增幅等级。 + // 这组键与 gm_admin.go 的系统邮件路径(线上已验证过的那条)完全一致, + // 少了 packed_flag_byte/upgrade_level 时,客户端重登重建列会落回 0 级。 + level := options.Reinforce + if options.AmplifyType > 0 { + level = options.AmplifyLevel + } + if level > 0 { + text := strconv.Itoa(level) + stack.Extra["ext_data0"] = text + stack.Extra["packed_flag_byte"] = text + stack.Extra["reinforce"] = text + stack.Extra["upgrade_level"] = text } if options.Forging > 0 { stack.Extra["forging"] = strconv.Itoa(options.Forging)