feedback5: merge W5U — packaging: tools/package.mjs (npm run package / package:lite: a runtime-only full zip with the listed art, ~402 MB, and a lite zip without it, ~21 MB; refusal list, personal-path scan, case-collision check, dry-run test), 焰狐龙梓兰's art entries dropped from data/assets.json, one lower-case file per module-icon type (dec-X / dec-x, WAH-Y collided on case-insensitive disks), DEPLOY / README download sections; version lines follow the merged 0.1.4 CHANGELOG (0.1.4 until the 0.2.0 bump); golden unchanged

This commit is contained in:
sganggs committed 2026-10-06 18:12:31 +08:00
commit 65653a21b0
15 files changed
+830 -463

No files matched your search

+14 -9
View File
@@ -2,7 +2,7 @@
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
![version](https://img.shields.io/badge/version-0.1.3-2ea44f)
![version](https://img.shields.io/badge/version-0.1.4-2ea44f)
![license](https://img.shields.io/badge/code%20license-GPL--3.0--or--later-blue)
![node](https://img.shields.io/badge/node-22%20%7C%2024-339933)
@@ -12,7 +12,7 @@
> - 本项目是玩家自制的**非官方同人作品**,与上海鹰角网络科技有限公司(Hypergryph)、Yostar 及其关联方**没有任何关系**,未获其授权或认可。
> - 《明日方舟》及「卫戍协议」相关的名称、角色、美术、音乐、音效、文本与数据等素材,版权归原权利人所有。这些素材**不适用**本项目的 GPL-3.0 许可证;GPL 只覆盖本项目自己编写的代码。
> - 仅供学习交流与个人非商业使用。**严禁任何形式的盈利**,包括但不限于:售卖本项目或整合包、付费下载或付费分发、收费服务器或收费代开、广告 / 打赏 / 会员等变现方式,以及其他任何商业用途。
> - 仓库源码不包含游戏的美术与音频素材(只有由官方数据表生成的数据和几张游戏截图,同样不适用 GPL);[Releases](../../releases/latest) 中的整合包为了方便玩家附带了素材,下载即视为同意本声明。请勿将素材用于本项目以外的用途或单独再分发。完整条款见 [NOTICE.md](NOTICE.md)。
> - 仓库源码不包含游戏的美术与音频素材(只有由官方数据表生成的数据和几张游戏截图,同样不适用 GPL);[Releases](../../releases/latest) 中的完整包为了方便玩家附带了素材(精简包不带,首次启动时从公开镜像下载),下载即视为同意本声明。请勿将素材用于本项目以外的用途或单独再分发。完整条款见 [NOTICE.md](NOTICE.md)。
> - 权利人如认为本项目侵犯其权益,请通过 Issue 联系,我们会**立即删除**相关内容。
> - 本项目按「现状」提供,**不提供任何担保**,使用风险自负。
@@ -37,7 +37,7 @@ English summary: [below](#english).
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
- 当前版本 0.1.3:修复了 0.1.2 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
- 当前版本 0.1.4:修复了 0.1.3 发布后玩家和 GitHub 上反馈的问题,并合并了多位贡献者的 PR,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
## 功能一览
@@ -62,13 +62,18 @@ English summary: [below](#english).
### 方式一:整合包(推荐)
整合包里已经包含代码、运行依赖和全部美术 / 音频(含官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。
[Releases](../../releases/latest) 里有两种整合包,代码和运行依赖完全相同,二选一:
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 400 MB,解压后约 590 MB):附带全部美术 / 音频(含官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。**推荐。**
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 21 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 440 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」)。适合下载大文件不方便的情况。
两种包都只含运行和部署需要的文件(服务器、客户端、数据、启动脚本、setup / doctor / 素材下载工具、许可证与说明、[docs/PLAYING.md](docs/PLAYING.md) 和 [docs/DEPLOY.md](docs/DEPLOY.md));测试、开发工具和设计文档只在源码仓库里。
1. **安装 Node.js 22 或 24(LTS)**
- Windows:在 PowerShell 里运行 `winget install OpenJS.NodeJS.LTS`,或到 <https://nodejs.org/zh-cn/download> 下载安装包。
- macOS:`brew install node@22`,或到官网下载安装包。
- Linux:发行版的包管理器、nvm 或 fnm。
2. **下载**:在 [Releases](../../releases/latest) 页面下载最新版本(v0.1.3)的整合包(zip),解压到一个路径较短的文件夹(Windows 上建议不要放在 OneDrive 同步的目录里)。
2. **下载**:在 [Releases](../../releases/latest) 页面下载最新版本的完整包(或精简包),解压到一个路径较短的文件夹(Windows 上建议不要放在 OneDrive 同步的目录里)。
3. **启动**
- Windows:双击 **`scripts\start-windows.bat`**。如果弹出「安全警告」,点「运行」;Windows 防火墙弹窗请勾选「专用网络」并允许。
- macOS / Linux:在解压出的文件夹里运行 `./scripts/start.sh`(或 `bash scripts/start.sh`)。
@@ -80,13 +85,13 @@ English summary: [below](#english).
git clone https://github.com/sganggs/Stronghold-Protocol.git
cd Stronghold-Protocol
npm install # 安装依赖(postinstall 会把 pixi / preact / three 复制到 public/vendor)
npm run setup # 检查环境,并从公开镜像下载约 270 MB 美术 / 音频(可中断,再次运行会续传)
npm run setup # 检查环境,并从公开镜像下载约 440 MB 美术 / 音频(可中断,再次运行会续传)
npm start # 启动服务器:http://localhost:3000
```
也可以直接运行启动脚本(Windows `scripts\start-windows.bat`,macOS / Linux `scripts/start.sh`):首次会自动安装依赖、下载素材,然后启动服务器并打开浏览器。
- **本地客户端素材(可选)**:官方 3D 棋盘、部分官方界面图标(交流按钮与表情面板的边框、模组类型图标等)和灼热 / 炽焰源石虫的官方模型需要从本机的《明日方舟》PC 客户端提取(Windows 原生客户端、macOS 的 CrossOver 或 PlayCover)。`npm run setup` 检测到客户端时会询问是否提取(需要 Python 3.8+,依赖装在项目内的 `.venv-extract`,不影响系统);之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定路径。没有客户端时游戏照常运行,这几样换成替代样式:2D 棋盘、样式相近的图标、染色的普通源石虫。表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载,不需要客户端。没有客户端的服务器(例如 Linux VPS)也可以从**同一版本**的整合包里复制 `public/assets/local/` 和 `data/local-assets.json`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 的「本地客户端素材」。
- **本地客户端素材(可选)**:官方 3D 棋盘、部分官方界面图标(交流按钮与表情面板的边框、模组类型图标等)和灼热 / 炽焰源石虫的官方模型需要从本机的《明日方舟》PC 客户端提取(Windows 原生客户端、macOS 的 CrossOver 或 PlayCover)。`npm run setup` 检测到客户端时会询问是否提取(需要 Python 3.8+,依赖装在项目内的 `.venv-extract`,不影响系统);之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定路径。没有客户端时游戏照常运行,这几样换成替代样式:2D 棋盘、样式相近的图标、染色的普通源石虫。表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载,不需要客户端。没有客户端的服务器(例如 Linux VPS)也可以从**同一版本**的整合包(完整包;精简包没有)里复制 `public/assets/local/` 和 `data/local-assets.json`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 的「本地客户端素材」。
- 素材下载优先使用 GitHub,失败时自动改用 jsDelivr 镜像。
- `npm run doctor`(即 `node tools/doctor.mjs`)可以随时诊断:Node 版本、素材是否完整、端口占用、局域网地址和防火墙。
@@ -94,7 +99,7 @@ npm start # 启动服务器:http://localhost:3000
| 项目 | 要求 |
|---|---|
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 400–500 MB(素材、依赖与可选的本地提取贴图);内存空闲约 100 MB,每局再加几 MB |
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 600–750 MB(素材、依赖与本地提取贴图:完整包解压后约 590 MB);内存空闲约 100 MB,每局再加几 MB |
| 玩家 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑、手机或平板(横屏) |
| 网络 | 首次进入游戏时,每位玩家要从开服的电脑下载几十 MB 素材(之后走浏览器缓存);对局中流量很小 |
@@ -235,7 +240,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Alliance*, played in the browser: solo, or 1–4 player co-op (AI teammates can fill seats). Combat is simulated in each player's browser, so a low-power PC can host.
- **Run:** download the all-in-one bundle from [Releases](../../releases/latest), install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~270 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons and two enemy models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins, and a server can copy `public/assets/local/` and `data/local-assets.json` from the release bundle of the same version).
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~400 MB, all the art inside) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~21 MB), which downloads the art (~440 MB) on its first start — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~440 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons and two enemy models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins, and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
- **Play with friends:** create a co-op room and share the 4-letter key or the `?room=KEY` link. On a LAN, use the address printed at start; otherwise use a virtual-LAN tool, a tunnel or a VPS — see [docs/DEPLOY.md](docs/DEPLOY.md).
- **Disclaimer:** not affiliated with or endorsed by Hypergryph or Yostar. All Arknights names, art, audio, text and data are © their respective owners and are **not** covered by this project's GPL licence. For study and personal non-commercial use only — no selling, paid distribution, paid servers or monetisation of any kind. Content will be removed on request of the rights holders. Provided "as is", without warranty.
- **License:** code GPL-3.0-or-later ([LICENSE](LICENSE)); game assets excluded.
+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"6a547fc5c937","generator":"tools/fetch-assets.mjs","stats":{"files":6994,"bytes":462620448,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":120},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
{"version":1,"hash":"467b2d44e171","generator":"tools/fetch-assets.mjs","stats":{"files":6978,"bytes":458333444,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":120},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
+5 -4
View File
@@ -77,7 +77,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
| Operator avatars, 180×180. Base, plus E2 when it exists. | yuanyan3060/ArknightsGameResource `avatar/` | `char/avatar/{charId}.png`, `char/avatar/{charId}_2.png` |
| Operator half-body portraits, 180×360 | yuanyan `portrait/` | `char/portrait/{charId}_1.png`, `_2.png` |
| Skill icons: every skill index of every planned operator (DESIGN §16 loadouts, 自选 picks) | yuanyan `skill/` | `skill/{iconId sanitized}.png` |
| Module type icons: the `typeIcon` of every module of `data/chess.json` and `data/backups.json` (the 干员调配 / 自选 module tiles when the local-client art lacks them) | AA2 `cn` `arts/ui/uniequiptype/{typeIcon}.png` (else its lower-case name: `WAH-Y` → `wah-y.png`) | `module/{typeIcon}.png` |
| Module type icons: the `typeIcon` of every module of `data/chess.json` and `data/backups.json` (the 干员调配 / 自选 module tiles when the local-client art lacks them) | AA2 `cn` `arts/ui/uniequiptype/{typeIcon}.png` (else its lower-case name: `WAH-Y` → `wah-y.png`) | `module/{typeIcon in lower case}.png` — one file per type: the official data spells one DEC X module `dec-X` and the others `dec-x`, and paths that differ only in case are one file on Windows / macOS and in a release zip |
| Enemy icons | yuanyan `enemy/`. Fallbacks: the handbook id, then the base id. | `enemy/icon/{enemyId}.png` |
| Token avatars | yuanyan `avatar/` | `token/avatar/{tokenId}.png` |
| Bond icons (the real autochess glyphs) | ArknightsAssets2 `cn` `ui/autochess/[uc]autochesscommon/arts/bondicon/`. Fallback: the camp logo. | `bond/{bondId}.png` |
@@ -99,7 +99,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
### Id scope
- **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators; plus every unit of `data/backups.json` research 07 does not list — the 72 自选 owned-6★ picks (`diy.ownedPool`, DATA.md §18; the collab operators are not in the data) — planned from 07's URL patterns (`tools/assets/plan.mjs patternOperator`, `tools/fetch-assets.mjs dataExtras`): avatar and portrait (E0–E1 and E2), the default-skin battle Spine Front / Back, the icon and skill sound of each skill, the sub-profession icon. 210 operators in all (207 with a Back model).
- **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators; plus every unit of `data/backups.json` research 07 does not list — the 71 自选 owned-6★ picks (`diy.ownedPool`, DATA.md §18; the collab operators are not in the data, and 焰狐龙梓兰's entries left the manifest when she left the pool in 0.2.0) — planned from 07's URL patterns (`tools/assets/plan.mjs patternOperator`, `tools/fetch-assets.mjs dataExtras`): avatar and portrait (E0–E1 and E2), the default-skin battle Spine Front / Back, the icon and skill sound of each skill, the sub-profession icon. 209 operators in all (206 with a Back model).
- **Tokens:** the 20 pool tokens, and the 38 summons of the 自选 picks (`data/backups.json tokens`) as extra tokens (below): their avatars; their battle Spine exists upstream only as skin variants, which the default locations miss — no Spine yet (the client draws the avatar).
- **Enemies:** 253 ids planned, 252 in the manifest (心烛 has no assets). The set is the union of:
- the 07 enemy list;
@@ -111,7 +111,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
- enemy units spawned by operator kits (research 03 skills/talents). For example, 隐德来希's default S3 summons 心烛 `enemy_5601_entlec` through the talent key `take_extra_enemy_key`. 心烛 has no icon and no Spine in any dump, so it has no manifest entry: it is reported as a miss, and the client must draw a glyph.
- **Extra tokens:** any `token_*` key of `data/tokens.json` that research does not list gets the default avatar and Spine locations.
- The non-token summons in that file (`enemy_9012_acloon` 炎佑, `char_605_cmedic`, `char_613_acmedc`) are found under `enemies` and `chars`.
- **Skill icons:** every skill index of every planned operator (the default one first; DESIGN §16 loadouts, the 自选 picks' three skills): 525 icons.
- **Skill icons:** every skill index of every planned operator (the default one first; DESIGN §16 loadouts, the 自选 picks' three skills): 522 icons.
- **UI:**
- every group from `07-assets.json → autochessUi`: rarity, elite and chess-level sprites, the shop panel and cards, HUD, bond board, equip slot, round dialog, band choose, settlement, prepare backdrop;
- `arts` (rarity stars, elite icons, the camp logos of pool nations, the loading illustrations used by the act2 modes, battle common sprites, act2 entry backdrops and season logo, item rarity frames);
@@ -174,7 +174,8 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
resultFour, resultThree, resultTwo, resultLose } },
// 干员战斗语音: an operator's official battle lines (charword_table.json placeType → slot,
// tools/assets/audio.mjs VOICE_SLOTS); a slot with several lines is an array and the client
// draws one (public/js/audio.js voice). Only these twelve ever play (DESIGN §21.30): the
// draws one (public/js/audio.js voice); a slot whose lines are all missing on disk is left
// out (an operator with none has no entry), never an empty array. Only these twelve ever play (DESIGN §21.30): the
// prep-only slots 干员报到 / 编入队伍 / 任命队长 are left out of the plan by default —
// nothing requests them and they cost 360 files (19.3 MB) per run — and `--voice-all` adds
// them (audio.mjs VOICE_PREP_SLOTS) for the complete official set
+25 -9
View File
@@ -10,7 +10,7 @@
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
| 磁盘 | 素材约 270 MB(`public/assets`)+ 依赖约 125 MB(`node_modules`);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。 |
| 磁盘 | 素材约 440 MB(`public/assets`)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 590 MB。 |
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
@@ -19,19 +19,20 @@
### 1.1 安装与首次启动
1. 安装 Node.js 22 LTS 和 Git(在 PowerShell 或「终端」里;用下面的完整包时不需要 Git):
1. 安装 Node.js 22 LTS 和 Git(在 PowerShell 或「终端」里;用下面的整合包时不需要 Git):
```powershell
winget install OpenJS.NodeJS.LTS
winget install Git.Git
```
装完**关闭并重新打开**终端,`node -v` 应显示 v22 或更高(winget 的 LTS 目前是 v24.x,同样可用)。没有 winget 时从 <https://nodejs.org/zh-cn/download> 和 <https://git-scm.com/download/win> 下载安装。
2. 下载,二选一。建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如 `C:\Stronghold-Protocol`:
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本(当前为 v0.1.3)的完整包 zip(已含依赖、前端库和全部素材,包括官方 3D 棋盘),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
2. 下载,三选一。建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如 `C:\Stronghold-Protocol`:
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 400 MB,解压后约 590 MB;已含运行依赖、前端库和全部素材,包括官方 3D 棋盘等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
- **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 21 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 440 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。
- **源码**:
```powershell
git clone https://github.com/sganggs/Stronghold-Protocol.git C:\Stronghold-Protocol
```
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;完整包已含,跳过)→ 复制前端库 → 下载约 270 MB 素材(完整包已含,跳过;显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;整合包已含,跳过)→ 复制前端库(整合包已含,跳过)→ 下载约 440 MB 素材(完整包已含,跳过;精简包和源码在这一步下载,显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
4. 窗口里会打印朋友可用的地址,例如 `http://192.168.1.23:3000`。用另一台设备打开它确认能进入。关闭窗口即停止服务器。
等价的手动命令:`npm ci`、`node tools/setup.mjs`、`npm start`。
@@ -126,7 +127,7 @@ node tools/setup.mjs # 补下载新增的素材(已有文件会
powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Restart
```
没装开机自启的话,最后一步改成重新双击 `start-windows.bat`。用 Releases 完整包的:停止服务器,把新版本的完整包解压到新目录后从那里启动即可(素材已包含;装了开机自启的,在新目录重新运行一次 `install-service-windows.ps1`)。用 GitHub「Download ZIP」源码包的:解压新版本后,把旧目录里的 `public\assets`、`public\fonts`、`.cache` 和 `data\local-assets.json`(若有)复制过去,可避免重新下载。
没装开机自启的话,最后一步改成重新双击 `start-windows.bat`。用 Releases 整合包的:停止服务器,把新版本的整合包解压到新目录后从那里启动即可(完整包已含素材;装了开机自启的,在新目录重新运行一次 `install-service-windows.ps1`)。用精简包或 GitHub「Download ZIP」源码包的:解压新版本后,把旧目录里的 `public\assets`、`public\fonts`、`.cache` 和 `data\local-assets.json`(若有)复制过去,可避免重新下载(setup 只补下新增的素材)。
## 2. 让不在同一网络的朋友加入
@@ -203,7 +204,7 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://
## 3. Docker
```bash
# A) 构建时下载素材(需要联网,约 250 MB)
# A) 构建时下载素材(需要联网,约 440 MB)
docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
@@ -213,7 +214,7 @@ docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \
-v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol
```
镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等)。健康检查:`GET /healthz`。
镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等)。健康检查:`GET /healthz`。
docker compose 示例:
@@ -270,7 +271,7 @@ services:
## 6. 本地客户端素材(可选)
`public/assets/local/` 和 `data/local-assets.json` 是从本机安装的《明日方舟》客户端里提取的官方素材(`tools/local-extract`,DESIGN §13):`node tools/setup.mjs` 检测到客户端时会询问是否提取,之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定客户端目录。setup 从公开镜像下载的素材不包含这部分,所以在没有客户端的电脑上(例如 Linux 服务器)从源码部署时不会有它;Releases 的完整包里已经带上了。
`public/assets/local/` 和 `data/local-assets.json` 是从本机安装的《明日方舟》客户端里提取的官方素材(`tools/local-extract`,DESIGN §13):`node tools/setup.mjs` 检测到客户端时会询问是否提取,之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定客户端目录。setup 从公开镜像下载的素材不包含这部分,所以在没有客户端的电脑上(例如 Linux 服务器)用源码或精简包部署时不会有它;Releases 的完整包里已经带上了。
没有本地素材时游戏照常运行,只是下面几样换成替代样式:
@@ -285,3 +286,18 @@ services:
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
## 7. 打包发布(维护者)
Releases 的两个 zip 由 `tools/package.mjs` 生成,在**源码仓库**里运行(整合包里没有这个工具):
```bash
npm run package -- --dry-run --list # 只检查:列出每个文件和大小,不写任何文件(精简包加 --lite)
npm run package -- --out <目录> # 完整包 Stronghold-Protocol-v<版本>.zip
npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版本>-lite.zip
```
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包相比,完整包少了约 570 个文件、解压后小约 25 MB,zip 小约 8 MB。
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
- **需要**:已下载素材的仓库(完整包)——打包前先联网运行一次 `node tools/fetch-assets.mjs`,补齐清单计划但本机还没有的素材(清单只列出磁盘上有的文件,打包工具看不出缺了哪些;`data/assets.json` 有变化就先提交);能访问 npm 的网络(`npm ci`);`zip`(或 bsdtar 的 `tar`,Windows 10 起自带)。`--out` 默认是系统临时目录下的 `stronghold-protocol-release`,不能在仓库里面;`--force` 覆盖已有的 zip,`--keep-stage` 保留打包用的目录供检查。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "stronghold-protocol-alliance",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stronghold-protocol-alliance",
"version": "0.1.3",
"version": "0.1.4",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "stronghold-protocol-alliance",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
"license": "GPL-3.0-or-later",
+1 -1
View File
@@ -3,7 +3,7 @@
export const PROTOCOL_VERSION = 1;
/** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version"
* (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */
export const APP_VERSION = '0.1.3';
export const APP_VERSION = '0.1.4';
export const MAX_SEATS = 4;
/**
+36 -1
View File
@@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dataExtras, parseArgs } from '../tools/fetch-assets.mjs';
import { dataExtras, orphanFiles, parseArgs } from '../tools/fetch-assets.mjs';
import { patternOperator, buildPlan } from '../tools/assets/plan.mjs';
import { indexAudio } from '../tools/assets/audio.mjs';
import { Downloader } from '../tools/assets/downloader.mjs';
@@ -65,6 +65,25 @@ test('patternOperator / buildPlan: an operator research 07 lacks gets the 07 URL
assert.equal(t.template.prof.sub.pioneer.alts[0].rel, 'prof/sub/pioneer.png');
});
test('module type icons: one lower-case file per type, so the official \'dec-X\' (uniequip_003_aglina) and \'dec-x\' share one; --prune keeps a file whose name differs only in case', () => {
const t = buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {}, moduleTypes: ['dec-X', 'dec-x', 'WAH-Y'] });
const rel = (k) => t.template.modules[k].alts[0].rel;
assert.deepEqual([rel('dec-X'), rel('dec-x'), rel('WAH-Y')], ['module/dec-x.png', 'module/dec-x.png', 'module/wah-y.png']);
assert.match(t.template.modules['dec-X'].alts[0].urls[0], /uniequiptype\/dec-X\.png$/, 'the upstream name keeps its case first');
const m = load('assets');
assert.equal(m.modules['dec-X'], m.modules['dec-x']);
const byCase = new Map();
const walk = (v) => {
if (typeof v === 'string' && v.startsWith('/assets/')) {
const k = v.toLowerCase();
assert.ok(!byCase.has(k) || byCase.get(k) === v, `${v} and ${byCase.get(k)} differ only in case: one file on Windows / macOS`);
byCase.set(k, v);
} else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x);
};
walk(m);
assert.deepEqual(orphanFiles(['module/WAH-Y.png', 'module/old.png', 'local/x.png', 'char/a.png'], new Set(['module/wah-y.png', 'char/a.png'])), ['module/old.png']);
});
test('--add-only: parsed, refused with --prune / --force; its downloader keeps every existing file and records what it wrote', async () => {
assert.equal(parseArgs(['--add-only']).addOnly, true);
assert.equal(parseArgs([]).addOnly, false);
@@ -91,3 +110,19 @@ test('the committed data/assets.json lists every 自选 operator\'s avatar, port
for (const t of dataExtras(BACKUPS, CHESS).moduleTypes) assert.ok(m.modules[t] && onDisk(m.modules[t]), `module type icon ${t}`);
assert.equal(m.stats.modules, Object.keys(m.modules).length);
});
test('an operator left out of 自选 (data/backups.json diy.excluded: the collab picks, 焰狐龙梓兰 MH05 among them) keeps no art in data/assets.json, so the full release zip (tools/package.mjs) never ships it', () => {
const m = load('assets');
const text = JSON.stringify(m);
assert.ok(BACKUPS.diy.excluded.includes('char_1048_orchd2'), '焰狐龙梓兰 is excluded');
for (const id of BACKUPS.diy.excluded) {
const code = id.split('_').slice(2).join('_');
assert.equal(m.chars[id], undefined, `${id}: chars`);
assert.equal(m.audio.sfx.units[id], undefined, `${id}: unit sfx`);
assert.equal(m.audio.voice?.[id], undefined, `${id}: voice`);
assert.ok(!Object.keys(m.skills).some((k) => k.startsWith(`skchr_${code}_`)), `${id}: skill icons`);
assert.ok(!text.includes(id), `${id}: no file of theirs`);
}
assert.equal(m.stats.chars, Object.keys(m.chars).length);
assert.equal(m.stats.skills, Object.keys(m.skills).length);
});
+15
View File
@@ -362,6 +362,21 @@ describe('audio banks and plan id sets', () => {
assert.equal(ok.value.p, '/assets/package.json');
assert.equal(ok.fallbacks.length, 1);
});
test('an array whose lines are all missing is dropped, with the entry it empties (a voice slot never downloaded)', () => {
const line = (rel) => ({ alts: [{ rel, urls: ['u'] }] });
const tpl = {
voice: {
a: { select: [line('nope/1.mp3'), line('nope/2.mp3')], place: [line('nope/3.mp3')] },
b: { select: [line('nope/4.mp3'), line('package.json')] },
},
empty: [],
};
const r = resolveTemplate(tpl, { root: ROOT, spine: new Map() });
assert.deepEqual(r.value.voice, { b: { select: ['/assets/package.json'] } }, 'operator a has no line on disk: no entry at all');
assert.deepEqual(r.value.empty, [], 'an array that was empty in the template stays');
assert.deepEqual(r.misses.sort(), ['voice.a.place[0]', 'voice.a.select[0]', 'voice.a.select[1]', 'voice.b.select[0]']);
});
});
// ---------------------------------------------------------------------------
+248 -135
View File
@@ -1,155 +1,268 @@
// Player-package file selection. Dry-run only: no zip, no npm ci.
// test/package.test.js — tools/package.mjs, the player release zips (docs/DEPLOY.md §7). No network, no npm install, no
// zip of the repository: the selection rules on sample paths and on the real `git ls-files`, then the packager itself
// (--dry-run, the scans and guards, one --no-install build) on a small temporary git checkout with fake art.
// Run: node --test test/package.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
extraPaths,
isExcluded,
packageOutIsUnsafe,
FOLDER, REFUSE, RUNTIME_RESEARCH, entryProblems, isRefused, packageOutIsUnsafe, plan, resolveSpecifier, scanFiles,
selectTracked,
textHasHomePath,
trackedFiles,
} from '../tools/package.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const TOOL = path.join(ROOT, 'tools', 'package.mjs');
// hermetic scans: not this machine's account name, but a planted one
const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' };
const SAMPLE = [
'server/index.js',
'server/sim/rng.js',
'shared/constants.js',
'public/index.html',
'public/js/main.js',
'public/vendor/preact.mjs',
'public/dev/game-mock.html',
'public/assets/board.png',
'public/fonts/fonts.css',
'data/chess.json',
'data/local-assets.json',
'tools/setup.mjs',
'tools/vendor.mjs',
'tools/fetch-assets.mjs',
'tools/assets/plan.mjs',
'tools/local-extract/extract.py',
'tools/golden.mjs',
'tools/check-imports.mjs',
'scripts/start.sh',
'docs/PLAYING.md',
'docs/DEPLOY.md',
'docs/WINDOWS.md',
'docs/ASSETS.md',
'docs/img/combat.jpg',
'docs/DESIGN.md',
'docs/SIM.md',
'docs/research/03-operators.json',
'docs/research/10-networking-hosting.md',
'test/version.test.js',
'package.json',
'package-lock.json',
'LICENSE',
'NOTICE.md',
'THIRD-PARTY-NOTICES.md',
'README.md',
'CHANGELOG.md',
'Dockerfile',
'.dockerignore',
'AGENTS.md',
'.env',
'.env.local',
'server/.env.production',
'pv/clip.mp4',
'3,9,11回合情况/note.txt',
'handoff/HANDOFF.md',
'node_modules/ws/index.js',
'.cache/x',
'eslint.config.js',
'jsconfig.json',
'types/core.js',
];
test('selectTracked keeps runtime files and drops the rest', () => {
const got = selectTracked(SAMPLE);
assert.deepEqual(got, selectTracked(SAMPLE, { lite: true }));
for (const rel of [
'server/index.js', 'tools/setup.mjs', 'tools/vendor.mjs', 'docs/PLAYING.md',
'docs/DEPLOY.md', 'public/index.html', 'data/chess.json', 'package.json',
'Dockerfile', 'scripts/start.sh', 'tools/assets/plan.mjs', 'tools/local-extract/extract.py',
]) assert.ok(got.includes(rel), rel);
for (const rel of [
'tools/golden.mjs', 'tools/check-imports.mjs', 'test/version.test.js', 'public/dev/game-mock.html',
'public/assets/board.png', 'public/fonts/fonts.css', 'docs/research/10-networking-hosting.md',
'docs/research/03-operators.json', 'docs/DESIGN.md', 'docs/SIM.md', 'AGENTS.md', '.env',
'.env.local', 'server/.env.production', 'pv/clip.mp4', '3,9,11回合情况/note.txt',
'handoff/HANDOFF.md', 'node_modules/ws/index.js', 'data/local-assets.json', 'eslint.config.js',
'types/core.js',
]) assert.ok(!got.includes(rel), rel);
assert.equal(isExcluded('docs/research/10-networking-hosting.md'), true);
});
test('lite extras skip art, fonts and the local manifest', () => {
assert.deepEqual(extraPaths({ lite: true, hasLocalManifest: true }), ['public/vendor']);
assert.deepEqual(extraPaths({ lite: false, hasLocalManifest: true }), [
'public/vendor', 'public/assets', 'public/fonts', 'data/local-assets.json',
]);
assert.deepEqual(extraPaths({ lite: false, hasLocalManifest: false }), [
'public/vendor', 'public/assets', 'public/fonts',
test('selection: runtime files in; tests, maintainer tools, other docs, dev pages, private and config files out', () => {
const { keep, drop } = selectTracked([
'server/index.js', 'server/sim/rng.js', 'shared/constants.js', 'public/index.html', 'public/js/main.js', 'public/i18n/en.json',
'data/chess.json', 'data/i18n/en.json', 'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs',
'tools/crop-board-atlas.mjs', 'tools/assets/plan.mjs', 'tools/assets/local-enemy-spines.json', 'tools/local-extract/extract.py',
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/start.sh', 'scripts/start-windows.bat', 'scripts/launch.mjs',
'scripts/install-service-windows.ps1', 'docs/PLAYING.md', 'docs/DEPLOY.md', ...RUNTIME_RESEARCH, 'package.json',
'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'README.md', 'CHANGELOG.md',
// out
'test/version.test.js', 'tools/golden.mjs', 'tools/build-data.mjs', 'tools/check-imports.mjs', 'tools/package.mjs',
'tools/i18n/fallback-remake.json', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'docs/WINDOWS.md', 'docs/ASSETS.md',
'docs/img/combat.jpg', 'docs/research/00-INDEX.md', 'docs/research/01-core-data.json', 'docs/research/03-operators.md',
'docs/research/10-networking-hosting.md', 'public/dev/game-mock.html', 'public/assets/x.png', 'public/fonts/fonts.css',
'public/vendor/pixi.min.js', 'data/local-assets.json', 'handoff/HANDOFF.md', '.github/workflows/ci.yml', 'types/core.js',
'eslint.config.js', 'jsconfig.json', 'Dockerfile', '.dockerignore', '.gitignore', '.gitattributes', 'AGENTS.md', '.env',
'server/.env.production', 'pv/clip.mp4', '3,9,11回合情况/note.txt', 'review/a.md', '.cache/x', 'server/__pycache__/a.pyc',
'public/.DS_Store', 'tools/local-extract/.venv-extract/x.py', 'node_modules/ws/index.js',
]);
for (const f of ['server/index.js', 'public/i18n/en.json', 'data/i18n/en.json', 'tools/assets/local-enemy-spines.json',
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/install-service-windows.ps1', 'docs/research/07-assets.json', 'CHANGELOG.md']) {
assert.ok(keep.includes(f), `keeps ${f}`);
}
assert.equal(keep.length, 34, keep.join(' '));
assert.equal(drop.length, 40, drop.join(' '));
assert.ok(!keep.some((f) => drop.includes(f)));
});
test('home-directory text and unsafe output directories', () => {
assert.equal(textHasHomePath('see /Users/example/game'), 'mac-users');
assert.equal(textHasHomePath('C:\\Users\\example\\game'), 'win-users');
assert.equal(textHasHomePath('prefix /home/example/game'), 'posix-home');
assert.equal(textHasHomePath('https://github.com/example/game'), null);
assert.equal(packageOutIsUnsafe(ROOT, ROOT), true);
assert.equal(packageOutIsUnsafe(path.dirname(ROOT), ROOT), true);
assert.equal(packageOutIsUnsafe(path.join(ROOT, 'dist-pack'), ROOT), false);
test('refusal list: 0.1.x\'s entries and what 0.2.0 leaves out, at the path or under it; .env and .venv anywhere', () => {
for (const r of ['pv', '3,9,11回合情况', 'review', 'docs/research/10-networking-hosting.md', '.cache', '.claude', '.git', 'logs',
'test/e2e/out', 'scripts/service.env.cmd', '.env', 'handoff', 'test', '.github']) assert.ok(REFUSE.includes(r), r);
for (const r of REFUSE) {
assert.ok(isRefused(r), r);
assert.ok(isRefused(`${r}/x`), `${r}/x`);
}
for (const p of ['data/.env', 'server/.env.local', '.venv-extract/lib/a.py', 'tools/.venv/x']) assert.ok(isRefused(p), p);
for (const p of ['server/index.js', 'docs/research/03-operators.json', 'testing.md', 'public/js/review.js']) assert.ok(!isRefused(p), p);
});
test('real git ls-files: runtime in, dev and private out', () => {
const got = new Set(selectTracked(trackedFiles(ROOT)));
assert.ok(got.has('server/index.js'));
assert.ok(got.has('tools/setup.mjs'));
assert.ok(got.has('docs/PLAYING.md'));
assert.ok(got.has('package-lock.json'));
assert.equal(got.has('tools/golden.mjs'), false);
assert.equal(got.has('tools/package.mjs'), false);
assert.equal(got.has('test/golden.test.js'), false);
assert.equal(got.has('docs/DESIGN.md'), false);
assert.equal(got.has('eslint.config.js'), false);
for (const rel of got) {
assert.equal(rel.startsWith('docs/research/'), false, rel);
assert.equal(rel.startsWith('test/'), false, rel);
assert.equal(rel.startsWith('public/dev/'), false, rel);
assert.equal(rel.startsWith('handoff/'), false, rel);
test('the real tracked tree: runtime in, the rest out; every shipped import, npm entry point, research table and spawned tool ships', () => {
const p = plan(ROOT, { lite: true, allowDirty: true, scan: false, measure: false });
assert.deepEqual(p.problems, []);
const got = new Set(p.files);
for (const f of ['server/index.js', 'tools/setup.mjs', 'tools/fetch-assets.mjs', 'scripts/start.sh', 'docs/PLAYING.md', 'docs/DEPLOY.md',
'package-lock.json', 'NOTICE.md', ...RUNTIME_RESEARCH]) assert.ok(got.has(f), f);
for (const f of ['tools/golden.mjs', 'tools/package.mjs', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'eslint.config.js', 'Dockerfile']) {
assert.ok(!got.has(f), f);
}
for (const f of got) assert.ok(!/^(?:test|handoff|\.github|types|public\/dev|docs\/img)\//.test(f), f);
const code = [...got].filter((f) => /\.(?:m?js|py)$/.test(f));
for (const f of code) {
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
// research tables named in shipped code (nodeData.js, fetch-assets.mjs, plan.mjs …)
for (const m of src.matchAll(/\b(\d\d-[a-z-]+\.json)\b/g)) assert.ok(got.has(`docs/research/${m[1]}`), `${f} names ${m[1]}`);
// files a shipped script starts or reads by path.join(ROOT, 'dir', …, 'file.ext') — setup's tools, launch's setup
for (const m of src.matchAll(/path\.join\(ROOT,\s*((?:'[^']+',\s*)*'[^']+\.[a-z]+')\)/g)) {
const rel = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]).join('/');
if (/^(?:tools|scripts|server|shared|docs)\//.test(rel)) assert.ok(got.has(rel), `${f} starts ${rel}`);
}
}
});
test('dry-run lists the lite set and prints both size estimates', () => {
const lite = spawnSync(process.execPath, ['tools/package.mjs', '--dry-run', '--lite'], {
cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
});
assert.equal(lite.status, 0, lite.stderr);
assert.match(lite.stdout, /^mode: lite/m);
assert.match(lite.stdout, /^dry-run: yes/m);
assert.match(lite.stdout, /^file server\/index\.js$/m);
assert.match(lite.stdout, /^file tools\/setup\.mjs$/m);
assert.match(lite.stdout, /^file docs\/PLAYING\.md$/m);
assert.match(lite.stdout, /^extra public\/vendor$/m);
assert.doesNotMatch(lite.stdout, /^file tools\/golden\.mjs$/m);
assert.doesNotMatch(lite.stdout, /^file test\//m);
assert.doesNotMatch(lite.stdout, /^extra public\/assets$/m);
assert.match(lite.stdout, /^uncompressed lite: \d+/m);
assert.match(lite.stdout, /^uncompressed full estimate: \d+/m);
assert.match(lite.stdout, /puppeteer-core/);
const full = spawnSync(process.execPath, ['tools/package.mjs', '--dry-run'], {
cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
});
assert.equal(full.status, 0, full.stderr);
assert.match(full.stdout, /^mode: full/m);
assert.match(full.stdout, /^extra public\/assets$/m);
assert.match(full.stdout, /^extra public\/fonts$/m);
const assets = Number((full.stdout.match(/^assets bytes: (\d+)/m) || [])[1]);
assert.ok(assets > 300 * 1048576, `assets bytes ${assets}`);
test('import specifiers resolve like the server mounts; npm entry points must ship', () => {
assert.equal(resolveSpecifier('public/js/ui/a.js', '../b.js'), 'public/js/b.js');
assert.equal(resolveSpecifier('public/js/a.js', '/sim/battle.js?v=2'), 'server/sim/battle.js');
assert.equal(resolveSpecifier('public/js/a.js', '/shared/constants.js'), 'shared/constants.js');
assert.equal(resolveSpecifier('public/js/a.js', '/js/store.js'), 'public/js/store.js');
assert.equal(resolveSpecifier('server/a.js', 'node:fs'), null);
assert.equal(resolveSpecifier('server/a.js', 'ws'), null);
const pkg = { main: 'server/index.js', scripts: { start: 'node server/index.js', setup: 'node tools/setup.mjs', doctor: 'node tools/golden.mjs',
launch: 'node scripts/launch.mjs', postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs && node tools/fetch-assets.mjs' } };
const shipped = ['server/index.js', 'tools/setup.mjs', 'scripts/launch.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs'];
assert.deepEqual(entryProblems(pkg, shipped), ['npm run doctor: tools/golden.mjs is not shipped']);
assert.deepEqual(entryProblems({ ...pkg, scripts: { ...pkg.scripts, doctor: undefined } }, shipped), ['npm script "doctor" is missing']);
});
test('output directory: never the repository, inside it or a parent of it', () => {
assert.equal(packageOutIsUnsafe(ROOT, ROOT), true);
assert.equal(packageOutIsUnsafe(path.dirname(ROOT), ROOT), true);
assert.equal(packageOutIsUnsafe(path.join(ROOT, 'dist'), ROOT), true);
assert.equal(packageOutIsUnsafe(path.join(os.tmpdir(), 'sp-release'), ROOT), false);
});
// ---------------------------------------------------------------------------------------------------------------------
// A temporary checkout: tracked runtime and non-runtime files, git-ignored fake art (one listed file with a URL-encoded
// name, an orphan like 焰狐龙梓兰's old files, the local-client extraction, fonts, OS clutter), private files.
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
function fakeCheckout() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-test-'));
const put = (rel, body = `// ${rel}\n`) => {
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
};
const scripts = { start: 'node server/index.js', setup: 'node tools/setup.mjs', doctor: 'node tools/doctor.mjs', launch: 'node scripts/launch.mjs',
postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs && node tools/fetch-assets.mjs',
test: 'node --test', golden: 'node tools/golden.mjs', package: 'node tools/package.mjs' };
put('package.json', JSON.stringify({ name: 'sp-test', version: '9.9.9', main: 'server/index.js', scripts, dependencies: {} }));
put('package-lock.json', JSON.stringify({ name: 'sp-test', version: '9.9.9', lockfileVersion: 3, packages: { '': { name: 'sp-test' } } }));
put('server/index.js', "import './sim/rng.js';\n");
put('server/sim/rng.js');
put('shared/constants.js');
put('public/index.html', '<!doctype html>\n');
put('public/js/main.js', "import { u } from './util.js';\nimport { h } from '../vendor/preact.module.js';\n");
put('public/js/util.js', 'export const u = 1;\n');
put('public/dev/mock.js');
put('data/chess.json', '{}\n');
put('data/assets.json', JSON.stringify({ chars: { a: { avatar: '/assets/char/a.png' } }, ui: { b: '/assets/ui/b%20c.png' }, fonts: { css: '/fonts/fonts.css' } }));
put('tools/setup.mjs', "import { n } from './assets/network.mjs';\n");
for (const f of ['tools/assets/network.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs', 'tools/crop-board-atlas.mjs',
'tools/local-extract/extract.py', 'tools/golden.mjs', 'tools/build-data.mjs', 'tools/package.mjs', 'scripts/launch.mjs',
'scripts/make-windows-bundle.mjs', 'test/a.test.js', 'handoff/HANDOFF.md', '.github/workflows/ci.yml', 'types/core.js', 'eslint.config.js',
'Dockerfile', 'AGENTS.md', 'review/notes.md', 'docs/DESIGN.md', 'docs/research/00-INDEX.md', 'docs/research/10-networking-hosting.md']) put(f);
put('scripts/start.sh', '#!/usr/bin/env bash\nexec node scripts/launch.mjs\n');
fs.chmodSync(path.join(dir, 'scripts/start.sh'), 0o755);
for (const f of ['README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'docs/PLAYING.md', 'docs/DEPLOY.md']) put(f, `# ${f}\n`);
for (const f of [...RUNTIME_RESEARCH, 'docs/research/01-core-data.json']) put(f, '{}\n');
put('docs/img/x.jpg', PNG);
put('.gitignore', 'public/assets/\npublic/fonts/\npublic/vendor/\ndata/local-assets.json\n.cache/\npv/\n');
// git-ignored: the art, the local extraction, fonts, caches, the promo folder
put('public/assets/char/a.png', PNG);
put('public/assets/ui/b c.png', PNG);
put('public/assets/char/char_1048_orchd2.png', PNG);
put('public/assets/local/g/x.webp', PNG);
put('public/assets/local/g/x.png', PNG);
put('public/assets/.DS_Store', 'junk');
put('public/fonts/fonts.css', '@font-face{}\n');
put('public/fonts/x.woff2', PNG);
put('data/local-assets.json', JSON.stringify({ version: 1, groups: { g: { x: { path: '/assets/local/g/x.webp' } } } }));
put('.cache/assets-report.json', '{}');
put('pv/clip.txt', 'promo');
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
assert.equal(git('init', '-q').status, 0);
assert.equal(git('add', '-A').status, 0);
return { dir, put, rm: (rel) => fs.rmSync(path.join(dir, rel), { force: true }) };
}
const runTool = (args) => spawnSync(process.execPath, [TOOL, ...args], { encoding: 'utf8', env: { ...process.env, ...ENV }, maxBuffer: 16 * 1024 * 1024 });
const listed = (stdout) => stdout.split('\n').filter((l) => l.startsWith('file ')).map((l) => l.slice(5));
const SHIPPED_TRACKED = ['CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'README.md', 'THIRD-PARTY-NOTICES.md', 'data/assets.json', 'data/chess.json',
'docs/DEPLOY.md', 'docs/PLAYING.md', ...RUNTIME_RESEARCH, 'package-lock.json', 'package.json', 'public/index.html', 'public/js/main.js',
'public/js/util.js', 'scripts/launch.mjs', 'scripts/start.sh', 'server/index.js', 'server/sim/rng.js', 'shared/constants.js',
'tools/assets/network.mjs', 'tools/crop-board-atlas.mjs', 'tools/doctor.mjs', 'tools/fetch-assets.mjs', 'tools/local-extract/extract.py',
'tools/setup.mjs', 'tools/vendor.mjs'].sort();
const ART = ['data/local-assets.json', 'public/assets/char/a.png', 'public/assets/local/g/x.png', 'public/assets/local/g/x.webp',
'public/assets/ui/b c.png', 'public/fonts/fonts.css', 'public/fonts/x.woff2'];
test('dry-run on a temporary checkout: the full and the lite file lists, the summary, nothing written', () => {
const { dir } = fakeCheckout();
try {
const before = fs.readdirSync(dir).sort();
const full = runTool(['--root', dir, '--dry-run', '--list', '--allow-dirty']);
assert.equal(full.status, 0, full.stdout + full.stderr);
assert.deepEqual(listed(full.stdout).sort(), [...SHIPPED_TRACKED, ...ART].sort());
assert.match(full.stdout, /^package: full · version 9\.9\.9$/m);
assert.match(full.stdout, /^left out art: 1 files under public\/assets that nothing lists/m, 'the orphan (an excluded operator\'s old file)');
assert.match(full.stdout, /^left out: .*test\/ 1 .*$/m);
assert.match(full.stdout, /^checks: ok$/m);
const lite = runTool(['--root', dir, '--dry-run', '--list', '--lite', '--allow-dirty']);
assert.equal(lite.status, 0, lite.stdout + lite.stderr);
assert.deepEqual(listed(lite.stdout), SHIPPED_TRACKED, 'lite: the same runtime files, no art, fonts or local manifest');
assert.match(lite.stdout, /^package: lite \(no game art\)/m);
assert.deepEqual(fs.readdirSync(dir).sort(), before, 'a dry-run writes nothing');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('scans and guards: home paths and the account name in shipped files (art too), missing art, a stray import, uncommitted files', () => {
const { dir, put, rm } = fakeCheckout();
const check = (opts = {}) => plan(dir, { allowDirty: true, env: ENV, ...opts }).problems;
try {
assert.deepEqual(check(), []);
assert.deepEqual(check({ lite: true }), []);
put('public/js/util.js', 'export const u = "/Users/someone/game";\n');
assert.deepEqual(check(), ['personal info: public/js/util.js (home-directory path)']);
put('public/js/util.js', 'export const u = 1;\n');
put('data/chess.json', '{"p": "C:\\\\Users\\\\Someone\\\\x"}\n');
assert.deepEqual(check({ lite: true }), ['personal info: data/chess.json (home-directory path)'], 'Windows path, JSON-escaped');
put('data/chess.json', '{}\n');
put('public/assets/char/a.png', Buffer.concat([PNG, Buffer.from('..PlantedName..')]));
assert.deepEqual(check(), ['personal info: public/assets/char/a.png (account name)'], 'binary art, any case');
assert.deepEqual(check({ lite: true }), [], 'the lite zip has no art');
put('public/assets/char/a.png', PNG);
put('test/a.test.js', '// /Users/someone plantedname\n');
assert.deepEqual(check(), [], 'files that never ship are not scanned');
rm('public/assets/ui/b c.png');
assert.match(check().join('\n'), /^missing art: public\/assets\/ui\/b c\.png/);
assert.deepEqual(check({ lite: true }), []);
put('public/assets/ui/b c.png', PNG);
put('public/js/main.js', "import { u } from './util.js';\nimport '../dev/mock.js';\n");
assert.deepEqual(check({ lite: true }), ['public/js/main.js:2 imports ../dev/mock.js (not shipped)']);
assert.ok(plan(dir, { lite: true, env: ENV, scan: false }).problems.some((x) => x.startsWith('uncommitted change: server/index.js')),
'staged, never committed: refused without --allow-dirty');
const cli = runTool(['--root', dir, '--dry-run', '--lite', '--allow-dirty']);
assert.equal(cli.status, 1, 'a problem fails the dry-run');
assert.match(cli.stdout, /^PROBLEMS \(1\):\n {2}public\/js\/main\.js:2 imports \.\.\/dev\/mock\.js/m);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// tools/package.mjs zips with `zip`, else with a bsdtar `tar --format zip` (Windows 10+, macOS; GNU tar cannot)
const hasZipTool = !spawnSync('zip', ['-v'], { encoding: 'utf8' }).error
|| /bsdtar/.test(spawnSync('tar', ['--version'], { encoding: 'utf8' }).stdout || '');
const hasUnzip = !spawnSync('unzip', ['-h'], { encoding: 'utf8' }).error;
test('a --no-install build of the temporary checkout zips exactly the plan in one Stronghold-Protocol/ folder', { skip: !hasZipTool && 'no zip / tar' }, () => {
const { dir } = fakeCheckout();
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-out-'));
try {
const r = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty']);
assert.equal(r.status, 0, r.stdout + r.stderr);
const zip = path.join(out, `${FOLDER}-v9.9.9.zip`);
assert.ok(fs.statSync(zip).size > 0);
assert.deepEqual(fs.readdirSync(out), [path.basename(zip)], 'the stage is removed after zipping');
if (hasUnzip) {
const entries = spawnSync('unzip', ['-Z1', zip], { encoding: 'utf8' }).stdout.split('\n').filter((l) => l && !l.endsWith('/'));
assert.deepEqual(entries.sort(), [...SHIPPED_TRACKED, ...ART].map((f) => `${FOLDER}/${f}`).sort());
}
const again = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty']);
assert.equal(again.status, 1, 'an existing zip is not replaced without --force');
assert.match(again.stderr, /already exists \(--force replaces it\)/);
const inRepo = runTool(['--root', dir, '--out', path.join(dir, 'dist'), '--no-install', '--allow-dirty', '--lite']);
assert.equal(inRepo.status, 1);
assert.match(inRepo.stderr, /refusing to write into the repository/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(out, { recursive: true, force: true });
}
});
test('the personal scan reads bytes, never reports the matched text, and skips files it cannot read', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-'));
try {
fs.writeFileSync(path.join(dir, 'a.bin'), Buffer.from([0, 1, 2, ...Buffer.from('/home/someone'), 3]));
fs.writeFileSync(path.join(dir, 'b.txt'), 'see https://github.com/users/x and /usr/lib');
fs.writeFileSync(path.join(dir, 'c.txt'), 'Built by PLANTEDNAME');
assert.deepEqual(scanFiles(dir, ['a.bin', 'b.txt', 'c.txt', 'missing.txt'], { names: ['plantedname'] }),
['a.bin (home-directory path)', 'c.txt (account name)']);
assert.deepEqual(scanFiles(dir, ['a.bin', 'c.txt'], { home: false, names: [] }), [], 'node_modules: the names only');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+6 -1
View File
@@ -112,7 +112,12 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
for (const u of [e.skel, e.atlas, ...e.textures]) files.add(u.replace(/^\/assets\//, ''));
return e;
}
if (Array.isArray(node)) return node.map((x, i) => walk(x, `${path}[${i}]`)).filter((x) => x !== undefined);
if (Array.isArray(node)) {
const lines = node.map((x, i) => walk(x, `${path}[${i}]`)).filter((x) => x !== undefined);
// An array whose every line is missing on disk is a missing entry, like a missing leaf (e.g. a voice slot whose
// lines were never downloaded here): dropped, never an empty [] that reads as a slot with no lines.
return lines.length || !node.length ? lines : undefined;
}
if (typeof node !== 'object') return node;
const out = {};
for (const [k, v] of Object.entries(node)) {
+5 -2
View File
@@ -595,8 +595,11 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
// --- module type icons ----------------------------------------------------
const modules = {};
for (const t of [...new Set(moduleTypes || [])].filter((x) => typeof x === 'string' && /^[a-z0-9-]+$/i.test(x)).sort()) {
// the client's file name, else its lower-case form (the type id of a few modules is mixed case: WAH-Y → wah-y.png)
modules[t] = leaf(alt(`module/${safeName(t)}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
// the client's file name, else its lower-case form (the type id of a few modules is mixed case: WAH-Y → wah-y.png).
// One lower-case file per type: the official data spells one DEC X module 'dec-X' (uniequip_003_aglina) and the
// others 'dec-x'; two paths that differ only in case are one file on Windows / macOS (and in a release zip built or
// extracted there), so a case-sensitive server would miss one of them.
modules[t] = leaf(alt(`module/${safeName(t).toLowerCase()}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
}
// --- 干员战斗语音 (excel/charword_table.json → audio.voice) ---------------------------------------------
+15 -3
View File
@@ -158,6 +158,19 @@ export function shrinkGuard(prev, next, { allowShrink = false, prune = false } =
return { dropped, write: dropped.length === 0 || !!allowShrink || !!prune };
}
/**
* Files under public/assets the manifest does not reference (`--prune` deletes them). public/assets/local/** belongs to
* tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete all of it. Compared without
* case: on Windows / macOS a listed path and a file whose name differs only in case are one file (module/WAH-Y.png on
* disk serves the listed module/wah-y.png), which --prune must not delete.
* @param {string[]} onDisk forward-slash paths relative to public/assets
* @param {Iterable<string>} referenced the manifest's files, same form
*/
export function orphanFiles(onDisk, referenced) {
const listed = new Set([...referenced].map((r) => r.toLowerCase()));
return onDisk.filter((r) => !listed.has(r.toLowerCase()) && !r.startsWith('local/'));
}
const mb = (n) => `${(n / 1048576).toFixed(1)} MB`;
const log = (m) => console.log(m);
@@ -363,9 +376,8 @@ async function main() {
const guard = shrinkGuard(current, manifest, opts);
if (guard.write) await writeJsonAtomic(MANIFEST, manifest);
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change). public/assets/local/**
// belongs to tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete all of it.
const orphans = (await listFiles(ASSETS)).filter((r) => !resolved.files.has(r) && !r.startsWith('local/'));
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change).
const orphans = orphanFiles(await listFiles(ASSETS), resolved.files);
if (opts.prune) for (const r of orphans) { try { await unlink(join(ASSETS, r)); } catch { /* ignore */ } }
const charIds = Object.keys(assets07.operators || {});
+455 -293
View File
@@ -1,343 +1,505 @@
// Player package: git-tracked runtime files, production dependencies, and (unless --lite) local art.
#!/usr/bin/env node
// tools/package.mjs — the two player release zips (docs/DEPLOY.md §7; README「方式一」says which one to pick).
//
// scripts/make-windows-bundle.mjs is the Windows portable-Node pack. This tool is the release zip:
// a runtime allowlist, not the whole tree. The Docker image still copies docs/research as a sim
// fallback; this zip does not. Players on --lite run `npm run setup` to download art and fonts.
// npm run package → Stronghold-Protocol-v<version>.zip full: what runs the game + the game art
// npm run package:lite → Stronghold-Protocol-v<version>-lite.zip the same without the art (`npm run setup`
// downloads it on the first start)
// node tools/package.mjs [--lite] [--dry-run [--list]] [--out <dir>] [--force] [--keep-stage] [--no-install]
// [--allow-dirty] [--root <dir>]
//
// node tools/package.mjs [--dry-run] [--lite] [--out <dir>] [--no-install]
// Both zips hold one folder, Stronghold-Protocol/, with only what a player runs — an allowlist over `git ls-files`, so
// untracked work, caches, logs and per-machine config never get in: server/, shared/, data/, public/ (not public/dev/),
// the start scripts, the tools a player runs (setup, vendor = the postinstall, fetch-assets + tools/assets, doctor, and
// what setup starts: tools/local-extract, crop-board-atlas), the research tables the Node server (server/sim/
// nodeData.js fallback) and fetch-assets read, package.json / package-lock.json, LICENSE / NOTICE.md /
// THIRD-PARTY-NOTICES.md, README.md, CHANGELOG.md, docs/PLAYING.md and docs/DEPLOY.md. `npm ci --omit=dev` in the
// stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the
// files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/,
// data/local-assets.json) when present — nothing else on disk, so art the data no longer lists (焰狐龙梓兰, left out of
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it.
// Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …),
// scripts/make-windows-bundle.mjs, the other docs (DESIGN, SIM, the research notes, docs/img …), public/dev/, handoff/,
// .github/, types/, lint / editor / Docker files (Docker builds from a git clone).
//
// --dry-run prints the file list and a size summary and writes nothing.
// A real full zip is large because public/assets is included. Do not point --out at the repo.
// Checks, all of them in --dry-run too (which writes nothing): the refusal list below; every relative import of a
// shipped module and every `node <file>` of the player npm scripts resolves to a shipped file; a full zip has every file
// data/assets.json and data/local-assets.json list; no shipped file carries a home-directory path (/Users/…,
// C:\Users\…, /home/…) or this machine's account name (text and binary, art included; node_modules only for the name);
// no shipped tracked file has an uncommitted change (--allow-dirty skips that). A build also checks that the stage holds
// exactly the plan before zipping.
// The account name comes from the OS at run time (never written in the repository): SP_PACKAGE_SCAN_USER=0 skips it
// (a name that is a common word), SP_PACKAGE_SCAN_NAMES=a,b adds more names to refuse.
//
// --out defaults to <tmp>/stronghold-protocol-release and must be outside the repository. --no-install skips npm ci
// (no node_modules, no public/vendor: a test build). Zipping needs `zip` (or a bsdtar `tar`, e.g. Windows 10+).
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { findSpecifiers } from './check-imports.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/** The one folder inside both zips (DEPLOY §1.1: "解压后把里面的 Stronghold-Protocol 文件夹放到…"). */
export const FOLDER = 'Stronghold-Protocol';
const ROOT_FILES = new Set([
'package.json', 'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md',
'README.md', 'CHANGELOG.md', 'Dockerfile', '.dockerignore',
]);
/** Root files a player gets. */
export const ROOT_FILES = ['package.json', 'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'README.md', 'CHANGELOG.md'];
/** The player docs. */
export const PLAYER_DOCS = ['docs/PLAYING.md', 'docs/DEPLOY.md'];
/** Research tables read at run time: server/sim/nodeData.js (the Node sim's fallback) and tools/fetch-assets.mjs. */
export const RUNTIME_RESEARCH = ['docs/research/03-operators.json', 'docs/research/05-enemies.json', 'docs/research/05-maps.json', 'docs/research/07-assets.json'];
/** The start scripts (scripts/make-windows-bundle.mjs is the maintainer's Windows pack, docs/WINDOWS.md). */
export const PLAYER_SCRIPTS = ['scripts/install-service-windows.ps1', 'scripts/launch.mjs', 'scripts/open-browser.mjs',
'scripts/run-server.cmd', 'scripts/start-windows.bat', 'scripts/start-windows.ps1', 'scripts/start.sh'];
/** The tools a player runs (npm run setup / doctor / assets, the postinstall) and the ones setup starts. */
export const PLAYER_TOOLS = ['tools/crop-board-atlas.mjs', 'tools/doctor.mjs', 'tools/fetch-assets.mjs', 'tools/setup.mjs', 'tools/vendor.mjs'];
/** Whole tool directories: fetch-assets' modules, the local-client extraction setup runs. */
export const PLAYER_TOOL_DIRS = ['tools/assets/', 'tools/local-extract/'];
/** Whole runtime directories (their tracked files). */
export const RUNTIME_DIRS = ['server/', 'shared/', 'data/', 'public/'];
/** Never from the tracked list: the dev pages, and what only the art plan adds (or npm ci writes). */
const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'public/vendor/'];
/** The npm scripts a player runs: each `node <file>` of them must ship. */
export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postinstall', 'vendor', 'assets'];
const PLAYER_DOCS = new Set([
'docs/PLAYING.md', 'docs/DEPLOY.md', 'docs/WINDOWS.md', 'docs/ASSETS.md',
]);
/**
* Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and
* what 0.2.0 leaves out on purpose. A path is refused when it is one of these or lies under one.
*/
export const REFUSE = ['pv', '3,9,11回合情况', 'review', 'docs/research/10-networking-hosting.md', '.cache', '.claude', '.git',
'logs', 'test/e2e/out', 'scripts/service.env.cmd', '.env', 'handoff',
'test', '.github', 'AGENTS.md', 'public/dev', 'node_modules/.cache'];
const RUNTIME_TOOLS = new Set([
'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs',
'tools/doctor.mjs', 'tools/crop-board-atlas.mjs',
]);
/** Home-directory paths: macOS / Linux (case as the OS writes them) and Windows (any case; / or \, JSON-escaped too). */
const HOME_POSIX = /\/Users\/[A-Za-z]|\/home\/[A-Za-z]/;
const HOME_WIN = /[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}[A-Za-z]/i;
/** OS account names too generic to refuse (CI runners, containers). */
const GENERIC_USERS = new Set(['root', 'user', 'admin', 'node', 'runner', 'ubuntu', 'ci', 'guest', 'app', 'build']);
const DROP_PREFIXES = [
'test/', 'public/dev/', 'public/assets/', 'public/fonts/', 'docs/research/',
'handoff/', 'node_modules/', '.cache/', 'pv/', '3,9,11回合情况/',
];
export const posixRel = (rel) => String(rel).split('\\').join('/');
const TEXT_EXT = new Set([
'.js', '.mjs', '.cjs', '.json', '.md', '.html', '.css', '.txt', '.yml', '.yaml',
'.ps1', '.sh', '.cmd', '.bat', '.py', '.svg',
]);
const HOME_PATTERNS = [
{ name: 'mac-users', re: /\/Users\// },
{ name: 'win-users', re: /[A-Za-z]:\\Users\\/ },
{ name: 'posix-home', re: /\/home\// },
];
export function posixRel(rel) {
return String(rel).split('\\').join('/');
}
/** True when the path must never ship, even if an allow rule would match. */
export function isExcluded(rel) {
/** True for a path the refusal list names (or lies under), a `.env` file or a `.venv*` directory anywhere. */
export function isRefused(rel) {
const p = posixRel(rel);
const base = p.slice(p.lastIndexOf('/') + 1);
const parts = p.split('/');
const base = parts[parts.length - 1];
if (base === '.env' || base.startsWith('.env.')) return true;
if (p === 'AGENTS.md' || p === 'docs/research/10-networking-hosting.md') return true;
return DROP_PREFIXES.some((pre) => p === pre.slice(0, -1) || p.startsWith(pre));
if (parts.some((s) => s.startsWith('.venv'))) return true;
return REFUSE.some((r) => p === r || p.startsWith(`${r}/`));
}
/** Git-tracked paths that belong in the player package. `lite` does not change this set. */
export function selectTracked(paths, _opts = {}) {
const out = [];
for (const raw of paths) {
const rel = posixRel(raw);
if (!rel || isExcluded(rel)) continue;
if (allowed(rel)) out.push(rel);
}
out.sort();
return out;
/** OS / editor / Python clutter that never ships (the old rsync excludes). */
export function isJunk(rel) {
const parts = posixRel(rel).split('/');
const base = parts[parts.length - 1];
if (parts.includes('__pycache__')) return true;
if (base === '.DS_Store' || base === 'Thumbs.db' || base === 'desktop.ini' || base.startsWith('._')) return true;
return /\.(?:py[cod]|log|tmp|swp)$/i.test(base);
}
function allowed(rel) {
if (rel.startsWith('server/') || rel.startsWith('shared/')) return true;
if (rel.startsWith('public/')) return true;
if (rel.startsWith('data/')) return rel !== 'data/local-assets.json';
if (rel.startsWith('scripts/')) return true;
if (RUNTIME_TOOLS.has(rel)) return true;
if (rel.startsWith('tools/assets/') || rel.startsWith('tools/local-extract/')) return true;
if (PLAYER_DOCS.has(rel) || rel.startsWith('docs/img/')) return true;
return ROOT_FILES.has(rel);
}
/** Directories and files copied in addition to the git allowlist. */
export function extraPaths({ lite, hasLocalManifest }) {
const paths = ['public/vendor'];
if (!lite) {
paths.push('public/assets', 'public/fonts');
if (hasLocalManifest) paths.push('data/local-assets.json');
}
return paths;
}
export function textHasHomePath(text) {
for (const { name, re } of HOME_PATTERNS) {
if (re.test(text)) return name;
}
return null;
}
/** True when `out` is the repo or a parent of it (a wipe there would take the repo with it). */
export function packageOutIsUnsafe(out, root = ROOT) {
const r = path.resolve(root);
const o = path.resolve(out);
if (o === r) return true;
const rel = path.relative(o, r);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
export function trackedFiles(root = ROOT) {
const r = spawnSync('git', ['-C', root, 'ls-files', '-z'], { maxBuffer: 32 * 1024 * 1024 });
if (r.error || r.status !== 0) {
throw new Error('git ls-files failed; package only tracked runtime files, so run it inside the repo');
}
return r.stdout.toString('utf8').split('\0').filter(Boolean);
}
function fileBytes(abs) {
try {
const st = fs.statSync(abs);
return st.isFile() ? st.size : 0;
} catch {
return 0;
}
}
/** Bytes under abs, following symlinks. Skips nested node_modules and .cache. */
export function treeBytes(abs) {
let total = 0;
const seen = new Set();
function walk(dir) {
let real;
try { real = fs.realpathSync(dir); } catch { return; }
if (seen.has(real)) return;
seen.add(real);
let entries;
try { entries = fs.readdirSync(real, { withFileTypes: true }); } catch { return; }
for (const ent of entries) {
if (ent.name === 'node_modules' || ent.name === '.cache') continue;
const p = path.join(real, ent.name);
let st;
try { st = fs.statSync(p); } catch { continue; }
if (st.isDirectory()) walk(p);
else if (st.isFile()) total += st.size;
}
}
try {
const st = fs.statSync(abs);
if (st.isFile()) return st.size;
if (st.isDirectory()) walk(abs);
} catch {
return 0;
}
return total;
}
/** Uncompressed size of production lockfile entries present under root/node_modules. Dev entries are skipped. */
export function prodModulesBytes(root, lock) {
let total = 0;
const packages = lock?.packages || {};
for (const [key, entry] of Object.entries(packages)) {
if (!key || entry.dev) continue;
total += treeBytes(path.join(root, key));
}
return total;
}
function exists(root, rel) {
try { return fs.statSync(path.join(root, rel)).isFile() || fs.statSync(path.join(root, rel)).isDirectory(); }
catch { return false; }
/** Whether a tracked file belongs in the player package. */
export function isPlayerFile(rel) {
const p = posixRel(rel);
if (!p || isRefused(p) || isJunk(p) || p === 'data/local-assets.json') return false;
if (NOT_TRACKED_SHIP.some((d) => p.startsWith(d))) return false;
if (RUNTIME_DIRS.some((d) => p.startsWith(d)) || PLAYER_TOOL_DIRS.some((d) => p.startsWith(d))) return true;
return ROOT_FILES.includes(p) || PLAYER_DOCS.includes(p) || RUNTIME_RESEARCH.includes(p) || PLAYER_SCRIPTS.includes(p) || PLAYER_TOOLS.includes(p);
}
/**
* Selection plus uncompressed byte counts. Does not copy or install.
* @param {string} root
* @param {{ lite?: boolean, paths?: string[] }} [opts]
* Split tracked paths into what ships and what is left out (both sorted).
* @param {string[]} paths
* @returns {{ keep: string[], drop: string[] }}
*/
export function measure(root, opts = {}) {
const lite = !!opts.lite;
const paths = opts.paths || trackedFiles(root);
const tracked = selectTracked(paths, { lite });
let trackedBytes = 0;
for (const rel of tracked) trackedBytes += fileBytes(path.join(root, rel));
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
const hasLocal = exists(root, 'data/local-assets.json');
const vendorBytes = treeBytes(path.join(root, 'public', 'vendor'));
const fontsBytes = treeBytes(path.join(root, 'public', 'fonts'));
const assetsBytes = treeBytes(path.join(root, 'public', 'assets'));
const manifestBytes = hasLocal ? fileBytes(path.join(root, 'data', 'local-assets.json')) : 0;
const prodBytes = prodModulesBytes(root, lock);
const liteBytes = trackedBytes + vendorBytes + prodBytes;
const fullBytes = liteBytes + fontsBytes + assetsBytes + manifestBytes;
return {
version: pkg.version,
lite,
tracked,
extras: extraPaths({ lite, hasLocalManifest: hasLocal }),
trackedBytes,
vendorBytes,
fontsBytes,
assetsBytes,
manifestBytes,
prodBytes,
liteBytes,
fullBytes,
prodDeps: Object.keys(pkg.dependencies || {}).sort(),
devDeps: Object.keys(pkg.devDependencies || {}).sort(),
assetsPresent: exists(root, 'public/assets'),
fontsPresent: exists(root, 'public/fonts'),
vendorPresent: exists(root, 'public/vendor'),
};
export function selectTracked(paths) {
const keep = [];
const drop = [];
for (const raw of paths) {
const p = posixRel(raw);
if (p) (isPlayerFile(p) ? keep : drop).push(p);
}
return { keep: keep.sort(), drop: drop.sort() };
}
export function formatSummary(m) {
const mb = (n) => `${(n / 1048576).toFixed(1)} MB`;
const lines = [
`mode: ${m.lite ? 'lite' : 'full'}`,
`version: ${m.version}`,
`tracked files: ${m.tracked.length}`,
`tracked bytes: ${m.trackedBytes}`,
`vendor bytes: ${m.vendorBytes}${m.vendorPresent ? '' : ' (absent)'}`,
`fonts bytes: ${m.fontsBytes}${m.lite ? ' (omitted in lite)' : ''}`,
`assets bytes: ${m.assetsBytes}${m.lite ? ' (omitted in lite)' : ''}`,
`local manifest bytes: ${m.manifestBytes}${m.lite ? ' (omitted in lite)' : ''}`,
`production node_modules estimate: ${m.prodBytes}`,
`uncompressed lite: ${m.liteBytes} (${mb(m.liteBytes)})`,
`uncompressed full estimate: ${m.fullBytes} (${mb(m.fullBytes)})`,
`production dependencies: ${m.prodDeps.join(' ')}`,
`omitted devDependencies: ${m.devDeps.join(' ')}`,
];
return lines.join('\n') + '\n';
export function trackedFiles(root) {
const r = spawnSync('git', ['-C', root, 'ls-files', '-z'], { maxBuffer: 64 * 1024 * 1024 });
if (r.error || r.status !== 0) throw new Error('git ls-files failed: the package takes tracked files only, run it in a git checkout');
return r.stdout.toString('utf8').split('\0').filter(Boolean);
}
function copyRel(root, stage, rel) {
const src = path.join(root, rel);
const dest = path.join(stage, rel);
if (!fs.existsSync(src)) throw new Error(`missing ${rel}`);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.cpSync(src, dest, { dereference: true, recursive: true });
/** Tracked files of `files` with uncommitted changes (staged or not). */
export function dirtyFiles(root, files) {
const r = spawnSync('git', ['-C', root, 'status', '--porcelain=v1', '-z', '--untracked-files=no'], { maxBuffer: 64 * 1024 * 1024 });
if (r.error || r.status !== 0) throw new Error('git status failed');
const want = new Set(files);
const out = [];
const parts = r.stdout.toString('utf8').split('\0');
for (let i = 0; i < parts.length; i++) {
const e = parts[i];
if (e.length < 4) continue;
if (e[0] === 'R' || e[0] === 'C') i++; // the rename's source follows
const p = e.slice(3);
if (want.has(p)) out.push(p);
}
return out.sort();
}
function scanStage(stage) {
const hits = [];
function walk(dir, rel) {
const isFile = (abs) => { try { return fs.statSync(abs).isFile(); } catch { return false; } };
const fileBytes = (abs) => { try { return fs.statSync(abs).size; } catch { return 0; } };
const readJson = (abs) => { try { return JSON.parse(fs.readFileSync(abs, 'utf8')); } catch { return null; } };
/** Files under root/rel (following symlinks), as posix paths relative to root; junk left out unless `junk`. */
export function listTree(root, rel, { junk = false } = {}) {
const out = [];
const walk = (abs, r) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const ent of entries) {
const child = rel ? `${rel}/${ent.name}` : ent.name;
if (child === 'node_modules' || child.startsWith('node_modules/') || child.startsWith('public/assets') || child.startsWith('public/fonts')) continue;
const abs = path.join(dir, ent.name);
if (ent.isDirectory()) { walk(abs, child); continue; }
if (!ent.isFile()) continue;
const ext = path.extname(ent.name).toLowerCase();
const named = ent.name === 'Dockerfile' || ent.name === '.dockerignore';
if (!named && !TEXT_EXT.has(ext)) continue;
let text;
try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
const hit = textHasHomePath(text);
if (hit) hits.push(`${child} (${hit})`);
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const child = r ? `${r}/${e.name}` : e.name;
if (!junk && isJunk(child)) continue;
const a = path.join(abs, e.name);
let st;
try { st = fs.statSync(a); } catch { continue; }
if (st.isDirectory()) walk(a, child);
else if (st.isFile()) out.push(child);
}
};
walk(path.join(root, rel), posixRel(rel));
return out.sort();
}
/** The '/assets/…' and '/fonts/…' URLs of a manifest, as public/… paths (the walk of tools/setup.mjs checkAssets). */
export function manifestFiles(manifest) {
const out = new Set();
const walk = (v) => {
if (typeof v === 'string') {
if (/^\/(?:assets|fonts)\//.test(v)) out.add(`public/${v.split('/').filter(Boolean).map(decodeURIComponent).join('/')}`);
} else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x);
};
walk(manifest);
return out;
}
/**
* The art of a full package: every file data/assets.json lists, public/fonts, public/assets/local/ and
* data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets
* that nothing lists (left out).
*/
export function artPlan(root, { lite = false } = {}) {
if (lite) return { files: [], missing: [], orphans: [], local: false };
const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {});
const local = isFile(path.join(root, 'data', 'local-assets.json'));
if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f);
const files = new Set();
const missing = [];
for (const f of listed) {
if (isFile(path.join(root, f))) files.add(f);
else missing.push(f);
}
for (const f of listTree(root, 'public/fonts')) files.add(f);
if (local) {
for (const f of listTree(root, 'public/assets/local')) files.add(f);
files.add('data/local-assets.json');
}
// without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles)
const lower = new Set([...files].map((f) => f.toLowerCase()));
const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase()));
return { files: [...files].sort(), missing: missing.sort(), orphans, local };
}
const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']];
/** The repository path an import specifier of `fromRel` names, or null (an npm package or a Node builtin). */
export function resolveSpecifier(fromRel, spec) {
const s = spec.replace(/[?#].*$/, '');
if (s.startsWith('./') || s.startsWith('../')) return path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), s));
if (!s.startsWith('/')) return null;
for (const [prefix, dir] of URL_MOUNTS) if (s.startsWith(prefix)) return dir + s.slice(prefix.length);
return `public${s}`; // the server's static mounts (server/http/static.js)
}
/**
* Imports of shipped modules that name a repository file the package lacks. public/vendor/ is npm ci's postinstall
* output; a specifier naming no file here (an import inside a template string, e.g. server/http/static.js's /data.js
* shim) is not an import of the package.
*/
export function importProblems(root, files) {
const shipped = new Set(files);
const out = [];
for (const rel of files) {
if (!/\.(?:m?js|cjs)$/.test(rel)) continue;
let src;
try { src = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; }
for (const { spec, line } of findSpecifiers(src)) {
const target = resolveSpecifier(rel, spec);
if (!target || shipped.has(target) || target.startsWith('public/vendor/') || !isFile(path.join(root, target))) continue;
out.push(`${rel}:${line} imports ${spec} (not shipped)`);
}
}
walk(stage, '');
return out;
}
/** The player npm scripts (and `main`) must start shipped files. */
export function entryProblems(pkg, files) {
const shipped = new Set(files);
const out = [];
if (pkg?.main && !shipped.has(pkg.main)) out.push(`package.json main ${pkg.main} is not shipped`);
for (const name of PLAYER_NPM_SCRIPTS) {
const cmd = pkg?.scripts?.[name];
if (!cmd) { out.push(`npm script "${name}" is missing`); continue; }
for (const m of cmd.matchAll(/\bnode\s+(?:--[\w-]+\s+)*([\w./-]+\.m?js)\b/g)) {
if (!shipped.has(m[1])) out.push(`npm run ${name}: ${m[1]} is not shipped`);
}
}
return out;
}
/**
* Names refused in the personal scan: this machine's account name (unless SP_PACKAGE_SCAN_USER=0, a generic or short
* one) and SP_PACKAGE_SCAN_NAMES (comma-separated).
*/
export function scanNames(env = process.env) {
const names = [];
if (env.SP_PACKAGE_SCAN_USER !== '0') {
let user = '';
try { user = os.userInfo().username; } catch { /* no passwd entry */ }
if (user.length >= 3 && !GENERIC_USERS.has(user.toLowerCase())) names.push(user);
}
for (const n of String(env.SP_PACKAGE_SCAN_NAMES || '').split(',')) if (n.trim().length >= 3) names.push(n.trim());
return [...new Set(names)];
}
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Files whose bytes (read as latin1, so binaries are scanned too) carry a home-directory path (`home`) or one of
* `names` (case-insensitive). Returns "path (what)" lines, never the matched text.
*/
export function scanFiles(root, files, { home = true, names = [] } = {}) {
const nameRe = names.length ? new RegExp(names.map((n) => escapeRe(Buffer.from(n, 'utf8').toString('latin1'))).join('|'), 'i') : null;
const hits = [];
for (const rel of files) {
let text;
try { text = fs.readFileSync(path.join(root, rel)).toString('latin1'); } catch { continue; }
if (home && (HOME_POSIX.test(text) || HOME_WIN.test(text))) hits.push(`${rel} (home-directory path)`);
else if (nameRe && nameRe.test(text)) hits.push(`${rel} (account name)`);
}
return hits;
}
function build(root, m, { out, noInstall }) {
if (packageOutIsUnsafe(out, root)) throw new Error(`refusing to write the package into the repo or a parent of it: ${out}`);
const folder = `Stronghold-Protocol-${m.version}${m.lite ? '-lite' : ''}`;
const stage = path.join(out, folder);
if (fs.existsSync(stage)) throw new Error(`package directory already exists: ${stage}`);
if (!m.lite) {
if (!m.assetsPresent) throw new Error('public/assets is missing; a full package needs the local art (or pass --lite)');
if (!m.fontsPresent) throw new Error('public/fonts is missing; a full package needs the local fonts (or pass --lite)');
/** Production lockfile entries present under root/node_modules, as file lists relative to root. */
function prodModuleFiles(root, lock) {
const out = [];
for (const [key, entry] of Object.entries(lock?.packages || {})) {
if (!key.startsWith('node_modules/') || entry.dev || entry.link) continue;
for (const f of listTree(root, key)) if (!f.slice(key.length + 1).includes('node_modules/')) out.push(f);
}
fs.mkdirSync(stage, { recursive: true });
for (const rel of m.tracked) copyRel(root, stage, rel);
for (const rel of m.extras) {
if (!fs.existsSync(path.join(root, rel))) {
if (rel === 'data/local-assets.json') continue;
if (rel === 'public/vendor' && !noInstall) continue; // npm ci postinstall (tools/vendor.mjs) fills it
throw new Error(`missing ${rel}`);
}
copyRel(root, stage, rel);
return out;
}
const sumBytes = (root, files) => files.reduce((n, f) => n + fileBytes(path.join(root, f)), 0);
/** Group left-out paths for the summary: `test/`, `tools/`, `docs/research/`, `(root)` … */
function groupOf(rel) {
const s = rel.split('/');
if (s.length === 1) return '(root)';
if ((s[0] === 'docs' || s[0] === 'public') && s.length > 2) return `${s[0]}/${s[1]}/`;
return `${s[0]}/`;
}
/**
* Everything a package would hold, with every check: no copying, no install. `scan: false` skips the personal-info
* scan and `measure: false` the node_modules / public/vendor sizes (tests that only want the selection).
* @param {string} root
* @param {{ lite?: boolean, paths?: string[], allowDirty?: boolean, env?: object, scan?: boolean, measure?: boolean }} [opts]
*/
export function plan(root, opts = {}) {
const lite = !!opts.lite;
const all = (opts.paths || trackedFiles(root)).map(posixRel);
const { keep, drop } = selectTracked(all);
const art = artPlan(root, { lite });
const pkg = readJson(path.join(root, 'package.json')) || {};
const lock = readJson(path.join(root, 'package-lock.json')) || {};
const files = [...keep, ...art.files];
const problems = [];
for (const f of files) if (isRefused(f) || isJunk(f)) problems.push(`refused: ${f}`);
const byCase = new Map();
for (const f of files) {
const k = f.toLowerCase();
if (byCase.has(k)) problems.push(`paths differ only in case (one file on Windows / macOS): ${byCase.get(k)} / ${f}`);
else byCase.set(k, f);
}
if (!noInstall) {
const r = spawnSync('npm', ['ci', '--omit=dev', '--no-audit', '--no-fund'], { cwd: stage, stdio: 'inherit' });
if (r.error || r.status !== 0) throw new Error('npm ci --omit=dev failed in the package directory');
for (const f of keep) if (!isFile(path.join(root, f))) problems.push(`missing tracked file: ${f}`);
for (const f of art.missing) problems.push(`missing art: ${f} (data/assets.json lists it; run npm run setup, or build --lite)`);
if (!lite && !art.files.some((f) => f.startsWith('public/assets/'))) problems.push('no art under public/assets: run npm run setup first, or build --lite');
for (const p of importProblems(root, keep)) problems.push(p);
for (const p of entryProblems(pkg, keep)) problems.push(p);
if (!opts.allowDirty) for (const f of dirtyFiles(root, keep)) problems.push(`uncommitted change: ${f} (commit it, or --allow-dirty)`);
const names = scanNames(opts.env || process.env);
const modules = opts.measure !== false || (opts.scan !== false && names.length) ? prodModuleFiles(root, lock) : [];
let scanned = 0;
if (opts.scan !== false) {
for (const h of scanFiles(root, files, { home: true, names })) problems.push(`personal info: ${h}`);
for (const h of scanFiles(root, modules, { home: false, names })) problems.push(`personal info: ${h}`);
scanned = files.length + (names.length ? modules.length : 0);
}
const dropped = {};
for (const f of drop) {
const g = groupOf(f);
dropped[g] ??= { files: 0, bytes: 0 };
dropped[g].files++;
dropped[g].bytes += fileBytes(path.join(root, f));
}
const trackedBytes = sumBytes(root, keep);
const artBytes = sumBytes(root, art.files);
const orphanBytes = sumBytes(root, art.orphans);
const modulesBytes = sumBytes(root, modules);
const vendorBytes = opts.measure !== false ? sumBytes(root, listTree(root, 'public/vendor')) : 0;
const dropBytes = Object.values(dropped).reduce((n, g) => n + g.bytes, 0);
return {
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, files, dropped, orphans: art.orphans,
localArt: art.local, problems, names: names.length, scanned,
bytes: {
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
total: trackedBytes + artBytes + modulesBytes + vendorBytes,
// what 0.1.x's whole-tree layout adds on top: every other tracked file and (full) the art nothing lists
leftOut: dropBytes + (lite ? 0 : orphanBytes), orphans: orphanBytes,
},
};
}
const MB = (n) => `${(n / 1048576).toFixed(1)} MB`;
export function formatSummary(p) {
const lines = [
`package: ${p.lite ? 'lite (no game art)' : 'full'} · version ${p.version}`,
`tracked: ${p.tracked.length} files, ${MB(p.bytes.tracked)}`,
];
if (!p.lite) {
lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`);
}
lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`);
lines.push(`uncompressed: ${MB(p.bytes.total)}; a 0.1.x whole-tree copy would add ${MB(p.bytes.leftOut)}`);
const groups = Object.entries(p.dropped).sort((a, b) => b[1].bytes - a[1].bytes);
if (groups.length) lines.push(`left out: ${groups.map(([g, v]) => `${g} ${v.files} (${MB(v.bytes)})`).join(', ')}`);
if (!p.lite && p.orphans.length) lines.push(`left out art: ${p.orphans.length} files under public/assets that nothing lists (${MB(p.bytes.orphans)})`);
lines.push(`personal-info scan: ${p.scanned} files, ${p.names ? `home paths + ${p.names} account name(s)` : 'home paths only (no account name to refuse)'}`);
lines.push(p.problems.length ? `PROBLEMS (${p.problems.length}):\n${p.problems.map((x) => ` ${x}`).join('\n')}` : 'checks: ok');
return lines.join('\n') + '\n';
}
/** True when `out` is the repository, inside it, or a parent of it. */
export function packageOutIsUnsafe(out, root = REPO) {
const r = path.resolve(root);
const o = path.resolve(out);
if (o === r) return true;
const inside = (a, b) => { const rel = path.relative(a, b); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); };
return inside(r, o) || inside(o, r);
}
function zipFolder(cwd, zipPath) {
// deflate everything: storing PNG / MP3 as they are made the full zip 8.5 MB larger and saved no real time
const z = spawnSync('zip', ['-q', '-r', '-X', zipPath, FOLDER], { cwd, stdio: 'inherit' });
if (!z.error && z.status === 0) return;
const t = spawnSync('tar', ['-c', '--format', 'zip', '-f', zipPath, FOLDER], { cwd, stdio: 'inherit' }); // bsdtar (Windows 10+, macOS)
if (t.error || t.status !== 0 || !isFile(zipPath)) throw new Error('zipping failed: install `zip` (or a bsdtar `tar`)');
}
/** Copy the plan into <out>/<name>/Stronghold-Protocol, npm ci, check the stage, zip it. */
export function build(root, p, { out, install = true, force = false, keepStage = false, env = process.env, log = console.log } = {}) {
if (p.problems.length) throw new Error('refusing to build: the plan has problems (see the summary)');
const dest = path.resolve(out);
if (packageOutIsUnsafe(dest, root)) throw new Error(`refusing to write into the repository or a parent of it: ${dest}`);
const name = `${FOLDER}-v${p.version}${p.lite ? '-lite' : ''}`; // 0.1.x's release asset names
const work = path.join(dest, name);
const stage = path.join(work, FOLDER);
const zipPath = path.join(dest, `${name}.zip`);
for (const t of [work, zipPath]) {
if (!fs.existsSync(t)) continue;
if (!force) throw new Error(`${t} already exists (--force replaces it)`);
fs.rmSync(t, { recursive: true, force: true });
}
log(`copying ${p.files.length} files → ${stage}`);
for (const rel of p.files) {
const src = path.join(root, rel);
const to = path.join(stage, rel);
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.copyFileSync(src, to, fs.constants.COPYFILE_FICLONE);
fs.chmodSync(to, fs.statSync(src).mode & 0o777);
}
if (install) {
log('npm ci --omit=dev …');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const r = spawnSync(npm, ['ci', '--omit=dev', '--no-audit', '--no-fund'], { cwd: stage, stdio: 'inherit', shell: process.platform === 'win32' });
if (r.error || r.status !== 0) throw new Error('npm ci --omit=dev failed in the stage');
fs.rmSync(path.join(stage, 'node_modules', '.cache'), { recursive: true, force: true });
if (!isFile(path.join(stage, 'public', 'vendor', 'pixi.min.js'))) throw new Error('public/vendor/pixi.min.js missing after npm ci (postinstall tools/vendor.mjs)');
}
const hits = scanStage(stage);
if (hits.length) {
throw new Error(`refusing to pack; home-directory paths in:\n${hits.join('\n')}`);
const problems = [];
const planned = new Set(p.files);
const staged = listTree(stage, '', { junk: true });
const rest = staged.filter((f) => !f.startsWith('node_modules/') && !f.startsWith('public/vendor/'));
for (const f of rest) if (!planned.has(f)) problems.push(`in the stage but not planned: ${f}`);
const have = new Set(rest);
for (const f of p.files) if (!have.has(f)) problems.push(`planned but not staged: ${f}`);
for (const r of REFUSE) if (fs.existsSync(path.join(stage, r))) problems.push(`refused: ${r} is in the stage`);
const names = scanNames(env);
for (const h of scanFiles(stage, rest, { home: true, names })) problems.push(`personal info: ${h}`);
const libs = staged.filter((f) => f.startsWith('node_modules/') || f.startsWith('public/vendor/'));
for (const h of scanFiles(stage, libs, { home: false, names })) problems.push(`personal info: ${h}`);
if (problems.length) throw new Error(`refusing to zip ${stage}:\n${problems.map((x) => ` ${x}`).join('\n')}`);
log(`zipping ${staged.length} files (${MB(sumBytes(stage, staged))}) → ${zipPath}`);
zipFolder(work, zipPath);
if (!keepStage) fs.rmSync(work, { recursive: true, force: true });
return { zipPath, zipBytes: fileBytes(zipPath), files: staged.length, stage: keepStage ? stage : null };
}
const USAGE = 'usage: node tools/package.mjs [--lite] [--dry-run [--list]] [--out <dir>] [--force] [--keep-stage] [--no-install] [--allow-dirty] [--root <dir>]';
export function parseArgs(argv) {
const o = { lite: false, dryRun: false, list: false, out: '', force: false, keepStage: false, install: true, allowDirty: false, root: REPO, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--lite') o.lite = true;
else if (a === '--dry-run') o.dryRun = true;
else if (a === '--list') o.list = true;
else if (a === '--force') o.force = true;
else if (a === '--keep-stage') o.keepStage = true;
else if (a === '--no-install') o.install = false;
else if (a === '--allow-dirty') o.allowDirty = true;
else if (a === '--help' || a === '-h') o.help = true;
else if (a === '--out' || a === '--root') {
const v = argv[++i];
if (!v || v.startsWith('--')) throw new Error(`${a} needs a directory\n${USAGE}`);
o[a.slice(2)] = path.resolve(v);
} else throw new Error(`unknown argument ${a}\n${USAGE}`);
}
const zipPath = path.join(out, `${folder}.zip`);
const zip = spawnSync('zip', ['-r', '-q', zipPath, folder], { cwd: out });
if (zip.error || zip.status !== 0) throw new Error('zip failed');
return { stage, zipPath };
return o;
}
function main(argv) {
let lite = false;
let dry = false;
let noInstall = false;
let out = '';
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--lite') lite = true;
else if (a === '--dry-run') dry = true;
else if (a === '--no-install') noInstall = true;
else if (a === '--out') {
out = argv[++i];
if (!out || out.startsWith('--')) {
console.error('package: --out needs a directory');
return 1;
}
} else if (a === '--help' || a === '-h') {
console.log('usage: node tools/package.mjs [--dry-run] [--lite] [--out <dir>] [--no-install]');
return 0;
} else {
console.error(`package: unknown argument ${a}`);
return 1;
}
let o;
try { o = parseArgs(argv); } catch (e) { console.error(`package: ${e.message}`); return 2; }
if (o.help) { console.log(USAGE); return 0; }
let p;
try { p = plan(o.root, { lite: o.lite, allowDirty: o.allowDirty }); } catch (e) { console.error(`package: ${e.message}`); return 1; }
process.stdout.write(formatSummary(p));
if (o.dryRun) {
if (o.list) for (const f of p.files) process.stdout.write(`file ${f}\n`);
return p.problems.length ? 1 : 0;
}
const m = measure(ROOT, { lite });
process.stdout.write(formatSummary(m));
if (dry) {
process.stdout.write('dry-run: yes\n');
for (const rel of m.tracked) process.stdout.write(`file ${rel}\n`);
for (const rel of m.extras) process.stdout.write(`extra ${rel}\n`);
if (p.problems.length) return 1;
try {
const r = build(o.root, p, { out: o.out || path.join(os.tmpdir(), 'stronghold-protocol-release'), install: o.install, force: o.force, keepStage: o.keepStage });
console.log(`zip: ${r.zipPath} (${MB(r.zipBytes)}, ${r.files} files)${r.stage ? ` · stage kept: ${r.stage}` : ''}`);
return 0;
} catch (e) {
console.error(`package: ${e.message}`);
return 1;
}
const dest = out || path.join(os.tmpdir(), `stronghold-protocol-package-${m.version}`);
const built = build(ROOT, m, { out: dest, noInstall });
console.log(`zip: ${built.zipPath}`);
return 0;
}
const invoked = process.argv[1]
&& import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
if (invoked) process.exit(main(process.argv.slice(2)));
const invoked = (() => { try { return pathToFileURL(fs.realpathSync(process.argv[1] || '')).href; } catch { return null; } })();
if (invoked === import.meta.url) process.exitCode = main(process.argv.slice(2));
+1 -1
View File
@@ -201,7 +201,7 @@ export function checkAssets() {
export const LOCAL_ART_FALLBACK = '3D 棋盘改用 2D,部分官方界面图标和灼热/炽焰源石虫模型用替代样式';
/** Where a machine without the client gets the local art (docs/DEPLOY.md §6「本地客户端素材」); shown by doctor (setup's row,
* printed on every start by scripts/launch.mjs, only points to that section). */
export const LOCAL_ART_COPY_HINT = '没有客户端的服务器可以从同一版本的整合包复制 public/assets/local 和 data/local-assets.json';
export const LOCAL_ART_COPY_HINT = '没有客户端的服务器可以从同一版本的整合包(完整包)复制 public/assets/local 和 data/local-assets.json';
/**
* Local-client art (optional): manifest entry count, whether the 3D board atlas is on disk and whether the extraction