启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches), 持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发 - 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道 - channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本 - 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab - native.py/server.py 新增对应转发 API - 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
This commit is contained in:
1 parent
977a4a1196
commit
0ced464168
15 files changed
+1022
-14
No files matched your search
+23
-4
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>90轮回</title>
|
<title>DNF90 灵狐小竹整合单机版</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<link rel="stylesheet" href="/shell.css">
|
<link rel="stylesheet" href="/shell.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -22,12 +22,13 @@
|
|||||||
<header class="shell-topnav">
|
<header class="shell-topnav">
|
||||||
<div class="shell-brand">
|
<div class="shell-brand">
|
||||||
<div class="shell-logo">90</div>
|
<div class="shell-logo">90</div>
|
||||||
<div><b>90轮回</b><span>启动器 <em>v2.1.0</em></span></div>
|
<div><b>灵狐小竹</b><span>启动器 <em>v2.1.0</em></span></div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="shell-nav">
|
<nav class="shell-nav">
|
||||||
<button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button>
|
<button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button>
|
||||||
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
|
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
|
||||||
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
|
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
|
||||||
|
<button class="shell-nav-item" data-shell-page="gamesettings"><span class="nav-ico">🎮</span>游戏设置</button>
|
||||||
<div class="shell-nav-gap"></div>
|
<div class="shell-nav-gap"></div>
|
||||||
<button class="shell-nav-item" data-shell-page="settings"><span class="nav-ico">⚙</span>设置</button>
|
<button class="shell-nav-item" data-shell-page="settings"><span class="nav-ico">⚙</span>设置</button>
|
||||||
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>游戏更新</button>
|
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>游戏更新</button>
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
<div class="hero-bg"></div>
|
<div class="hero-bg"></div>
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-kicker">DNF · 90级 · 本地服务端</div>
|
<div class="hero-kicker">DNF · 90级 · 本地服务端</div>
|
||||||
<h1 id="shellServerName">Server90轮回</h1>
|
<h1 id="shellServerName">DNF90 灵狐小竹整合单机版</h1>
|
||||||
<div class="hero-status">
|
<div class="hero-status">
|
||||||
<span class="live-dot" id="shellStatusDot"></span>
|
<span class="live-dot" id="shellStatusDot"></span>
|
||||||
<span id="shellStatusText">检测中…</span>
|
<span id="shellStatusText">检测中…</span>
|
||||||
@@ -122,6 +123,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="shell-page" id="page-gamesettings">
|
||||||
|
<div class="page-pad">
|
||||||
|
<div class="page-heading"><div><h2>游戏设置</h2><p class="muted">调整服务端频道与活动;改动实时生效,无需重启。</p></div><button class="shell-btn" id="gsRefresh">刷新</button></div>
|
||||||
|
<div class="st-tabs" id="gsTabs">
|
||||||
|
<button class="st-tab active" data-gs="channels">频道开关</button>
|
||||||
|
<button class="st-tab" data-gs="activities">活动开关</button>
|
||||||
|
</div>
|
||||||
|
<div id="gsChannelsPane">
|
||||||
|
<div class="gs-summary muted" id="gsChannelSummary">正在加载频道…</div>
|
||||||
|
<div class="gs-grid" id="gsChannelGrid"><div class="empty-box">加载中…</div></div>
|
||||||
|
</div>
|
||||||
|
<div id="gsActivitiesPane" style="display:none">
|
||||||
|
<div class="gs-summary muted" id="gsActivitySummary">正在加载活动…</div>
|
||||||
|
<div class="gs-list" id="gsActivityList"><div class="empty-box">加载中…</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="shell-page" id="page-changelog">
|
<section class="shell-page" id="page-changelog">
|
||||||
<div class="page-pad"><h2>游戏更新</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
|
<div class="page-pad"><h2>游戏更新</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -160,7 +179,7 @@
|
|||||||
<div class="win-resize nw" data-edge="topleft"></div><div class="win-resize ne" data-edge="topright"></div>
|
<div class="win-resize nw" data-edge="topleft"></div><div class="win-resize ne" data-edge="topright"></div>
|
||||||
<div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div>
|
<div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div>
|
||||||
|
|
||||||
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 单机模拟器!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
|
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 灵狐小竹整合单机版!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
|
||||||
<div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div>
|
<div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div>
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
|
|||||||
@@ -622,3 +622,22 @@ class NativeClient(object):
|
|||||||
def loadout_apply(self, character_id, slots):
|
def loadout_apply(self, character_id, slots):
|
||||||
return self._unwrap(self._post("/local/dnf-gm/loadout/apply", {
|
return self._unwrap(self._post("/local/dnf-gm/loadout/apply", {
|
||||||
"character_id": str(character_id), "slots": slots}))
|
"character_id": str(character_id), "slots": slots}))
|
||||||
|
|
||||||
|
# ---- 游戏设置:活动开关 / 频道开关(Native,服务端在线)--------
|
||||||
|
def get_activity_switches(self):
|
||||||
|
"""GET /dnf/gm/activity-switches -> GMActivitySwitches"""
|
||||||
|
return self._unwrap(self._get("/dnf/gm/activity-switches"))
|
||||||
|
|
||||||
|
def set_activity_switch(self, key, enabled):
|
||||||
|
"""POST /dnf/gm/activity-switches {key:str, enabled:bool}"""
|
||||||
|
return self._unwrap(self._post("/dnf/gm/activity-switches", {
|
||||||
|
"key": str(key), "enabled": bool(enabled)}))
|
||||||
|
|
||||||
|
def get_channel_switches(self):
|
||||||
|
"""GET /dnf/gm/channel-switches -> GMChannelSwitches"""
|
||||||
|
return self._unwrap(self._get("/dnf/gm/channel-switches"))
|
||||||
|
|
||||||
|
def set_channel_switch(self, channel_id, enabled):
|
||||||
|
"""POST /dnf/gm/channel-switch {channel_id:int, enabled:bool}"""
|
||||||
|
return self._unwrap(self._post("/dnf/gm/channel-switch", {
|
||||||
|
"channel_id": int(channel_id), "enabled": bool(enabled)}))
|
||||||
+64
-1
@@ -578,6 +578,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._api_character_black_diamond(qs)
|
self._api_character_black_diamond(qs)
|
||||||
elif path == "/api/gameplay":
|
elif path == "/api/gameplay":
|
||||||
self._api_gameplay(qs)
|
self._api_gameplay(qs)
|
||||||
|
elif path == "/api/game-settings/activity-switches":
|
||||||
|
self._api_activity_switches()
|
||||||
|
elif path == "/api/game-settings/channel-switches":
|
||||||
|
self._api_channel_switches()
|
||||||
elif path == "/api/quests":
|
elif path == "/api/quests":
|
||||||
self._api_quests(qs)
|
self._api_quests(qs)
|
||||||
elif path == "/api/quests/search":
|
elif path == "/api/quests/search":
|
||||||
@@ -638,6 +642,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._api_gameplay_fatigue_mode(data)
|
self._api_gameplay_fatigue_mode(data)
|
||||||
elif path == "/api/gameplay/burning":
|
elif path == "/api/gameplay/burning":
|
||||||
self._api_gameplay_burning(data)
|
self._api_gameplay_burning(data)
|
||||||
|
elif path == "/api/game-settings/activity-switch":
|
||||||
|
self._api_activity_switch(data)
|
||||||
|
elif path == "/api/game-settings/channel-switch":
|
||||||
|
self._api_channel_switch(data)
|
||||||
elif path == "/api/character/fatigue/refill":
|
elif path == "/api/character/fatigue/refill":
|
||||||
self._api_fatigue_refill(data)
|
self._api_fatigue_refill(data)
|
||||||
elif path == "/api/quests/action":
|
elif path == "/api/quests/action":
|
||||||
@@ -1214,6 +1222,61 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._json({"error": str(e)}, 500)
|
self._json({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
def _api_activity_switches(self):
|
||||||
|
if not self._require_native():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = NATIVE.get_activity_switches()
|
||||||
|
self._json({"ok": True, "data": res})
|
||||||
|
except NativeError as e:
|
||||||
|
self._json({"error": str(e)}, 502)
|
||||||
|
except Exception as e:
|
||||||
|
self._json({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
def _api_activity_switch(self, data):
|
||||||
|
if not self._require_native():
|
||||||
|
return
|
||||||
|
key = data.get("key", "")
|
||||||
|
enabled = bool(data.get("enabled", False))
|
||||||
|
if not key:
|
||||||
|
self._json({"error": "missing key"}, 400)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = NATIVE.set_activity_switch(key, enabled)
|
||||||
|
self._json({"ok": True, "data": res})
|
||||||
|
except NativeError as e:
|
||||||
|
self._json({"error": str(e)}, 502)
|
||||||
|
except Exception as e:
|
||||||
|
self._json({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
def _api_channel_switches(self):
|
||||||
|
if not self._require_native():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = NATIVE.get_channel_switches()
|
||||||
|
self._json({"ok": True, "data": res})
|
||||||
|
except NativeError as e:
|
||||||
|
self._json({"error": str(e)}, 502)
|
||||||
|
except Exception as e:
|
||||||
|
self._json({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
def _api_channel_switch(self, data):
|
||||||
|
if not self._require_native():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
channel_id = int(data.get("channel_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._json({"error": "missing or invalid channel_id"}, 400)
|
||||||
|
return
|
||||||
|
enabled = bool(data.get("enabled", False))
|
||||||
|
try:
|
||||||
|
res = NATIVE.set_channel_switch(channel_id, enabled)
|
||||||
|
self._json({"ok": True, "data": res})
|
||||||
|
except NativeError as e:
|
||||||
|
self._json({"error": str(e)}, 502)
|
||||||
|
except Exception as e:
|
||||||
|
self._json({"error": str(e)}, 500)
|
||||||
|
|
||||||
def _api_dungeon_unlock(self, data):
|
def _api_dungeon_unlock(self, data):
|
||||||
char_id = str(data.get("id", ""))
|
char_id = str(data.get("id", ""))
|
||||||
kind = data.get("kind", "difficulties")
|
kind = data.get("kind", "difficulties")
|
||||||
@@ -2221,7 +2284,7 @@ def show_window(url):
|
|||||||
|
|
||||||
# 主窗口:启动器(无边框,屏幕居中)
|
# 主窗口:启动器(无边框,屏幕居中)
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
"90轮回",
|
"DNF90 灵狐小竹整合单机版",
|
||||||
url,
|
url,
|
||||||
width=1204,
|
width=1204,
|
||||||
height=717,
|
height=717,
|
||||||
|
|||||||
@@ -371,3 +371,31 @@ body.gm-fullscreen .win-resize{display:none}
|
|||||||
.shell-win-controls .tb-btn:hover{background:rgba(255,255,255,.1);color:#fff}
|
.shell-win-controls .tb-btn:hover{background:rgba(255,255,255,.1);color:#fff}
|
||||||
.shell-win-controls .tb-close:hover{background:var(--danger);color:#fff}
|
.shell-win-controls .tb-close:hover{background:var(--danger);color:#fff}
|
||||||
|
|
||||||
|
/* ================= 游戏设置(频道开关 / 活动开关) ================= */
|
||||||
|
.st-tabs{display:flex;gap:6px;margin:14px 0 16px;border-bottom:1px solid var(--line)}
|
||||||
|
.st-tab{appearance:none;background:transparent;border:0;border-bottom:2px solid transparent;
|
||||||
|
color:var(--muted);padding:8px 16px;cursor:pointer;font-size:14px}
|
||||||
|
.st-tab:hover{color:var(--text)}
|
||||||
|
.st-tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:500}
|
||||||
|
.gs-summary{font-size:12px;margin-bottom:12px}
|
||||||
|
.gs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
||||||
|
.gs-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
|
||||||
|
.gs-card.gs-off{opacity:.72}
|
||||||
|
.gs-card-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}
|
||||||
|
.gs-card-name{font-weight:600;color:var(--text)}
|
||||||
|
.gs-card-meta{font-size:12px;margin-bottom:12px;min-height:17px}
|
||||||
|
.gs-card-actions{display:flex;gap:8px}
|
||||||
|
.gs-list{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.gs-row{display:flex;align-items:center;justify-content:space-between;gap:16px;
|
||||||
|
background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
||||||
|
.gs-row.gs-off{opacity:.72}
|
||||||
|
.gs-row-main{flex:1;min-width:0}
|
||||||
|
.gs-row-name{font-weight:600;color:var(--text);margin-bottom:3px}
|
||||||
|
.gs-row-desc{font-size:12px}
|
||||||
|
.gs-switch{position:relative;width:44px;height:24px;border-radius:12px;border:0;
|
||||||
|
background:rgba(255,255,255,.12);cursor:pointer;flex-shrink:0;transition:background .2s;padding:0}
|
||||||
|
.gs-switch .gs-knob{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;
|
||||||
|
background:#fff;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
|
||||||
|
.gs-switch.on{background:var(--primary)}
|
||||||
|
.gs-switch.on .gs-knob{left:22px}
|
||||||
|
|
||||||
+169
-2
@@ -1,13 +1,13 @@
|
|||||||
/* 90轮回启动器 · 单窗口页面与工具整合 */
|
/* 90轮回启动器 · 单窗口页面与工具整合 */
|
||||||
(function () {
|
(function () {
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const pages = ['start','gm','loadout','settings','changelog'];
|
const pages = ['start','gm','loadout','gamesettings','settings','changelog'];
|
||||||
let shellGmInit = false;
|
let shellGmInit = false;
|
||||||
let loConfig = null, loPage = 0, loScheme = -1;
|
let loConfig = null, loPage = 0, loScheme = -1;
|
||||||
let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null;
|
let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null;
|
||||||
let hotkeyCapture = false;
|
let hotkeyCapture = false;
|
||||||
|
|
||||||
const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',settings:'设置',changelog:'更新日志'};
|
const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',gamesettings:'工具 / 游戏设置',settings:'设置',changelog:'更新日志'};
|
||||||
|
|
||||||
function shellGo(page) {
|
function shellGo(page) {
|
||||||
if (!pages.includes(page)) page = 'start';
|
if (!pages.includes(page)) page = 'start';
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
$('shellCrumb').textContent = crumb[page] || page;
|
$('shellCrumb').textContent = crumb[page] || page;
|
||||||
if (page === 'start') loadStart();
|
if (page === 'start') loadStart();
|
||||||
if (page === 'loadout') loadLoadout();
|
if (page === 'loadout') loadLoadout();
|
||||||
|
if (page === 'gamesettings') loadGameSettings();
|
||||||
if (page === 'settings') loadSettings();
|
if (page === 'settings') loadSettings();
|
||||||
if (page === 'changelog') loadChangelog();
|
if (page === 'changelog') loadChangelog();
|
||||||
if (page === 'gm') {
|
if (page === 'gm') {
|
||||||
@@ -488,6 +489,172 @@
|
|||||||
}
|
}
|
||||||
$('setRefresh').addEventListener('click', loadSettings);
|
$('setRefresh').addEventListener('click', loadSettings);
|
||||||
|
|
||||||
|
// ==================== 游戏设置(频道开关 / 活动开关) ====================
|
||||||
|
let gsCurrentTab = 'channels';
|
||||||
|
const gsToast = (msg, type = 'info') => {
|
||||||
|
if (typeof gmToast === 'function') gmToast(msg, type);
|
||||||
|
else console.log(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
function gsSwitchTab(tab) {
|
||||||
|
gsCurrentTab = tab;
|
||||||
|
document.querySelectorAll('#gsTabs .st-tab').forEach(el =>
|
||||||
|
el.classList.toggle('active', el.dataset.gs === tab));
|
||||||
|
$('gsChannelsPane').style.display = tab === 'channels' ? '' : 'none';
|
||||||
|
$('gsActivitiesPane').style.display = tab === 'activities' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function gsChannelTypeText(type) {
|
||||||
|
const t = Number(type);
|
||||||
|
if (t === 22) return '特殊区域';
|
||||||
|
if (t === 11) return '普通城镇';
|
||||||
|
if (t === 2 || t === 3) return '引导';
|
||||||
|
return '频道 ' + t;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gsLoadChannels() {
|
||||||
|
const grid = $('gsChannelGrid');
|
||||||
|
const summary = $('gsChannelSummary');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.innerHTML = '<div class="empty-box">加载中…</div>';
|
||||||
|
try {
|
||||||
|
const r = await api('/api/game-settings/channel-switches');
|
||||||
|
if (!r.ok) {
|
||||||
|
grid.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
|
||||||
|
summary.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = r.data || {};
|
||||||
|
const list = d.channels || [];
|
||||||
|
summary.textContent = '共 ' + (d.total_channels ?? list.length) + ' 个频道 · 已启用 ' +
|
||||||
|
(d.enabled_channels ?? '—') + ' · 已停用 ' + (d.disabled_channels ?? '—');
|
||||||
|
if (!list.length) {
|
||||||
|
grid.innerHTML = '<div class="empty-box">暂无频道数据。请确认服务端已启动。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = list.map(ch => {
|
||||||
|
const on = !!ch.enabled;
|
||||||
|
return `<div class="gs-card ${on ? '' : 'gs-off'}">
|
||||||
|
<div class="gs-card-head">
|
||||||
|
<span class="gs-card-name">${escapeHtml(ch.name || ('频道 ' + ch.id))}</span>
|
||||||
|
<span class="gm-tag ${on ? 'gm-tag-green' : 'gm-tag-gray'}">${on ? '启用' : '停用'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gs-card-meta muted">ID ${ch.id} · ${gsChannelTypeText(ch.type)}${ch.group ? ' · ' + escapeHtml(ch.group) : ''}${ch.port ? ' · 端口 ' + ch.port : ''}</div>
|
||||||
|
<div class="gs-card-actions">
|
||||||
|
<button class="shell-btn ${on ? '' : 'primary'}" data-gs-channel="${ch.id}" data-gs-enable="${!on}">${on ? '停用' : '启用'}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
grid.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gsLoadActivities() {
|
||||||
|
const list = $('gsActivityList');
|
||||||
|
const summary = $('gsActivitySummary');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '<div class="empty-box">加载中…</div>';
|
||||||
|
try {
|
||||||
|
const r = await api('/api/game-settings/activity-switches');
|
||||||
|
if (!r.ok) {
|
||||||
|
list.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
|
||||||
|
summary.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = r.data || {};
|
||||||
|
const items = d.switches || [];
|
||||||
|
const onCount = items.filter(x => x.enabled).length;
|
||||||
|
summary.textContent = '共 ' + items.length + ' 个活动 · 已开启 ' + onCount;
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = '<div class="empty-box">暂无活动数据。请确认服务端已启动。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(it => {
|
||||||
|
const on = !!it.enabled;
|
||||||
|
return `<div class="gs-row ${on ? '' : 'gs-off'}">
|
||||||
|
<div class="gs-row-main">
|
||||||
|
<div class="gs-row-name">${escapeHtml(it.title || it.key)}</div>
|
||||||
|
<div class="gs-row-desc muted">${escapeHtml(it.description || '')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="gs-switch ${on ? 'on' : ''}" data-gs-activity="${escapeHtml(it.key)}" data-gs-enable="${!on}" role="switch" aria-checked="${on}">
|
||||||
|
<span class="gs-knob"></span>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGameSettings() {
|
||||||
|
gsSwitchTab(gsCurrentTab);
|
||||||
|
if (gsCurrentTab === 'channels') await gsLoadChannels();
|
||||||
|
else await gsLoadActivities();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新按钮
|
||||||
|
$('gsRefresh').addEventListener('click', () => {
|
||||||
|
if (gsCurrentTab === 'channels') gsLoadChannels();
|
||||||
|
else gsLoadActivities();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab 切换
|
||||||
|
document.querySelectorAll('#gsTabs .st-tab').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
gsSwitchTab(el.dataset.gs);
|
||||||
|
if (gsCurrentTab === 'channels') gsLoadChannels();
|
||||||
|
else gsLoadActivities();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 频道开关点击(事件委托)
|
||||||
|
$('gsChannelGrid').addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('button[data-gs-channel]');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = Number(btn.dataset.gsChannel);
|
||||||
|
const enable = btn.dataset.gsEnable === 'true';
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '处理中…';
|
||||||
|
try {
|
||||||
|
const r = await post('/api/game-settings/channel-switch', { channel_id: id, enabled: enable });
|
||||||
|
if (r.ok) {
|
||||||
|
gsToast(enable ? '频道已启用' : '频道已停用', 'success');
|
||||||
|
await gsLoadChannels();
|
||||||
|
} else {
|
||||||
|
gsToast('操作失败:' + (r.error || '未知错误'), 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = enable ? '启用' : '停用';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
gsToast('操作失败:' + (e && e.message ? e.message : e), 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = enable ? '启用' : '停用';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 活动开关点击(事件委托)
|
||||||
|
$('gsActivityList').addEventListener('click', async (e) => {
|
||||||
|
const sw = e.target.closest('button[data-gs-activity]');
|
||||||
|
if (!sw) return;
|
||||||
|
const key = sw.dataset.gsActivity;
|
||||||
|
const enable = sw.dataset.gsEnable === 'true';
|
||||||
|
sw.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await post('/api/game-settings/activity-switch', { key: key, enabled: enable });
|
||||||
|
if (r.ok) {
|
||||||
|
gsToast(enable ? '活动已开启' : '活动已关闭', 'success');
|
||||||
|
await gsLoadActivities();
|
||||||
|
} else {
|
||||||
|
gsToast('操作失败:' + (r.error || '未知错误'), 'error');
|
||||||
|
sw.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
gsToast('操作失败:' + (e && e.message ? e.message : e), 'error');
|
||||||
|
sw.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium)
|
// 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium)
|
||||||
// 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar)
|
// 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar)
|
||||||
['shell-topnav', 'shell-topbar'].forEach((sel) => {
|
['shell-topnav', 'shell-topbar'].forEach((sel) => {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ const (
|
|||||||
// gmChannelReloadOperation 频道热重载:重读 channel_info.etc 并热替换频道表 +
|
// gmChannelReloadOperation 频道热重载:重读 channel_info.etc 并热替换频道表 +
|
||||||
// 对账游戏端口监听,不需要重启进程。
|
// 对账游戏端口监听,不需要重启进程。
|
||||||
gmChannelReloadOperation = "POST /dnf/gm/channels/reload"
|
gmChannelReloadOperation = "POST /dnf/gm/channels/reload"
|
||||||
|
// gmActivitySwitchesOperation 活动开关设置:切换某活动是否下发。
|
||||||
|
gmActivitySwitchesOperation = "POST /dnf/gm/activity-switches"
|
||||||
|
// gmChannelSwitchOperation 频道开关设置:切换某频道是否启用。
|
||||||
|
gmChannelSwitchOperation = "POST /dnf/gm/channel-switch"
|
||||||
// gmRecordQueryOperation 记录查询。只读操作,但沿用统一的操作串便于审计日志对齐。
|
// gmRecordQueryOperation 记录查询。只读操作,但沿用统一的操作串便于审计日志对齐。
|
||||||
gmRecordQueryOperation = "POST /dnf/gm/records/query"
|
gmRecordQueryOperation = "POST /dnf/gm/records/query"
|
||||||
)
|
)
|
||||||
@@ -125,6 +129,58 @@ func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) {
|
|||||||
}
|
}
|
||||||
writeGMData(w, http.StatusOK, result)
|
writeGMData(w, http.StatusOK, result)
|
||||||
}))
|
}))
|
||||||
|
// 活动开关:读取当前各活动的开关状态(供启动器「游戏设置 → 活动开关」展示)。
|
||||||
|
env.AdminMux.HandleFunc("GET /dnf/gm/activity-switches", read(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switches, err := service.GMActivitySwitches(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeGMToolError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeGMData(w, http.StatusOK, switches)
|
||||||
|
}))
|
||||||
|
// 活动开关:切换某活动的开/关。
|
||||||
|
env.AdminMux.HandleFunc("POST /dnf/gm/activity-switches", write(gmActivitySwitchesOperation, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := decodeGMJSON(w, r, &request); err != nil {
|
||||||
|
writeGMToolError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switches, err := service.GMSetActivitySwitch(r.Context(), request.Key, request.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
writeGMToolError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeGMData(w, http.StatusOK, switches)
|
||||||
|
}))
|
||||||
|
// 频道开关:读取当前各频道的启用状态(含被手动关闭的频道)。
|
||||||
|
env.AdminMux.HandleFunc("GET /dnf/gm/channel-switches", read(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switches, err := service.GMChannelSwitches(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeGMToolError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeGMData(w, http.StatusOK, switches)
|
||||||
|
}))
|
||||||
|
// 频道开关:切换某频道的启用/停用(改运行时目录并热对账端口,无需重启)。
|
||||||
|
env.AdminMux.HandleFunc("POST /dnf/gm/channel-switch", write(gmChannelSwitchOperation, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
ChannelID int `json:"channel_id"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := decodeGMJSON(w, r, &request); err != nil {
|
||||||
|
writeGMToolError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switches, err := service.GMSetChannelSwitch(r.Context(), request.ChannelID, request.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
writeGMToolError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeGMData(w, http.StatusOK, switches)
|
||||||
|
}))
|
||||||
// 记录查询(点券消费/对话/副本)。三类记录都落在服务端的 JSONL 文件里,
|
// 记录查询(点券消费/对话/副本)。三类记录都落在服务端的 JSONL 文件里,
|
||||||
// 这里只做检索,不改任何运行态,所以走 read()。
|
// 这里只做检索,不改任何运行态,所以走 read()。
|
||||||
env.AdminMux.HandleFunc("GET /dnf/gm/records/meta", read(func(w http.ResponseWriter, r *http.Request) {
|
env.AdminMux.HandleFunc("GET /dnf/gm/records/meta", read(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type Options struct {
|
|||||||
GamePortBase int
|
GamePortBase int
|
||||||
MaxUsers int
|
MaxUsers int
|
||||||
CurrentUsers int
|
CurrentUsers int
|
||||||
|
// DisabledIDs 是运行时被关闭的频道 ID 集合(来自启动器「频道开关」)。
|
||||||
|
// 这些频道不进入目录,也不会开游戏端口监听。
|
||||||
|
DisabledIDs map[int]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catalog 是从 channel_info.etc 派生的只读频道目录。
|
// Catalog 是从 channel_info.etc 派生的只读频道目录。
|
||||||
@@ -56,6 +59,9 @@ func New(index *channelinfo.Index, options Options) (*Catalog, error) {
|
|||||||
if item.ID <= 0 {
|
if item.ID <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if options.DisabledIDs != nil && options.DisabledIDs[item.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, ok := seen[item.ID]; ok {
|
if _, ok := seen[item.ID]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ import (
|
|||||||
// 严格版:源表缺少引导频道时直接失败。只开少数频道(甚至只开特殊区域频道)的运维配置
|
// 严格版:源表缺少引导频道时直接失败。只开少数频道(甚至只开特殊区域频道)的运维配置
|
||||||
// 请走 Build90CNOnlineScriptAllowingMissingBootstrap。
|
// 请走 Build90CNOnlineScriptAllowingMissingBootstrap。
|
||||||
func Build90CNOnlineScript(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int) ([]byte, error) {
|
func Build90CNOnlineScript(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int) ([]byte, error) {
|
||||||
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
|
return Build90CNOnlineScriptFiltered(data, sourceServerID, advertiseServerID, bootstrapChannelID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build90CNOnlineScriptFiltered 是 Build90CNOnlineScript 的带禁用频道过滤版。
|
||||||
|
// disabledIDs 里的频道不会进入下发给客户端的频道表(与 channelcatalog 的过滤保持一致)。
|
||||||
|
func Build90CNOnlineScriptFiltered(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int, disabledIDs map[int]bool) ([]byte, error) {
|
||||||
|
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, disabledIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -45,12 +51,22 @@ func Build90CNOnlineScriptAllowingMissingBootstrap(
|
|||||||
data []byte,
|
data []byte,
|
||||||
sourceServerID, advertiseServerID, bootstrapChannelID int,
|
sourceServerID, advertiseServerID, bootstrapChannelID int,
|
||||||
) ([]byte, bool, error) {
|
) ([]byte, bool, error) {
|
||||||
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
|
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build90CNOnlineScriptAllowingMissingBootstrapFiltered 是带禁用频道过滤的宽松版。
|
||||||
|
func Build90CNOnlineScriptAllowingMissingBootstrapFiltered(
|
||||||
|
data []byte,
|
||||||
|
sourceServerID, advertiseServerID, bootstrapChannelID int,
|
||||||
|
disabledIDs map[int]bool,
|
||||||
|
) ([]byte, bool, error) {
|
||||||
|
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, disabledIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func build90CNOnlineScript(
|
func build90CNOnlineScript(
|
||||||
data []byte,
|
data []byte,
|
||||||
sourceServerID, advertiseServerID, bootstrapChannelID int,
|
sourceServerID, advertiseServerID, bootstrapChannelID int,
|
||||||
|
disabledIDs map[int]bool,
|
||||||
) ([]byte, bool, error) {
|
) ([]byte, bool, error) {
|
||||||
if sourceServerID <= 0 || advertiseServerID < 0 || bootstrapChannelID <= 0 {
|
if sourceServerID <= 0 || advertiseServerID < 0 || bootstrapChannelID <= 0 {
|
||||||
return nil, false, errors.New("channel script source, advertisement, and bootstrap IDs are invalid")
|
return nil, false, errors.New("channel script source, advertisement, and bootstrap IDs are invalid")
|
||||||
@@ -124,6 +140,10 @@ func build90CNOnlineScript(
|
|||||||
if activeServer == sourceServerID {
|
if activeServer == sourceServerID {
|
||||||
record := append([]string(nil), fields[start:end]...)
|
record := append([]string(nil), fields[start:end]...)
|
||||||
id, _ := strconv.Atoi(record[0])
|
id, _ := strconv.Atoi(record[0])
|
||||||
|
if disabledIDs != nil && disabledIDs[id] {
|
||||||
|
start = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
channelType, _ := strconv.Atoi(record[2])
|
channelType, _ := strconv.Atoi(record[2])
|
||||||
record[2] = strconv.Itoa(OnlineTypeFor90CN(id, channelType))
|
record[2] = strconv.Itoa(OnlineTypeFor90CN(id, channelType))
|
||||||
if id == bootstrapChannelID {
|
if id == bootstrapChannelID {
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ func (s *Service) buildScriptBody() ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
script, err = s.buildChannelScript(raw)
|
script, err = s.buildChannelScriptFiltered(raw, s.loadDisabledChannelIDs())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build 90CN online channel script: %w", err)
|
return nil, fmt.Errorf("build 90CN online channel script: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package dnfbridge
|
||||||
|
|
||||||
|
// gm_activity_switches.go 提供「活动开关」的运行时配置:服主在启动器的
|
||||||
|
// 「游戏设置 → 活动开关」页里切换某个活动是否下发,这里负责持久化 + 缓存,
|
||||||
|
// 并让活动目录下发(buildCurrentOnlineEventInfoBodyAt)与各活动投影读取它。
|
||||||
|
//
|
||||||
|
// 为什么单独一份 scope 而不是塞进 gm-gameplay:
|
||||||
|
//
|
||||||
|
// gameplay 那一份 scope 管的是"数值旋钮"(掉落率、倍率、疲劳、燃烧),
|
||||||
|
// 活动开关管的是"这个活动要不要在客户端显示/可参与",语义与读写频率都不同。
|
||||||
|
// 分开后,启动器页面可以各自独立刷新,也不用担心一份 scope 里键越堆越多。
|
||||||
|
//
|
||||||
|
// 持久化与 gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。
|
||||||
|
// 缓存未就绪时首次读取落一次仓储,之后走内存;写入即时更新缓存并落盘。
|
||||||
|
//
|
||||||
|
// 开关的默认值(defaults)来自当前部署的真实状态,不是"全开":
|
||||||
|
//
|
||||||
|
// spend_time 累计在线得好礼(2347) 默认关:本部署走 PC 房泡点(228)分支
|
||||||
|
// level_aiming 等级预约(222) 默认关:按运营要求已关闭
|
||||||
|
// joust 骑士马战(2365) 默认关:活动已取消
|
||||||
|
// monthly 月度签到(2370) 默认开
|
||||||
|
// weekly 周周签到(2208) 默认开
|
||||||
|
// pcroom PC 房时间点(228) 默认开:当前生效的泡点活动
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gmActivitySwitchesScope = "global:dnf90:gm-activity-switches"
|
||||||
|
|
||||||
|
// 活动开关键名。值与 true/false 对应("1"/"true" 为开)。
|
||||||
|
const (
|
||||||
|
gmActivitySpendTimeKey = "spend_time"
|
||||||
|
gmActivityLevelAimingKey = "level_aiming"
|
||||||
|
gmActivityJoustKey = "joust"
|
||||||
|
gmActivityMonthlyKey = "monthly_attendance"
|
||||||
|
gmActivityWeeklyKey = "weekly_attendance"
|
||||||
|
gmActivityPCRoomKey = "pcroom_timepoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gmActivitySwitchDefinition 描述一个可切换的活动。
|
||||||
|
type gmActivitySwitchDefinition struct {
|
||||||
|
Key string // 持久化键名
|
||||||
|
Title string // 中文名
|
||||||
|
Description string // 中文说明
|
||||||
|
Default bool // 默认开关状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmActivitySwitchDefinitions 全部可切换活动(顺序即启动器展示顺序)。
|
||||||
|
var gmActivitySwitchDefinitions = []gmActivitySwitchDefinition{
|
||||||
|
{
|
||||||
|
Key: gmActivityPCRoomKey,
|
||||||
|
Title: "PC房时间点奖励",
|
||||||
|
Description: "泡点/挂机得奖励(事件 228/561)。当前部署默认开启。",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: gmActivitySpendTimeKey,
|
||||||
|
Title: "累计在线得好礼",
|
||||||
|
Description: "按累计在线时长领取奖励(事件 2347)。与 PC 房泡点互斥,默认关闭。",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: gmActivityMonthlyKey,
|
||||||
|
Title: "月度签到",
|
||||||
|
Description: "每月推荐副本通关计数领奖(事件 2370)。默认开启。",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: gmActivityWeeklyKey,
|
||||||
|
Title: "周周签到",
|
||||||
|
Description: "每周签到领奖(事件 2208)。默认开启。",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: gmActivityLevelAimingKey,
|
||||||
|
Title: "等级预约(命运/决策)",
|
||||||
|
Description: "全职LV预约得好礼(事件 222)。按运营要求默认关闭。",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: gmActivityJoustKey,
|
||||||
|
Title: "骑士马战",
|
||||||
|
Description: "骑士马战竞猜活动(事件 2365)。已取消,默认关闭。",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMActivitySwitch 单个活动的当前开关状态。
|
||||||
|
type GMActivitySwitch struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMActivitySwitches 活动开关总览(供 GM 接口 / 启动器展示)。
|
||||||
|
type GMActivitySwitches struct {
|
||||||
|
Switches []GMActivitySwitch `json:"switches"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmActivitySwitchesFromRecord 从设置记录解析全部活动开关。
|
||||||
|
// 缺失的键回落对应默认值;非法值回落默认值(不做硬失败,保持可恢复)。
|
||||||
|
func gmActivitySwitchesFromRecord(record dnfrepo.SettingsRecord, found bool) map[string]bool {
|
||||||
|
out := make(map[string]bool, len(gmActivitySwitchDefinitions))
|
||||||
|
for _, def := range gmActivitySwitchDefinitions {
|
||||||
|
out[def.Key] = def.Default
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, def := range gmActivitySwitchDefinitions {
|
||||||
|
raw := strings.TrimSpace(record.Values[def.Key])
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[def.Key] = raw == "1" || strings.EqualFold(raw, "true")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMActivitySwitches 返回当前活动开关总览(带缓存)。
|
||||||
|
func (s *Service) GMActivitySwitches(ctx context.Context) (GMActivitySwitches, error) {
|
||||||
|
if s == nil {
|
||||||
|
return GMActivitySwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
s.gmActivitySwitchesMu.Lock()
|
||||||
|
defer s.gmActivitySwitchesMu.Unlock()
|
||||||
|
if s.gmActivitySwitchesReady {
|
||||||
|
return s.gmActivitySwitchesSnapshotLocked(), nil
|
||||||
|
}
|
||||||
|
repositories, ok := s.repositoryGroup()
|
||||||
|
if !ok || repositories.Settings == nil {
|
||||||
|
return GMActivitySwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
|
||||||
|
if err != nil {
|
||||||
|
return GMActivitySwitches{}, err
|
||||||
|
}
|
||||||
|
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
|
||||||
|
s.gmActivitySwitchesAt = record.UpdatedAt
|
||||||
|
s.gmActivitySwitchesReady = true
|
||||||
|
return s.gmActivitySwitchesSnapshotLocked(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMSetActivitySwitch 切换某个活动的开关状态,返回更新后的总览。
|
||||||
|
func (s *Service) GMSetActivitySwitch(ctx context.Context, key string, enabled bool) (GMActivitySwitches, error) {
|
||||||
|
if s == nil {
|
||||||
|
return GMActivitySwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(strings.ToLower(key))
|
||||||
|
if !validGMActivitySwitchKey(key) {
|
||||||
|
return GMActivitySwitches{}, fmt.Errorf("%w: 未知的活动开关 %q", ErrGMInvalidRequest, key)
|
||||||
|
}
|
||||||
|
s.gmActivitySwitchesMu.Lock()
|
||||||
|
defer s.gmActivitySwitchesMu.Unlock()
|
||||||
|
repositories, ok := s.repositoryGroup()
|
||||||
|
if !ok || repositories.Settings == nil {
|
||||||
|
return GMActivitySwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
// 先确保缓存已就绪(读当前值),再改。
|
||||||
|
if !s.gmActivitySwitchesReady {
|
||||||
|
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
|
||||||
|
if err != nil {
|
||||||
|
return GMActivitySwitches{}, err
|
||||||
|
}
|
||||||
|
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
|
||||||
|
s.gmActivitySwitchesAt = record.UpdatedAt
|
||||||
|
s.gmActivitySwitchesReady = true
|
||||||
|
}
|
||||||
|
// 读最新记录用于落盘(避免覆盖其它并发写入)。
|
||||||
|
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
|
||||||
|
if err != nil {
|
||||||
|
return GMActivitySwitches{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
record = dnfrepo.SettingsRecord{Scope: gmActivitySwitchesScope}
|
||||||
|
} else {
|
||||||
|
record = dnfrepo.CloneSettings(record)
|
||||||
|
}
|
||||||
|
if record.Values == nil {
|
||||||
|
record.Values = make(map[string]string)
|
||||||
|
}
|
||||||
|
record.Values[key] = strconv.FormatBool(enabled)
|
||||||
|
record.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
|
||||||
|
return GMActivitySwitches{}, err
|
||||||
|
}
|
||||||
|
s.gmActivitySwitches[key] = enabled
|
||||||
|
s.gmActivitySwitchesAt = record.UpdatedAt
|
||||||
|
return s.gmActivitySwitchesSnapshotLocked(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// activitySwitchEnabled 读取某个活动的当前开关(内部无锁读取,调用方自行加锁)。
|
||||||
|
func (s *Service) activitySwitchEnabledLocked(key string) bool {
|
||||||
|
if s == nil || s.gmActivitySwitches == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
enabled, ok := s.gmActivitySwitches[key]
|
||||||
|
return ok && enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivitySwitchEnabled 读取某个活动的当前开关(公开、带锁)。
|
||||||
|
//
|
||||||
|
// 语义:开关「开启」意味着该活动照常下发/可参与;「关闭」意味着不下发、
|
||||||
|
// 客户端不显示该活动。未持久化过时回落定义里的默认值。
|
||||||
|
//
|
||||||
|
// 注意默认值的含义:本方法在缓存未就绪时会尽力加载一次(懒加载),
|
||||||
|
// 加载失败或仓储不可用时,回落"默认值"——即该活动在定义里的 Default。
|
||||||
|
// 这样即使 GM 接口从未被调用,玩法侧也能按部署默认(而非全关)工作。
|
||||||
|
func (s *Service) ActivitySwitchEnabled(ctx context.Context, key string) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(strings.ToLower(key))
|
||||||
|
s.gmActivitySwitchesMu.Lock()
|
||||||
|
defer s.gmActivitySwitchesMu.Unlock()
|
||||||
|
if !s.gmActivitySwitchesReady {
|
||||||
|
if repositories, ok := s.repositoryGroup(); ok && repositories.Settings != nil {
|
||||||
|
if record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope); err == nil {
|
||||||
|
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
|
||||||
|
s.gmActivitySwitchesAt = record.UpdatedAt
|
||||||
|
s.gmActivitySwitchesReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.gmActivitySwitches == nil {
|
||||||
|
for _, def := range gmActivitySwitchDefinitions {
|
||||||
|
if def.Key == key {
|
||||||
|
return def.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if enabled, ok := s.gmActivitySwitches[key]; ok {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
for _, def := range gmActivitySwitchDefinitions {
|
||||||
|
if def.Key == key {
|
||||||
|
return def.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmActivitySwitchesSnapshotLocked 组装总览(调用方须持 gmActivitySwitchesMu)。
|
||||||
|
func (s *Service) gmActivitySwitchesSnapshotLocked() GMActivitySwitches {
|
||||||
|
switches := make([]GMActivitySwitch, 0, len(gmActivitySwitchDefinitions))
|
||||||
|
for _, def := range gmActivitySwitchDefinitions {
|
||||||
|
enabled := def.Default
|
||||||
|
if s.gmActivitySwitches != nil {
|
||||||
|
if value, ok := s.gmActivitySwitches[def.Key]; ok {
|
||||||
|
enabled = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switches = append(switches, GMActivitySwitch{
|
||||||
|
Key: def.Key,
|
||||||
|
Title: def.Title,
|
||||||
|
Description: def.Description,
|
||||||
|
Enabled: enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return GMActivitySwitches{
|
||||||
|
Switches: switches,
|
||||||
|
UpdatedAt: s.gmActivitySwitchesAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validGMActivitySwitchKey(key string) bool {
|
||||||
|
for _, def := range gmActivitySwitchDefinitions {
|
||||||
|
if def.Key == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package dnfbridge
|
||||||
|
|
||||||
|
// gm_channel_switches.go 提供「频道开关」的运行时配置:服主在启动器的
|
||||||
|
// 「游戏设置 → 频道开关」页里关掉某个频道,这里持久化禁用频道集合,
|
||||||
|
// 并在频道目录(channelcatalog.Catalog)与客户端频道脚本(channelScript)
|
||||||
|
// 构建时过滤掉被禁用的频道,随后对账游戏端口监听(关掉的频道不再开端口)。
|
||||||
|
//
|
||||||
|
// 与 gm_channel_reload.go 的热重载区别:
|
||||||
|
//
|
||||||
|
// 热重载是"重读 channel_info.etc 这个文件",适合运维在文件里改完后一键生效;
|
||||||
|
// 频道开关是"运行时按频道 ID 记录禁用状态",不要求改文件,开关即改内存目录 +
|
||||||
|
// 落盘,重启后依然生效(从 Settings 读回)。
|
||||||
|
//
|
||||||
|
// 持久化与 activity/gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||||||
|
"longheng.io/server/internal/modules/dnf/channelinfo"
|
||||||
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||||
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gmChannelSwitchesScope = "global:dnf90:gm-channel-switches"
|
||||||
|
|
||||||
|
// gmChannelDisabledKeyPrefix 是持久化里"禁用频道"键的前缀,键形如 "disabled:<id>"。
|
||||||
|
const gmChannelDisabledKeyPrefix = "disabled:"
|
||||||
|
|
||||||
|
// GMChannelSwitch 单个频道的开关状态。
|
||||||
|
type GMChannelSwitch struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Type uint8 `json:"type"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMChannelSwitches 频道开关总览(供 GM 接口 / 启动器展示)。
|
||||||
|
type GMChannelSwitches struct {
|
||||||
|
TotalChannels int `json:"total_channels"`
|
||||||
|
EnabledChannels int `json:"enabled_channels"`
|
||||||
|
DisabledChannels int `json:"disabled_channels"`
|
||||||
|
Channels []GMChannelSwitch `json:"channels"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmDisabledChannelIDsFromRecord 从设置记录解析禁用频道 ID 集合。
|
||||||
|
func gmDisabledChannelIDsFromRecord(record dnfrepo.SettingsRecord, found bool) map[int]bool {
|
||||||
|
out := make(map[int]bool)
|
||||||
|
if !found {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for key, value := range record.Values {
|
||||||
|
if !strings.HasPrefix(key, gmChannelDisabledKeyPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value) != "1" && !strings.EqualFold(strings.TrimSpace(value), "true") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idText := strings.TrimPrefix(key, gmChannelDisabledKeyPrefix)
|
||||||
|
id, err := strconv.Atoi(idText)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmDisabledChannelIDsLocked 返回当前禁用的频道 ID 集合(调用方须持 gmChannelSwitchesMu)。
|
||||||
|
func (s *Service) gmDisabledChannelIDsLocked() map[int]bool {
|
||||||
|
if s == nil || s.gmChannelSwitches == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.gmChannelSwitches
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMChannelSwitches 返回当前频道开关总览(带缓存)。
|
||||||
|
func (s *Service) GMChannelSwitches(ctx context.Context) (GMChannelSwitches, error) {
|
||||||
|
if s == nil {
|
||||||
|
return GMChannelSwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
s.gmChannelSwitchesMu.Lock()
|
||||||
|
defer s.gmChannelSwitchesMu.Unlock()
|
||||||
|
if s.gmChannelSwitchesReady {
|
||||||
|
return s.gmChannelSwitchesSnapshotLocked(), nil
|
||||||
|
}
|
||||||
|
repositories, ok := s.repositoryGroup()
|
||||||
|
if !ok || repositories.Settings == nil {
|
||||||
|
return GMChannelSwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
|
||||||
|
if err != nil {
|
||||||
|
return GMChannelSwitches{}, err
|
||||||
|
}
|
||||||
|
s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found)
|
||||||
|
s.gmChannelSwitchesAt = record.UpdatedAt
|
||||||
|
s.gmChannelSwitchesReady = true
|
||||||
|
return s.gmChannelSwitchesSnapshotLocked(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMSetChannelSwitch 切换某频道的启用状态,返回更新后的总览。
|
||||||
|
//
|
||||||
|
// 生效机制:更新持久化的禁用集合 → 重建频道目录与脚本(应用禁用过滤)→
|
||||||
|
// 对账游戏端口监听。与 GMReloadChannels 一样,失败时保留原运行态。
|
||||||
|
func (s *Service) GMSetChannelSwitch(ctx context.Context, channelID int, enabled bool) (GMChannelSwitches, error) {
|
||||||
|
if s == nil {
|
||||||
|
return GMChannelSwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
if channelID <= 0 {
|
||||||
|
return GMChannelSwitches{}, fmt.Errorf("%w: channel id must be positive", ErrGMInvalidRequest)
|
||||||
|
}
|
||||||
|
s.gmChannelSwitchesMu.Lock()
|
||||||
|
defer s.gmChannelSwitchesMu.Unlock()
|
||||||
|
repositories, ok := s.repositoryGroup()
|
||||||
|
if !ok || repositories.Settings == nil {
|
||||||
|
return GMChannelSwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
if !s.gmChannelSwitchesReady {
|
||||||
|
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
|
||||||
|
if err != nil {
|
||||||
|
return GMChannelSwitches{}, err
|
||||||
|
}
|
||||||
|
s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found)
|
||||||
|
s.gmChannelSwitchesAt = record.UpdatedAt
|
||||||
|
s.gmChannelSwitchesReady = true
|
||||||
|
}
|
||||||
|
// 确认该频道确实存在于当前目录(不能凭空开关一个不存在的频道)。
|
||||||
|
active := s.currentCatalog()
|
||||||
|
if active == nil {
|
||||||
|
return GMChannelSwitches{}, ErrGMUnavailable
|
||||||
|
}
|
||||||
|
if _, exists := active.Channel(channelID); !exists {
|
||||||
|
return GMChannelSwitches{}, fmt.Errorf("%w: channel %d not found", ErrGMInvalidRequest, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读最新记录落盘。
|
||||||
|
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
|
||||||
|
if err != nil {
|
||||||
|
return GMChannelSwitches{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
record = dnfrepo.SettingsRecord{Scope: gmChannelSwitchesScope}
|
||||||
|
} else {
|
||||||
|
record = dnfrepo.CloneSettings(record)
|
||||||
|
}
|
||||||
|
if record.Values == nil {
|
||||||
|
record.Values = make(map[string]string)
|
||||||
|
}
|
||||||
|
key := gmChannelDisabledKeyPrefix + strconv.Itoa(channelID)
|
||||||
|
if enabled {
|
||||||
|
delete(record.Values, key)
|
||||||
|
} else {
|
||||||
|
record.Values[key] = "1"
|
||||||
|
}
|
||||||
|
record.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
|
||||||
|
return GMChannelSwitches{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新内存缓存。
|
||||||
|
newDisabled := gmDisabledChannelIDsFromRecord(record, true)
|
||||||
|
s.gmChannelSwitches = newDisabled
|
||||||
|
s.gmChannelSwitchesAt = record.UpdatedAt
|
||||||
|
|
||||||
|
// 应用过滤:重建目录与脚本并热替换(复用热重载的对账逻辑,但源是内存禁用集合)。
|
||||||
|
if err := s.applyChannelSwitchesLocked(newDisabled); err != nil {
|
||||||
|
// 目录应用失败不覆盖已落盘的开关,下次读回仍会尝试应用。
|
||||||
|
return GMChannelSwitches{}, fmt.Errorf("%w: 应用频道开关失败: %v", ErrGMUnavailable, err)
|
||||||
|
}
|
||||||
|
return s.gmChannelSwitchesSnapshotLocked(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyChannelSwitchesLocked 用禁用集合重建频道目录与脚本并热替换(调用方须持 gmChannelSwitchesMu)。
|
||||||
|
// 与 GMReloadChannels 同源,但过滤来源是内存禁用集合而非 etc 文件。
|
||||||
|
func (s *Service) applyChannelSwitchesLocked(disabled map[int]bool) error {
|
||||||
|
raw, err := osReadFile(s.options.channelInfoFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load dnf channel info: %w", err)
|
||||||
|
}
|
||||||
|
index, err := channelinfo.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse dnf channel info: %w", err)
|
||||||
|
}
|
||||||
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{
|
||||||
|
ServerID: s.channelServerID(),
|
||||||
|
DisabledIDs: disabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
script, err := s.buildChannelScriptFiltered(raw, disabled)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build 90CN online channel script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
oldCatalog := s.catalog
|
||||||
|
runCtx := s.runCtx
|
||||||
|
if s.cancel == nil || runCtx == nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("服务未启动,无法应用频道开关")
|
||||||
|
}
|
||||||
|
s.catalog = catalog
|
||||||
|
s.channelScript = append([]byte(nil), script...)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// 对账游戏端口监听(与 GMReloadChannels 同逻辑)。
|
||||||
|
oldPorts := []int(nil)
|
||||||
|
if oldCatalog != nil {
|
||||||
|
oldPorts = oldCatalog.GamePorts()
|
||||||
|
}
|
||||||
|
newPorts := catalog.GamePorts()
|
||||||
|
for _, port := range oldPorts {
|
||||||
|
if containsIntValue(newPorts, port) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = s.closeGamePort(port)
|
||||||
|
}
|
||||||
|
for _, port := range newPorts {
|
||||||
|
if containsIntValue(oldPorts, port) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.listenGamePort(runCtx, port); err != nil {
|
||||||
|
s.logWarn("dnfbridge channel switch open port failed", "port", port, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.logInfo("dnfbridge channel switches applied",
|
||||||
|
"disabled", len(disabled),
|
||||||
|
"total_channels", len(catalog.Channels()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildChannelScriptFiltered 用禁用集合生成客户端频道脚本。
|
||||||
|
func (s *Service) buildChannelScriptFiltered(raw []byte, disabled map[int]bool) ([]byte, error) {
|
||||||
|
if s.channelTableStrict() {
|
||||||
|
return channelinfo.Build90CNOnlineScriptFiltered(
|
||||||
|
raw,
|
||||||
|
s.channelServerID(),
|
||||||
|
s.channelAdvertiseID(),
|
||||||
|
dnfenum.BootstrapChannelID,
|
||||||
|
disabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
script, bootstrapPresent, err := channelinfo.Build90CNOnlineScriptAllowingMissingBootstrapFiltered(
|
||||||
|
raw,
|
||||||
|
s.channelServerID(),
|
||||||
|
s.channelAdvertiseID(),
|
||||||
|
dnfenum.BootstrapChannelID,
|
||||||
|
disabled,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !bootstrapPresent {
|
||||||
|
s.logWarn("dnfbridge channel table has no bootstrap channel; applying switch anyway",
|
||||||
|
"bootstrap_channel", dnfenum.BootstrapChannelID)
|
||||||
|
}
|
||||||
|
return script, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmChannelSwitchesSnapshotLocked 组装总览(调用方须持 gmChannelSwitchesMu)。
|
||||||
|
func (s *Service) gmChannelSwitchesSnapshotLocked() GMChannelSwitches {
|
||||||
|
active := s.currentCatalog()
|
||||||
|
disabled := s.gmDisabledChannelIDsLocked()
|
||||||
|
if active == nil {
|
||||||
|
return GMChannelSwitches{
|
||||||
|
Channels: []GMChannelSwitch{},
|
||||||
|
UpdatedAt: s.gmChannelSwitchesAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channels := active.Channels()
|
||||||
|
out := make([]GMChannelSwitch, 0, len(channels))
|
||||||
|
enabledCount := 0
|
||||||
|
for _, channel := range channels {
|
||||||
|
isDisabled := disabled != nil && disabled[channel.ID]
|
||||||
|
enabled := !isDisabled
|
||||||
|
if enabled {
|
||||||
|
enabledCount++
|
||||||
|
}
|
||||||
|
out = append(out, GMChannelSwitch{
|
||||||
|
ID: channel.ID,
|
||||||
|
Name: channel.Name,
|
||||||
|
Group: channel.Group,
|
||||||
|
Type: channel.Type,
|
||||||
|
Port: channel.Port,
|
||||||
|
Enabled: enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return GMChannelSwitches{
|
||||||
|
TotalChannels: len(channels),
|
||||||
|
EnabledChannels: enabledCount,
|
||||||
|
DisabledChannels: len(channels) - enabledCount,
|
||||||
|
Channels: out,
|
||||||
|
UpdatedAt: s.gmChannelSwitchesAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,10 @@ func (s *Service) sendCurrentMonthlyAttendance(session *gameSession, claim bool)
|
|||||||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 活动开关:月度签到被关闭时不下发(客户端活动窗口不显示该活动)。
|
||||||
|
if !s.ActivitySwitchEnabled(context.Background(), gmActivityMonthlyKey) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
catalog, err := s.currentMonthlyCatalog(ctx)
|
catalog, err := s.currentMonthlyCatalog(ctx)
|
||||||
|
|||||||
@@ -488,12 +488,15 @@ func (s *Service) sendCurrentSpendTimeProtocolState(
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
var eventInfoTransport []byte
|
var eventInfoTransport []byte
|
||||||
|
// 活动开关:pcroom_timepoint 开关决定走「PC 房时间点(228)」还是「累计在线得好礼(2347)」。
|
||||||
|
// 开关关闭时强制走 2347 分支(即使 etc/pcroomtimepoint.etc 存在)。
|
||||||
|
usePCRoom := catalog.nativePCRoom != nil && s.ActivitySwitchEnabled(context.Background(), gmActivityPCRoomKey)
|
||||||
if includeFirstProcessDescriptor {
|
if includeFirstProcessDescriptor {
|
||||||
eventInfoBody, err := buildCurrentOnlineEventInfoBodyAt(
|
eventInfoBody, err := buildCurrentOnlineEventInfoBodyAt(
|
||||||
catalog.rewardItemIDs,
|
catalog.rewardItemIDs,
|
||||||
catalog.sumRewardItemIDs,
|
catalog.sumRewardItemIDs,
|
||||||
catalog.totalStageSeconds,
|
catalog.totalStageSeconds,
|
||||||
catalog.nativePCRoom != nil,
|
usePCRoom,
|
||||||
catalog.laborDay, s.gameplayNow(),
|
catalog.laborDay, s.gameplayNow(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -526,7 +529,7 @@ func (s *Service) sendCurrentSpendTimeProtocolState(
|
|||||||
}
|
}
|
||||||
// Install the directory before the selected online owner's state. The
|
// Install the directory before the selected online owner's state. The
|
||||||
// original four-plus-four panel is 228/561; 2347/1206 is a different HUD.
|
// original four-plus-four panel is 228/561; 2347/1206 is a different HUD.
|
||||||
if catalog.nativePCRoom != nil {
|
if usePCRoom {
|
||||||
if err := s.sendCurrentPCRoomTimePoint(session, catalog, 0, -1); err != nil {
|
if err := s.sendCurrentPCRoomTimePoint(session, catalog, 0, -1); err != nil {
|
||||||
return descriptorSent, err
|
return descriptorSent, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,16 @@ type Service struct {
|
|||||||
gmFatigueMode string
|
gmFatigueMode string
|
||||||
gmBurningEnabled bool
|
gmBurningEnabled bool
|
||||||
gmGameplaySettingsAt time.Time
|
gmGameplaySettingsAt time.Time
|
||||||
|
// gmActivitySwitches 是活动开关的运行时缓存(见 gm_activity_switches.go)。
|
||||||
|
gmActivitySwitchesMu sync.Mutex
|
||||||
|
gmActivitySwitchesReady bool
|
||||||
|
gmActivitySwitches map[string]bool
|
||||||
|
gmActivitySwitchesAt time.Time
|
||||||
|
// gmChannelSwitches 是频道开关(禁用频道 ID 集合)的运行时缓存(见 gm_channel_switches.go)。
|
||||||
|
gmChannelSwitchesMu sync.Mutex
|
||||||
|
gmChannelSwitchesReady bool
|
||||||
|
gmChannelSwitches map[int]bool
|
||||||
|
gmChannelSwitchesAt time.Time
|
||||||
automaticRobotAppearanceMu sync.Mutex
|
automaticRobotAppearanceMu sync.Mutex
|
||||||
automaticRobotAvatarConfig *currentAvatarFittingRoomConfig
|
automaticRobotAvatarConfig *currentAvatarFittingRoomConfig
|
||||||
automaticRobotAvatarLoadErr error
|
automaticRobotAvatarLoadErr error
|
||||||
@@ -1015,17 +1025,40 @@ func (s *Service) loadChannelAssets() (*channelcatalog.Catalog, []byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("parse dnf channel info: %w", err)
|
return nil, nil, fmt.Errorf("parse dnf channel info: %w", err)
|
||||||
}
|
}
|
||||||
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: s.channelServerID()})
|
disabled := s.loadDisabledChannelIDs()
|
||||||
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{
|
||||||
|
ServerID: s.channelServerID(),
|
||||||
|
DisabledIDs: disabled,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
script, err := s.buildChannelScript(raw)
|
script, err := s.buildChannelScriptFiltered(raw, disabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("build 90CN online channel script: %w", err)
|
return nil, nil, fmt.Errorf("build 90CN online channel script: %w", err)
|
||||||
}
|
}
|
||||||
return catalog, script, nil
|
return catalog, script, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadDisabledChannelIDs 读取已持久化的禁用频道集合。启动时 Settings 未就绪或读失败
|
||||||
|
// 时返回 nil(视为无禁用频道),不阻塞服务启动。
|
||||||
|
func (s *Service) loadDisabledChannelIDs() map[int]bool {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
repositories, ok := s.repositoryGroup()
|
||||||
|
if !ok || repositories.Settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||||
|
defer cancel()
|
||||||
|
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return gmDisabledChannelIDsFromRecord(record, true)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) buildChannelScript(raw []byte) ([]byte, error) {
|
func (s *Service) buildChannelScript(raw []byte) ([]byte, error) {
|
||||||
if s.channelTableStrict() {
|
if s.channelTableStrict() {
|
||||||
return channelinfo.Build90CNOnlineScript(
|
return channelinfo.Build90CNOnlineScript(
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ func (s *Service) sendCurrentWeeklyAttendance(session *gameSession, claim, bonus
|
|||||||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 活动开关:周周签到被关闭时不下发(客户端活动窗口不显示该活动)。
|
||||||
|
if !s.ActivitySwitchEnabled(context.Background(), gmActivityWeeklyKey) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c, err := s.currentWeeklyCatalog(ctx)
|
c, err := s.currentWeeklyCatalog(ctx)
|
||||||
|
|||||||
Reference in new issue
Block a user