Compare commits

7 Commits
Author SHA1 Message Date
90CN b64de0f2a4 修复启动器退出时 _MEI 临时目录清理弹窗 2026-11-05 23:33:52 +08:00
90CN 1e936bfb84 一键换装内嵌 gmweb:取消独立 exe 启动并支持页面热键 2026-11-05 21:08:20 +08:00
90CN ab778e9e4c 修复团本频道不显示次数:活动目录可用时补发 op1303 2026-11-05 20:46:50 +08:00
hanfuit b88e1a223b docs:新增远程发版脚本,Release 可用一条命令发布
Gitea 的 Release 全流程走 REST API(实测可行),不用点网页:

- docs/release/publish_release.py(纯标准库,无依赖):校验 tag → 建/更新
  Release → 上传附件 → 回读自检;支持 --dry-run / --draft / --prerelease /
  --expect-commit / --no-asset / --insecure
- 默认先比对本地 tag 与远端 tag 指向的提交,不一致直接拒绝 —— 2026.11.05
  就是 tag 落后 12 个提交,源码包是旧代码且外表看不出来
- .url 里仍是 REPLACE_WITH_YOUR_LINK 时拒绝发版(--force 可跳过)
- 附件同名先删再传,脚本可重复执行(幂等)
- docs/release/README.md 改为「一条命令发版 + 手工兜底」两套流程,附实测记录
- 根 README 发布流程、AGENTS.md 发版清单挂上脚本用法

实测:临时 tag 走完整流程 创建 201 → 上传 201 → 下载 200(内容正确)→
重复执行后只有一个同名附件 → 删除干净(release 204 / tag 已清)。
注意 Gitea 附件字段名必须是 attachment,用 file 会 500 "http: no such file"。
2026-11-05 19:44:14 +08:00
hanfuit 0897761c47 docs:补充 18112 进程判读与 serve.py 需重启的规则
起因:同步 gmweb 时看到 18112 被占,先判断成「启动器在跑」、又改口成「残留 pythonw
serve.py」,两次都错 —— 实际是多调用模式下的 gmweb 子进程,进程名就叫 DNF90启动器.exe。

- 排查文档新增小节「18112 上跑的可能是启动器本身」:给出命令行三态判别表
  (带 serve.py 参数 = gmweb 服务 / 只有 exe 路径且占 19527 = 主进程 /
  父 pid 不存在 = 遗留服务),并附 Get-CimInstance 取证命令
- 明确推论:该进程跑的是启动那一刻的 serve.py;静态文件读盘即生效,
  但路由不会,症状是「页面新版、接口旧版,按钮点了没反应」
- AGENTS.md 8.3 同步补上「改 serve.py 后必须让 18112 进程重启」的部署动作
2026-11-05 19:38:32 +08:00
hanfuit 90877233fd docs:AGENTS.md 补协作约定(走分支、推前先 rebase、推完要同步)
起因:别人直接推 main(cc54603),本地未同步就分叉;同时 tag 与发布包错位。
把这些变成明文规则,避免下一个人或下一个 AI 重犯。

- 8.1 推送前先 rebase(强制):git status -sb → git pull --rebase → push 固定顺序,
  禁止 push 被拒后用 reset --hard,附冲突处理与真实案例
- 8.2 多人/多 AI 协作走分支:跨文件、双人同改 gmweb/ 必须走分支 + 合并请求;
  单人确定改动与纯文档可直推,但仍受 8.1 约束
- 8.3 推完不等于生效:同步 Z:/90server 与桌面补丁包 → diff -rq → 重打包 → 通知重启
- 发版清单新增:打 tag 前用 git log -1 与 ls-remote 的 ^{} 双向确认指向实际发布的提交
- 当前状态记录 tag 基线:v2026.11.05 → 3b240ec(annotated),后续提交不在该 tag 内
- 不要做的事补三条:未 pull 就 push、reset --hard 覆盖、tag 错位发 Release
2026-11-05 19:34:32 +08:00
hanfuit cc546031b0 gmweb:游戏内一键换装、方案配置与全局热键说明 2026-11-05 18:19:24 +08:00
12 changed files with 1206 additions and 25 deletions

No files matched your search

