小助手内嵌 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:
1 parent
6e570fe820
commit
18e98ca7c9
13 files changed
+1206
-11
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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, '<').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 = '<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); }
|
||||
})();
|
||||
@@ -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
@@ -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:]))
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user