Windows 便携包:只收 git 跟踪的文件、生产依赖、钉死的 Node 版本与许可证 (#30)
This commit is contained in:
1 parent
502f183e43
commit
50498bbe07
5 files changed
+956
No files matched your search
@@ -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) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 |
|
||||
|
||||
@@ -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
@@ -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))。
|
||||
@@ -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; });
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user