+56 -1
View File
@@ -201,12 +201,20 @@ msedge --headless=new --disable-gpu --hide-scrollbars --window-size=800,720 \
- [ ] 版本号三处一致(`CURRENT_VERSION` / 客户端显示 / 更新日志) - [ ] 版本号三处一致(`CURRENT_VERSION` / 客户端显示 / 更新日志)
- [ ] **干净目录**(非中文、无空格)实跑一遍再对外发 - [ ] **干净目录**(非中文、无空格)实跑一遍再对外发
- [ ] Release 说明用模板;附件传 `docs/release/网盘下载.url`(别直传十几 G 的包) - [ ] 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) ## 7. 当前状态(2026-11-05)
- 版本 `v2026.11.05`;`main` 与 `origin/main` 同步 - 版本 `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工具/一键换装/游戏设置/游戏更新[当前|历史]/关于我们),关于页三列二维码 - 启动器:六页(开始/GM工具/一键换装/游戏设置/游戏更新[当前|历史]/关于我们),关于页三列二维码
- gmweb:四页,关于页三列(赞助我们 353 | 官方QQ群 178 | 团队公众号 179,面板宽 712) - gmweb:四页,关于页三列(赞助我们 353 | 官方QQ群 178 | 团队公众号 179,面板宽 712)
- 已修复:启动器打包后自我套娃开窗(P-001),带单实例互斥 + 运行日志 + WebView2 固定数据目录 - 已修复:启动器打包后自我套娃开窗(P-001),带单实例互斥 + 运行日志 + WebView2 固定数据目录
@@ -220,7 +228,51 @@ msedge --headless=new --disable-gpu --hide-scrollbars --window-size=800,720 \
2. 一次提交一件事;改了接口就同步改对应文档(服务端接口 → `docs/服务端开发文档.md`) 2. 一次提交一件事;改了接口就同步改对应文档(服务端接口 → `docs/服务端开发文档.md`)
3. 只提交源码与文档;产物走 `.gitignore` 3. 只提交源码与文档;产物走 `.gitignore`
4. 新踩的坑,追加到 `docs/踩坑记录.md`(编号延续 P-0xx,格式照抄现有条目) 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 资源 - ❌ 不要提交 exe/dll/`dnf_items.json`/PVF 资源
- ❌ 不要把本机测试用的掉落改动带进对外发布的服务端 - ❌ 不要把本机测试用的掉落改动带进对外发布的服务端
- ❌ 不要公开分发或倒卖本仓库代码与资源(`pvf/` 除外,但受 GPL-3.0 约束) - ❌ 不要公开分发或倒卖本仓库代码与资源(`pvf/` 除外,但受 GPL-3.0 约束)
- ❌ 不要在没 `git pull --rebase` 的情况下直接 `push` —— 见 8.1
- ❌ 不要在 push 被拒后用 `git reset --hard` 或强推 `--force` 覆盖别人的提交 —— 先 `pull --rebase`
- ❌ 不要在 tag 指向错误提交的情况下发 Release —— 见第 6 章最后一项
+9
View File
@@ -145,6 +145,15 @@ pvfexport -pvf Script.pvf -out dnf_items.json -compact
6. **网盘地址**:`docs/release/网盘下载.url` 已指向百度网盘真实分享(提取码 `s33r`),作为 Release 附件上传——十几个 G 的包不适合直传,`.url` 是最省事的缓兵之计;重新分享后记得同步改模板表格 6. **网盘地址**:`docs/release/网盘下载.url` 已指向百度网盘真实分享(提取码 `s33r`),作为 Release 附件上传——十几个 G 的包不适合直传,`.url` 是最省事的缓兵之计;重新分享后记得同步改模板表格
7. **验证**:启动器首页版本号、更新日志条数、关于页二维码、GM 页三列是否都正常 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 一定要成对。 > ⚠️ **DLL 指纹绑定**:`90CN.dll` 的 SHA-256 在编译期编进 `DNF90Server.exe`(`boundDllSHA256`)。换 DLL 必须重编服务端,否则客户端连接会被拒绝。发布包里 DLL 与 EXE 一定要成对。
## 九、关键设计说明 ## 九、关键设计说明
+47 -2
View File
@@ -1,13 +1,56 @@
# 发布物料(docs/release) # 发布物料(docs/release)
每次发版要贴到仓库 Release 里的两样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。 每次发版要贴到仓库 Release 里的几样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。
| 文件 | 用途 | | 文件 | 用途 |
|------|------| |------|------|
| `publish_release.py` | **远程发版脚本**:校验 tag → 建/更新 Release → 上传附件(见下) |
| `发布说明模板.md` | Release 正文模板:本次更新、下载表格、安装步骤、说明 | | `发布说明模板.md` | Release 正文模板:本次更新、下载表格、安装步骤、说明 |
| `网盘下载.url` | Windows 网络快捷方式,双击直接打开网盘页面;作为附件上传 | | `网盘下载.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 描述,按本次改动填「本次更新」 1. 复制 `发布说明模板.md` 的内容到 Release 描述,按本次改动填「本次更新」
2. 核对下载链接:模板里的下载表格已填当前百度网盘地址与提取码,换盘或重新分享时同步替换 2. 核对下载链接:模板里的下载表格已填当前百度网盘地址与提取码,换盘或重新分享时同步替换
@@ -23,9 +66,11 @@
4. 附件区上传 `网盘下载.url`(注意:不是把整个网盘文件传上来) 4. 附件区上传 `网盘下载.url`(注意:不是把整个网盘文件传上来)
5. 检查版本号与更新日志:`launcher/server.py` 的 `CURRENT_VERSION`、发布包根目录的 `更新日志.txt` 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 - 仓库里的 `网盘下载.url` 现在指向**真实分享**(百度网盘,提取码 `s33r`);重新分享会生成新链接,记得同步改 `.url` 与模板表格,避免玩家点开 404
- 传完自己双击验证一次,确认能打开网盘页面而不是 404 - 传完自己双击验证一次,确认能打开网盘页面而不是 404
- token 只走环境变量 `GITEA_TOKEN` 或 `--token`,**不要**提交到仓库里的任何文件
- 发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须配套(DLL 指纹绑定),缺一个客户端就连不上 - 发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须配套(DLL 指纹绑定),缺一个客户端就连不上
+302
View File
@@ -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()
+29
View File
@@ -121,6 +121,35 @@ DNF90启动器.exe probe.py # 旧版:hit.txt 不生成,却开出一个
| `警告:注册表里没查到 WebView2 运行时` | 机器缺 Edge WebView2 Runtime,装一下即可(窗口会是空白) | | `警告:注册表里没查到 WebView2 运行时` | 机器缺 Edge WebView2 Runtime,装一下即可(窗口会是空白) |
| 完全没有日志文件 | exe 所在目录不可写(如 Program Files),换目录或给写权限 | | 完全没有日志文件 | 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 同时失效,表现就是「能开窗口但空白、也没有日志」。 - **exe 名字带中文**没问题,日志和 WebView2 目录都能正常处理;但 exe 放在**只读目录**会让日志与 WebView2 同时失效,表现就是「能开窗口但空白、也没有日志」。
+3
View File
@@ -67,6 +67,9 @@
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : ''); panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
} }
try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { } try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { }
if (name === 'loadout' && typeof window.LoadoutTab === 'function') {
window.LoadoutTab();
}
} }
function bindTabs() { function bindTabs() {
+49
View File
@@ -11,6 +11,7 @@
<div id="tabs"> <div id="tabs">
<a class="tab on" data-tab="home" href="javascript:void(0)">状态</a> <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="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="item" href="javascript:void(0)">发物品</a>
<a class="tab" data-tab="about" href="javascript:void(0)">关于</a> <a class="tab" data-tab="about" href="javascript:void(0)">关于</a>
</div> </div>
@@ -129,6 +130,53 @@
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div> <div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
</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="pane" id="pane-about">
<div class="about-inner"> <div class="about-inner">
<div class="about-card"> <div class="about-card">
@@ -193,5 +241,6 @@
<div id="log"></div> <div id="log"></div>
<script src="/app.js?v=20261105C"></script> <script src="/app.js?v=20261105C"></script>
<script src="/loadout.js?v=20261105A"></script>
</body> </body>
</html> </html>
+519
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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
View File
@@ -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" 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 # --------------------------------------------------------------------- HTTP
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -146,18 +265,41 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream")) self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
def do_GET(self) -> None: 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/"): if self.path.startswith("/api/"):
code, data, ctype = proxy("GET", self.path[4:], None) code, data, ctype = proxy("GET", self.path[4:], None)
self._send(code, data, ctype) self._send(code, data, ctype)
return return
self._static(self.path.split("?")[0]) self._static(route)
def do_POST(self) -> None: 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/"): if not self.path.startswith("/api/"):
self._send(404, b"not found", "text/plain") self._send(404, b"not found", "text/plain")
return 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) code, data, ctype = proxy("POST", self.path[4:], body)
self._send(code, data, ctype) self._send(code, data, ctype)
+13
View File
@@ -112,6 +112,19 @@ select.inp { padding: 3px 2px; }
#log .tip { color: #e8c979; } #log .tip { color: #e8c979; }
.hint { color: #666e80; font-size: 11px; margin-top: 6px; } .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 { .li .new {
font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px; font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;
+23 -19
View File
@@ -134,9 +134,6 @@ PVF_PATH = os.path.join(CLIENT_DIR, "Script.pvf")
# 一键换装工具(独立工具,放在服务端根目录的 LoadoutTool 文件夹下) # 一键换装工具(独立工具,放在服务端根目录的 LoadoutTool 文件夹下)
LOADOUT_DIR = os.path.join(SERVER_ROOT, "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") LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.json")
@@ -384,6 +381,19 @@ def start_gmweb():
_logf("gmweb:%d 已在监听,跳过启动" % GM_WEB_PORT) _logf("gmweb:%d 已在监听,跳过启动" % GM_WEB_PORT)
return return
if getattr(sys, "frozen", False):
# A frozen onefile launcher must not spawn itself for gmweb: the
# child keeps the parent _MEI directory open after the window closes,
# which makes PyInstaller report "Failed to remove temporary directory".
_logf("gmweb: frozen mode uses in-process server")
start_gmweb_inproc(script)
for _ in range(30):
if port_open(GM_WEB_PORT):
_logf("gmweb: http://127.0.0.1:%d/ ready" % GM_WEB_PORT)
return
time.sleep(0.2)
return
try: try:
kwargs = {"cwd": os.path.dirname(script), kwargs = {"cwd": os.path.dirname(script),
"stdout": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
@@ -767,25 +777,19 @@ def flow_stop_server():
def flow_launch_loadout(): def flow_launch_loadout():
"""一键换装:启动 DNFLoadout.exe。""" """One-click loadout: open the embedded gmweb loadout page."""
log("== 一键换装 ==") log("== One-click loadout ==")
if not os.path.exists(LOADOUT_EXE):
log("[错误] 找不到换装工具:%s" % LOADOUT_EXE)
return
if not server_running(): if not server_running():
log("[提示] 服务端未运行,换装工具可能无法连接。建议先「开始游戏」。") log("[hint] server is not running; loadout may not read characters/equipment")
log("正在启动换装工具…") url = "http://127.0.0.1:%d/#loadout" % GM_WEB_PORT
log("opening loadout page: %s" % url)
try: try:
subprocess.Popen( webbrowser.open(url)
[LOADOUT_EXE], cwd=LOADOUT_DIR, log("loadout page opened; the in-game GM window has the same tab")
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
close_fds=True)
log("换装工具已启动。")
except Exception as e: except Exception as e:
log("[错误] 启动失败:%s" % repr(e)) log("[error] open failed: %s" % repr(e))
# ------------------------------------------------------------------ Database / Native client initialization
# ------------------------------------------------------------------ 数据库 / Native 客户端初始化
def load_instance(path): def load_instance(path):
if not path or not os.path.exists(path): if not path or not os.path.exists(path):
return {} return {}
@@ -1074,7 +1078,7 @@ class Handler(BaseHTTPRequestHandler):
"client_dir_exists": os.path.isdir(client_dir), "client_dir_exists": os.path.isdir(client_dir),
"control_exists": os.path.exists(CONTROL), "control_exists": os.path.exists(CONTROL),
"sponsor_exists": os.path.exists(SPONSOR_IMG), "sponsor_exists": os.path.exists(SPONSOR_IMG),
"loadout_exists": os.path.exists(LOADOUT_EXE), "loadout_exists": True,
"db": DB_PATH, "db": DB_PATH,
"db_exists": bool(DB_PATH) and os.path.exists(DB_PATH), "db_exists": bool(DB_PATH) and os.path.exists(DB_PATH),
"native_available": NATIVE is not None, "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 { if err := s.sendCurrentRaidWeeklySchedules(session, "spend_time_after_op108"); err != nil {
return descriptorSent, err 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 活动状态与 // 骑士马战(joust)活动已取消:登录快照不再推送 op1240 活动状态与
// op1241 骑手名单;客户端因此不会把 owner 609 的竞猜界面置为可用。 // op1241 骑手名单;客户端因此不会把 owner 609 的竞猜界面置为可用。
// 结算/历史实现保留为死代码,便于日后按需恢复。 // 结算/历史实现保留为死代码,便于日后按需恢复。