diff --git a/README.md b/README.md index a39e53d9..88e66006 100644 --- a/README.md +++ b/README.md @@ -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) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 | diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index aa94f532..1b503cf0 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -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 diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md new file mode 100644 index 00000000..687bced9 --- /dev/null +++ b/docs/WINDOWS.md @@ -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 上打**: +官方归档解压出来的布局固定是 `/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))。 diff --git a/scripts/make-windows-bundle.mjs b/scripts/make-windows-bundle.mjs new file mode 100644 index 00000000..410bccd1 --- /dev/null +++ b/scripts/make-windows-bundle.mjs @@ -0,0 +1,577 @@ +#!/usr/bin/env node +// scripts/make-windows-bundle.mjs — 打一份「开箱即用」的 Windows 便携包(docs/WINDOWS.md)。 +// +// node scripts/make-windows-bundle.mjs [--out ] [--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 产物目录(默认 <仓库上一级>/Stronghold-Protocol-Windows) + --no-node 不下载便携版 Node(目标机器需自备 Node 22+) + --force 目录已存在时先删掉(只肯删空目录,或上一次打的便携包;其余情况拒绝) + --node-version X 换一个 Node 版本(默认 ${NODE_PIN.version});换版本必须同时给 --sha256 + --sha256 该版本 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} 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 的固定布局:/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; }); diff --git a/test/windows-bundle.test.js b/test/windows-bundle.test.js new file mode 100644 index 00000000..a286bc0c --- /dev/null +++ b/test/windows-bundle.test.js @@ -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 }); + } + }); +});