Compare commits
6
Commits
v2026.11.05
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e936bfb84 | ||
|
|
ab778e9e4c | ||
|
|
b88e1a223b | ||
|
|
0897761c47 | ||
|
|
90877233fd | ||
|
|
cc546031b0 |
No files matched your search
@@ -201,12 +201,20 @@ msedge --headless=new --disable-gpu --hide-scrollbars --window-size=800,720 \
|
||||
- [ ] 版本号三处一致(`CURRENT_VERSION` / 客户端显示 / 更新日志)
|
||||
- [ ] **干净目录**(非中文、无空格)实跑一遍再对外发
|
||||
- [ ] Release 说明用模板;附件传 `docs/release/网盘下载.url`(别直传十几 G 的包)
|
||||
- [ ] **打 tag 前先确认它指向「实际发布的那个提交」**:`git log --oneline -1 v2026.11.xx`,
|
||||
再用 `git ls-remote --tags origin` 看远端解析到的 `^{}` 是不是同一个 sha。
|
||||
tag 与代码错位时,Release 的「源码包」是一份旧代码,排查起来极难想到
|
||||
- [ ] 上面两项可以交给脚本,它先比对 tag 再动手:
|
||||
`python docs/release/publish_release.py --version v2026.11.xx --dry-run`(看过计划后去掉 `--dry-run`)
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前状态(2026-11-05)
|
||||
|
||||
- 版本 `v2026.11.05`;`main` 与 `origin/main` 同步
|
||||
- **tag 基线**:`v2026.11.05` 指向 `3b240ec`(annotated),就是本次补丁包对应的代码;
|
||||
之后的提交(如 `cc54603` 一键换装)**不在**这个 tag 里,发下一版要另打 `v2026.11.06`
|
||||
- Release `2026.11.05更新`(id=3)绑此 tag,附件 `90CN灵宝小竹整合20261105更新补丁.rar`
|
||||
- 启动器:六页(开始/GM工具/一键换装/游戏设置/游戏更新[当前|历史]/关于我们),关于页三列二维码
|
||||
- gmweb:四页,关于页三列(赞助我们 353 | 官方QQ群 178 | 团队公众号 179,面板宽 712)
|
||||
- 已修复:启动器打包后自我套娃开窗(P-001),带单实例互斥 + 运行日志 + WebView2 固定数据目录
|
||||
@@ -220,7 +228,51 @@ msedge --headless=new --disable-gpu --hide-scrollbars --window-size=800,720 \
|
||||
2. 一次提交一件事;改了接口就同步改对应文档(服务端接口 → `docs/服务端开发文档.md`)
|
||||
3. 只提交源码与文档;产物走 `.gitignore`
|
||||
4. 新踩的坑,追加到 `docs/踩坑记录.md`(编号延续 P-0xx,格式照抄现有条目)
|
||||
5. 推送前 `git status -sb` 确认工作区干净、与 `origin/main` 无分叉
|
||||
|
||||
### 8.1 推送前先 rebase(强制,不是建议)
|
||||
|
||||
`main` 是**多人共享**的分支,别人(包括另一个 AI)随时可能已经推过。本地 commit 后直接 `push`
|
||||
会被拒(non-fast-forward);这时最容易发生的坏操作是「先 reset 再重来」,改动就丢了。
|
||||
|
||||
固定顺序,别跳步:
|
||||
|
||||
```bash
|
||||
git status -sb # ① 工作区必须干净;看 ahead/behind
|
||||
git pull --rebase # ② 把本地提交挪到远端最新之上,有冲突当场解决
|
||||
git push origin main # ③ 推
|
||||
```
|
||||
|
||||
- 同步别人的改动时用 `git merge --ff-only origin/main` 或 `git pull --rebase`,**不要** `git reset --hard`
|
||||
- rebase 遇到冲突:改完 `git add <文件>` → `git rebase --continue`;搞不定就 `git rebase --abort` 回到原样再来
|
||||
- 推送后用 `git log --oneline -3` 确认自己的提交在最新,而不是被顶回旧位置
|
||||
|
||||
> 真实案例:`cc54603`(别人的一键换装)就是直接推的 `main`,本地因未同步而分叉;
|
||||
> 好在工作区干净才能 `--ff-only` 快进过去,否则就得手工合。
|
||||
|
||||
### 8.2 多人 / 多 AI 协作走分支,别直推 `main`
|
||||
|
||||
| 场景 | 做法 |
|
||||
|---|---|
|
||||
| 改动跨多个文件、要评审、或不确定是否上线 | 建分支 `feat/xxx` / `fix/xxx` → 推送 → 发合并请求(Pull Request),由维护者合并 |
|
||||
| 同时有两人在改同一个目录(尤其 `gmweb/`) | **必须走分支**,否则后推的人一定覆盖前者 |
|
||||
| 单人、改动确定、改完立刻部署验证 | 可以直推 `main`,但仍要遵守 8.1 |
|
||||
| 只改文档 | 可以直推 `main`,仍要遵守 8.1 |
|
||||
|
||||
分支命名:`feat/换装方案`、`fix/启动器端口` 这类「类型/主题」,合并后删掉分支。
|
||||
|
||||
### 8.3 推完不等于生效
|
||||
|
||||
仓库改了,`Z:\90server` 和桌面补丁包**还是旧的**(见 R3)。凡是改了 `gmweb/`、启动器或客户端的提交,
|
||||
推完要:同步部署副本 → `diff -rq` 核对 → 该重打包的重打包 → 通知实际在玩的人重启启动器。
|
||||
|
||||
**改 `serve.py` 尤其要注意**:静态文件(`index.html` / `loadout.js` / `.css`)每次请求读盘、替换即生效,
|
||||
但 `serve.py` 的路由是**进程启动时注册**的。抓 18112 的那个进程(进程名可能就叫 `DNF90启动器.exe`,
|
||||
看命令行有没有 `serve.py --port 18112`)不重启,就会出现「页面是新版、接口是旧版」——
|
||||
按钮点着没反应,最难查。所以:同步完先看 18112 是谁在听,该关就关,再重新打开启动器。
|
||||
|
||||
> 真实案例:`cc54603` 推了 gmweb 一键换装,但部署目录没同步,游戏里看不到新功能;
|
||||
> 同时 18112 被一个残留 `serve.py` 占着,启动器看到端口已占用就跳过启动,
|
||||
> 于是「改了、推了、也重启了,还是旧页面」(见 P-007、P-008)。
|
||||
|
||||
---
|
||||
|
||||
@@ -234,3 +286,6 @@ msedge --headless=new --disable-gpu --hide-scrollbars --window-size=800,720 \
|
||||
- ❌ 不要提交 exe/dll/`dnf_items.json`/PVF 资源
|
||||
- ❌ 不要把本机测试用的掉落改动带进对外发布的服务端
|
||||
- ❌ 不要公开分发或倒卖本仓库代码与资源(`pvf/` 除外,但受 GPL-3.0 约束)
|
||||
- ❌ 不要在没 `git pull --rebase` 的情况下直接 `push` —— 见 8.1
|
||||
- ❌ 不要在 push 被拒后用 `git reset --hard` 或强推 `--force` 覆盖别人的提交 —— 先 `pull --rebase`
|
||||
- ❌ 不要在 tag 指向错误提交的情况下发 Release —— 见第 6 章最后一项
|
||||
@@ -145,6 +145,15 @@ pvfexport -pvf Script.pvf -out dnf_items.json -compact
|
||||
6. **网盘地址**:`docs/release/网盘下载.url` 已指向百度网盘真实分享(提取码 `s33r`),作为 Release 附件上传——十几个 G 的包不适合直传,`.url` 是最省事的缓兵之计;重新分享后记得同步改模板表格
|
||||
7. **验证**:启动器首页版本号、更新日志条数、关于页二维码、GM 页三列是否都正常
|
||||
|
||||
> **第 5、6 步可以一条命令搞定**(Gitea REST API,脚本只用标准库):
|
||||
>
|
||||
> ```bash
|
||||
> set GITEA_TOKEN=xxxx
|
||||
> python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md
|
||||
> ```
|
||||
>
|
||||
> 它会先核对本地/远端 tag 是否指向同一提交(防「Release 挂在旧代码上」),再建或更新 Release、上传 `.url`,可重复执行。加 `--dry-run` 只打印计划。详见 [docs/release/README.md](docs/release/README.md)。
|
||||
|
||||
> ⚠️ **DLL 指纹绑定**:`90CN.dll` 的 SHA-256 在编译期编进 `DNF90Server.exe`(`boundDllSHA256`)。换 DLL 必须重编服务端,否则客户端连接会被拒绝。发布包里 DLL 与 EXE 一定要成对。
|
||||
|
||||
## 九、关键设计说明
|
||||
|
||||
+47
-2
@@ -1,13 +1,56 @@
|
||||
# 发布物料(docs/release)
|
||||
|
||||
每次发版要贴到仓库 Release 里的两样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。
|
||||
每次发版要贴到仓库 Release 里的几样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `publish_release.py` | **远程发版脚本**:校验 tag → 建/更新 Release → 上传附件(见下) |
|
||||
| `发布说明模板.md` | Release 正文模板:本次更新、下载表格、安装步骤、说明 |
|
||||
| `网盘下载.url` | Windows 网络快捷方式,双击直接打开网盘页面;作为附件上传 |
|
||||
|
||||
## 发版步骤
|
||||
## 一条命令发版(推荐)
|
||||
|
||||
Gitea 的 Release 全流程都能走 REST API,不用点网页。脚本只用 Python 标准库,不需要装任何依赖。
|
||||
|
||||
```bash
|
||||
# 1) token 放环境变量(只对当前窗口有效,别写进 .bat 提交上去)
|
||||
set GITEA_TOKEN=xxxxxxxxxxxxxxxx
|
||||
|
||||
# 2) 先干跑:只打印计划,不产生任何写操作
|
||||
python docs/release/publish_release.py --version v2026.11.06 --dry-run
|
||||
|
||||
# 3) 正式发(说明正文从模板读)
|
||||
python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md
|
||||
```
|
||||
|
||||
它按六步走,任何一步不对就停下,不会留下半个 Release:
|
||||
|
||||
| 步骤 | 做什么 | 失败是否中止 |
|
||||
|---|---|---|
|
||||
| 1 | 解析本地 tag,与远端 tag 双向比对指向的提交 | **是** |
|
||||
| 2 | 读说明正文(`--notes-file` 优先,其次 `--notes`) | — |
|
||||
| 3 | 该 tag 已有 Release 就更新,没有就创建 | **是** |
|
||||
| 4 | 检查附件;`.url` 里若还是 `REPLACE_WITH_YOUR_LINK` 就拒绝 | **是** |
|
||||
| 5 | 上传附件;同名先删再传,**可以反复执行** | **是** |
|
||||
| 6 | 回读自检(附件清单、是否草稿),打印 `html_url` | — |
|
||||
|
||||
常用参数:
|
||||
|
||||
```bash
|
||||
--draft # 先存草稿(网页上只有自己可见),确认后去掉 --draft 重跑即转正式
|
||||
--prerelease # 标记预发布
|
||||
--expect-commit 3b240ec # 强制要求 tag 指向该提交,比默认更严
|
||||
--asset 某个文件 # 可重复;默认只传 docs/release/网盘下载.url
|
||||
--no-asset # 只发说明,不传附件
|
||||
--insecure # 自签证书的内网 Gitea 才需要
|
||||
--api / --repo # 换服务器或换仓库时用
|
||||
```
|
||||
|
||||
**为什么第 1 步要卡 tag**:2026.11.05 发版时,Release 挂着的 tag 指向一个**落后 12 个提交**的旧提交,源码包于是是旧代码,从网页上完全看不出来。脚本现在把这件事挡在发版之前,`--expect-commit` 还能多加一道。
|
||||
|
||||
> 实测记录(2026-11-05):临时 tag 跑完整流程,创建 `201` → 上传 `201` → 下载 `200`(拿到正确的 `.url` 内容)→ 重复执行只保留一个同名附件 → 删除干净。字段名必须是 `attachment`,用 `file` 会返回 `500 http: no such file`。
|
||||
|
||||
## 手工发版(不想用脚本时)
|
||||
|
||||
1. 复制 `发布说明模板.md` 的内容到 Release 描述,按本次改动填「本次更新」
|
||||
2. 核对下载链接:模板里的下载表格已填当前百度网盘地址与提取码,换盘或重新分享时同步替换
|
||||
@@ -23,9 +66,11 @@
|
||||
|
||||
4. 附件区上传 `网盘下载.url`(注意:不是把整个网盘文件传上来)
|
||||
5. 检查版本号与更新日志:`launcher/server.py` 的 `CURRENT_VERSION`、发布包根目录的 `更新日志.txt`
|
||||
6. **确认 tag 指向**:`git log --oneline -1 v2026.11.xx` 与 `git ls-remote --tags origin` 里的 `^{}` 是同一个 sha
|
||||
|
||||
## 注意
|
||||
|
||||
- 仓库里的 `网盘下载.url` 现在指向**真实分享**(百度网盘,提取码 `s33r`);重新分享会生成新链接,记得同步改 `.url` 与模板表格,避免玩家点开 404
|
||||
- 传完自己双击验证一次,确认能打开网盘页面而不是 404
|
||||
- token 只走环境变量 `GITEA_TOKEN` 或 `--token`,**不要**提交到仓库里的任何文件
|
||||
- 发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须配套(DLL 指纹绑定),缺一个客户端就连不上
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DNF90 远程发版:创建 / 更新 Gitea Release 并上传附件(纯标准库,无需 pip)
|
||||
|
||||
它做的事,等价于你在网页上点的那几下,但可以重复执行、不会手抖:
|
||||
|
||||
1. 校验本地 tag 存在,并解析它指向的提交(可 --expect-commit 强制核对)
|
||||
2. 该 tag 已有 Release → 更新说明;没有 → 创建(--draft 可先存草稿)
|
||||
3. 上传附件(默认 docs/release/网盘下载.url),同名附件自动替换
|
||||
4. 打印 Release 地址,附一次自检结果
|
||||
|
||||
用法
|
||||
----
|
||||
# token 放环境变量,别写进命令历史
|
||||
set GITEA_TOKEN=xxxxxxxx
|
||||
|
||||
# 先干跑,看它准备做什么(不产生任何写操作)
|
||||
python docs/release/publish_release.py --version v2026.11.06 --dry-run
|
||||
|
||||
# 正式发:说明从文件读,附件用默认的网盘 .url
|
||||
python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md
|
||||
|
||||
# 存成草稿先看看,确认后再去掉 --draft 重跑(会自动转为正式)
|
||||
python docs/release/publish_release.py --version v2026.11.06 --notes-file x.md --draft
|
||||
|
||||
为什么要单独校验 tag 指向
|
||||
----------------------
|
||||
2026.11.05 发版时踩过:Release 挂在 tag 上,而 tag 指着一个落后 12 个提交的旧提交,
|
||||
源码包于是是旧代码,而且从外表看不出来。所以本脚本默认会拿本地 `git rev-list -n1 <tag>`
|
||||
和远端 tag 解析结果双向比对,对不上直接拒绝发版。
|
||||
|
||||
安全
|
||||
----
|
||||
- token 只从 `--token` 或环境变量 `GITEA_TOKEN` 读,不落盘、不进仓库
|
||||
- `--insecure` 才跳过 TLS 校验(自签证书的内网 Gitea 用),默认严格校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_API = "https://git.hanfuit.com/api/v1"
|
||||
DEFAULT_REPO = "Lingbao/90CNServer"
|
||||
DEFAULT_ASSET = os.path.join("docs", "release", "网盘下载.url")
|
||||
PLACEHOLDER = "REPLACE_WITH_YOUR_LINK"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 输出
|
||||
def say(msg=""):
|
||||
try:
|
||||
print(msg)
|
||||
except UnicodeEncodeError: # 老控制台 cp936
|
||||
print(msg.encode("gbk", "replace").decode("gbk", "replace"))
|
||||
|
||||
|
||||
def ok(msg):
|
||||
say(" [OK] " + msg)
|
||||
|
||||
|
||||
def warn(msg):
|
||||
say(" [警告] " + msg)
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
say(" [失败] " + msg)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ HTTP 层
|
||||
class Api:
|
||||
def __init__(self, base, repo, token, insecure=False):
|
||||
self.base = base.rstrip("/")
|
||||
self.repo = repo
|
||||
self.token = token
|
||||
self.ctx = ssl._create_unverified_context() if insecure else ssl.create_default_context()
|
||||
|
||||
def _url(self, path):
|
||||
return "%s/repos/%s%s" % (self.base, self.repo, path)
|
||||
|
||||
def call(self, method, path, payload=None, raw=None, ctype=None):
|
||||
"""返回 (status, 解析后的 json 或 None)。4xx/5xx 不抛异常,交由调用方判断。"""
|
||||
data = None
|
||||
if raw is not None:
|
||||
data = raw
|
||||
elif payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
ctype = "application/json"
|
||||
req = urllib.request.Request(self._url(path), data=data, method=method)
|
||||
req.add_header("Authorization", "token " + self.token)
|
||||
req.add_header("Accept", "application/json")
|
||||
if ctype:
|
||||
req.add_header("Content-Type", ctype)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60, context=self.ctx) as resp:
|
||||
body = resp.read()
|
||||
return resp.status, (json.loads(body.decode("utf-8")) if body.strip() else None)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", "replace")
|
||||
try:
|
||||
return exc.code, json.loads(body)
|
||||
except Exception:
|
||||
return exc.code, {"message": body[:300]}
|
||||
except urllib.error.URLError as exc:
|
||||
die("连不上 %s:%s" % (self.base, exc.reason))
|
||||
|
||||
def upload(self, release_id, filepath, name=None):
|
||||
"""multipart 上传。注意字段名必须是 attachment —— 用 file 会 500 'no such file'。"""
|
||||
name = name or os.path.basename(filepath)
|
||||
with open(filepath, "rb") as fh:
|
||||
blob = fh.read()
|
||||
boundary = "----DNF90Release%s" % os.urandom(8).hex()
|
||||
ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
parts = []
|
||||
parts.append(("--%s\r\n" % boundary).encode())
|
||||
parts.append(('Content-Disposition: form-data; name="attachment"; filename="%s"\r\n' % name).encode("utf-8"))
|
||||
parts.append(("Content-Type: %s\r\n\r\n" % ctype).encode())
|
||||
parts.append(blob)
|
||||
parts.append(("\r\n--%s--\r\n" % boundary).encode())
|
||||
body = b"".join(parts)
|
||||
q = urllib.parse.urlencode({"name": name})
|
||||
return self.call("POST", "/releases/%s/assets?%s" % (release_id, q), raw=body,
|
||||
ctype="multipart/form-data; boundary=%s" % boundary)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- git
|
||||
def git(*args):
|
||||
try:
|
||||
out = subprocess.run(["git"] + list(args), capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def local_tag_commit(tag):
|
||||
return git("rev-list", "-n", "1", tag)
|
||||
|
||||
|
||||
def current_branch_commit():
|
||||
return git("rev-parse", "HEAD")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- main
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="远程创建/更新 Gitea Release 并上传附件")
|
||||
ap.add_argument("--version", required=True, help="tag 名,例如 v2026.11.06")
|
||||
ap.add_argument("--name", help="Release 标题,默认「<version> 更新」")
|
||||
ap.add_argument("--notes", help="Release 说明正文")
|
||||
ap.add_argument("--notes-file", help="从文件读说明正文(优先于 --notes)")
|
||||
ap.add_argument("--asset", action="append", default=None,
|
||||
help="附件路径,可重复;默认 %s" % DEFAULT_ASSET)
|
||||
ap.add_argument("--expect-commit", help="要求 tag 必须指向该提交(默认取本地 tag 解析值)")
|
||||
ap.add_argument("--draft", action="store_true", help="创建为草稿(不公开)")
|
||||
ap.add_argument("--prerelease", action="store_true", help="标记为预发布")
|
||||
ap.add_argument("--no-asset", action="store_true", help="只发说明,不传附件")
|
||||
ap.add_argument("--force", action="store_true", help="允许 .url 里仍是占位地址")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不做写操作")
|
||||
ap.add_argument("--token", help="Gitea token(默认读环境变量 GITEA_TOKEN)")
|
||||
ap.add_argument("--api", default=os.environ.get("GITEA_API", DEFAULT_API))
|
||||
ap.add_argument("--repo", default=os.environ.get("GITEA_REPO", DEFAULT_REPO))
|
||||
ap.add_argument("--insecure", action="store_true", help="跳过 TLS 校验(自签证书)")
|
||||
args = ap.parse_args()
|
||||
|
||||
token = args.token or os.environ.get("GITEA_TOKEN")
|
||||
if not token:
|
||||
die("缺少 token:设置环境变量 GITEA_TOKEN,或传 --token")
|
||||
if os.environ.get("GITEA_TOKEN") and not args.token:
|
||||
warn("正在使用环境变量里的 token(ps 历史里看不到,但别写进 bat 脚本)")
|
||||
|
||||
api = Api(args.api, args.repo, token, args.insecure)
|
||||
tag = args.version
|
||||
title = args.name or ("%s 更新" % tag)
|
||||
|
||||
say("== 1. 校验 tag ==")
|
||||
local_sha = local_tag_commit(tag)
|
||||
if not local_sha:
|
||||
die("本地找不到了 tag %s:先 `git tag -a %s <commit>` 再推送" % (tag, tag))
|
||||
ok("本地 %s → %s" % (tag, local_sha[:10]))
|
||||
if args.expect_commit and not local_sha.startswith(args.expect_commit):
|
||||
die("tag 指向 %s,与要求的 %s 不符 —— 拒绝发版" % (local_sha[:10], args.expect_commit[:10]))
|
||||
|
||||
head = current_branch_commit()
|
||||
if head and head != local_sha:
|
||||
behind = git("rev-list", "--count", "%s..%s" % (local_sha, head))
|
||||
warn("HEAD 比 tag 多 %s 个提交(这是正常的:tag 只对应已发布的那个版本)" % (behind or "?"))
|
||||
|
||||
status, remote_tag = api.call("GET", "/tags/%s" % urllib.parse.quote(tag))
|
||||
if status == 200:
|
||||
rsha = ((remote_tag or {}).get("commit") or {}).get("sha")
|
||||
if rsha and not rsha.startswith(local_sha[:10]):
|
||||
die("远端 tag 指向 %s,本地是 %s —— 先把 tag 推上去再说" % (rsha[:10], local_sha[:10]))
|
||||
ok("远端 tag 一致(%s)" % (rsha or "?")[:10])
|
||||
elif status == 404:
|
||||
die("远端没有 tag %s:先 `git push origin refs/tags/%s`" % (tag, tag))
|
||||
else:
|
||||
die("查询远端 tag 失败:HTTP %s %s" % (status, (remote_tag or {}).get("message")))
|
||||
|
||||
say("== 2. 说明正文 ==")
|
||||
body = ""
|
||||
if args.notes_file:
|
||||
with open(args.notes_file, "r", encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
ok("读取 %s(%d 字符)" % (args.notes_file, len(body)))
|
||||
elif args.notes:
|
||||
body = args.notes
|
||||
ok("使用命令行说明(%d 字符)" % len(body))
|
||||
else:
|
||||
warn("没有说明正文,Release 描述会是空的")
|
||||
|
||||
say("== 3. 检查已有 Release ==")
|
||||
status, exist = api.call("GET", "/releases/tags/%s" % urllib.parse.quote(tag))
|
||||
if status == 200 and isinstance(exist, dict):
|
||||
rid = exist.get("id")
|
||||
suffix = ",并转正式" if (exist.get("draft") and not args.draft) else ""
|
||||
ok("已存在 Release id=%s;本次将更新说明%s" % (rid, suffix))
|
||||
action = "update"
|
||||
have_assets = [a.get("name") for a in (exist.get("assets") or [])]
|
||||
elif status == 404:
|
||||
ok("还没有 Release,本次将创建")
|
||||
action = "create"
|
||||
rid, have_assets = None, []
|
||||
else:
|
||||
die("查询 Release 失败:HTTP %s %s" % (status, (exist or {}).get("message")))
|
||||
|
||||
say("== 4. 附件 ==")
|
||||
assets = [] if args.no_asset else (args.asset or [DEFAULT_ASSET])
|
||||
for path in assets:
|
||||
if not os.path.exists(path):
|
||||
die("附件不存在:%s" % path)
|
||||
size = os.path.getsize(path)
|
||||
if path.lower().endswith(".url"):
|
||||
with open(path, "rb") as fh:
|
||||
text = fh.read().decode("utf-8", "replace")
|
||||
if PLACEHOLDER in text and not args.force:
|
||||
die("%s 里还是占位地址 %s,先填真实网盘链接(或用 --force 硬发)" % (path, PLACEHOLDER))
|
||||
state = "(将替换同名)" if os.path.basename(path) in have_assets else ""
|
||||
ok("%s %.1f KB%s" % (path, size / 1024.0, state))
|
||||
|
||||
if args.dry_run:
|
||||
say("")
|
||||
say("== 干跑结束,未做任何写操作 ==")
|
||||
say("将执行:%s Release「%s」(tag %s) %s" % (
|
||||
"创建" if action == "create" else "更新", title, tag,
|
||||
",附件 " + str(len(assets)) + " 个" if assets else ",无附件"))
|
||||
return
|
||||
|
||||
say("== 5. 写入 ==")
|
||||
if action == "create":
|
||||
payload = {"tag_name": tag, "target_commitish": local_sha, "name": title,
|
||||
"body": body, "draft": bool(args.draft), "prerelease": bool(args.prerelease)}
|
||||
status, rel = api.call("POST", "/releases", payload=payload)
|
||||
if status not in (201, 200):
|
||||
die("创建失败:HTTP %s %s" % (status, (rel or {}).get("message")))
|
||||
rid = rel.get("id")
|
||||
ok("已创建 Release id=%s" % rid)
|
||||
else:
|
||||
payload = {"name": title, "body": body, "draft": bool(args.draft),
|
||||
"prerelease": bool(args.prerelease)}
|
||||
status, rel = api.call("PATCH", "/releases/%s" % rid, payload=payload)
|
||||
if status not in (200, 201):
|
||||
die("更新失败:HTTP %s %s" % (status, (rel or {}).get("message")))
|
||||
ok("已更新 Release id=%s" % rid)
|
||||
|
||||
for path in assets:
|
||||
name = os.path.basename(path)
|
||||
# 同名先删,保证可重复执行
|
||||
for a in (rel.get("assets") or []):
|
||||
if a.get("name") == name:
|
||||
api.call("DELETE", "/releases/%s/assets/%s" % (rid, a.get("id")))
|
||||
ok("删除同名旧附件 %s" % name)
|
||||
status, res = api.upload(rid, path, name)
|
||||
if status not in (201, 200):
|
||||
die("上传 %s 失败:HTTP %s %s" % (name, status, (res or {}).get("message")))
|
||||
ok("已上传 %s(%.1f KB)" % (name, (res or {}).get("size", 0) / 1024.0))
|
||||
|
||||
say("== 6. 自检 ==")
|
||||
status, final = api.call("GET", "/releases/%s" % rid)
|
||||
if status != 200:
|
||||
warn("回读失败 HTTP %s" % status)
|
||||
else:
|
||||
got = [a.get("name") for a in (final.get("assets") or [])]
|
||||
ok("附件:%s" % (", ".join(got) if got else "无"))
|
||||
ok("状态:%s" % ("草稿" if final.get("draft") else "已发布"))
|
||||
say("")
|
||||
say("Release 地址:%s" % final.get("html_url"))
|
||||
if final.get("draft"):
|
||||
say("提示:当前是草稿,网页上只有你自己可见;去掉 --draft 再跑一次即转正式")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -121,6 +121,35 @@ DNF90启动器.exe probe.py # 旧版:hit.txt 不生成,却开出一个
|
||||
| `警告:注册表里没查到 WebView2 运行时` | 机器缺 Edge WebView2 Runtime,装一下即可(窗口会是空白) |
|
||||
| 完全没有日志文件 | exe 所在目录不可写(如 Program Files),换目录或给写权限 |
|
||||
|
||||
### 18112 上跑的可能是「启动器」本身,别被进程名骗了
|
||||
|
||||
多调用模式的代价:gmweb 子进程的**进程名就是 `DNF90启动器.exe`**,真实身份只在命令行里。
|
||||
|
||||
```
|
||||
DNF90启动器.exe Z:\90server\gmweb\serve.py --port 18112
|
||||
```
|
||||
|
||||
实际踩过:看到 18112 被占、进程名是启动器,先判断成「启动器在跑」,又找不到窗口可点
|
||||
(父进程早退了、只剩这个子进程),于是改口成「残留的 `pythonw serve.py`」——两次都错了。
|
||||
**名字不可靠,看命令行**:
|
||||
|
||||
```powershell
|
||||
Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq <pid> } |
|
||||
Select-Object Name,ProcessId,ParentProcessId,CommandLine | Format-List
|
||||
```
|
||||
|
||||
| 命令行特征 | 身份 |
|
||||
|---|---|
|
||||
| 带 `...\gmweb\serve.py --port 18112` | gmweb 服务进程(正常形态,父进程通常就是启动器) |
|
||||
| 只有 exe 路径,且自己占着 `19527` | 启动器主进程 |
|
||||
| 上面那条的 `ParentProcessId` 已不存在 | 父启动器退了,留下的是**遗留 gmweb 服务**(能继续用,但跑的是旧代码) |
|
||||
|
||||
> **关键推论:它跑的是「启动那一刻」的 `serve.py`。**
|
||||
> 静态文件(`index.html` / `loadout.js` / `.css`)每次请求都读盘,替换后立即生效;
|
||||
> 但 `serve.py` 的路由是进程启动时注册的,**替换文件不会生效**。
|
||||
> 症状是「页面是新版(按钮都在),接口却是旧版(点了没反应 / 404)」。
|
||||
> 所以同步过 `gmweb/` 之后,必须让占着 18112 的进程重启一次(关启动器 → 确认端口空了 → 重新打开)。
|
||||
|
||||
## 七、相关坑(顺带记录)
|
||||
|
||||
- **exe 名字带中文**没问题,日志和 WebView2 目录都能正常处理;但 exe 放在**只读目录**会让日志与 WebView2 同时失效,表现就是「能开窗口但空白、也没有日志」。
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
|
||||
}
|
||||
try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { }
|
||||
if (name === 'loadout' && typeof window.LoadoutTab === 'function') {
|
||||
window.LoadoutTab();
|
||||
}
|
||||
}
|
||||
|
||||
function bindTabs() {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<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="loadout" href="javascript:void(0)">一键换装</a>
|
||||
<a class="tab" data-tab="item" href="javascript:void(0)">发物品</a>
|
||||
<a class="tab" data-tab="about" href="javascript:void(0)">关于</a>
|
||||
</div>
|
||||
@@ -129,6 +130,53 @@
|
||||
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-loadout">
|
||||
<div class="row">
|
||||
<label class="lb">角色</label>
|
||||
<select id="lo-char" class="inp sm"></select>
|
||||
<button id="lo-refresh" class="btn">刷新</button>
|
||||
<span id="lo-msg" class="dim"></span>
|
||||
</div>
|
||||
<div class="lo-wrap">
|
||||
<div class="lo-col lo-left">
|
||||
<div class="sec">换装页</div>
|
||||
<div class="row">
|
||||
<select id="lo-page" class="inp sm"></select>
|
||||
<button id="lo-page-add" class="btn sm">新增</button>
|
||||
<button id="lo-page-ren" class="btn sm">重命名</button>
|
||||
<button id="lo-page-del" class="btn sm">删除</button>
|
||||
</div>
|
||||
<div class="sec">方案</div>
|
||||
<div id="lo-schemes" class="list fixed"></div>
|
||||
<div class="row">
|
||||
<button id="lo-save" class="btn pri">保存当前装备为方案</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="lo-scheme-add" class="btn sm">新增方案</button>
|
||||
<button id="lo-scheme-ren" class="btn sm">重命名</button>
|
||||
<button id="lo-scheme-del" class="btn sm">删除</button>
|
||||
</div>
|
||||
<div class="hint">保存后会给当前角色创建一套换装方案。</div>
|
||||
<div id="lo-hotkeys" class="hint"></div>
|
||||
</div>
|
||||
<div class="lo-col lo-right">
|
||||
<div class="sec">方案详情</div>
|
||||
<div id="lo-detail" class="cur">未选择方案</div>
|
||||
<div id="lo-slots" class="lo-slots"></div>
|
||||
<div class="row">
|
||||
<button id="lo-hotkey" class="btn">设置快捷键</button>
|
||||
<button id="lo-hotkey-clear" class="btn sm">清除</button>
|
||||
<span id="lo-hotkey-text" class="dim"></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="lo-apply" class="btn pri">应用方案</button>
|
||||
<span id="lo-cooldown" class="dim"></span>
|
||||
</div>
|
||||
<div class="hint">每次换装完成后进入 <b style="color:#e8c979">10 秒冷却</b>;冷却期间点击按钮或按全局热键都会被服务端拒绝。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-about">
|
||||
<div class="about-inner">
|
||||
<div class="about-card">
|
||||
@@ -193,5 +241,6 @@
|
||||
<div id="log"></div>
|
||||
|
||||
<script src="/app.js?v=20261105C"></script>
|
||||
<script src="/loadout.js?v=20261105A"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,519 @@
|
||||
/* 一键换装(游戏内 GM 窗口)
|
||||
* IE11 兼容:ES5 + XMLHttpRequest,不使用箭头函数/模板字符串/flex。
|
||||
* 数据:/api/loadout/config(本地 config.json),角色/装备/应用走 serve.py 代理到 18111。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var S = {
|
||||
cfg: null,
|
||||
chars: [],
|
||||
charName: '',
|
||||
page: 0,
|
||||
scheme: -1,
|
||||
equipment: [],
|
||||
cooldown: {},
|
||||
timer: null
|
||||
};
|
||||
|
||||
function keyText(code) {
|
||||
if (code >= 112 && code <= 123) { return 'F' + (code - 111); }
|
||||
if (code >= 96 && code <= 105) { return 'Num' + (code - 96); }
|
||||
if (code >= 48 && code <= 57) { return String.fromCharCode(code); }
|
||||
if (code >= 65 && code <= 90) { return String.fromCharCode(code); }
|
||||
if (code === 13) { return 'Enter'; }
|
||||
if (code === 32) { return 'Space'; }
|
||||
return 'VK' + code;
|
||||
}
|
||||
|
||||
function modText(mods) {
|
||||
var out = [];
|
||||
if (mods & 2) { out.push('Ctrl'); }
|
||||
if (mods & 1) { out.push('Alt'); }
|
||||
if (mods & 4) { out.push('Shift'); }
|
||||
return out;
|
||||
}
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function log(text, cls) {
|
||||
var box = $('log');
|
||||
if (!box) { return; }
|
||||
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(text);
|
||||
box.appendChild(d);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
while (box.childNodes.length > 120) { box.removeChild(box.firstChild); }
|
||||
}
|
||||
|
||||
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('连不上本地代理(gmweb/serve.py 未启动?)', 'err');
|
||||
if (cb) { cb(null); }
|
||||
return;
|
||||
}
|
||||
var data = null;
|
||||
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
|
||||
if (!data) {
|
||||
log('响应不是 JSON:' + String(xhr.responseText || '').substring(0, 120), 'err');
|
||||
if (cb) { cb(null); }
|
||||
return;
|
||||
}
|
||||
if (data.ok === false || data.success === false) {
|
||||
log('失败:' + (data.error || data.message || '未知错误'), 'err');
|
||||
if (cb) { cb(null); }
|
||||
return;
|
||||
}
|
||||
if (cb) { cb(data); }
|
||||
};
|
||||
xhr.send(body ? JSON.stringify(body) : null);
|
||||
}
|
||||
|
||||
function msg(text) {
|
||||
var el = $('lo-msg');
|
||||
if (el) { el.innerHTML = esc(text || ''); }
|
||||
}
|
||||
|
||||
function currentPage() {
|
||||
if (!S.cfg || !S.cfg.Pages || !S.cfg.Pages.length) { return null; }
|
||||
if (S.page < 0 || S.page >= S.cfg.Pages.length) { S.page = 0; }
|
||||
return S.cfg.Pages[S.page];
|
||||
}
|
||||
|
||||
function normalizeConfig(data) {
|
||||
if (!data || !data.Pages || !data.Pages.length) {
|
||||
data = { ServerUrl: 'http://127.0.0.1:18111', AdminToken: '', ActivePage: 0,
|
||||
Pages: [{ Name: '默认页', Schemes: [] }] };
|
||||
}
|
||||
if (!data.Pages[0].Schemes) { data.Pages[0].Schemes = []; }
|
||||
S.page = parseInt(data.ActivePage, 10) || 0;
|
||||
if (S.page < 0 || S.page >= data.Pages.length) { S.page = 0; }
|
||||
return data;
|
||||
}
|
||||
|
||||
function loadHotkeys() {
|
||||
api('/loadout/hotkeys', 'GET', null, function (d) {
|
||||
var el = $('lo-hotkeys');
|
||||
if (!el) { return; }
|
||||
if (!d || !d.data || !(d.data.schemes || []).length) {
|
||||
el.innerHTML = '全局热键:当前页没有配置快捷键';
|
||||
return;
|
||||
}
|
||||
var parts = [];
|
||||
var schemes = d.data.schemes;
|
||||
for (var i = 0; i < schemes.length; i++) {
|
||||
var sc = schemes[i];
|
||||
var mods = modText(sc.modifiers || 0);
|
||||
mods.push(keyText(sc.keycode));
|
||||
parts.push(esc(sc.name || '') + ' → ' + esc(mods.join('+')));
|
||||
}
|
||||
el.innerHTML = '全局热键(当前页):' + parts.join(' ');
|
||||
});
|
||||
}
|
||||
|
||||
function loadConfig(cb) {
|
||||
api('/loadout/config', 'GET', null, function (d) {
|
||||
if (!d) { msg('读取换装配置失败'); if (cb) { cb(false); } return; }
|
||||
S.cfg = normalizeConfig(d.data || {});
|
||||
renderPages();
|
||||
renderSchemes();
|
||||
loadHotkeys();
|
||||
if (cb) { cb(true); }
|
||||
});
|
||||
}
|
||||
|
||||
function saveConfig(cb) {
|
||||
if (!S.cfg) { return; }
|
||||
S.cfg.ActivePage = S.page;
|
||||
api('/loadout/config', 'POST', S.cfg, function (d) {
|
||||
if (d) { msg('换装配置已保存'); log('换装配置已保存', 'ok'); loadHotkeys(); }
|
||||
if (cb) { cb(!!d); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderPages() {
|
||||
var sel = $('lo-page');
|
||||
if (!sel) { return; }
|
||||
var pages = (S.cfg && S.cfg.Pages) || [];
|
||||
var h = [];
|
||||
for (var i = 0; i < pages.length; i++) {
|
||||
h.push('<option value="' + i + '">' + esc(pages[i].Name || ('页 ' + (i + 1))) + '</option>');
|
||||
}
|
||||
sel.innerHTML = h.join('');
|
||||
sel.value = String(S.page);
|
||||
}
|
||||
|
||||
function renderSchemes() {
|
||||
var box = $('lo-schemes');
|
||||
if (!box) { return; }
|
||||
var page = currentPage();
|
||||
var schemes = (page && page.Schemes) || [];
|
||||
if (!schemes.length) {
|
||||
box.innerHTML = '<div class="lo-empty">当前页还没有方案</div>';
|
||||
S.scheme = -1;
|
||||
renderDetail();
|
||||
return;
|
||||
}
|
||||
var h = [];
|
||||
for (var i = 0; i < schemes.length; i++) {
|
||||
var sc = schemes[i];
|
||||
h.push('<div class="li' + (i === S.scheme ? ' on' : '') + '" data-i="' + i + '">' +
|
||||
'<span class="nm">' + esc(sc.Name || ('方案 ' + (i + 1))) + '</span>' +
|
||||
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '') +
|
||||
' · ' + ((sc.Slots || []).length) + ' 件</span></div>');
|
||||
}
|
||||
box.innerHTML = h.join('');
|
||||
var lis = box.getElementsByClassName('li');
|
||||
for (var k = 0; k < lis.length; k++) {
|
||||
lis[k].onclick = function () {
|
||||
S.scheme = parseInt(this.getAttribute('data-i'), 10);
|
||||
renderSchemes();
|
||||
};
|
||||
}
|
||||
if (S.scheme < 0 || S.scheme >= schemes.length) { S.scheme = 0; }
|
||||
var active = box.getElementsByClassName('li')[S.scheme];
|
||||
if (active) { active.className = 'li on'; }
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
var page = currentPage();
|
||||
var schemes = (page && page.Schemes) || [];
|
||||
var detail = $('lo-detail');
|
||||
var slotsBox = $('lo-slots');
|
||||
if (!detail || !slotsBox) { return; }
|
||||
var sc = schemes[S.scheme];
|
||||
if (!sc) {
|
||||
detail.innerHTML = '未选择方案';
|
||||
slotsBox.innerHTML = '';
|
||||
var emptyHotkey = $('lo-hotkey-text');
|
||||
if (emptyHotkey) { emptyHotkey.innerHTML = ''; }
|
||||
return;
|
||||
}
|
||||
detail.innerHTML = '<b>' + esc(sc.Name || '') + '</b>' +
|
||||
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '未绑定角色') + '</span>';
|
||||
var hotkeyTextEl = $('lo-hotkey-text');
|
||||
if (hotkeyTextEl) { hotkeyTextEl.innerHTML = '快捷键:' + esc(hotkeyText(sc)); }
|
||||
var slots = sc.Slots || [];
|
||||
if (!slots.length) {
|
||||
slotsBox.innerHTML = '<div class="lo-empty">该方案没有保存装备</div>';
|
||||
return;
|
||||
}
|
||||
var h = [];
|
||||
for (var i = 0; i < slots.length; i++) {
|
||||
h.push('<div class="lo-slot">部位 <b>' + esc(slots[i].slot) + '</b>' +
|
||||
'<span class="id">ID ' + esc(slots[i].item_id) + '</span></div>');
|
||||
}
|
||||
slotsBox.innerHTML = h.join('');
|
||||
}
|
||||
|
||||
function hotkeyText(sc) {
|
||||
if (!sc || !sc.KeyCode) { return '未设置'; }
|
||||
var parts = modText(sc.Modifiers || 0);
|
||||
parts.push(keyText(sc.KeyCode));
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
function currentScheme() {
|
||||
var page = currentPage();
|
||||
var schemes = (page && page.Schemes) || [];
|
||||
return schemes[S.scheme] || null;
|
||||
}
|
||||
|
||||
function setHotkey() {
|
||||
var sc = currentScheme();
|
||||
if (!sc) { msg('请先选择方案'); return; }
|
||||
S.capture = true;
|
||||
msg('请按下快捷键,Esc 取消');
|
||||
var handler = function (ev) {
|
||||
ev = ev || window.event;
|
||||
var code = ev.keyCode || ev.which;
|
||||
if (code === 27) {
|
||||
S.capture = false;
|
||||
document.removeEventListener('keydown', handler, true);
|
||||
msg('已取消设置快捷键');
|
||||
return false;
|
||||
}
|
||||
if (!code || code === 16 || code === 17 || code === 18) {
|
||||
return false;
|
||||
}
|
||||
var mods = 0;
|
||||
if (ev.ctrlKey) { mods |= 2; }
|
||||
if (ev.altKey) { mods |= 1; }
|
||||
if (ev.shiftKey) { mods |= 4; }
|
||||
sc.KeyCode = code;
|
||||
sc.Modifiers = mods;
|
||||
S.capture = false;
|
||||
document.removeEventListener('keydown', handler, true);
|
||||
saveConfig(function (ok) {
|
||||
if (ok) {
|
||||
renderDetail();
|
||||
loadHotkeys();
|
||||
msg('快捷键已保存:' + hotkeyText(sc));
|
||||
}
|
||||
});
|
||||
if (ev.preventDefault) { ev.preventDefault(); }
|
||||
return false;
|
||||
};
|
||||
document.addEventListener('keydown', handler, true);
|
||||
}
|
||||
|
||||
function clearHotkey() {
|
||||
var sc = currentScheme();
|
||||
if (!sc) { msg('请先选择方案'); return; }
|
||||
sc.KeyCode = 0;
|
||||
sc.Modifiers = 0;
|
||||
saveConfig(function (ok) {
|
||||
if (ok) {
|
||||
renderDetail();
|
||||
loadHotkeys();
|
||||
msg('快捷键已清除');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addScheme() {
|
||||
var page = currentPage();
|
||||
if (!page) { return; }
|
||||
var name = window.prompt('方案名称', '方案' + ((page.Schemes || []).length + 1));
|
||||
if (!name) { return; }
|
||||
var character = window.prompt('绑定角色游戏内名称', S.charName || '') || '';
|
||||
page.Schemes = page.Schemes || [];
|
||||
page.Schemes.push({
|
||||
Name: String(name),
|
||||
CharacterName: String(character),
|
||||
Modifiers: 0,
|
||||
KeyCode: 0,
|
||||
Slots: []
|
||||
});
|
||||
S.scheme = page.Schemes.length - 1;
|
||||
saveConfig(function (ok) {
|
||||
if (ok) {
|
||||
renderSchemes();
|
||||
msg('已新增方案:' + name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renameScheme() {
|
||||
var sc = currentScheme();
|
||||
if (!sc) { msg('请先选择方案'); return; }
|
||||
var name = window.prompt('重命名方案', sc.Name || '');
|
||||
if (!name) { return; }
|
||||
sc.Name = String(name);
|
||||
saveConfig(function (ok) {
|
||||
if (ok) {
|
||||
renderSchemes();
|
||||
msg('方案已重命名');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteScheme() {
|
||||
var page = currentPage();
|
||||
var sc = currentScheme();
|
||||
if (!page || !sc) { msg('请先选择方案'); return; }
|
||||
if (!window.confirm('删除方案“' + (sc.Name || '') + '”?')) { return; }
|
||||
page.Schemes.splice(S.scheme, 1);
|
||||
S.scheme = -1;
|
||||
saveConfig(function (ok) {
|
||||
if (ok) {
|
||||
renderSchemes();
|
||||
msg('方案已删除');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadChars() {
|
||||
api('/loadout/characters', 'GET', null, function (d) {
|
||||
if (!d) { msg('角色读取失败,确认服务端已启动'); return; }
|
||||
S.chars = d.data || [];
|
||||
var sel = $('lo-char');
|
||||
if (!sel) { return; }
|
||||
var old = S.charName;
|
||||
var h = ['<option value="">选择角色</option>'];
|
||||
for (var i = 0; i < S.chars.length; i++) {
|
||||
var c = S.chars[i];
|
||||
h.push('<option value="' + esc(c.name) + '">' + esc(c.name) +
|
||||
'(Lv.' + esc(c.level) + (c.online ? ' · 在线' : '') + ')</option>');
|
||||
}
|
||||
sel.innerHTML = h.join('');
|
||||
if (old) { sel.value = old; }
|
||||
if (!sel.value && S.chars.length === 1) { sel.value = S.chars[0].name; }
|
||||
S.charName = sel.value || '';
|
||||
msg('角色 ' + S.chars.length + ' 个');
|
||||
});
|
||||
}
|
||||
|
||||
function fetchEquipment(characterName, cb) {
|
||||
api('/local/dnf-gm/loadout/equipment?character_id=' + encodeURIComponent(characterName), 'GET', null, function (d) {
|
||||
if (!d) { cb(null); return; }
|
||||
cb((d.data && d.data.slots) || []);
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrent() {
|
||||
if (!S.charName) { msg('请先选择角色'); return; }
|
||||
if (!S.cfg) { return; }
|
||||
msg('正在读取当前装备…');
|
||||
fetchEquipment(S.charName, function (slots) {
|
||||
if (!slots) { msg('读取当前装备失败'); return; }
|
||||
if (!slots.length) { msg('当前没有可保存的装备'); return; }
|
||||
var name = window.prompt('方案名称', S.charName + ' 当前装备');
|
||||
if (!name) { msg('已取消'); return; }
|
||||
var page = currentPage();
|
||||
if (!page) { return; }
|
||||
if (!page.Schemes) { page.Schemes = []; }
|
||||
page.Schemes.push({
|
||||
Name: String(name),
|
||||
CharacterName: S.charName,
|
||||
Modifiers: 0,
|
||||
KeyCode: 0,
|
||||
Slots: slots
|
||||
});
|
||||
S.scheme = page.Schemes.length - 1;
|
||||
saveConfig(function (ok) {
|
||||
if (ok) { renderSchemes(); msg('已保存方案:' + name); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cooldownRemaining(name) {
|
||||
var until = S.cooldown[name] || 0;
|
||||
return Math.max(0, Math.ceil((until - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
function tickCooldown() {
|
||||
var el = $('lo-cooldown');
|
||||
var page = currentPage();
|
||||
var schemes = (page && page.Schemes) || [];
|
||||
var sc = schemes[S.scheme];
|
||||
if (!el) { return; }
|
||||
if (!sc) { el.innerHTML = ''; return; }
|
||||
var remain = cooldownRemaining(sc.CharacterName || '');
|
||||
el.innerHTML = remain > 0 ? ('换装冷却 ' + remain + ' 秒') : '';
|
||||
}
|
||||
|
||||
function applyScheme() {
|
||||
var page = currentPage();
|
||||
var schemes = (page && page.Schemes) || [];
|
||||
var sc = schemes[S.scheme];
|
||||
if (!sc) { msg('请先选择方案'); return; }
|
||||
if (!sc.CharacterName || !(sc.Slots || []).length) { msg('方案没有角色或装备'); return; }
|
||||
var remain = cooldownRemaining(sc.CharacterName);
|
||||
if (remain > 0) { msg('换装冷却中,剩余 ' + remain + ' 秒'); return; }
|
||||
msg('正在应用方案…');
|
||||
api('/local/dnf-gm/loadout/apply', 'POST', { character_id: sc.CharacterName, slots: sc.Slots }, function (d) {
|
||||
if (!d) { msg('应用失败'); return; }
|
||||
var r = d.data || {};
|
||||
var applied = (r.applied || []).length;
|
||||
var skipped = (r.skipped || []).length;
|
||||
var sec = parseInt(r.cooldown_seconds, 10) || 0;
|
||||
if (sec > 0) {
|
||||
S.cooldown[sc.CharacterName] = Date.now() + sec * 1000;
|
||||
}
|
||||
msg('应用完成:成功 ' + applied + ' 件,跳过 ' + skipped + ' 件' +
|
||||
(sec > 0 ? (',冷却 ' + sec + ' 秒') : ''));
|
||||
log('换装 ' + sc.Name + ':成功 ' + applied + ',跳过 ' + skipped, 'ok');
|
||||
tickCooldown();
|
||||
});
|
||||
}
|
||||
|
||||
function addPage() {
|
||||
var name = window.prompt('新换装页名称', '新页面');
|
||||
if (!name || !S.cfg) { return; }
|
||||
S.cfg.Pages.push({ Name: String(name), Schemes: [] });
|
||||
S.page = S.cfg.Pages.length - 1;
|
||||
S.scheme = -1;
|
||||
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
|
||||
}
|
||||
|
||||
function renamePage() {
|
||||
var page = currentPage();
|
||||
if (!page || !S.cfg) { return; }
|
||||
var name = window.prompt('重命名换装页', page.Name || '');
|
||||
if (!name) { return; }
|
||||
page.Name = String(name);
|
||||
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
|
||||
}
|
||||
|
||||
function deletePage() {
|
||||
if (!S.cfg || S.cfg.Pages.length <= 1) { msg('至少保留一个换装页'); return; }
|
||||
var page = currentPage();
|
||||
if (!page) { return; }
|
||||
if (!window.confirm('删除换装页「' + page.Name + '」及其方案?')) { return; }
|
||||
S.cfg.Pages.splice(S.page, 1);
|
||||
S.page = Math.max(0, S.page - 1);
|
||||
S.scheme = -1;
|
||||
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
|
||||
}
|
||||
|
||||
function bind() {
|
||||
var sel = $('lo-page');
|
||||
if (sel) {
|
||||
sel.onchange = function () {
|
||||
S.page = parseInt(this.value, 10) || 0;
|
||||
S.scheme = -1;
|
||||
if (S.cfg) { S.cfg.ActivePage = S.page; }
|
||||
renderSchemes();
|
||||
loadHotkeys();
|
||||
// The launcher hotkey worker reads ActivePage from config.json. Persist
|
||||
// the page switch here, otherwise a hotkey assigned on this page never
|
||||
// becomes the active page's hotkey.
|
||||
saveConfig(function (ok) {
|
||||
if (ok) { loadHotkeys(); }
|
||||
});
|
||||
};
|
||||
}
|
||||
var ch = $('lo-char');
|
||||
if (ch) { ch.onchange = function () { S.charName = this.value; }; }
|
||||
var b;
|
||||
if ((b = $('lo-refresh'))) { b.onclick = function () { loadChars(); }; }
|
||||
if ((b = $('lo-save'))) { b.onclick = saveCurrent; }
|
||||
if ((b = $('lo-apply'))) { b.onclick = applyScheme; }
|
||||
if ((b = $('lo-page-add'))) { b.onclick = addPage; }
|
||||
if ((b = $('lo-page-ren'))) { b.onclick = renamePage; }
|
||||
if ((b = $('lo-page-del'))) { b.onclick = deletePage; }
|
||||
if ((b = $('lo-scheme-add'))) { b.onclick = addScheme; }
|
||||
if ((b = $('lo-scheme-ren'))) { b.onclick = renameScheme; }
|
||||
if ((b = $('lo-scheme-del'))) { b.onclick = deleteScheme; }
|
||||
if ((b = $('lo-hotkey'))) { b.onclick = setHotkey; }
|
||||
if ((b = $('lo-hotkey-clear'))) { b.onclick = clearHotkey; }
|
||||
}
|
||||
|
||||
function loadTab() {
|
||||
msg('正在读取换装配置…');
|
||||
loadConfig(function (ok) {
|
||||
if (!ok) { return; }
|
||||
loadChars();
|
||||
if (!S.timer) { S.timer = setInterval(tickCooldown, 500); }
|
||||
});
|
||||
}
|
||||
|
||||
window.LoadoutTab = loadTab;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bind);
|
||||
} else {
|
||||
bind();
|
||||
}
|
||||
|
||||
// 通过 #loadout 直接打开时,app.js 可能已经在 loadout.js 之前切过页
|
||||
if ((location.hash || '').replace('#', '') === 'loadout') {
|
||||
setTimeout(loadTab, 0);
|
||||
}
|
||||
})();
|
||||
+145
-3
@@ -115,6 +115,125 @@ def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]:
|
||||
return 502, msg, "application/json; charset=utf-8"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 换装配置
|
||||
|
||||
def _loadout_config_path() -> Path:
|
||||
"""LoadoutTool/config.json,优先部署根目录,其次运行目录。"""
|
||||
roots = [HERE.parent]
|
||||
env = os.environ.get("DNF90_SERVER_ROOT")
|
||||
if env:
|
||||
roots.insert(0, Path(env))
|
||||
for root in roots:
|
||||
p = root / "LoadoutTool" / "config.json"
|
||||
if p.is_file():
|
||||
return p
|
||||
return roots[0] / "LoadoutTool" / "config.json"
|
||||
|
||||
|
||||
def _default_loadout_config() -> dict:
|
||||
return {
|
||||
"ServerUrl": "http://127.0.0.1:18111",
|
||||
"AdminToken": "",
|
||||
"Pages": [{"Name": "默认页", "Schemes": []}],
|
||||
"ActivePage": 0,
|
||||
}
|
||||
|
||||
|
||||
def _read_loadout_config() -> dict:
|
||||
p = _loadout_config_path()
|
||||
try:
|
||||
if p.is_file():
|
||||
data = json.loads(p.read_text(encoding="utf-8-sig"))
|
||||
if isinstance(data, dict):
|
||||
if not isinstance(data.get("Pages"), list) or not data["Pages"]:
|
||||
data["Pages"] = [{"Name": "默认页", "Schemes": []}]
|
||||
data["ActivePage"] = max(0, min(int(data.get("ActivePage") or 0), len(data["Pages"]) - 1))
|
||||
# AdminToken 只留在本地文件,网页端永远拿不到。
|
||||
data["AdminToken"] = ""
|
||||
return data
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
return _default_loadout_config()
|
||||
|
||||
|
||||
def _write_loadout_config(data: dict) -> tuple[int, bytes, str]:
|
||||
if not isinstance(data, dict):
|
||||
return 400, b'{"ok":false,"error":"config must be an object"}', "application/json"
|
||||
pages = data.get("Pages")
|
||||
if not isinstance(pages, list) or not pages:
|
||||
return 400, b'{"ok":false,"error":"Pages must be a non-empty list"}', "application/json"
|
||||
p = _loadout_config_path()
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 网页提交的配置里没有 token;保留磁盘上原来的 token。
|
||||
existing = {}
|
||||
if p.is_file():
|
||||
try:
|
||||
existing = json.loads(p.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, ValueError):
|
||||
existing = {}
|
||||
data = dict(data)
|
||||
data["AdminToken"] = existing.get("AdminToken", "") if isinstance(existing, dict) else ""
|
||||
tmp = p.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(p)
|
||||
except OSError as exc:
|
||||
return 500, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False).encode(), "application/json"
|
||||
return 200, b'{"ok":true}', "application/json"
|
||||
|
||||
|
||||
def _loadout_hotkeys() -> dict:
|
||||
"""Return the active page's configured global hotkeys for the UI hint."""
|
||||
cfg = _read_loadout_config()
|
||||
pages = cfg.get("Pages") or []
|
||||
try:
|
||||
page = pages[int(cfg.get("ActivePage") or 0)]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
page = pages[0] if pages else {}
|
||||
out = []
|
||||
for scheme in (page.get("Schemes") or []):
|
||||
keycode = int(scheme.get("KeyCode") or 0)
|
||||
if keycode <= 0:
|
||||
continue
|
||||
out.append({
|
||||
"name": scheme.get("Name") or "",
|
||||
"character": scheme.get("CharacterName") or "",
|
||||
"keycode": keycode,
|
||||
"modifiers": int(scheme.get("Modifiers") or 0),
|
||||
})
|
||||
return {"page": page.get("Name") or "", "schemes": out}
|
||||
|
||||
|
||||
def _loadout_characters() -> tuple[int, bytes, str]:
|
||||
"""Character list for the loadout page.
|
||||
|
||||
The admin endpoint without a selector is bound to the logged-in account and
|
||||
can be empty when the game client is still at the login screen. The
|
||||
launcher's local DB API already exposes the full local character list, so
|
||||
reuse it here and keep the loadout page usable before entering the game.
|
||||
"""
|
||||
try:
|
||||
req = request.Request("http://127.0.0.1:19527/api/characters",
|
||||
headers={"Accept": "application/json"})
|
||||
with OPENER.open(req, timeout=8) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
chars = data.get("data") if isinstance(data, dict) else None
|
||||
out = []
|
||||
for c in chars or []:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
out.append({
|
||||
"character_id": str(c.get("character_id") or ""),
|
||||
"name": c.get("name") or "",
|
||||
"level": c.get("level") or 0,
|
||||
"online": bool(c.get("online")),
|
||||
})
|
||||
return 200, json.dumps({"ok": True, "data": out}, ensure_ascii=False).encode(), "application/json; charset=utf-8"
|
||||
except (OSError, ValueError) as exc:
|
||||
return 502, json.dumps({"ok": False, "error": "读取角色失败:%s" % exc},
|
||||
ensure_ascii=False).encode(), "application/json; charset=utf-8"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- HTTP
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -146,18 +265,41 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
|
||||
|
||||
def do_GET(self) -> None:
|
||||
route = self.path.split("?", 1)[0]
|
||||
if route == "/api/loadout/config":
|
||||
payload = json.dumps({"ok": True, "data": _read_loadout_config()}, ensure_ascii=False).encode()
|
||||
self._send(200, payload, "application/json; charset=utf-8")
|
||||
return
|
||||
if route == "/api/loadout/characters":
|
||||
code, payload, ctype = _loadout_characters()
|
||||
self._send(code, payload, ctype)
|
||||
return
|
||||
if route == "/api/loadout/hotkeys":
|
||||
payload = json.dumps({"ok": True, "data": _loadout_hotkeys()}, ensure_ascii=False).encode()
|
||||
self._send(200, payload, "application/json; charset=utf-8")
|
||||
return
|
||||
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])
|
||||
self._static(route)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
route = self.path.split("?", 1)[0]
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b"{}"
|
||||
if route == "/api/loadout/config":
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
except ValueError:
|
||||
self._send(400, b'{"ok":false,"error":"invalid json"}', "application/json")
|
||||
return
|
||||
code, payload, ctype = _write_loadout_config(data)
|
||||
self._send(code, payload, ctype)
|
||||
return
|
||||
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)
|
||||
|
||||
|
||||
@@ -112,6 +112,19 @@ select.inp { padding: 3px 2px; }
|
||||
#log .tip { color: #e8c979; }
|
||||
.hint { color: #666e80; font-size: 11px; margin-top: 6px; }
|
||||
|
||||
/* 一键换装:IE11 兼容,float 两栏 */
|
||||
.lo-wrap { margin-top: 4px; overflow: hidden; }
|
||||
.lo-col { float: left; box-sizing: border-box; }
|
||||
.lo-left { width: 42%; padding-right: 10px; }
|
||||
.lo-right { width: 58%; border-left: 1px solid #2b3040; padding-left: 10px; min-height: 260px; }
|
||||
.lo-slots { margin-top: 6px; border: 1px solid #2b3040; background: #101319; max-height: 166px; overflow: auto; }
|
||||
.lo-slot { padding: 3px 8px; border-bottom: 1px solid #1c212b; font-size: 11px; color: #9aa2b4; }
|
||||
.lo-slot b { color: #dfe3ea; font-weight: normal; }
|
||||
.lo-slot .id { color: #e8c979; margin-left: 8px; }
|
||||
.lo-scheme-meta { color: #7c8496; font-size: 11px; margin-left: 6px; }
|
||||
.lo-empty { padding: 10px; color: #666e80; text-align: center; }
|
||||
#lo-cooldown { margin-left: 8px; }
|
||||
|
||||
/* 背包页:刚发进去那一行的「新」标记 */
|
||||
.li .new {
|
||||
font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;
|
||||
|
||||
+10
-19
@@ -134,9 +134,6 @@ PVF_PATH = os.path.join(CLIENT_DIR, "Script.pvf")
|
||||
|
||||
# 一键换装工具(独立工具,放在服务端根目录的 LoadoutTool 文件夹下)
|
||||
LOADOUT_DIR = os.path.join(SERVER_ROOT, "LoadoutTool")
|
||||
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "换装工具.exe")
|
||||
if not os.path.exists(LOADOUT_EXE):
|
||||
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe")
|
||||
|
||||
# 一键换装的持久化配置路径
|
||||
LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.json")
|
||||
@@ -767,25 +764,19 @@ def flow_stop_server():
|
||||
|
||||
|
||||
def flow_launch_loadout():
|
||||
"""一键换装:启动 DNFLoadout.exe。"""
|
||||
log("== 一键换装 ==")
|
||||
if not os.path.exists(LOADOUT_EXE):
|
||||
log("[错误] 找不到换装工具:%s" % LOADOUT_EXE)
|
||||
return
|
||||
"""One-click loadout: open the embedded gmweb loadout page."""
|
||||
log("== One-click loadout ==")
|
||||
if not server_running():
|
||||
log("[提示] 服务端未运行,换装工具可能无法连接。建议先「开始游戏」。")
|
||||
log("正在启动换装工具…")
|
||||
log("[hint] server is not running; loadout may not read characters/equipment")
|
||||
url = "http://127.0.0.1:%d/#loadout" % GM_WEB_PORT
|
||||
log("opening loadout page: %s" % url)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[LOADOUT_EXE], cwd=LOADOUT_DIR,
|
||||
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
|
||||
close_fds=True)
|
||||
log("换装工具已启动。")
|
||||
webbrowser.open(url)
|
||||
log("loadout page opened; the in-game GM window has the same tab")
|
||||
except Exception as e:
|
||||
log("[错误] 启动失败:%s" % repr(e))
|
||||
log("[error] open failed: %s" % repr(e))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 数据库 / Native 客户端初始化
|
||||
# ------------------------------------------------------------------ Database / Native client initialization
|
||||
def load_instance(path):
|
||||
if not path or not os.path.exists(path):
|
||||
return {}
|
||||
@@ -1074,7 +1065,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"client_dir_exists": os.path.isdir(client_dir),
|
||||
"control_exists": os.path.exists(CONTROL),
|
||||
"sponsor_exists": os.path.exists(SPONSOR_IMG),
|
||||
"loadout_exists": os.path.exists(LOADOUT_EXE),
|
||||
"loadout_exists": True,
|
||||
"db": DB_PATH,
|
||||
"db_exists": bool(DB_PATH) and os.path.exists(DB_PATH),
|
||||
"native_available": NATIVE is not None,
|
||||
|
||||
@@ -553,6 +553,17 @@ func (s *Service) sendCurrentSpendTimeProtocolState(
|
||||
if err := s.sendCurrentRaidWeeklySchedules(session, "spend_time_after_op108"); err != nil {
|
||||
return descriptorSent, err
|
||||
}
|
||||
// The catalog-available branch used to stop after op1206. The
|
||||
// channel-select window then never received op1303, so its account
|
||||
// daily and account weekly labels stayed hidden. Send the same
|
||||
// three-row table as the catalog-unavailable branch, after the
|
||||
// client-required weekly-then-daily order.
|
||||
if err := s.sendCurrentRaidDailyAvailability(
|
||||
session,
|
||||
"spend_time_after_op108_raid_daily_availability",
|
||||
); err != nil {
|
||||
return descriptorSent, err
|
||||
}
|
||||
// 骑士马战(joust)活动已取消:登录快照不再推送 op1240 活动状态与
|
||||
// op1241 骑手名单;客户端因此不会把 owner 609 的竞猜界面置为可用。
|
||||
// 结算/历史实现保留为死代码,便于日后按需恢复。
|
||||
|
||||
Reference in new issue
Block a user