小助手内嵌 GM 页:网页重定向 + 物品分页 + 装备强化增幅发放

- 90CN.dll 新增 IE 控件接管,把腾讯小助手页面重定向到本地 GM 页
- gmweb:物品列表改分页(每页 10/20/50,列表固定 6 行高)
- 发放走 grant-options,支持强化 0-31、增幅属性+等级、锻造 0-8、品质
- 服务端补齐 amplify_level:等级统一写入 ext_data0/packed_flag_byte/reinforce/upgrade_level
- 增幅与强化互斥校验,避免写出客户端无法创建的装备
This commit is contained in:
hanfuit committed 2026-11-05 12:20:46 +08:00
1 parent 6e570fe820
commit 18e98ca7c9
13 files changed
+1206 -11

No files matched your search

+16
View File
@@ -38,6 +38,7 @@
#include <algorithm>
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")
+9
View File
@@ -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
+292
View File
@@ -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 <windows.h>
#include <ole2.h>
#include <oleacc.h>
#include <mshtml.h>
#include <string>
// 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<size_t>(need - 1), L'\0');
MultiByteToWideChar(CP_ACP, 0, s, -1, &out[0], need);
return out;
}
std::wstring out(static_cast<size_t>(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<LPCWSTR>(&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<DWORD>(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<int>(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:<port>/" 前缀,用来判断当前文档是否已经在我们的页面上。
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<FindContext*>(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<FindContext*>(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<LPARAM>(&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<LRESULT>(result), IID_IHTMLDocument2, 0,
reinterpret_cast<void**>(&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());
}
+402
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 = '<div class="empty">没有角色</div>'; return; }
var h = [];
for (var i = 0; i < S.chars.length; i++) {
var c = S.chars[i];
h.push('<div class="li' + (String(c.character_id) === String(S.charId) ? ' on' : '') +
'" data-id="' + esc(c.character_id) + '" data-name="' + esc(c.name) + '" data-gold="' + esc(c.gold) +
'" data-level="' + esc(c.level) + '"><span class="nm">' + esc(c.name) +
'</span><span class="meta">Lv.' + esc(c.level) + ' · 金币 ' + esc(c.gold) +
(c.online ? ' · 在线' : '') + '</span></div>');
}
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) + ' <span style="color:#7c8496">(id=' + esc(S.charId) + ')</span>';
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 = '<div class="empty">没有匹配的物品</div>';
return;
}
var h = [];
for (var i = 0; i < S.items.length; i++) {
var it = S.items[i];
h.push('<div class="li' + (it.item_id === S.itemId ? ' on' : '') +
'" data-i="' + i + '">' +
'<span class="nm">' + esc(it.name) + '</span>' +
'<span class="meta">id ' + esc(it.item_id) + ' · ' + esc(kindText(it)) +
(canUpgrade(it) ? '' : '') + '</span></div>');
}
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) + ' <span class="dim">(id ' + esc(S.itemId) + ')</span> · ' + 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); }
})();
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<title>GM 控制台</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="hd">
<div id="hd-t">GM 控制台</div>
<div id="hd-s"><span id="dot" class="dot off"></span><span id="stat">连接中…</span></div>
</div>
<div id="tabs">
<a class="tab on" data-tab="home" href="javascript:void(0)">状态</a>
<a class="tab" data-tab="char" href="javascript:void(0)">角色</a>
<a class="tab" data-tab="item" href="javascript:void(0)">发物品</a>
<a class="tab" data-tab="mail" href="javascript:void(0)">邮件</a>
</div>
<div id="body">
<div class="pane on" id="pane-home">
<table class="kv">
<tr><th>在线角色</th><td id="v-online">-</td></tr>
<tr><th>角色库</th><td id="v-repo">-</td></tr>
<tr><th>物品库</th><td id="v-items">-</td></tr>
<tr><th>等级上限</th><td id="v-cap">-</td></tr>
</table>
<div class="sec">当前角色</div>
<div id="cur" class="cur">未选择 —— 到「角色」页选一个</div>
<div class="sec">快捷操作</div>
<div class="row">
<button id="btn-refresh" class="btn">刷新状态</button>
<button id="btn-resync" class="btn">同步角色数据</button>
</div>
</div>
<div class="pane" id="pane-char">
<div class="row">
<input id="q-char" class="inp" placeholder="角色名 / 账号,留空列全部">
<button id="btn-search-char" class="btn">查询</button>
</div>
<div id="char-list" class="list"></div>
<div class="sec">改数值</div>
<div class="row">
<label class="lb">金币</label><input id="in-gold" class="inp sm" value="">
<button id="btn-gold" class="btn">写入</button>
</div>
<div class="row">
<label class="lb">等级</label><input id="in-level" class="inp sm" value="">
<button id="btn-level" class="btn">写入</button>
</div>
</div>
<div class="pane" id="pane-item">
<div class="row">
<input id="q-item" class="inp" placeholder="物品名关键字,如 太刀 / 强化">
<select id="in-page-size" class="inp sm">
<option value="10">每页 10 行</option>
<option value="20" selected>每页 20 行</option>
<option value="50">每页 50 行</option>
</select>
<button id="btn-search-item" class="btn">搜索</button>
</div>
<div id="item-list" class="list fixed"></div>
<div class="pager">
<button id="btn-prev" class="btn sm">上一页</button>
<span id="page-info" class="pg">-</span>
<button id="btn-next" class="btn sm">下一页</button>
</div>
<div id="picked" class="cur">未选择物品 —— 先搜索,再点一行</div>
<div class="sec">装备属性 <span class="dim">(武器 / 防具 / 首饰)</span></div>
<div class="row">
<label class="lb">强化</label><input id="in-reinforce" class="inp xs" value="0">
<label class="lb">增幅</label><select id="in-amplify" class="inp xs2">
<option value="0" selected>不增幅</option>
<option value="1">1 力量</option>
<option value="2">2 智力</option>
<option value="3">3 体力</option>
<option value="4">4 精神</option>
</select>
</div>
<div class="row">
<label class="lb">增幅等级</label><input id="in-amplify-level" class="inp xs" value="0">
<label class="lb">锻造</label><input id="in-forging" class="inp xs" value="0">
</div>
<div class="row">
<label class="lb">品质</label><select id="in-quality" class="inp sm">
<option value="" selected>默认</option>
<option value="random">随机</option>
<option value="top">最上级</option>
</select>
<label class="lb">数量</label><input id="in-count" class="inp xs" value="1">
</div>
<div class="row">
<button id="btn-grant" class="btn pri">发到背包</button>
<button id="btn-mailitem" class="btn">发到邮箱</button>
</div>
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
</div>
<div class="pane" id="pane-mail">
<div class="row"><label class="lb">标题</label><input id="in-title" class="inp" value="GM 邮件"></div>
<div class="row"><label class="lb">内容</label><input id="in-body" class="inp" value="来自 GM 的问候"></div>
<div class="row"><label class="lb">附金币</label><input id="in-goldmail" class="inp sm" value="0"></div>
<div class="row">
<button id="btn-mail" class="btn pri">发送到当前角色</button>
</div>
<div class="hint">收件人取「角色」页选中的角色;未选中会提示。</div>
</div>
</div>
<div id="log"></div>
<script src="/app.js"></script>
</body>
</html>
+181
View File
@@ -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/<path> -> http://127.0.0.1:18111/<path>
"""
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:]))
+10
View File
@@ -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
+98
View File
@@ -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; }
+29
View File
@@ -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)
@@ -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,
@@ -10,8 +10,8 @@ const (
boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1"
boundMovieBytes = int64(22846532)
boundDllSHA256 = "C7BE377CC2795612074CAE70F071523E19E32964CED355762A2B59F48247071A"
boundDllBytes = int64(1215488)
boundDllSHA256 = "E947A0C2E36EA24C3F2F557E7D3207C76F9B6E63192E2555B6D4B73593EE17F1"
boundDllBytes = int64(1223168)
)
+19 -5
View File
@@ -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
@@ -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)