Windows 便携包:只收 git 跟踪的文件、生产依赖、钉死的 Node 版本与许可证 (#30)

This commit is contained in:
Lu Ke authored and GitHub committed 2026-10-04 14:20:11 +08:00
1 parent 502f183e43
commit 50498bbe07
5 files changed
+956

No files matched your search

+1
View File
@@ -167,6 +167,7 @@ npm start # 启动服务器:http://localhost:3000
| [CHANGELOG.md](CHANGELOG.md) | 更新记录:每个版本修复了什么、哪些反馈经核实不是问题 |
| [docs/PLAYING.md](docs/PLAYING.md) | 玩法指南:流程、经济、招募与晋升、摆阵、联防、盟约、最终攻势、结算称号 |
| [docs/DEPLOY.md](docs/DEPLOY.md) | 部署指南:Windows 开服与开机自启、防火墙、组网 / 隧道、反向代理与 HTTPS、Docker、systemd、排错 |
| [docs/WINDOWS.md](docs/WINDOWS.md) | Windows 便携包:怎么打一份「零安装」包(`scripts/make-windows-bundle.mjs`)、包里放了什么、授权注意事项 |
| [docs/DESIGN.md](docs/DESIGN.md) | 架构与契约(英文):技术栈、目录分工、网络协议、渲染与 UI、各次试玩后的规则修订 |
| [docs/SIM.md](docs/SIM.md) | 战斗模拟引擎参考(英文):钩子、技能描述格式、职业默认行为 |
| [docs/META.md](docs/META.md) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 |
+33
View File
@@ -18,6 +18,7 @@ them (with `node_modules/`, which keeps each package's own licence file).
| [htm](https://github.com/developit/htm) | 3.1.1 | Apache-2.0 | UI templates — `public/vendor/htm.module.js` | no (npm) | yes |
| [three.js](https://github.com/mrdoob/three.js) | 0.186.1 | MIT | official 3D board — `public/vendor/three.core.js`, `three.module.js` | no (npm) | yes |
| [ws](https://github.com/websockets/ws) | 8.22.0 | MIT | WebSocket server (`server/`) | no (npm) | yes (`node_modules/`) |
| [Node.js](https://nodejs.org/) | v22.23.3 (pinned in `scripts/make-windows-bundle.mjs`) | MIT | the portable `node\node.exe` (**Windows portable package only** — the integration bundle in [Releases](../../releases/latest) ships no `node.exe`) | no (downloaded from nodejs.org at package time, sha256 verified) | **Windows portable package only** (`node\node.exe`, with `node\LICENSE-node.txt`) |
| [puppeteer-core](https://github.com/puppeteer/puppeteer) | 25.12.0 | Apache-2.0 | optional browser tests (dev dependency) | no (npm) | no |
| [Ark-Unpacker](https://github.com/isHarryh/Ark-Unpacker) LZ4AK decoder | — | BSD-3-Clause | `tools/local-extract/aklz4.py` (optional local extraction) | **yes** — keeps its notice; full text also in `tools/local-extract/LICENSE-Ark-Unpacker.txt` | yes |
| [UnityPy](https://github.com/K0lb3/UnityPy) (via MooncellWiki/UnityPy), [lz4](https://github.com/python-lz4/python-lz4), [Pillow](https://github.com/python-pillow/Pillow) | see `tools/local-extract/requirements.txt` | MIT / BSD-3-Clause / MIT-CMU | optional local extraction; installed by pip into `.venv-extract` only when the host opts in | no | no |
@@ -188,6 +189,38 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
### Node.js — MIT
The Windows portable package (`scripts/make-windows-bundle.mjs`) redistributes the official Node.js binary
(`node\node.exe`). The integration bundle in Releases does **not** contain it — this row applies to the portable
package only. Its complete licence file, taken verbatim from the same official archive, ships next to the binary as
`node\LICENSE-node.txt`; the core MIT grant is reproduced here for convenience.
```text
Copyright Node.js contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
```
The official archive also carries notices for the software bundled inside Node.js (V8, OpenSSL, npm, …); those are
included in the same `LICENSE-node.txt`.
### Ark-Unpacker (tools/local-extract/aklz4.py) — BSD-3-Clause
```text
+123
View File
@@ -0,0 +1,123 @@
# Windows 便携包(零安装方案)
本文说明怎么打一份「目标机器什么都不用装」的 Windows 便携包,包里放了什么、没放什么,以及发出去之前要确认的授权。
相关代码:`scripts/make-windows-bundle.mjs`(打包)、`scripts/launch.mjs`(准备 + 起服务器 + 打开浏览器)。
## 1. 这份包解决什么
便携包只做一件事:**让这台 Windows 电脑当服务器**——包内自带 Node、素材与生产依赖,目标机器什么都不用装,
双击 `启动游戏.bat` 就在本机开服并自动打开浏览器,控制台会打印可以发给朋友的局域网地址
(自己玩、或和同宿舍/同办公室的朋友玩)。
**连别人的服务器不需要这个包**:直接用浏览器打开对方的网址就行,和你在浏览器里输网址是同一件事,
本机不用跑服务、不用下素材。包里因此没有「连接服务器」这个入口。
## 2. 打一份便携包
先准备素材(`public/assets`、`public/fonts`、`public/vendor` 都不进版本库):
```bash
node tools/setup.mjs
```
然后在仓库里(**任意平台**,只要有 Node 22+ 且能上网下载官方 Node 归档)执行:
```bash
node scripts/make-windows-bundle.mjs # 产物默认在 <仓库上一级>/Stronghold-Protocol-Windows
node scripts/make-windows-bundle.mjs --out D:\Game\Stronghold-Protocol-Windows --force
node scripts/make-windows-bundle.mjs --no-node # 目标机器已装 Node 22+ 时不必带便携 Node
node scripts/make-windows-bundle.mjs --node-version v22.23.3 --sha256 <64 位十六进制> # 换 Node 版本
```
`--out` 请给一个**专用目录**(像上面那样指到某个父目录下的子目录)。加 `--force` 时只会删掉两种情况:
目录是空的,或者里面就是上一次打的便携包(包根有 `README-开箱即用.md` 和 `app\`)。
其余情况一律拒绝并提示 —— 免得手滑把 `--out` 指到仓库、桌面或者其它有东西的目录上,`--force` 就把它们删了。
指向仓库本身或仓库的上级目录时更是在第一步就被挡下来。
产出的始终是 **Windows** 包(内含 win-x64 的 `node.exe`),但它**可以在 macOS / Linux 上打**:
官方归档解压出来的布局固定是 `<zip 名>/node.exe`,与打包机器是什么系统无关。
产物内容:
```
node\node.exe 官方 Windows x64 便携版 Node(版本与 sha256 钉在仓库里,只取 node.exe)
node\LICENSE-node.txt Node 自己的许可证(MIT,与 node.exe 出自同一个官方归档)
app\ 游戏本体:代码 + 生产依赖 + public(全部素材)+ data,完全离线
启动游戏.bat app\scripts\launch.mjs --no-setup
README-开箱即用.md 给玩家看的说明(含非官方 / 严禁盈利 / 素材版权声明)
LICENSE / NOTICE.md / THIRD-PARTY-NOTICES.md
```
把整个文件夹拷到目标电脑 —— **什么都不用安装**,双击 `启动游戏.bat` 即可(等于 `app\scripts\launch.mjs --no-setup`:
素材已经在包里,跳过联网准备,直接起服务器并打开浏览器)。
卸载=删除文件夹(不写注册表、不放系统目录)。素材约 330 MB 是硬成本,包因此比较大。
## 3. 包里放了什么、没放什么
`app\` 的文件清单来自 **`git ls-files`**,不是手写的跳过表。因此被 `.gitignore` 挡在版本库外的本机文件
(`.env`、`.venv*`、`.claude/`、`scripts/service.env.cmd` …)**天然进不了包** ——
它们里面可能有密钥或本机路径,旧实现用一张窄表整树复制,漏一项就是把它打进别人下载的压缩包。
在此之上额外放入三份不进版本库、但游戏必需的资源,以及用 `npm ci --omit=dev` **重新装好的生产依赖**:
* `public/assets`、`public/fonts`、`public/vendor`(由 `tools/setup.mjs` 下载 / 生成);
* `node_modules` 只含生产依赖 —— `puppeteer-core` 这类 devDependency 是开发测试用的,打进发行包只会白涨体积;
* `data/local-assets.json`(本机提取过 3D 棋盘贴图时才有):贴图本体在 `public/assets/local`(约 68 MB,会随
`public/assets` 进包),但游戏是靠这份 JSON 才知道有哪些贴图可用 —— 只带贴图不带清单,玩家拿到的是 68 MB
用不上的文件。清单不存在时会提示先跑 `node tools/setup.mjs --local`。
* `test/` 一律不进包(省体积)。
整树复制时会跳过符号链接与**点开头的条目**:打包机器的 `.DS_Store` 既不属于项目,也不该出现在别人下载的包里。
Node 版本与 sha256 **钉死在 `scripts/make-windows-bundle.mjs` 的 `NODE_PIN`** 里(不用 `latest-v22.x`):
那样今天打包和上个月打包内容不同,出了问题也无法复现。换版本必须显式给 `--node-version`(要写成 `vX.Y.Z` 这样的
具体版本)+ `--sha256`。
官方 zip 的下载缓存放在仓库的 **`.cache/`**(不进版本库),而 **`node.exe` 每次都从校验过的 zip 重新解压**:
解压目标是新建的临时目录、用完即删。缓存的是 zip,不是解压结果 —— 解压中断留下的残缺 `node.exe` 与校验过的
zip 没有任何对应关系,复用就等于把坏文件发出去;系统临时目录在 Linux 上还是共享的。
解压器按平台兜底:`tar`(Windows 10+ 与 macOS 自带的 bsdtar 都认得 zip)→ `unzip`(GNU tar 解不了 zip,
Linux 上是这一档)→ Windows 自带的 `Expand-Archive`。
关于 `--out` 与 `--force`:`--out` 指向**仓库本身或它的上级目录**时直接拒绝;即使指到别处,`--force` 也只会在
「目录是空的」或「里面就是上一次打的便携包(包根有 `README-开箱即用.md` 和 `app\`)」时才删除,
其它情况一律拒绝并不动那个目录 —— 路径比较挡不住大小写、符号链接、8.3 短名这些花样,所以规则是反着写的:
只有能确认「这就是上次的产物」才动手。
## 4. 授权(发出去之前请确认你同意)
便携包把整个游戏原样带给别人,所以包根会带上本项目的声明文件,包内 `README-开箱即用.md` 也完整重复了一遍:
> [!IMPORTANT]
> - 本项目是玩家自制的**非官方同人作品**,与上海鹰角网络科技有限公司(Hypergryph)、Yostar 及其关联方**没有任何关系**,未获其授权或认可。
> - 《明日方舟》及「卫戍协议」相关的名称、角色、美术、音乐、音效、文本与数据等素材,版权归原权利人所有。这些素材**不适用**本项目的 GPL-3.0 许可证;GPL 只覆盖本项目自己编写的代码。
> - 仅供学习交流与个人非商业使用。**严禁任何形式的盈利**,包括但不限于:售卖本项目或整合包、付费下载或付费分发、收费服务器或收费代开、广告 / 打赏 / 会员等变现方式,以及其他任何商业用途。
> - 本包为了方便玩家附带了游戏的美术与音频素材,下载即视为同意本声明。请勿将素材用于本项目以外的用途或**单独再分发**。完整条款见 [NOTICE.md](../NOTICE.md)。
> - 权利人如认为本项目侵犯其权益,请通过 Issue 联系,我们会**立即删除**相关内容。
> - 本项目按「现状」提供,**不提供任何担保**,使用风险自负。
内置 Node.js(MIT)与其它第三方组件的许可见 [THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md) 与
`node\LICENSE-node.txt`(`node.exe` 自己的许可证,从同一个官方归档里取出来一起发)。
## 5. 常见问题
| 症状 | 处理 |
|---|---|
| 双击 `.bat` 一闪而过 | 在命令行里跑 `启动游戏.bat` 看报错,或手动运行 `node\node.exe app\scripts\launch.mjs --no-setup`(`.bat` 会在非 0 退出时暂停)。**`--no-setup` 别省**:省掉它会去联网准备素材,而且 PATH 上不一定有 `node` |
| 提示找不到 node | 便携包应含 `node\node.exe`;没有就用 `--no-node` 的包,并自行安装 Node 22/24 LTS |
| 端口被占用 | 关掉占用 3000 的程序(`node tools/doctor.mjs` 会指出是谁),或在启动命令后加 `--port 3001` |
| 浏览器没自动打开 | 手动访问 `http://127.0.0.1:<端口>`;`--no-open` / `SP_NO_BROWSER=1` 会禁用自动打开 |
| 朋友连不上 | 防火墙专用网络未放行、或不在同一网段;用 `node tools/doctor.mjs` 诊断(源仓库里见 [DEPLOY.md](DEPLOY.md) 的防火墙一节) |
| 进游戏时弹出「下载文件信息」 | 那是 IDM / 迅雷这类下载管理器在嗅探音频地址,不是游戏在下载。新版本已经改走不带扩展名的 `/media/…` 路由(见 [PR #28](https://github.com/sganggs/Stronghold-Protocol/pull/28)),**应该不会再弹**;如果还弹,把弹出的地址贴到 Issue,或在下载器里加例外(如 `http://127.0.0.1:3000/*`)、玩游戏时先退出它 |
| 首次进游戏很慢 | 每位玩家要下载几十 MB 素材(本机开服时是本机读盘),之后走浏览器缓存 |
| 在 Linux 上打包报解压失败 | GNU tar 不认 zip:装一个 `unzip`(或在文档允许的范围内改用 macOS / Windows 打包) |
## 6. 安全与体积说明
* 便携包里的 `node.exe` 来自 nodejs.org 官方发行版,**按仓库里钉死的 sha256 校验**后才复制,未做任何修改;
同时取出同一个归档里的 `LICENSE` 作为 `node\LICENSE-node.txt` 一起发出去。
* `app\` 里只有版本库跟踪的文件 + 生产依赖 + 素材 + 3D 棋盘清单,被 `.gitignore` 排除的本机文件
(`.env`、`.claude/`、`scripts/service.env.cmd` 等)不会进包。
* 游戏不写注册表、不装服务;`启动游戏.bat` 内容只有几行(切到 UTF-8 代码页 → 找 `node\node.exe` →
跑 `app\scripts\launch.mjs --no-setup`)。
* 想做成随开机启动的 Windows 服务,用仓库自带的 `scripts/install-service-windows.ps1`(面向整合包/源码部署,见 [DEPLOY.md](DEPLOY.md))。
+577
View File
@@ -0,0 +1,577 @@
#!/usr/bin/env node
// scripts/make-windows-bundle.mjs — 打一份「开箱即用」的 Windows 便携包(docs/WINDOWS.md)。
//
// node scripts/make-windows-bundle.mjs [--out <dir>] [--no-node] [--force]
//
// 产物目录(默认 <仓库的上一级>\Stronghold-Protocol-Windows):
// node\node.exe 官方 Windows x64 便携版 Node(版本与 sha256 钉在下面的 NODE_PIN)
// node\LICENSE-node.txt Node 自己的许可证(和 node.exe 一起从官方 zip 里取出来)
// app\ 游戏本体:**只收 git 跟踪的文件** + 生产依赖 + 素材,离线可玩
// 启动游戏.bat app\scripts\launch.mjs --no-setup
// README-开箱即用.md 给玩家看的说明(含非官方 / 严禁盈利 / 素材版权声明)
// LICENSE / NOTICE.md / THIRD-PARTY-NOTICES.md
//
// 目标机器什么都不用装:解压 → 双击 启动游戏.bat。素材约 330 MB 是硬成本,包因此较大。
//
// 三条硬规则(都是踩过的坑):
// 1. app\ 的文件清单来自 `git ls-files`,不是手写的跳过表 —— `.env` / `.venv` / `.claude` /
// `scripts/service.env.cmd` 这些本机文件被 .gitignore 挡在版本库外,因此天然进不了发行包。
// 旧实现用一张窄表整树复制,漏一项就是把密钥打进别人下载的压缩包。
// 2. node_modules 用 `npm ci --omit=dev` 重新装一遍,不带 devDependency(puppeteer-core 之类)。
// 3. node.exe **每次都从校验过的官方 zip 重新解压**:下载的 zip 缓存进仓库的 .cache\,解压目录是
// 新建的临时目录、用完即删。旧实现「临时目录里那个 node.exe 还在就直接用」,和校验过的 zip 没有
// 对应关系 —— 上次解压中断留下的残缺文件会被打进包,共享的 /tmp 也不安全。
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const IS_WIN = process.platform === 'win32';
const MB = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
/** 官方 Node zip 的下载缓存(不进版本库)。放仓库下而不是系统临时目录:后者是共享的,中断留下的残片会混进来。 */
const NODE_CACHE_DIR = path.join(ROOT, '.cache');
/**
* 便携版 Node:版本与 sha256 **钉死在仓库里**,运行时只认这个常量。
*
* 不用 `latest-v22.x`:那样今天打的包和上个月打的包内容不同,出了问题也无从复现;而且校验哈希是运行时
* 现去 nodejs.org 抓的,等于把「发出去的二进制是什么」交给当时的网络应答决定。换版本请改这里
* (并在 `--node-version` 的同时用 `--sha256` 显式覆盖)。
*/
const NODE_PIN = Object.freeze({
version: 'v22.23.3',
file: 'node-v22.23.3-win-x64.zip',
sha256: '2b0ff57b049cda1bbcea2240eec20467018713c1efe1f7360c2681859b90ed71',
});
/** 这几份不进版本库(npm / tools/setup.mjs 生成),但必须进包,否则游戏缺素材或缺前端库。 */
const ASSET_DIRS = ['public/assets', 'public/fonts', 'public/vendor'];
/** 版本库里有、但便携包不要的(测试代码,省体积)。 */
const SKIP_TRACKED = ['test/'];
/** 包根要带的许可证 / 声明。 */
const LEGAL_FILES = ['LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md'];
/**
* 本机从游戏文件里提取出来的 3D 棋盘贴图清单(不进版本库)。
*
* `public/assets/local`(约 68 MB)会随 `public/assets` 一起进包,但游戏是**靠这份 JSON** 才知道哪些贴图
* 可用 —— 只带贴图不带清单,玩家拿到的是 68 MB 用不上的文件。它存在时就一起收。
*/
const LOCAL_ASSET_MANIFEST = path.join('data', 'local-assets.json');
function parseArgs(argv) {
const o = { out: '', node: true, force: false, nodeSpec: '', sha256: '' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const [k, v] = a.split('=');
const val = () => (v !== undefined ? v : argv[++i]);
if (k === '--out') o.out = String(val() || '');
else if (k === '--node-version') o.nodeSpec = String(val() || '');
else if (k === '--sha256') o.sha256 = String(val() || '').toLowerCase();
else if (a === '--no-node') o.node = false;
else if (a === '--force') o.force = true;
else if (a === '-h' || a === '--help') o.help = true;
}
return o;
}
const HELP = `node scripts/make-windows-bundle.mjs — 生成 Windows 开箱即用便携包
--out <dir> 产物目录(默认 <仓库上一级>/Stronghold-Protocol-Windows)
--no-node 不下载便携版 Node(目标机器需自备 Node 22+)
--force 目录已存在时先删掉(只肯删空目录,或上一次打的便携包;其余情况拒绝)
--node-version X 换一个 Node 版本(默认 ${NODE_PIN.version});换版本必须同时给 --sha256
--sha256 <hash> 该版本 win-x64.zip 的 sha256(取自官方 SHASUMS256.txt)
app\\ 里只放 git 跟踪的文件 + 生产依赖(npm ci --omit=dev)+ public/{assets,fonts,vendor};
因此 .env / .venv / .claude / scripts/service.env.cmd 这些本机文件不会被打进去。
data/local-assets.json 存在时(本机提取过 3D 棋盘贴图)会一起收,否则贴图进了包也用不上。
不压缩、不改 index.html、不装开始界面:包里的 启动游戏.bat 就是
\`app\\scripts\\launch.mjs --no-setup\`(素材已在包里,不需要联网准备)。
`;
/**
* 路径的规范形式:把符号链接、macOS 的 `/tmp`、Windows 的 8.3 短名与大小写都归一。
*
* 产物目录通常还不存在,`fs.realpathSync.native` 会 ENOENT,所以只对**已存在的最深祖先**做归一,
* 剩下的段原样接回去。Windows / macOS 的文件系统默认不区分大小写,比较前统一转小写 —— 不然
* `--out c:\users\…\stronghold-protocol` 这种写法会绕过检查,而它指向的就是仓库本身。
* @param {string} p
* @returns {string}
*/
function canonical(p) {
let head = path.resolve(p);
const tail = [];
for (;;) {
try {
head = fs.realpathSync.native(head);
break;
} catch {
const parent = path.dirname(head);
if (parent === head) break; // 到根都还不存在:只能按字面量比
tail.unshift(path.basename(head));
head = parent;
}
}
const joined = tail.length ? path.join(head, ...tail) : head;
// 只有 Linux 的默认文件系统区分大小写;darwin 上按不区分处理是偏保守的一侧(宁可拒绝也不误删)。
return process.platform === 'linux' ? joined : joined.toLowerCase();
}
/**
* `--out` 指到仓库本身或它的**上级**目录时,`--force` 会先 `rm -rf` 那个目录 —— 也就是把仓库整个删掉。
* 这种路径直接拒绝(指向仓库内部是允许的,只是产物会出现在 git status 里)。
*
* 注意这只是第一道防线:真正的保险是下面 `forceDeleteVerdict()` 那条「不确定就不删」的规则,
* 因为再小心的路径比较也挡不住 8.3 短名之类的花样。
* @param {string} out
* @param {string} [root]
*/
export function outDirIsUnsafe(out, root = ROOT) {
const r = canonical(root);
const o = canonical(out);
if (o === r) return true;
const rel = path.relative(o, r); // 从 out 看 root 的相对位置
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
/** 上一次打的便携包长这样:包根有这份说明,还有一个 app\ 目录。 */
const BUNDLE_MARKERS = Object.freeze(['README-开箱即用.md', 'app']);
/**
* `--force` 允不允许删掉这个目录?
*
* 只认三种情况:目录还不存在;目录是空的;目录**看起来就是上次打的便携包**。
* 其余一律拒绝 —— 路径比较挡不住大小写、符号链接、8.3 短名的花招,所以规则反过来写:
* 只有能确认「这就是上次的产物」才动手删,认不出来就什么都不删。
* @param {string} dir
* @returns {'missing' | 'empty' | 'bundle' | 'refuse'}
*/
export function forceDeleteVerdict(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return e?.code === 'ENOENT' ? 'missing' : 'refuse'; // 读不出来(权限 / 不是目录)就别删
}
if (entries.length === 0) return 'empty';
const app = entries.find((e) => e.name === 'app');
const looksLikeBundle = BUNDLE_MARKERS.every((n) => entries.some((e) => e.name === n))
&& !!app && (app.isDirectory() || app.isSymbolicLink());
return looksLikeBundle ? 'bundle' : 'refuse';
}
/**
* 版本库里跟踪的文件(仓库相对路径,posix 分隔符)。
*
* 这是 app\ 文件清单的唯一来源。用 NUL 分隔读,避免中文 / 空格文件名被 git 转义或截断。
* @returns {string[]}
*/
function trackedFiles() {
const r = spawnSync('git', ['-C', ROOT, 'ls-files', '-z'], { maxBuffer: 256 * 1024 * 1024 });
if (r.error || r.status !== 0) {
throw new Error('git ls-files 失败:打包只收版本库里跟踪的文件,请在完整的 git 仓库里运行');
}
return r.stdout.toString('utf8').split('\0').filter(Boolean);
}
/** 按相对路径清单逐个复制(自动建目录);源文件不存在就跳过(例如素材还没下载)。 */
export async function copyFiles(relPaths, dst, root = ROOT) {
let files = 0; let bytes = 0;
for (const rel of relPaths) {
const from = path.join(root, rel);
const to = path.join(dst, rel);
let st;
try {
// eslint-disable-next-line no-await-in-loop
st = await fsp.stat(from);
} catch { continue; }
if (!st.isFile()) continue;
// eslint-disable-next-line no-await-in-loop
await fsp.mkdir(path.dirname(to), { recursive: true });
// eslint-disable-next-line no-await-in-loop
await fsp.copyFile(from, to);
files++; bytes += st.size;
}
return { files, bytes };
}
/**
* 整目录复制(素材 / 依赖),跳过符号链接与点开头的条目。
*
* 点开头的条目一律不要:`public/assets` 里可能躺着打包机器自己的 `.DS_Store`,它既不属于项目也不该发出去。
*/
export async function copyDir(src, dst) {
let files = 0; let bytes = 0;
const walk = async (d, out) => {
await fsp.mkdir(out, { recursive: true });
const entries = await fsp.readdir(d, { withFileTypes: true });
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const from = path.join(d, e.name);
const to = path.join(out, e.name);
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) {
// eslint-disable-next-line no-await-in-loop
await walk(from, to);
continue;
}
if (!e.isFile()) continue;
// eslint-disable-next-line no-await-in-loop
await fsp.copyFile(from, to);
files++;
try {
// eslint-disable-next-line no-await-in-loop
bytes += (await fsp.stat(to)).size;
} catch { /* ignore */ }
}
};
await walk(src, dst);
return { files, bytes };
}
async function dirSize(dir) {
let bytes = 0; let files = 0;
const walk = async (d) => {
let entries;
try { entries = await fsp.readdir(d, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) await walk(p);
else if (e.isFile()) { files++; try { bytes += (await fsp.stat(p)).size; } catch { /* ignore */ } }
}
};
await walk(dir);
return { bytes, files };
}
/**
* 只装生产依赖,装到 app\node_modules。
*
* 在临时目录里 `npm ci --omit=dev` 再搬过来:既拿到干净的生产依赖(不带 puppeteer-core 这类
* devDependency),又不会动本仓库自己的 node_modules。
*/
async function installProductionDeps(appDir) {
const stage = path.join(appDir, '.deps-stage');
await fsp.rm(stage, { recursive: true, force: true });
await fsp.mkdir(stage, { recursive: true });
for (const f of ['package.json', 'package-lock.json']) {
const src = path.join(ROOT, f);
if (!fs.existsSync(src)) throw new Error(`缺少 ${f}:无法安装生产依赖`);
await fsp.copyFile(src, path.join(stage, f));
}
console.log(' · 安装生产依赖 npm ci --omit=dev(稍等)…');
// --ignore-scripts:本仓库的 postinstall 是 `node tools/vendor.mjs`(把 npm 里的前端库拷进
// public/vendor)。这个暂存目录里只有一份 package.json,脚本根本不存在,npm ci 会在 postinstall
// 阶段 MODULE_NOT_FOUND;而 public/vendor 本来就是整目录进包(见 ASSET_DIRS),不需要再跑一次。
// 依赖里也没有需要编译的原生模块(htm / pixi / preact / three / ws 都是纯 JS)。
const args = ['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'];
// Windows 上 npm 是 npm.cmd(批处理),不能直接 spawn;显式走 cmd.exe,避免 shell:true 的参数转义告警。
const r = IS_WIN
? spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm', ...args], { cwd: stage, stdio: 'inherit' })
: spawnSync('npm', args, { cwd: stage, stdio: 'inherit' });
if (r.error || r.status !== 0) throw new Error('npm ci --omit=dev 失败(便携包需要生产依赖,请先联网)');
await fsp.rm(path.join(appDir, 'node_modules'), { recursive: true, force: true });
await fsp.rename(path.join(stage, 'node_modules'), path.join(appDir, 'node_modules'));
await fsp.rm(stage, { recursive: true, force: true });
}
/**
* PowerShell 单引号字符串的字面量,内部 `'` 要写成 `''`。
*
* 路径是拼进 `-Command` 的,合法路径里带 `'`(中文输入法很容易打出来)时直接拼接会截断命令,
* 解压那一档就整条失效。
* @param {string} s
*/
export function psSingleQuote(s) {
return `'${String(s).replace(/'/g, "''")}'`;
}
/**
* 把 zip 解到 dir(必须是已存在的空目录)。
*
* 三种解压器按平台兜底:`tar`(Windows 10+ 的 bsdtar、macOS 的 bsdtar 都认得 zip)、`unzip`(GNU tar
* 解不了 zip,Linux 上是这一档)、Windows 自带的 `Expand-Archive`。全试过才报错。
* @returns {string} 实际成功的命令名
*/
function extractZip(zipPath, dir) {
const attempts = [
['tar', ['-xf', zipPath, '-C', dir]],
['unzip', ['-q', zipPath, '-d', dir]],
];
if (IS_WIN) attempts.push(['powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath ${psSingleQuote(zipPath)} -DestinationPath ${psSingleQuote(dir)} -Force`]]);
let last = '';
for (const [cmd, args] of attempts) {
const r = spawnSync(cmd, args, { stdio: 'ignore' });
if (!r.error && r.status === 0) return cmd;
last = `${cmd}: ${r.error ? r.error.message : `退出码 ${r.status}`}`;
}
throw new Error(`解压 ${path.basename(zipPath)} 失败(需要 tar / unzip,Windows 上也可以是 Expand-Archive):${last}`);
}
/**
* 确保官方 zip 在仓库的 .cache\ 里,且 sha256 与仓库里钉死的一致。
* @returns {Promise<string>} zip 的绝对路径
*/
async function ensureNodeZip(version, wantHash) {
const zipName = `node-${version}-win-x64.zip`;
const zipPath = path.join(NODE_CACHE_DIR, zipName);
if (fs.existsSync(zipPath)) {
const h = crypto.createHash('sha256').update(await fsp.readFile(zipPath)).digest('hex');
if (h === wantHash) return zipPath;
console.log(` · ${path.relative(ROOT, zipPath)} 校验失败,重新下载`);
}
const url = `https://nodejs.org/dist/${version}/${zipName}`;
console.log(` · 下载 ${url}(约 30 MB)`);
const res = await fetch(url);
if (!res.ok) throw new Error(`下载失败 ${res.status} ${res.statusText}`);
const buf = Buffer.from(await res.arrayBuffer());
const got = crypto.createHash('sha256').update(buf).digest('hex');
if (got !== wantHash) throw new Error(`${zipName} sha256 不匹配:\n 期望 ${wantHash}\n 实际 ${got}`);
await fsp.mkdir(NODE_CACHE_DIR, { recursive: true });
await fsp.writeFile(zipPath, buf);
return zipPath;
}
/**
* 便携版 Node:从**校验过的** zip 解出 node.exe 与 LICENSE。
*
* 每次都新建一个临时目录重新解压,绝不复用上一次解压出来的文件 —— 缓存的是 zip,不是解压结果:
* 解压中断留下的残缺 node.exe 与 zip 没有任何对应关系,直接复制就会把坏文件发出去。
* @param {string} bundleNodeDir
* @param {string} version 形如 v22.23.3
* @param {string} zipPath 已校验 sha256 的官方 win-x64 zip
*/
async function extractPortableNode(bundleNodeDir, version, zipPath) {
const zipName = `node-${version}-win-x64.zip`;
const rootName = zipName.replace(/\.zip$/, ''); // win-x64 zip 的固定布局:<zip 名>/node.exe
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'sp-node-'));
try {
const tool = extractZip(zipPath, tmp);
const nodeExe = path.join(tmp, rootName, 'node.exe');
const license = path.join(tmp, rootName, 'LICENSE');
if (!fs.existsSync(nodeExe)) throw new Error(`解压后没找到 node.exe:${nodeExe}`);
// 发出去的二进制必须带它自己的许可证 —— 官方 zip 里就有,取出来放旁边。
if (!fs.existsSync(license)) throw new Error(`官方 zip 里没有 LICENSE:${license}(不能就这样打包发出去)`);
await fsp.mkdir(bundleNodeDir, { recursive: true });
await fsp.copyFile(nodeExe, path.join(bundleNodeDir, 'node.exe'));
await fsp.copyFile(license, path.join(bundleNodeDir, 'LICENSE-node.txt'));
console.log(` 解压器:${tool}`);
return {
version,
bytes: (await fsp.stat(path.join(bundleNodeDir, 'node.exe'))).size,
licenseBytes: (await fsp.stat(path.join(bundleNodeDir, 'LICENSE-node.txt'))).size,
};
} finally {
await fsp.rm(tmp, { recursive: true, force: true });
}
}
/** 启动 .bat(内容保持纯 ASCII,中文只出现在文件名里)。 */
function bat(body) {
return `@echo off\r\nchcp 65001 >nul\r\nsetlocal\r\nset "HERE=%~dp0"\r\nset "NODE="\r\nif exist "%HERE%node\\node.exe" set "NODE=%HERE%node\\node.exe"\r\nif not defined NODE set "NODE=node"\r\n${body}\r\nset "CODE=%ERRORLEVEL%"\r\nif not "%CODE%"=="0" pause\r\nexit /b %CODE%\r\n`;
}
/**
* 包内说明。带不带便携版 Node 会影响三处措辞(是否需要预装 Node、许可证在哪、目录结构),
* 所以先算好片段再拼,别在模板里嵌套引号。
*/
export function bundleReadme({ version, withNode }) {
const nodeNeed = withNode
? `目标机器**不需要安装 Node**:包内的 \`node\\node.exe\` 就是便携版 Node ${version}。`
: '这个包**没有带便携版 Node**,请先在这台机器上安装 Node 22 或 24(LTS)。';
const nodeLicence = withNode ? ' 与 `node\\LICENSE-node.txt`(Node 自己的 MIT 许可证)' : '';
const tree = withNode
? `node\\node.exe 便携版 Node ${version}(官方 x64,已经 sha256 校验)
node\\LICENSE-node.txt Node 自己的许可证(MIT)
app\\ 游戏本体:server / shared / public(全部素材)/ data / scripts / tools
启动游戏.bat app\\scripts\\launch.mjs --no-setup
README-开箱即用.md 本文件
LICENSE / NOTICE.md / THIRD-PARTY-NOTICES.md`
: `app\\ 游戏本体:server / shared / public(全部素材)/ data / scripts / tools
启动游戏.bat app\\scripts\\launch.mjs --no-setup
README-开箱即用.md 本文件
LICENSE / NOTICE.md / THIRD-PARTY-NOTICES.md
(没有 node\\ 这一层:本包不带便携版 Node,请自行安装 Node 22/24 LTS)`;
return `# 卫戍协议:盟约 · Windows 开箱即用包
解压后**双击 \`启动游戏.bat\`** 即可,素材与依赖都在包里,不需要再下载任何东西。
${nodeNeed}
**不联网也能玩**:美术 / 音频 / 依赖 / 字体全部在包内,断网时用自带的 \`app\\public\\fonts\`
(Bender / Novecento Wide)和系统黑体,玩法不受影响。**联网时**页面还会去 Google Fonts 加载
Noto Sans SC 这类网页字体(\`index.html\` 里那条外链这次没有改,它也不阻塞渲染),
只是让中文更接近原版观感 —— 断网不会因此卡住或报错。
## 声明
> [!IMPORTANT]
> - 本项目是玩家自制的**非官方同人作品**,与上海鹰角网络科技有限公司(Hypergryph)、Yostar 及其关联方**没有任何关系**,未获其授权或认可。
> - 《明日方舟》及「卫戍协议」相关的名称、角色、美术、音乐、音效、文本与数据等素材,版权归原权利人所有。这些素材**不适用**本项目的 GPL-3.0 许可证;GPL 只覆盖本项目自己编写的代码。
> - 仅供学习交流与个人非商业使用。**严禁任何形式的盈利**,包括但不限于:售卖本项目或整合包、付费下载或付费分发、收费服务器或收费代开、广告 / 打赏 / 会员等变现方式,以及其他任何商业用途。
> - 本包为了方便玩家附带了游戏的美术与音频素材,下载即视为同意本声明。请勿将素材用于本项目以外的用途或**单独再分发**。完整条款见 [NOTICE.md](NOTICE.md)。
> - 权利人如认为本项目侵犯其权益,请通过 Issue 联系,我们会**立即删除**相关内容。
> - 本项目按「现状」提供,**不提供任何担保**,使用风险自负。
包内 [LICENSE](LICENSE) 里的 GPL-3.0-or-later 覆盖的是**本项目自己编写的代码**;内置 Node.js(MIT)以及其它
第三方组件的许可见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)${nodeLicence}。
## 怎么玩
\`\`\`
本机当服务器 双击 启动游戏.bat:在这台电脑开服,浏览器自动打开;
控制台会打印「发给朋友」的局域网地址(形如 http://192.168.1.23:3000)
连别人的服务器 不需要这个包,直接用浏览器打开对方的网址就行
\`\`\`
第一次开服时 Windows 防火墙可能弹窗,勾选**允许专用网络**(否则朋友连不上)。
建房后把 4 位「同盟密钥」或「复制链接」(\`…/?room=密钥\`)发给朋友即可。
## 目录结构
\`\`\`
${tree}
\`\`\`
卸载=直接删掉整个文件夹(不写注册表、不放系统目录)。存档/昵称在该电脑的浏览器 localStorage 里。
`;
}
async function main() {
const o = parseArgs(process.argv.slice(2));
if (o.help) { console.log(HELP); return 0; }
const out = path.resolve(o.out || path.join(path.dirname(ROOT), 'Stronghold-Protocol-Windows'));
const appDir = path.join(out, 'app');
const nodeDir = path.join(out, 'node');
// 版本与哈希:默认用仓库里钉死的那一份;换版本必须同时给 --sha256,否则拒绝。
let nodeVersion = NODE_PIN.version;
let nodeHash = NODE_PIN.sha256;
if (o.nodeSpec) {
if (!/^v?\d+\.\d+\.\d+$/.test(o.nodeSpec)) {
console.error(`✖ --node-version 要写成 v22.23.3 这样的具体版本,收到的是 ${o.nodeSpec}(latest-22.x 这类浮动版本不行,那样两次打包内容会不一样)。`);
return 1;
}
nodeVersion = o.nodeSpec.startsWith('v') ? o.nodeSpec : `v${o.nodeSpec}`;
if (nodeVersion !== NODE_PIN.version) {
if (!/^[0-9a-f]{64}$/.test(o.sha256)) {
console.error(`✖ --node-version ${nodeVersion} 与仓库里钉死的 ${NODE_PIN.version} 不同,必须同时给 --sha256 <64 位十六进制>(取自官方 SHASUMS256.txt)。`);
return 1;
}
nodeHash = o.sha256;
}
}
console.log(`\n卫戍协议 · Windows 开箱即用包\n 源仓库:${ROOT}\n 产物: ${out}\n`);
// --out 指到仓库本身 / 上级目录时直接拒绝;--force 另外只肯删「空目录」或「上一次打的包」。
if (outDirIsUnsafe(out)) {
console.error(`✖ --out 指向仓库本身或它的上级目录:${out}\n 加 --force 会把仓库删掉,请换一个仓库之外的目录(例如 D:\\Game\\Stronghold-Protocol-Windows)。`);
return 1;
}
const verdict = forceDeleteVerdict(out);
if (verdict !== 'missing') {
if (!o.force) {
console.error(`✖ ${out} 已存在。要覆盖请加 --force —— 它只会删掉空目录,或上一次打的便携包,其余情况一律拒绝。`);
return 1;
}
if (verdict === 'refuse') {
console.error(`✖ ${out} 已存在,但它既不是空目录,也不像上一次打的便携包(包根要有 README-开箱即用.md 和 app\\)。`
+ '\n 为免误删,这里不会动它:请自己确认后删掉,或换一个 --out(例如 D:\\Game\\Stronghold-Protocol-Windows)。');
return 1;
}
await fsp.rm(out, { recursive: true, force: true });
console.log(` · 清掉 ${out}(${verdict === 'empty' ? '空目录' : '上一次的便携包'})`);
}
await fsp.mkdir(out, { recursive: true });
// 1) 游戏代码:只收 git 跟踪的文件
const all = trackedFiles();
const wanted = all.filter((rel) => !SKIP_TRACKED.some((p) => rel === p || rel.startsWith(p)));
console.log(` · 复制游戏本体(git 跟踪的 ${wanted.length} 个文件,略过 ${all.length - wanted.length} 个 test/ 文件)…`);
const copied = await copyFiles(wanted, appDir);
console.log(` 完成:${copied.files} 个文件 / ${MB(copied.bytes)}`);
// 2) 素材与前端库(不进版本库,必须存在)
for (const d of ASSET_DIRS) {
if (!fs.existsSync(path.join(ROOT, d))) {
throw new Error(`缺少 ${d} —— 先运行 node tools/setup.mjs 把素材 / 前端库准备好`);
}
}
console.log(` · 复制素材与前端库(${ASSET_DIRS.join('、')})…`);
let assetFiles = 0; let assetBytes = 0;
for (const d of ASSET_DIRS) {
// eslint-disable-next-line no-await-in-loop
const s = await copyDir(path.join(ROOT, d), path.join(appDir, d));
assetFiles += s.files; assetBytes += s.bytes;
}
console.log(` 完成:${assetFiles} 个文件 / ${MB(assetBytes)}`);
// 2b) 3D 棋盘贴图的清单(本机提取过才有):贴图在 public/assets/local 里,靠这份 JSON 才会被游戏采用。
const localManifest = path.join(ROOT, LOCAL_ASSET_MANIFEST);
const localTextures = path.join(ROOT, 'public', 'assets', 'local');
if (fs.existsSync(localManifest)) {
await fsp.copyFile(localManifest, path.join(appDir, LOCAL_ASSET_MANIFEST));
console.log(` 带上 ${LOCAL_ASSET_MANIFEST}(3D 棋盘贴图的清单,贴图本体在 public/assets/local)`);
} else if (fs.existsSync(localTextures)) {
console.log(` ! 有 public/assets/local 但没有 ${LOCAL_ASSET_MANIFEST}:贴图进了包也用不上,`
+ '先跑 node tools/setup.mjs --local 生成清单(或删掉该目录)');
}
// 3) 生产依赖
await installProductionDeps(appDir);
const deps = await dirSize(path.join(appDir, 'node_modules'));
console.log(` 完成:${deps.files} 个文件 / ${MB(deps.bytes)}(只含生产依赖)`);
// 4) 便携版 Node(每次都从校验过的 zip 重新解压)
let nodeInfo = { version: '(未打包,目标机器需自备 Node 22+)', bytes: 0 };
if (o.node) {
console.log(` · 准备便携版 Node ${nodeVersion}…`);
await fsp.mkdir(nodeDir, { recursive: true });
const zipPath = await ensureNodeZip(nodeVersion, nodeHash);
nodeInfo = await extractPortableNode(nodeDir, nodeVersion, zipPath);
console.log(` 完成:Node ${nodeInfo.version} / ${MB(nodeInfo.bytes)} + LICENSE`);
}
// 5) 包根的法律文件(本项目的 LICENSE / NOTICE / 第三方声明)
for (const f of LEGAL_FILES) {
const src = path.join(ROOT, f);
if (!fs.existsSync(src)) {
console.log(` ! 版本库里没有 ${f},包根将缺少这份声明`);
continue;
}
await fsp.copyFile(src, path.join(out, f));
}
await fsp.writeFile(path.join(out, '启动游戏.bat'), bat('"%NODE%" "%HERE%app\\scripts\\launch.mjs" --no-setup %*'), 'latin1');
await fsp.writeFile(path.join(out, 'README-开箱即用.md'), bundleReadme({ version: nodeVersion, withNode: !!o.node }), 'utf8');
const total = await dirSize(out);
console.log(`\n✔ 便携包已生成:${out}\n ${total.files} 个文件 / ${MB(total.bytes)}`);
console.log(' 双击「启动游戏.bat」即可(本机开服,浏览器自动打开)。');
return 0;
}
// 作为脚本运行时才打包(被 import 时只导出纯函数,方便测试)。
// 用 realpath 比较:路径经过符号链接(macOS 的 /tmp、Windows 的 8.3 短名)时 argv[1] 与 import.meta.url
// 的字面量不同,直接比 URL 会让脚本什么都不做就退出。
function isMain() {
try {
return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
} catch { return false; }
}
if (isMain()) main().then((code) => { process.exitCode = code ?? 0; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; });
+222
View File
@@ -0,0 +1,222 @@
// test/windows-bundle.test.js — Windows 便携包脚本里那几处「写错就会毁掉一次打包」的纯函数。
//
// 打包本身要联网下载官方 Node、还要 `npm ci`,不适合放进单元测试;这里盯住这些:
// * `--out` 指向仓库自己或它的上级时,`--force` 会 `rm -rf` 掉仓库 —— 必须直接拒绝;
// * 路径比较要经得起大小写(Windows / macOS 默认不区分)、符号链接、8.3 短名;
// * `--force` 只肯删「空目录」或「上一次打的便携包」,认不出来就不许动;
// * 素材是整树复制的,点开头的条目(打包机器的 .DS_Store)与符号链接不该跟着进包;
// * 按清单复制时,缺失的源文件跳过而不是抛错(素材可能还没下载);
// * 包内说明的措辞:不能声称「不访问外网」(Google Fonts 外链没改),`--no-node` 时也不能说不用装 Node。
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const mod = (rel) => import(pathToFileURL(path.join(ROOT, rel)).href);
describe('make-windows-bundle.mjs: --out 不能指向仓库自己或它的上级', () => {
test('仓库本身与它的上级目录都要拒绝', async () => {
const { outDirIsUnsafe } = await mod('scripts/make-windows-bundle.mjs');
assert.equal(outDirIsUnsafe(ROOT), true, '仓库本身');
assert.equal(outDirIsUnsafe(path.dirname(ROOT)), true, '仓库的上一级');
assert.equal(outDirIsUnsafe(path.resolve(path.dirname(ROOT), '..')), true, '再上一级');
assert.equal(outDirIsUnsafe(path.join(ROOT, '..', path.basename(ROOT))), true, '写成 .. 绕一圈也还是仓库自己');
});
test('仓库之外的目录放行(默认产物就是仓库的兄弟目录)', async () => {
const { outDirIsUnsafe } = await mod('scripts/make-windows-bundle.mjs');
assert.equal(outDirIsUnsafe(path.join(path.dirname(ROOT), 'Stronghold-Protocol-Windows')), false, '默认产物位置');
assert.equal(outDirIsUnsafe(path.join(os.tmpdir(), 'sp-bundle-out')), false, '临时目录');
// 指向仓库**内部**是允许的:只是产物会出现在 git status 里,不会删掉仓库。
assert.equal(outDirIsUnsafe(path.join(ROOT, 'dist-win')), false, '仓库内的子目录');
});
test('大小写不同也算同一个目录(Windows / macOS 的文件系统默认不区分大小写)', async () => {
const { outDirIsUnsafe } = await mod('scripts/make-windows-bundle.mjs');
// Linux 的默认文件系统区分大小写:`/HOME/X` 就是另一个目录,放行才对。
const sameThing = process.platform !== 'linux';
assert.equal(outDirIsUnsafe(ROOT.toUpperCase()), sameThing, `大写写法的仓库路径(${ROOT.toUpperCase()})`);
assert.equal(outDirIsUnsafe(ROOT.replace(/\//g, path.sep).toLowerCase()), sameThing, '全小写写法');
});
test('经过符号链接的路径会被认出来(macOS 的 /tmp 就是指向 /private/tmp 的链接)', async () => {
const { outDirIsUnsafe } = await mod('scripts/make-windows-bundle.mjs');
const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'sp-link-'));
try {
const real = path.join(base, 'real', 'Stronghold-Protocol');
await fsp.mkdir(real, { recursive: true });
const link = path.join(base, 'link');
try {
// Windows 上目录符号链接要管理员权限,junction 不用;其它平台用 dir。
await fsp.symlink(path.join(base, 'real'), link, process.platform === 'win32' ? 'junction' : 'dir');
} catch {
return; // 建不出链接的环境(缺权限 / 缺支持)就跳过这一条
}
assert.equal(outDirIsUnsafe(path.join(link, 'Stronghold-Protocol'), real), true,
'同一个目录,一条路径经过符号链接也要认出来');
assert.equal(outDirIsUnsafe(path.join(link, 'Stronghold-Protocol', 'dist'), real), false, '仓库内部仍然放行');
} finally {
await fsp.rm(base, { recursive: true, force: true });
}
});
});
describe('make-windows-bundle.mjs: --force 只肯删空目录或上一次的包', () => {
test('不存在 / 空目录 / 上一次打的包 → 可以删', async () => {
const { forceDeleteVerdict } = await mod('scripts/make-windows-bundle.mjs');
const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'sp-force-'));
try {
assert.equal(forceDeleteVerdict(path.join(base, 'nope')), 'missing');
const empty = path.join(base, 'empty');
await fsp.mkdir(empty);
assert.equal(forceDeleteVerdict(empty), 'empty');
const bundle = path.join(base, 'bundle');
await fsp.mkdir(path.join(bundle, 'app'), { recursive: true });
await fsp.writeFile(path.join(bundle, 'README-开箱即用.md'), '# x');
await fsp.writeFile(path.join(bundle, 'LICENSE'), 'x');
assert.equal(forceDeleteVerdict(bundle), 'bundle');
} finally {
await fsp.rm(base, { recursive: true, force: true });
}
});
test('有东西但不是上次的包 → 一律拒绝(包括桌面、仓库、只有半份的产物)', async () => {
const { forceDeleteVerdict } = await mod('scripts/make-windows-bundle.mjs');
const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'sp-force2-'));
try {
const desktop = path.join(base, 'Desktop');
await fsp.mkdir(desktop);
await fsp.writeFile(path.join(desktop, '重要文件.txt'), 'x');
assert.equal(forceDeleteVerdict(desktop), 'refuse', '别人的目录不能删');
assert.equal(forceDeleteVerdict(ROOT), 'refuse', '仓库根不是便携包');
assert.equal(forceDeleteVerdict(path.dirname(ROOT)), 'refuse', '仓库的上级也不是');
const half = path.join(base, 'half');
await fsp.mkdir(path.join(half, 'app'), { recursive: true });
assert.equal(forceDeleteVerdict(half), 'refuse', '只有 app 没有说明:可能是别人的目录,不删');
const half2 = path.join(base, 'half2');
await fsp.mkdir(half2);
await fsp.writeFile(path.join(half2, 'README-开箱即用.md'), '# x');
assert.equal(forceDeleteVerdict(half2), 'refuse', '只有说明没有 app:同样不删');
const file = path.join(base, 'afile');
await fsp.writeFile(file, 'x');
assert.equal(forceDeleteVerdict(file), 'refuse', '同名文件不是目录,不删');
} finally {
await fsp.rm(base, { recursive: true, force: true });
}
});
});
describe('make-windows-bundle.mjs: 包内说明的措辞', () => {
test('不再声称「不访问外网」,改成联网 / 断网两种情况都讲清', async () => {
const { bundleReadme } = await mod('scripts/make-windows-bundle.mjs');
const r = bundleReadme({ version: 'v22.23.3', withNode: true });
assert.ok(!/不访问外网/.test(r), 'index.html 的 Google Fonts 外链没改,不能说这个包不访问外网');
assert.match(r, /不联网也能玩/, '断网可用要写清楚');
assert.match(r, /Google Fonts/, '联网时会去 Google Fonts 加载字体,要如实说明');
assert.match(r, /断网/, '断网时的回退也要写');
assert.match(r, /不需要安装 Node/);
assert.match(r, /node\\node\.exe/);
});
test('每条授权要点都在(素材归原权利人 / 不适用 GPL / 不单独再分发 / 可要求删除 / 无担保)', async () => {
const { bundleReadme } = await mod('scripts/make-windows-bundle.mjs');
for (const withNode of [true, false]) {
const r = bundleReadme({ version: 'v22.23.3', withNode });
assert.match(r, /版权归原权利人所有/, `withNode=${withNode}`);
assert.match(r, /不适用.*GPL/, `withNode=${withNode}`);
assert.match(r, /单独再分发/, `withNode=${withNode}`);
assert.match(r, /立即删除/, `withNode=${withNode}`);
assert.match(r, /不提供任何担保/, `withNode=${withNode}`);
assert.match(r, /严禁任何形式的盈利/, `withNode=${withNode}`);
assert.ok(!/本包按 GPL/.test(r), `withNode=${withNode}: 不能写「本包按 GPL-3.0-or-later 分发」`);
}
});
test('--no-node 时不再写「不需要安装 Node」,目录结构也不列 node\\', async () => {
const { bundleReadme } = await mod('scripts/make-windows-bundle.mjs');
const r = bundleReadme({ version: 'v22.23.3', withNode: false });
assert.ok(!/不需要安装 Node/.test(r), '没带便携 Node 就不能说不用装');
assert.match(r, /没有带便携版 Node/, '要明确告诉玩家自己去装 Node 22/24');
assert.ok(!/node\\node\.exe/.test(r), '目录结构里不该出现 node.exe');
assert.ok(!/LICENSE-node\.txt/.test(r), '也不该提 node\\LICENSE-node.txt');
});
});
describe('make-windows-bundle.mjs: PowerShell 单引号转义', () => {
test("路径里的 ' 要写成 ''(否则 Expand-Archive 那条命令会被截断)", async () => {
const { psSingleQuote } = await mod('scripts/make-windows-bundle.mjs');
assert.equal(psSingleQuote('C:\\tmp\\node.zip'), "'C:\\tmp\\node.zip'");
assert.equal(psSingleQuote("C:\\it's here\\node.zip"), "'C:\\it''s here\\node.zip'");
assert.equal(psSingleQuote("a'b'c"), "'a''b''c'");
});
});
describe('make-windows-bundle.mjs: 素材整树复制', () => {
test('copyDir 跳过点开头的条目(打包机器自己的 .DS_Store)与符号链接', async () => {
const { copyDir } = await mod('scripts/make-windows-bundle.mjs');
const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'sp-copydir-'));
try {
const src = path.join(base, 'src');
const dst = path.join(base, 'dst');
await fsp.mkdir(path.join(src, 'sub', '.hidden'), { recursive: true });
await fsp.writeFile(path.join(src, 'a.png'), 'a');
await fsp.writeFile(path.join(src, '.DS_Store'), 'junk');
await fsp.writeFile(path.join(src, 'sub', 'b.png'), 'bb');
await fsp.writeFile(path.join(src, 'sub', '.keep'), 'junk');
await fsp.writeFile(path.join(src, 'sub', '.hidden', 'c.png'), 'ccc');
const r = await copyDir(src, dst);
assert.equal(r.files, 2, '只数 a.png 与 sub/b.png');
assert.equal(fs.existsSync(path.join(dst, 'a.png')), true);
assert.equal(fs.existsSync(path.join(dst, 'sub', 'b.png')), true);
assert.equal(fs.existsSync(path.join(dst, '.DS_Store')), false, '.DS_Store 不进包');
assert.equal(fs.existsSync(path.join(dst, 'sub', '.keep')), false);
assert.equal(fs.existsSync(path.join(dst, 'sub', '.hidden')), false, '点开头的目录整棵跳过');
// 符号链接:Windows 上建链接需要权限,建不出来就跳过这条(复制逻辑本身仍然跳过链接)。
let linked = false;
try {
await fsp.symlink(path.join(src, 'a.png'), path.join(src, 'link.png'));
linked = true;
} catch { /* 没有权限就算了 */ }
if (linked) {
const dst2 = path.join(base, 'dst2');
await copyDir(src, dst2);
assert.equal(fs.existsSync(path.join(dst2, 'link.png')), false, '符号链接不复制(目标可能指向仓库外)');
}
} finally {
await fsp.rm(base, { recursive: true, force: true });
}
});
test('copyFiles 跳过不存在的文件,其余照常复制', async () => {
const { copyFiles } = await mod('scripts/make-windows-bundle.mjs');
const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'sp-copyfiles-'));
try {
const src = path.join(base, 'src');
const dst = path.join(base, 'dst');
await fsp.mkdir(path.join(src, 'data'), { recursive: true });
await fsp.mkdir(path.join(src, 'server'), { recursive: true });
await fsp.writeFile(path.join(src, 'server', 'index.js'), 'code');
await fsp.writeFile(path.join(src, 'data', 'local-assets.json'), '{}');
const r = await copyFiles(['server/index.js', 'data/local-assets.json', 'public/assets/missing.png'], dst, src);
assert.equal(r.files, 2, '缺失的那个被跳过');
assert.equal(fs.existsSync(path.join(dst, 'server', 'index.js')), true, '目录自动建出来');
assert.equal(fs.existsSync(path.join(dst, 'data', 'local-assets.json')), true);
assert.equal(fs.existsSync(path.join(dst, 'public')), false, '没有文件就不该建空目录');
} finally {
await fsp.rm(base, { recursive: true, force: true });
}
});
});