0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 18:28:50 +08:00
commit 3d5d6b380d
47 files changed
+2325 -136

No files matched your search

+11
View File
@@ -1,3 +1,14 @@
## 0.2.6-dev — 未发布
### 开服流量
- 静音或把背景音乐音量调到 0 时不再下载背景音乐,重新打开声音后才下载当前该放的曲子(关音乐的玩家每局少下约 7–14 MB)。
- 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,一局单人对局的服务器消息实测从 173 KB 降到 60 KB。
- 素材地址带内容哈希:浏览器把图片、模型和音频缓存一年,服务器更新、重新解压或 Docker 重建后只重新下载内容变了的文件;哈希存在 `.cache/asset-hashes.json`,`SP_ASSET_INDEX=0` 可关闭。
- 支持 brotli 压缩(浏览器只在 HTTPS 页面和 localhost 上使用),`.skel` 和数据文件比 gzip 再小约四分之一。
- 本地提取的「玩法说明」教程图另存无损 WebP(25.6 MB → 17.4 MB),看说明时只预载下一页。
- 流量统计:`/healthz?traffic=1` 显示服务器发出的各类流量;「问题反馈」的诊断信息增加本页下载量。
- 部署指南:固定访问地址、反代开 HTTP/2、Cloudflare 缓存规则。
## 0.2.5 — 2026-10-12
### 新功能
+5 -2
View File
@@ -1,8 +1,10 @@
# 卫戍协议:盟约 · Stronghold Protocol: Alliance
> **开发版(dev 分支):不稳定,请勿用于公开服务器**。当前为 0.2.6-dev,尚未发布;稳定版请使用 Releases 中的 0.2.5。
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
![version](https://img.shields.io/badge/version-0.2.5-2ea44f)
![version](https://img.shields.io/badge/version-0.2.6--dev-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)
@@ -37,7 +39,7 @@ English summary: [below](#english).
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
- 当前版本 0.2.5:3D 棋盘按战场显示官方场景,新增「官方伤害数字」选项,首领每局随机配对,固定赠品和已展示的干员牌不再被库存阻断,并修复了 0.2.4 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
- 当前开发版 0.2.6-dev(未发布):减轻开服电脑的流量——静音时不下载背景音乐、WebSocket 消息压缩、素材地址带内容哈希长期缓存、brotli 压缩。最近发布版 0.2.5:3D 棋盘按战场显示官方场景,新增「官方伤害数字」选项,首领每局随机配对,固定赠品和已展示的干员牌不再被库存阻断,并修复了 0.2.4 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
## 功能一览
@@ -120,6 +122,7 @@ npm start # 启动服务器:http://localhost:3000
| `DEBUG` | 空 | 设为任意值输出详细日志 |
| `SP_NO_BROWSER` | 空 | 设为 `1` 时启动脚本不自动打开浏览器 |
| `SP_BOT_EMOTES` | 空 | AI 队友的对局表情回应(战斗、整备、合成、联防、收礼时即刻的 `m.emote` 广播);设为 `0` 时整体关闭 |
| `SP_ASSET_INDEX` | 空 | 素材地址带内容哈希、浏览器长期缓存(0.2.6 起默认开启,哈希存在 `.cache/asset-hashes.json`);设为 `0` 时关闭,素材清单原样下发 |
设置方式:macOS / Linux `PORT=8080 npm start`;PowerShell `$env:PORT=8080; npm start`;cmd `set "PORT=8080" && npm start`。健康检查:`GET /healthz`。
+3 -2
View File
@@ -77,7 +77,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| path | what |
|---|---|
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`), `static.js` (the mounts), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts, the `~<hash>` asset routes), `media.js`, `files.js` (MIME, brotli / gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `assetIndex.js` (the content hashes of `public/assets/` and `public/fonts/`, cached in `.cache/asset-hashes.json`), `assetViews.js` (the served `/data/assets.json` and `/data/local-assets.json`: every art URL with its hash segment; ASSETS.md), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `server/data.js` | loads `data/*.json` once (frozen) |
@@ -131,7 +131,8 @@ data/*.json ──────────┬─▶ server/data.js ────
├─▶ public/js/data.js ──────────▶ the UI
└─▶ public/js/battle/runner.js ─▶ the browser's battles (setSimData)
data/i18n/en.json ──────▶ public/js/data.js ──────────▶ the English game texts
public/assets/ ─────────▶ public/js/render/, public/js/audio.js (URLs listed in data/assets.json)
public/assets/ ─────────▶ public/js/render/, public/js/audio.js (URLs listed in data/assets.json,
served with content-hash segments: server/http/assetViews.js)
```
- **Game data.** `tools/build-data.mjs` downloads the official zh_CN tables into `.cache/gamedata/` (or reads them
+35 -2
View File
@@ -211,11 +211,11 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
- **Fonts:** OTF/TTF files are converted to WOFF2 by a built-in encoder (`tools/assets/woff2.mjs`: Brotli with null transforms).
- Its output was verified lossless against Google's reference `woff2` decoder.
- `fonts.css` lists WOFF2 first and falls back to the original file.
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. `extract.py --webp` adds the copies to an existing extraction without the client.
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. The 19 local 玩法说明 pages get a WebP copy too (`GUIDE_WEBP_MODE`, lossless: 25.6 MB → 17.4 MB on the CN client's pages; lossy quality 90 would be 4.2 MB and waits for the maintainer's side-by-side look), listed in place of the PNG, which stays beside it; the mirror copies under `ui/guide/` stay PNG. `extract.py --webp` adds the copies to an existing extraction without the client.
## Manifest schema (`data/assets.json`)
All paths are URL paths relative to the site root, for example `/assets/char/avatar/char_002_amiya.png`. **Only entries whose files exist on disk are emitted.** When a key is missing, the client should use its fallback (research 07 §5.6).
All paths are URL paths relative to the site root, for example `/assets/char/avatar/char_002_amiya.png` — in the committed file; a server hands them out with a content-hash segment ("The committed manifest and the served manifests" below). **Only entries whose files exist on disk are emitted.** When a key is missing, the client should use its fallback (research 07 §5.6).
```js
{
@@ -470,6 +470,39 @@ Other renderer rules from research 07 §5.4–5.5:
- **Missing unit SFX:** use `audio.sfx.battle.enemyHit` or a WebAudio blip (research 07 §6.4).
- **Bond icons:** if the real glyph ever fails, the stored file is the nation camp logo.
## The committed manifest and the served manifests
`data/assets.json` in git (and as `tools/fetch-assets.mjs` writes it) and `data/local-assets.json` (as
`tools/local-extract` writes it) hold plain URLs. Since 0.2.6 (host bandwidth, layer 1) the server hands out a view of
each (`server/http/assetIndex.js`, `server/http/assetViews.js`):
- Shortly after the start (1 s) the server hashes every file under `public/assets/` and `public/fonts/` in the
background: SHA-256, the first 16 hex digits. The hashes are kept in `.cache/asset-hashes.json` by path + size +
mtime, so a restart only stats the files and reads none of the unchanged ones (12,691 files / 724 MB on the
maintainer's machine: 1.3 s for the first build, 0.25 s after that). A file whose size or mtime changed is hashed
again; a re-extraction, `setup --force` or a Docker rebuild then costs the players only the files whose bytes changed.
- `GET /data/assets.json` and `GET /data/local-assets.json` answer with every art URL carrying its file's hash as the
first segment: `/assets/char/avatar/char_003_kalts.png` → `/assets/~398f32e2ff78d9e6/char/avatar/char_003_kalts.png`,
`/fonts/~<hash>/…`. Audio keeps its `/assets/~<hash>/audio/….mp3` form; `public/js/media.js` asks for it as
`/media/~<hash>/…` (the download-manager route, `shared/media.js`).
- A Spine model's skeleton, atlas and pages carry one hash, the model's (SHA-256 of the files' hashes): pixi-spine finds
the atlas by the skeleton's name and the pages next to the atlas, so the URLs share a folder. In `data/assets.json`
every `{ skel, atlas, textures }` object is a model; in `data/local-assets.json` every `spine/…` group (the files a
`spineLocal` record names).
- The server strips the segment and serves the file at the plain path. When the segment names the file's current bytes
(its own hash, or its model's) the answer is `Cache-Control: public, max-age=31536000, immutable`; any other segment —
a page loaded before an update, a URL derived from another file's — gets the current file with `no-cache`, so an old
page keeps working. The ETag of every indexed file is its content hash (`"398f32e2ff78d9e6"`, plus `-br` / `-gz` when
compressed), on the plain URL too.
- A path segment, never `?v=`: `assets.js validSpine` matches `/\.skel$/`, `audio.js SKILL_MODE_FILE` the `.mp3` ending,
pixi-spine takes the atlas name from the skeleton's basename and resolves the pages against the atlas's folder — a
query string survives none of that.
- A URL the index has no hash for (the file is missing, or not hashed yet) stays plain; plain URLs work as before
(`public, max-age=86400`, revalidated after a day).
- Until the index is ready, with `SP_ASSET_INDEX=0`, or on a static host without this server, the manifests go out as
committed. The views are rebuilt in memory when a manifest or the hashes change; neither file is ever written by the
server, and the `hash` field is the committed one. The index lives in `.cache/` only and is never packaged.
## Verification
`node --test test/assets.test.js` covers the pure helpers: the resolver, the atlas normalizer, the format sniffers, WOFF2, audio banks, the plan id sets, the downloader against a fake network, and the self-heal of corrupt skeletons.
+12 -2
View File
@@ -9,7 +9,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 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测;0.2.6 起 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,实测一局单人对局的服务器消息从 173 KB 降到 60 KB,约为原来的三分之一)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。0.2.6 起服务器启动后在后台给素材算内容哈希(第一次约 1–20 秒,看机器和硬盘;之后只检查大小和修改时间),下发的素材清单里每个地址带上哈希:浏览器把图片、模型和音频缓存一年,服务器更新、重新解压或 Docker 重建之后,玩家只重新下载内容真的变了的文件。哈希存在 `.cache/asset-hashes.json`,删掉会重新计算;目录不可写(例如只读的容器)时只在内存里算。 |
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 710 MB。 |
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
@@ -170,6 +170,8 @@ cloudflared tunnel --url http://localhost:3000
把输出的 `https://xxxx.trycloudflare.com` 发给朋友。页面是 https 时客户端自动改用 `wss://`,不需要任何配置;服务器会通过隧道转发的 `CF-Connecting-IP` 识别真实来源(`TRUST_PROXY=auto`)。临时隧道每次启动地址都不同,且没有可用性保证;需要固定地址请使用 Cloudflare 账号 + 自己域名的「命名隧道」。
**固定访问地址能省很多流量**:浏览器的缓存、设置、统计和断线令牌都按地址(域名或 IP + 端口)分开保存。地址一换,对玩家来说就是一个新网站:每个人进游戏都要把图片、模型和音频重新下载一遍(第一局约 20–50 MB),设置和本机统计也看不到了。临时隧道每次重启都换域名,所以固定朋友圈子长期开服时,建议用命名隧道、Tailscale(2.1)或固定的公网 IP / 局域网 IP(1.3),让大家一直用同一个地址。
### 2.3 路由器端口转发
仅当你有**公网 IPv4**(很多宽带是运营商级 NAT,没有公网 IP,此时请用 2.1 / 2.2):
@@ -219,7 +221,11 @@ server {
}
```
**HTTP/2**:客户端由数百个小脚本模块组成(0.2.0 起进入对局时约 340 个),隔着公网时建议让代理以 HTTP/2 提供页面:所有模块走同一条连接,远距离玩家首次进入对局明显更快。Caddy 默认就是 HTTP/2;Nginx 见上面的 `http2`。服务器本身只说 HTTP/1.1,局域网或本机游玩不受影响。
**HTTP/2**:客户端由数百个小脚本模块组成(0.2.0 起进入对局时约 340 个),隔着公网时建议让代理以 HTTP/2 提供页面:所有模块走同一条连接,远距离玩家首次进入对局明显更快。Caddy 默认就是 HTTP/2;Nginx 见上面的 `http2`。服务器本身只说 HTTP/1.1,局域网或本机游玩不受影响。HTTP/2 省的是往返等待,不减少字节数。
**压缩**:服务器自己压缩脚本、样式、数据和 Spine 的 `.skel` / `.atlas`:浏览器支持 brotli 时用 brotli(0.2.6 起,质量 5,比 gzip 快且更小:实测 `.skel` 小约 27%、游戏数据 JSON 小约 26%、前端库小约 14%、脚本小约 5%),否则用 gzip;图片和音频本来就是压缩格式,原样发送。浏览器只在 HTTPS 页面(以及 `localhost`)上声明支持 brotli,所以通过代理或隧道的 https 地址访问时用 brotli,局域网里的 `http://192.168.x.x:3000` 这类地址用 gzip。代理不需要另开压缩(Caddy 的 `encode`、Nginx 的 `gzip` 遇到已经压缩的响应会跳过)。
**Cloudflare 缓存**(命名隧道或自己的域名经过 Cloudflare 时):Cloudflare 默认只缓存常见扩展名,`.skel`、`.atlas` 和没有扩展名的音频地址 `/media/…` 都不在其中,每个请求都会回到开服的电脑。在 Cloudflare 控制台的「缓存规则」里加一条:主机名是你的游戏域名、且 URI 路径以 `/assets/`、`/fonts/` 或 `/media/` 开头时「符合缓存条件」,边缘 TTL 选「使用源站的 Cache-Control」。0.2.6 起素材清单里的地址带内容哈希(`/assets/~<哈希>/…`),服务器给这类地址一年的 `immutable`,换了内容的文件会换地址,所以长期缓存是安全的;`/data/`、页面和脚本不要加进规则(它们要随服务器更新)。注意:边缘公开缓存官方素材会增加被投诉的风险,大量媒体文件也要留意 Cloudflare 的服务条款;只给自己人玩时不加这条规则也可以。
https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://同一域名/ws`;http 时用 `ws://`。服务器本身只提供 http,证书由代理 / 隧道负责。代理与服务器在同一台机器或内网时,`TRUST_PROXY=auto` 会信任它的 `X-Forwarded-For` / `X-Real-IP`;代理在公网另一台机器上时设 `TRUST_PROXY=1`(同时确保游戏端口只对代理开放)。
@@ -289,6 +295,8 @@ services:
| 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` |
| 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) |
| 素材地址里的 `~` 加一串字母数字 | `/assets/~<哈希>/…` 是 0.2.6 起的带版本地址(见第 0 节「网络」),浏览器可以长期缓存,属于正常现象。怀疑它导致问题时,可以用 `SP_ASSET_INDEX=0` 启动服务器临时关闭(清单改回普通地址),并请在 issue 里说明 |
| 想知道开服电脑上传了多少流量 | 浏览器打开 `http://localhost:3000/healthz?traffic=1`(换成你的端口):`traffic.http` 是服务器启动以来各类内容(`assets` 图片和模型、`audio` 音频、`code` 页面和脚本、`data` 游戏数据和素材清单)发出的字节数和请求数(压缩后的实际字节,`notModified` 是只回了「没变」的 304),`traffic.ws` 是 WebSocket 的线上字节(`wireOut` / `wireIn`,压缩后)和压缩前的消息字节(`payloadOut` / `payloadIn`)。数字只在内存里,重启后从 0 开始。玩家那边:设置 →「问题反馈」复制的诊断里有一行 `traffic:`,是这个页面从打开起下载的字节数(`cached` 是浏览器缓存直接给的,不占流量;`external` 是其他网站,例如 Google 字体) |
| 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 |
## 6. 本地客户端素材(可选)
@@ -315,6 +323,8 @@ services:
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
**「玩法说明」教程图的下载量**(0.2.6 起):本地提取的 19 页教程图每页 1.2–1.5 MB,玩家每翻一页都要从开服的电脑下载。提取时同样给每页写一份无损 WebP,清单列 WebP,PNG 留在旁边;19 页合计从约 25.6 MB 降到约 17.4 MB,画面完全不变。看说明时只预载下一页(以前前后两页都预载)。已经提取过的同样用 `tools/local-extract/extract.py --webp` 补上。
## 7. 打包发布(维护者)
Releases 的 zip(完整包、精简包,0.2.1 起还有更新包)由 `tools/package.mjs` 生成,在**源码仓库**里运行(整合包里没有这个工具):
+2 -1
View File
@@ -4,7 +4,7 @@ This is the **single source of truth** for every implementer. Research lives in
Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English.
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. **0.2.5** (2026-10-12) fixed gifts and already displayed operator offers under the shared stock, random boss pairing, the official battlefield scenes on the 3D board (optional local art: the full bundle includes them when extracted, a missing scene keeps the metal board; §15, §30.21), the official damage-number option, the 驭械术师 / hit-tile / bot ports and the community fixes after 0.2.4 — §30, the state described by this document; its rules supersede the corresponding 0.2.4 ones. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. **0.2.5** (2026-10-12) fixed gifts and already displayed operator offers under the shared stock, random boss pairing, the official battlefield scenes on the 3D board (optional local art: the full bundle includes them when extracted, a missing scene keeps the metal board; §15, §30.21), the official damage-number option, the 驭械术师 / hit-tile / bot ports and the community fixes after 0.2.4 — §30; its rules supersede the corresponding 0.2.4 ones. **0.2.6-dev** adds the host-bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket compression, content-hashed asset URLs with long caching, brotli) — §31, the current development state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
---
@@ -37,3 +37,4 @@ normative lines it rewrote).
| §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) |
| §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) |
| §30 | [history/0.2.5.md](history/0.2.5.md) | 0.2.5 — after the 0.2.4 release (2026-10-12) |
| §31 | [history/0.2.6.md](history/0.2.6.md) | 0.2.6-dev — after the 0.2.5 release |
+1 -1
View File
@@ -184,7 +184,7 @@
标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合、打开页面以来按类别统计的下载量(图片和模型、音频、代码、数据),以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
## 13. 统计(本机)
+4 -2
View File
@@ -40,6 +40,8 @@ view.resize() ; view.destroy()
`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it.
Asset URLs (0.2.6, host bandwidth layer 1): the client takes every art, model and audio URL from `data/assets.json` and `data/local-assets.json` and builds none itself, so the content-hash segment the server puts in front (`/assets/~<hash>/…`, docs/ASSETS.md "The committed manifest and the served manifests") needs nothing on this side: `validSpine` still sees `.skel`, pixi-spine finds the atlas and its pages under the same segment (a model shares one hash), audio is fetched as `/media/~<hash>/…`. The two URLs the client derives from another file's — the 3D board's `tiles.json` beside the atlas and the token death sound beside `charDie` — are served `no-cache` (the board) or drop the segment (`shared/assetPaths.js unhashedUrl`, the sound).
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
---
@@ -62,7 +64,7 @@ its board-space position and facing, including on a mirrored boss half. They can
from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates
the selected variant at battle start (§30.22).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15.
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. While the music is silent — muted, or the BGM volume at 0 — no BGM is downloaded: the track the screen wants is remembered, a different one still playing stops, and the wanted track is fetched once the music is audible again (`AudioManager.bgmSilent`; about 14 MB a match for a player who turned the music off).
---
@@ -73,7 +75,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
- `scene/<stageId>/` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.21): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm<i>`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities/<season>/level_<stage>/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes <dir>` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files.
- `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).
- `emoticon/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5).
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5). `extract.py` writes a lossless WebP copy beside each page and the manifest lists the copy (`GUIDE_WEBP_MODE`; 25.6 MB → 17.4 MB); the viewer preloads only the next page (`ui/guide.js guidePreloadUrl`), not the previous one too.
- `projectiles/*` — battle projectile sprites.
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
- `spine/token/<id>/` — token (summon) models no web dump carries (40: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
+1 -1
View File
@@ -4,7 +4,7 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 8. Network protocol (shared/protocol.js is normative)
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5, `/healthz?traffic=1`): 173 KB of server frames → 60 KB on the wire.
### 8.1 Session & lobby
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries, ops?}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`; `ops` — 0.2.2 — the per-operator 潜能 / 练度 `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, `m.private.ops`; absent = none set: every operator 潜能 6, 精英2 Lv.60) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
+10 -4
View File
@@ -42,12 +42,18 @@ server/
index.js process entry: startServer() wires server/http/ (plain node:http + ws); boots when run directly
http/
config.js ROOT, served dirs, env (PORT, HOST, TRUST_PROXY, DEBUG), options handed to net.js / lobby.js
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames)
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames,
permessage-deflate from 1 KB)
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards,
the ~<hash> segment of /assets/ /fonts/ /media/ URLs stripped (immutable when it names the file)
media.js /media/… extension-less audio → public/assets/audio
files.js one file → response: MIME, gzip (.skel/.atlas/.json/.js/.css …), ETag / 304, cache policy, ranges
files.js one file → response: MIME, brotli q5 / gzip (.skel/.atlas/.json/.js/.css …), ETag (content hash when known) / 304,
cache policy, ranges; serveBuffer for the served manifests
buildTag.js build tag of the served browser runtime (/healthz build, public/js/ui/buildGuard.js)
routes.js request listener: 414 / 400 / 405 guards, GET /healthz, then static files
routes.js request listener: 414 / 400 / 405 guards, GET /healthz (?traffic=1: the counters), then static files
traffic.js traffic counters: HTTP body bytes by category (assets / audio / code / data), WebSocket wire / payload
assetIndex.js content hashes (SHA-256, 16 hex) of public/assets + public/fonts, background build, .cache/asset-hashes.json
assetViews.js the served /data/assets.json + /data/local-assets.json: art URLs as /assets/~<hash>/… (immutable when it matches)
common.js security headers, URL split, error page, JSON replies, bare 400 for unparseable requests
boot.js boot banner (Local / LAN / tunnel URLs), port-in-use hint, graceful SIGINT / SIGTERM
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
+95
View File
@@ -0,0 +1,95 @@
# DESIGN §31 — 0.2.6-dev
Part of [DESIGN.md](../DESIGN.md). Development after the 0.2.5 release.
## 31. Host bandwidth, layers 0 and 1
Unreleased. The maintainer's decision of 2026-10-12 on the bandwidth study (its measurements: a new player's first match costs the
host about 52 MB, audio ≈ 40 % of it; returning players re-download after a re-extraction, a `setup --force`, a Docker
rebuild or a changed tunnel address): do layer 0 (small fixes) and layer 1 (content-hashed URLs, long caching, brotli)
first. Later layers (WebP / Opus variants, an asset-only service worker, a desktop client mode, importing an asset pack in
the browser) are agreed and not part of this note. Nothing here touches the battle sim, the protocol messages or the
committed data.
### 31.1 Layer 0 — small fixes
- **No BGM while the music is silent.** Muted, or the BGM volume at 0, `AudioManager.playBgm` only remembers the wanted
track (a different one still playing stops); `setVolumes` fetches and starts it once the music is audible again. SFX
and voices were already gated. Normative line: design/client.md Audio.
- **WebSocket permessage-deflate** (`server/http/websocket.js WS_DEFLATE`): frames from 1 KB, no server context
takeover, a 2 KB server window, zlib memLevel 7 (about 72 KB of zlib state per connection). [ASSUMED] the parameters
come from measured solo sessions (the server counters: 173 KB of frames → 60 KB on the wire; replaying 216 captured
frames, windows above 2 KB gained nothing); co-op messages are the same kinds. A client that offers a narrower server
window than 2 KB, which ws would refuse with a 400, connects uncompressed instead (`narrowDeflateOffer`). Normative
line: design/network.md §8 Transport.
- **玩法说明 pages as WebP.** `tools/local-extract/extract.py` writes a WebP copy beside each of the 19 local pages and
the manifest lists it (`GUIDE_WEBP_MODE`); the guide preloads only the next page instead of both neighbours. The
copies are lossless (25.6 MB → 17.4 MB on the CN client's pages). Lossy quality 90 would make them 4.2 MB; per the
decision that tutorial pages may go lossy only after a side-by-side comparison, that switch (one constant, then
`extract.py --webp`) is left to the maintainer. Normative lines: design/client.md §13, ASSETS.md Post-processing.
- **Traffic counters.** `GET /healthz?traffic=1` adds `traffic` (HTTP body bytes and requests by category — assets,
audio, code, data, other — and 304s; WebSocket wire bytes after compression and payload bytes before it) and
`assetHashes` (the index state). The 问题反馈 diagnostics gain a `traffic:` line: the page's Resource Timing
`transferSize` by the same categories (`shared/assetPaths.js trafficCategory`), other sites apart. Nothing is stored
or uploaded.
- **DEPLOY.md**: a stable address keeps every player's cache, settings and reconnect token (a quick tunnel's new domain
is a new site to the browser); HTTP/2 through the proxy saves round trips, not bytes; browsers offer brotli on HTTPS
pages and localhost only; a Cloudflare cache rule for `/assets/`, `/fonts/`, `/media/` (with the risk note).
### 31.2 Layer 1 — content-hashed URLs, long caching, brotli
- **Content-hash index** (`server/http/assetIndex.js`): 1 s after the start, SHA-256 (16 hex) of every file under
`public/assets/` and `public/fonts/` (dot names skipped, linked folders followed once), computed off the main thread,
cached in `.cache/asset-hashes.json` by path + size + mtime (12,691 files / 724 MB here: 1.3 s the first time, 0.25 s
after). A file whose size or mtime differs from its record is never vouched for; serving it asks for a rescan (at most
one per 10 s). [ASSUMED] the 1 s delay and the 10 s rescan gap. A name that differs from a file's only in case finds
that file (the manifest's `/assets/module/wah-y.png` is `WAH-Y.png` on this machine). `SP_ASSET_INDEX=0` turns it off.
- **Served manifests** (`server/http/assetViews.js`): `/data/assets.json` and `/data/local-assets.json` go out with every
art URL carrying its file's hash as the first segment (`/assets/~<hash>/…`, `/fonts/~<hash>/…`; audio stays
`/assets/~<hash>/audio/….mp3`). A Spine model — a `{ skel, atlas, textures }` object of data/assets.json, a `spine/…`
group of data/local-assets.json — carries one hash for all its files, since pixi-spine finds the atlas and the pages
next to the skeleton. `no-cache` with the view's own ETag; rebuilt in memory when a manifest or the hashes change; the
committed files are never written. Until the index is ready, without it, or on a static host: the files as committed.
- **The `~hash` routes**: `static.js` strips the segment for `/assets/`, `/fonts/` and `/media/`; a hash that names the
file's current bytes (its own or its model's) answers `public, max-age=31536000, immutable`, any other the current
file with `no-cache` (an old page keeps working). Every indexed file's ETag is its content hash, on plain URLs too.
Path segments, never `?v=`: `validSpine`'s `/\.skel$/`, `SKILL_MODE_FILE`'s `.mp3`, pixi-spine's basename and folder
resolution all need the URL to end in the file name.
- **Media**: `public/js/media.js` turns `/assets/~<hash>/audio/x.mp3` into `/media/~<hash>/x`; `server/http/media.js`
resolves it like `/media/x`. The token death sound derived next to `charDie` drops `charDie`'s hash (`unhashedUrl`).
- **Brotli** (`server/http/files.js`): quality 5 on the fly with the same memory cache (one entry per file and
encoding), for clients that name `br` at least as high as gzip; gzip for the rest (`*` alone never picks brotli
[ASSUMED]); `-br` / `-gz` ETag suffixes. Measured against gzip-6 on this repository: `.skel` −27 %, `data/*.json`
−26 %, vendor −14 %, `public/js` and `server/sim` −5 %, `.atlas` −7 %.
### 31.3 Measured sample session
Measured sample session (headless Chrome, a fresh profile, `test/e2e/fastServer.mjs` solo match from round 3 to the
result after round 5, host bytes only, response headers included; the shop and the enemies vary between runs):
| session | code | data | assets | audio | WebSocket (server → client) | host total |
|---|---:|---:|---:|---:|---:|---:|
| before, music on | 3.16 MB | 0.67 MB | 9.0–9.7 MB | 7.9 MB | 163–179 KB (no compression) | 20.8–21.4 MB |
| after, music on | 2.95 MB | 0.62 MB | 9.5 MB | 8.0 MB | 60 KB on the wire (173 KB payload) | 21.1 MB |
| before, muted | 3.16 MB | 0.67 MB | 9.6 MB | 7.3 MB (BGM) | 165 KB | 20.7 MB |
| after, muted | 2.95 MB | 0.62 MB | 9.5 MB | 0 | 57 KB on the wire | 13.1 MB |
A first visit with music on saves little (brotli on code and data, the WebSocket); a muted player saves the BGM. The
caching gains are for returning players: within a day both versions answer from the browser cache, but after that the
old version revalidated every asset of the session (≈ 340 requests here) and re-downloaded everything whose mtime had
changed, while a hashed URL is never asked for again until its bytes change.
### 31.4 Open items and tests
Open: `/fonts/fonts.css` (linked by index.html) and the woff2 files it names keep plain URLs (content ETags only); the
optional versioned vendor URLs (layer 1.5) are not done; lossy tutorial pages wait for the comparison.
Tests: `test/ui/audio.test.js` (BGM gating; hashed audio through `/media/~hash/`), `test/ws-compression.test.js`,
`test/local-extract.test.js` (guide WebP), `test/ui/localArt.test.js` (next-page preload), `test/traffic.test.js`,
`test/asset-paths.test.js`, `test/diag.test.js` (traffic line), `test/asset-index.test.js` (index, views, `~hash`
routes), `test/lobby.test.js` (brotli negotiation, streamed brotli, content ETags), `test/media-url.test.js`,
`test/content/tokens_summons.test.js`; the browser suites `test/render/browser.test.js` (Spine through hashed URLs,
`RENDER_E2E=1`), `test/ui/bond-audio.e2e.test.js` (`SP_E2E=1`, the cue through `/media/~hash/`) and
`test/ui/real.e2e.test.js` (`SP_REAL_E2E=1`: the co-op run loads Spine, audio and art through hashed URLs). The e2e
servers wait for the asset index before the first page (`test/e2e/client.mjs startRealServer`, the in-process ones in
their `before`), so the browser suites see the URLs a player sees. Golden: unchanged.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "stronghold-protocol-alliance",
"version": "0.2.5",
"version": "0.2.6-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stronghold-protocol-alliance",
"version": "0.2.5",
"version": "0.2.6-dev",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "stronghold-protocol-alliance",
"version": "0.2.5",
"version": "0.2.6-dev",
"private": true,
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
"license": "GPL-3.0-or-later",
+27 -4
View File
@@ -13,7 +13,9 @@
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
// - Channels: master → { bgm, sfx, voice } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// keeps playing across phases (prep and combat share a track). While the music is silent — muted, or the BGM volume
// at 0 — no track is downloaded (≈ 14 MB a match): the wanted one is remembered, another one playing stops, and the
// track the screen wants then starts once the music is audible again (`setVolumes`; host bandwidth, layer 0).
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
@@ -68,6 +70,7 @@
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
import { PHASE } from '../../shared/constants.js';
import { mediaUrl } from './media.js';
import { unhashedUrl } from '../../shared/assetPaths.js';
const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
@@ -191,11 +194,14 @@ export function unitSoundClass(info) {
return 'char';
}
/** URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie. */
/**
* URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie — without charDie's
* content-hash segment, which names charDie's bytes, not this file's (the plain URL is cached for a day).
*/
function tokenDieUrl(manifest) {
const b = manifest?.audio?.sfx?.battle;
if (typeof b?.tokenDie === 'string') return b.tokenDie;
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? b.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? unhashedUrl(b.charDie).replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
}
/**
@@ -662,6 +668,7 @@ export class AudioManager {
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
const wasSilent = this.bgmSilent;
this.volumes = {
bgm: n(v?.bgm, this.volumes.bgm),
sfx: n(v?.sfx, this.volumes.sfx),
@@ -670,8 +677,13 @@ export class AudioManager {
};
this._applyVolumes();
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
// the music became audible again: download and start the track the screen wants now (none was fetched meanwhile)
if (wasSilent && !this.bgmSilent && this.ctx && this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
}
/** Is the music silent — muted, or the BGM volume at 0? Then no BGM is downloaded (playBgm). */
get bgmSilent() { return this.volumes.muted || this.volumes.bgm <= 0; }
/**
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
* line on air finishes in its own dub; the next one follows the setting.
@@ -766,12 +778,23 @@ export class AudioManager {
// ---- BGM ------------------------------------------------------------------------------------------------
/**
* Switch BGM (null stops). Same loop URL ⇒ no restart.
* Switch BGM (null stops). Same loop URL ⇒ no restart. Before the first gesture, or while the music is silent
* (`bgmSilent`), the key is only remembered (`wantBgm`) and nothing is downloaded; a different track that is still
* playing stops. `_unlock` / `setVolumes` start the remembered track once it can be heard.
* @param {string|null} key see bgmKeyFor
*/
playBgm(key) {
try {
if (!this.ctx) { this.wantBgm = key; return; }
if (this.bgmSilent) {
this.wantBgm = key;
const silentTrack = resolveBgm(this.getManifest(), key);
if (this.bgm && silentTrack && this.bgm.loopUrl === silentTrack.loop) { this.bgm.key = key; return; }
++this.bgmToken; // a start still waiting for its download stays silent too
this._fadeOutBgm();
return;
}
this.wantBgm = null;
const track = resolveBgm(this.getManifest(), key);
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
if (!track && !this.bgm) return;
+82 -4
View File
@@ -5,19 +5,23 @@
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
// tools replay.
// tools replay. The report's `traffic:` line sums what this page downloaded (Resource Timing `transferSize`: bytes
// on the wire, 0 for a response from the browser cache) by the categories of shared/assetPaths.js trafficCategory,
// other sites apart as `external` (host bandwidth, layer 0; the server's own counters: /healthz?traffic=1).
//
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
// (a failed request or error push the player was shown)
// errorLog() / errorCount() / clearErrorLog()
// setBattleSource(fn) the battle runner registers how to read the battle on screen
// installTrafficMeter() / recordResourceEntry(entry) / trafficSummary() / clearTraffic() the page's download totals
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
//
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
import { APP_VERSION } from '../../shared/constants.js';
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
export const LOG_MAX = 40;
export const STACK_LINES = 6;
@@ -87,6 +91,78 @@ export const errorLog = () => log.map((e) => ({ ...e }));
export const errorCount = () => total;
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
// ---- traffic: what this page downloaded ---------------------------------------------------------------------
/** @type {Map<string, { requests: number, bytes: number, cached: number }>} category → totals */
const traffic = new Map();
let trafficMeter = null;
/**
* Add one Resource Timing entry (a PerformanceResourceTiming, or the page's own navigation entry) to the totals: bytes
* on the wire (`transferSize`; headers included, 0 when the browser cache answered — counted as `cached`). A same-origin
* URL counts under trafficCategory(path), another site's under `external`; data: / blob: URLs are not downloads.
* @param {{ name?: string, transferSize?: number, decodedBodySize?: number }} entry
* @param {string} [origin] the page's origin
*/
export function recordResourceEntry(entry, origin = globalThis.location?.origin) {
if (!entry || typeof entry.name !== 'string') return;
let u;
try { u = new URL(entry.name, origin || 'http://localhost'); } catch { return; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
const cat = origin && u.origin !== origin ? 'external' : trafficCategory(u.pathname);
let t = traffic.get(cat);
if (!t) traffic.set(cat, (t = { requests: 0, bytes: 0, cached: 0 }));
const bytes = Number(entry.transferSize) || 0;
t.requests++;
t.bytes += bytes;
if (bytes === 0 && (Number(entry.decodedBodySize) || 0) > 0) t.cached++;
}
/**
* Start summing this page's downloads (main.js at boot): the entries already in the Resource Timing buffer, then
* every new one (a PerformanceObserver also gets the entries a full buffer drops). False where unsupported.
* @param {any} [win]
*/
export function installTrafficMeter(win = globalThis) {
if (trafficMeter) return true;
const PO = win?.PerformanceObserver;
if (typeof PO !== 'function') return false;
try {
const origin = win.location?.origin;
for (const e of win.performance?.getEntriesByType?.('navigation') || []) recordResourceEntry(e, origin);
trafficMeter = new PO((list) => { for (const e of list.getEntries()) recordResourceEntry(e, origin); });
trafficMeter.observe({ type: 'resource', buffered: true });
return true;
} catch {
trafficMeter = null;
return false;
}
}
/** The totals so far: `{ [category]: { requests, bytes, cached } }` in report order, plus `total`. */
export function trafficSummary() {
const out = {};
const total = { requests: 0, bytes: 0, cached: 0 };
for (const cat of [...TRAFFIC_CATEGORIES, 'external']) {
const t = traffic.get(cat);
if (!t) continue;
out[cat] = { ...t };
if (cat !== 'external') { total.requests += t.requests; total.bytes += t.bytes; total.cached += t.cached; }
}
out.total = total;
return out;
}
export function clearTraffic() { traffic.clear(); }
const sizeOf = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} KB` : `${n} B`);
/** `assets 9.7 MB (254, 41 cached) | … | host total 21.4 MB (869)` — the report's traffic line; null when nothing was recorded. */
export function trafficLine(summary) {
if (!summary || !summary.total || !(summary.total.requests > 0)) return null;
const parts = Object.entries(summary).filter(([k]) => k !== 'total')
.map(([k, t]) => `${k} ${sizeOf(t.bytes)} (${t.requests}${t.cached ? `, ${t.cached} cached` : ''})`);
return `${parts.join(' | ')} | host total ${sizeOf(summary.total.bytes)} (${summary.total.requests})`;
}
let battleSource = null;
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
@@ -177,10 +253,10 @@ const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== fa
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
* prose and exact string values in the JSON.
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
* now?: number, pageStart?: number, version?: string }} input
* now?: number, pageStart?: number, version?: string, traffic?: ReturnType<typeof trafficSummary> | null }} input
*/
export function buildDiagnostics(input) {
const { state = {}, env = {}, settings = null, battle = null } = input;
const { state = {}, env = {}, settings = null, battle = null, traffic: trafficTotals = null } = input;
const entries = input.entries ?? errorLog();
const now = input.now ?? Date.now();
const lines = [];
@@ -208,6 +284,8 @@ export function buildDiagnostics(input) {
if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
}
const tl = trafficLine(trafficTotals);
if (tl) lines.push(`traffic: ${tl}`);
const shown = entries.slice().reverse();
const tot = input.total ?? total;
const drop = input.dropped ?? dropped;
@@ -265,5 +343,5 @@ export function browserEnv() {
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
*/
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null, traffic: trafficSummary() });
}
+3 -1
View File
@@ -55,7 +55,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
import { recordError } from './diag.js';
import { recordError, installTrafficMeter } from './diag.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -346,6 +346,8 @@ function installGlobalErrorHandlers() {
async function boot() {
installGlobalErrorHandlers();
// what this page downloads, by category, for the 问题反馈 report's traffic line (diag.js; nothing is sent anywhere)
installTrafficMeter();
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
installDeviceSupport();
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
+7 -2
View File
@@ -11,10 +11,14 @@
//
// `server/http/media.js` resolves /media/… back to the real file under public/assets/audio and still answers with
// `Content-Type: audio/mpeg` + range support; Web Audio sniffs the container, so the URL is all the same to it.
// A served manifest's content-hashed URL keeps its segment (shared/assetPaths.js; host bandwidth, layer 1):
//
// /assets/~0123456789abcdef/audio/bgm/act1.mp3 → /media/~0123456789abcdef/bgm/act1
// A host that does not implement /media/ keeps working: `audio.js` falls back to the original URL whenever the
// /media/ response is unusable (a 404, or a 200 that is not audio at all — some static hosts answer a missing
// path with the SPA's index.html).
import { MEDIA_PREFIX, AUDIO_EXTS } from '../../shared/media.js';
import { splitHashedPath } from '../../shared/assetPaths.js';
const AUDIO_PATH = /^\/assets\/audio\/(.+)$/i;
@@ -36,7 +40,8 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
// Cross-origin (or absolute while the page origin is unknown) stays untouched: that host serves its own media.
const absolute = /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//');
if (origin ? u.origin !== origin : absolute) return url;
const m = AUDIO_PATH.exec(u.pathname);
const { hash, path: plain } = splitHashedPath(u.pathname);
const m = AUDIO_PATH.exec(plain);
if (!m) return url;
let rest = m[1];
const ext = AUDIO_EXTS.find((e) => rest.toLowerCase().endsWith(e));
@@ -44,5 +49,5 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
rest = rest.slice(0, -ext.length);
const segments = rest.split('/');
if (!rest || segments.some((s) => !s || s === '.' || s === '..' || s.startsWith('.'))) return url;
return `${MEDIA_PREFIX}${rest}${u.search}`;
return `${MEDIA_PREFIX}${hash ? `~${hash}/` : ''}${rest}${u.search}`;
}
+16 -3
View File
@@ -3,7 +3,8 @@
// downloads from the public mirror (data/assets.json → ui['guide/<key>']; GitHub issue #42) — data.js artUrls; a page
// whose picture fails to load tries its next URL. The pages are stored squashed to 1024² (both copies) and are
// displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons, ←/→ (A/D) keys, page
// dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
// dots, thumbnails; Esc or the backdrop closes. Only the next page is preloaded (a page is 0.9–1.5 MB; the one before
// has usually just been seen, so preloading it too doubled the download — host bandwidth, layer 0).
// With no page listed the viewer shows the official loading-screen tips (config.tips) instead, and so does a page none
// of whose copies loads (`guideStage`: data/assets.json lists the downloaded pages but the files are not on disk — a
// `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter
@@ -66,6 +67,19 @@ export function guideStage(page, failed) {
return src ? { kind: 'image', src } : { kind: 'tips' };
}
/**
* The one page the viewer preloads while page `i` is shown: the next one (wrapping around), its first URL that has not
* failed — null without pages. The previous page is not preloaded (see the header).
* @param {Array<{ urls?: string[] }>} pages guidePages()
* @param {number} i
* @param {Set<string>} [failed]
*/
export function guidePreloadUrl(pages, i, failed) {
const n = Array.isArray(pages) ? pages.length : 0;
if (!n || n < 2) return null;
return nextArtUrl(pages[(((i + 1) % n) + n) % n]?.urls, failed);
}
/** Open/closed + current page. */
export const guideStore = createStore({ open: false, page: 0 });
@@ -136,8 +150,7 @@ export function GuideHost() {
useEffect(() => {
if (!open || !n) return;
preload(nextArtUrl(pages[(i + 1) % n]?.urls, failed));
preload(nextArtUrl(pages[(i + n - 1) % n]?.urls, failed));
preload(guidePreloadUrl(pages, i, failed));
}, [open, i, n, failed]);
if (!open) return null;
+352
View File
@@ -0,0 +1,352 @@
// server/http/assetIndex.js — the content hashes of the served art (host bandwidth, layer 1; docs/ASSETS.md "The served
// manifests"):
//
// * built in the background shortly after the start (ASSET_INDEX_DELAY_MS): every file under public/assets/ and
// public/fonts/ (dot names skipped like the static server does, linked folders followed once), the first 16 hex
// digits of the SHA-256 of its bytes (WebCrypto, off the main thread);
// * remembered in .cache/asset-hashes.json by path + size + mtime: a restart only stats the files and reads none of
// the unchanged ones again; a file whose size or mtime changed is hashed again (a re-extraction, `setup --force`,
// a Docker rebuild then costs the players only the files whose bytes changed);
// * never trusted beyond what it checked: `hashOf(rel, stat)` answers only while the file still has the size and
// mtime it was hashed with, else null — the caller then serves the plain way (1-day cache, size-mtime ETag) and
// the miss asks for a rescan (at most one every RESCAN_GAP_MS);
// * models: lists of files that share one URL hash — a Spine skeleton, its atlas and its pages (pixi-spine finds the
// atlas next to the skeleton and the pages next to the atlas, so all their URLs carry the same segment). A model's
// hash is the SHA-256 of its members' hashes; assetViews.js hands the lists over (`setModels`), read from the
// manifests. `urlHashOk` accepts a file's own hash or the hash of a model it belongs to.
//
// Rel paths are POSIX and relative to public/ ('assets/char/avatar/x.png', 'fonts/bender-regular.woff2'). A rel that
// differs from a file's name only in case finds that file when no other name matches it that way (Windows and macOS
// serve `wah-y.png` from `WAH-Y.png`; on Linux the request is a 404 anyway).
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash, webcrypto } from 'node:crypto';
import { ASSET_HASH_HEX } from '../../shared/assetPaths.js';
import { noopLog } from './config.js';
import { IMMUTABLE_CACHE } from './files.js';
/** Folders of public/ whose files are hashed. */
export const ASSET_INDEX_ROOTS = Object.freeze(['assets', 'fonts']);
/** The first build waits this long after the start (a server that is only started and stopped again — tests — reads nothing). */
export const ASSET_INDEX_DELAY_MS = 1000;
/** Shortest time between two rescans a stale or unknown file asks for. */
export const RESCAN_GAP_MS = 10_000;
/** Version of .cache/asset-hashes.json. */
export const HASH_CACHE_VERSION = 1;
const STAT_CONCURRENCY = 64;
const HASH_CONCURRENCY = 4;
const hex = (bytes) => Buffer.from(bytes).toString('hex').slice(0, ASSET_HASH_HEX);
/** First ASSET_HASH_HEX hex digits of the SHA-256 of `data` (synchronous; model hashes, manifests, tests). */
export function hash16(data) {
return createHash('sha256').update(data).digest('hex').slice(0, ASSET_HASH_HEX);
}
/** The same for a file's bytes, computed off the main thread (WebCrypto runs in libuv's thread pool). */
async function digest16(buf) {
return hex(await webcrypto.subtle.digest('SHA-256', buf));
}
/** Run `fn` over `items` with at most `n` at a time; stops early when `stop()` says so. */
async function eachLimit(items, n, fn, stop) {
let i = 0;
const worker = async () => {
while (i < items.length && !stop()) {
const item = items[i++];
await fn(item);
}
};
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
}
/**
* The asset index of one server.
* @param {{ publicDir: string, roots?: readonly string[], cacheFile?: string | null, log?: object, delayMs?: number,
* rescanGapMs?: number, now?: () => number }} opts
* cacheFile: where the hashes persist (null: in memory only — a test's temporary public/)
*/
export function createAssetIndex({ publicDir, roots = ASSET_INDEX_ROOTS, cacheFile = null, log = noopLog, delayMs = ASSET_INDEX_DELAY_MS,
rescanGapMs = RESCAN_GAP_MS, now = Date.now }) {
const base = path.resolve(publicDir);
/** @type {Map<string, { size: number, mtimeMs: number, hash: string }>} */
let records = new Map();
/** @type {Map<string, string | null>} lower-case rel → the rel (null: two names differ only in case) */
let lower = new Map();
/** @type {string[][]} */
let modelLists = [];
/** @type {Map<string, Set<string>>} rel → hashes of the models it belongs to */
let modelOf = new Map();
let generation = 0;
let ready = false;
let closed = false;
let building = null; // the build in flight (a promise)
let again = false; // another build was asked for while one ran
let startTimer = null;
let rescanTimer = null;
let lastBuildAt = -Infinity;
let readyResolve;
const readyPromise = new Promise((r) => { readyResolve = r; });
const listeners = new Set();
const last = { files: 0, bytes: 0, hashed: 0, reused: 0, ms: 0, at: null, error: null };
/** The cache file's records ({} when absent, unreadable or another version). */
function loadCache() {
if (!cacheFile) return new Map();
try {
const doc = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
if (!doc || doc.version !== HASH_CACHE_VERSION || typeof doc.files !== 'object' || !doc.files) return new Map();
const out = new Map();
for (const [rel, v] of Object.entries(doc.files)) {
if (Array.isArray(v) && Number.isFinite(v[0]) && Number.isFinite(v[1]) && typeof v[2] === 'string' && v[2].length === ASSET_HASH_HEX) {
out.set(rel, { size: v[0], mtimeMs: v[1], hash: v[2] });
}
}
return out;
} catch {
return new Map();
}
}
/** Write the records (temp file + rename: a concurrent reader never sees half a file). Failures only log. */
async function saveCache(map) {
if (!cacheFile) return;
const files = {};
for (const [rel, r] of [...map].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) files[rel] = [r.size, r.mtimeMs, r.hash];
const tmp = `${cacheFile}.${process.pid}.tmp`;
try {
await fsp.mkdir(path.dirname(cacheFile), { recursive: true });
await fsp.writeFile(tmp, JSON.stringify({ version: HASH_CACHE_VERSION, algo: 'sha256/16', files }));
await fsp.rename(tmp, cacheFile);
} catch (e) {
log.debug?.('[assets] hash cache not saved', e?.code || e?.message);
fsp.rm(tmp, { force: true }).catch(() => {});
}
}
/** Every file under the roots: [rel, abs] (dot names skipped, a folder reached twice — a link loop — walked once). */
async function listFiles() {
const out = [];
const seen = new Set();
const walk = async (abs, rel) => {
let real;
try { real = await fsp.realpath(abs); } catch { return; }
if (seen.has(real)) return;
seen.add(real);
let ents;
try { ents = await fsp.readdir(abs, { withFileTypes: true }); } catch { return; }
for (const d of ents) {
if (closed) return;
if (d.name.startsWith('.')) continue;
const childAbs = path.join(abs, d.name);
const childRel = `${rel}/${d.name}`;
if (d.isDirectory()) await walk(childAbs, childRel);
else if (d.isFile()) out.push([childRel, childAbs]);
else if (d.isSymbolicLink()) {
const st = await fsp.stat(childAbs).catch(() => null);
if (st?.isDirectory()) await walk(childAbs, childRel);
else if (st?.isFile()) out.push([childRel, childAbs]);
}
}
};
for (const root of roots) await walk(path.join(base, root), root);
return out;
}
/** Model hashes from the current records (a model with a member the index does not know has none). */
function computeModels() {
const next = new Map();
for (const list of modelLists) {
const h = modelHashOf(list);
if (!h) continue;
for (const rel of list) {
const c = canonical(rel);
let set = next.get(c);
if (!set) next.set(c, (set = new Set()));
set.add(h);
}
}
modelOf = next;
}
/** The indexed name of `rel`: itself, or the one file whose name differs from it only in case (else null). */
function canonical(rel) {
if (typeof rel !== 'string') return null;
if (records.has(rel)) return rel;
return lower.get(rel.toLowerCase()) || null;
}
function recordOf(rel) {
const c = canonical(rel);
return c ? records.get(c) : null;
}
function modelHashOf(list) {
if (!Array.isArray(list) || !list.length) return null;
const parts = [];
for (const rel of list) {
const r = recordOf(rel);
if (!r) return null;
parts.push(r.hash);
}
return hash16(parts.join('\n'));
}
/** One build; true when the records changed (a new generation). */
async function build() {
const t0 = now();
const known = records.size ? records : loadCache();
const next = new Map();
let hashed = 0;
let reused = 0;
let bytes = 0;
const files = await listFiles();
const toHash = [];
await eachLimit(files, STAT_CONCURRENCY, async ([rel, abs]) => {
const st = await fsp.stat(abs).catch(() => null);
if (!st || !st.isFile()) return;
bytes += st.size;
const k = known.get(rel);
if (k && k.size === st.size && k.mtimeMs === st.mtimeMs) { next.set(rel, k); reused++; } else toHash.push([rel, abs, st]);
}, () => closed);
await eachLimit(toHash, HASH_CONCURRENCY, async ([rel, abs, st]) => {
try {
const buf = await fsp.readFile(abs);
const after = await fsp.stat(abs);
// changed while it was read: hashed again by the next build
if (after.size !== st.size || after.mtimeMs !== st.mtimeMs || buf.length !== st.size) return;
next.set(rel, { size: st.size, mtimeMs: st.mtimeMs, hash: await digest16(buf) });
hashed++;
} catch { /* vanished or unreadable: not in the index */ }
}, () => closed);
if (closed) return false;
// commit: records, model hashes and the generation change together (one synchronous step)
let changed = next.size !== records.size;
if (!changed) for (const [rel, r] of next) { const o = records.get(rel); if (!o || o.hash !== r.hash || o.size !== r.size || o.mtimeMs !== r.mtimeMs) { changed = true; break; } }
const cacheChanged = hashed > 0 || next.size !== known.size;
records = next;
lower = new Map();
for (const rel of next.keys()) { const k = rel.toLowerCase(); lower.set(k, lower.has(k) ? null : rel); }
if (changed) { generation++; computeModels(); }
Object.assign(last, { files: next.size, bytes, hashed, reused, ms: now() - t0, at: new Date().toISOString(), error: null });
lastBuildAt = now();
if (cacheChanged) await saveCache(next);
if (hashed) log.info?.(`[assets] content hashes: ${next.size} files, ${hashed} hashed, ${reused} unchanged (${Math.round(last.ms)} ms)`);
return changed;
}
function markReady() {
if (ready) return;
ready = true;
readyResolve();
}
function notify() {
for (const fn of [...listeners]) { try { fn(generation); } catch (e) { log.error?.('[assets] index listener failed', e); } }
}
/** Build now (or once more after the build in flight). */
function refresh() {
if (closed) return Promise.resolve();
if (building) { again = true; return building; }
building = (async () => {
try {
do {
again = false;
const changed = await build();
if (closed) break;
markReady();
if (changed) notify();
} while (again && !closed);
} catch (e) {
last.error = e?.message || String(e);
log.warn?.('[assets] content hash index failed', e?.message || e);
} finally {
building = null;
if (!closed) markReady();
}
})();
return building;
}
return {
/** Start the background build (after `delayMs`); returns at once. Idempotent. */
start() {
if (closed || startTimer || building || ready) return;
if (!(delayMs > 0)) { refresh(); return; }
startTimer = setTimeout(() => { startTimer = null; refresh(); }, delayMs);
startTimer.unref?.();
},
refresh,
/** Resolves once the first build is done (never rejects). */
whenReady() { return readyPromise; },
get ready() { return ready; },
get generation() { return generation; },
/** Files with a hash. */
get size() { return records.size; },
/**
* The file's content hash while it still has the size and mtime it was hashed with (`stat` given), else null.
* Without `stat`: the hash of the last build.
* @param {string} rel @param {{ size: number, mtimeMs: number }} [stat]
*/
hashOf(rel, stat) {
const r = recordOf(rel);
if (!r) return null;
if (stat && (stat.size !== r.size || stat.mtimeMs !== r.mtimeMs)) return null;
return r.hash;
},
/** Does `urlHash` name exactly these bytes: the file's own hash, or the hash of a model it belongs to? */
urlHashOk(rel, stat, urlHash) {
if (typeof urlHash !== 'string') return false;
const own = this.hashOf(rel, stat);
if (!own) return false;
return own === urlHash || !!modelOf.get(canonical(rel))?.has(urlHash);
},
/** The hash of a model (its members' hashes hashed), null while a member is unknown. */
modelHash: (list) => modelHashOf(list),
/** Replace the model lists (assetViews.js, from the manifests). */
setModels(lists) {
modelLists = Array.isArray(lists) ? lists.filter((l) => Array.isArray(l) && l.length) : [];
computeModels();
},
/** A file was served that the index does not know, or no longer matches: rescan (at most one per rescanGapMs). */
noteMiss() {
if (closed || !ready || rescanTimer || building) return;
const wait = Math.max(0, lastBuildAt + rescanGapMs - now());
rescanTimer = setTimeout(() => { rescanTimer = null; refresh(); }, wait);
rescanTimer.unref?.();
},
/** `fn(generation)` after a build changed the records; returns the unsubscribe. */
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
stats() {
return { ready, building: !!building, generation, files: records.size, models: modelLists.length, last: { ...last } };
},
/** Stop: timers cleared, a build in flight abandoned. */
close() {
closed = true;
if (startTimer) { clearTimeout(startTimer); startTimer = null; }
if (rescanTimer) { clearTimeout(rescanTimer); rescanTimer = null; }
readyResolve(); // a waiter is let go; `ready` stays as it was
},
};
}
/**
* The ETag source and cache policy of a file of the asset index's folders (public/assets, public/fonts), for
* serveFile: its content hash when the index has it for these bytes (a miss asks for a rescan); for a URL with a hash
* segment `immutable` when that hash names these bytes, else `no-cache`.
* @param {ReturnType<typeof createAssetIndex> | null} assetIndex
* @param {string} rel the file under public/ ('assets/…', POSIX)
* @param {import('node:fs').Stats} stat
* @param {string | null} urlHash the URL's hash segment
* @returns {{ contentHash?: string | null, cacheControl?: string }}
*/
export function hashedFileOpts(assetIndex, rel, stat, urlHash) {
if (!assetIndex) return urlHash ? { cacheControl: 'no-cache' } : {};
const contentHash = assetIndex.hashOf(rel, stat);
if (!contentHash) assetIndex.noteMiss(rel);
if (!urlHash) return { contentHash };
return { contentHash, cacheControl: contentHash && assetIndex.urlHashOk(rel, stat, urlHash) ? IMMUTABLE_CACHE : 'no-cache' };
}
+185
View File
@@ -0,0 +1,185 @@
// server/http/assetViews.js — the served manifests (host bandwidth, layer 1; docs/ASSETS.md "The served manifests"):
// data/assets.json and data/local-assets.json as this server hands them out, every art URL carrying the content hash
// of its file as its first segment (shared/assetPaths.js) — `/assets/char/avatar/x.png` →
// `/assets/~<hash>/char/avatar/x.png`, `/fonts/~<hash>/…` — so the browser keeps the file for a year and asks again
// only when the bytes change. Audio keeps the `/assets/~<hash>/audio/….mp3` form: public/js/media.js turns it into
// `/media/~<hash>/…` when it fetches it.
//
// * A Spine model's skeleton, atlas and pages carry ONE hash, that of the model (assetIndex.js models): pixi-spine
// finds the atlas by the skeleton's name and the pages next to the atlas, so their URLs share a folder. A model is
// every `{ skel, atlas, textures }` object of data/assets.json (chars' front / back, tokens, enemies) and every
// `spine/…` group of data/local-assets.json (the files a `spineLocal` record names).
// * A URL the index has no hash for (not on disk, not hashed yet) stays as it is.
// * The committed data/assets.json is never written: a view is built in memory from the file and the index, again
// when either changes (the file's size / mtime, the index's generation), and once after every index build, so the
// index knows the model lists before any page asks.
// * No index, or not ready yet: no view (`get` → null) and the files go out as they are.
import fsp from 'node:fs/promises';
import path from 'node:path';
import { hashedPath } from '../../shared/assetPaths.js';
import { hash16 } from './assetIndex.js';
import { noopLog } from './config.js';
/** The manifests that have a view, by their name under data/. */
export const VIEW_MANIFESTS = Object.freeze(['assets.json', 'local-assets.json']);
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const ART_URL = /^\/(?:assets|fonts)\/./;
/** URL path of a manifest → the index's rel path ('/assets/a%20b.png' → 'assets/a b.png'), or null. */
export function relOf(url) {
if (typeof url !== 'string' || !ART_URL.test(url) || /[?#]/.test(url)) return null;
let p;
try { p = decodeURIComponent(url); } catch { return null; }
if (p.includes('\0') || p.includes('\\')) return null;
const segs = p.slice(1).split('/');
if (segs.some((s) => s === '' || s === '.' || s === '..')) return null;
return segs.join('/');
}
/** Is `o` a Spine object of data/assets.json (`{ skel: '/….skel', atlas, textures? }`)? */
const isSpine = (o) => isObj(o) && typeof o.skel === 'string' && o.skel.startsWith('/') && /\.skel$/.test(o.skel);
/** The URLs of a Spine object: skeleton, atlas, pages (the `<skel name>.png` when it lists none). */
function spineUrls(o) {
const out = [o.skel];
if (typeof o.atlas === 'string' && o.atlas) out.push(o.atlas);
const pages = Array.isArray(o.textures) ? o.textures.filter((u) => typeof u === 'string' && u) : [];
out.push(...(pages.length ? pages : [o.skel.replace(/\.skel$/, '.png')]));
return out;
}
/**
* The served copy of a manifest and its model lists. `index`: hashOf(rel) / modelHash(rels) of assetIndex.js.
* @param {any} doc the parsed manifest (not changed)
* @param {'assets.json'|'local-assets.json'} name
* @param {{ hashOf: (rel: string) => string | null, modelHash: (rels: string[]) => string | null }} index
* @returns {{ doc: any, models: string[][], hashed: number, plain: number }}
*/
export function rewriteManifest(doc, name, index) {
const models = [];
let hashed = 0;
let plain = 0;
const own = (url) => {
const rel = relOf(url);
const h = rel ? index.hashOf(rel) : null;
if (h) { hashed++; return hashedPath(url, h); }
if (rel) plain++;
return url;
};
/** All URLs of one model with the model's hash (or all left plain when a member is unknown). */
const model = (urls) => {
const rels = urls.map(relOf);
if (rels.some((r) => !r)) return null;
models.push(rels);
const h = index.modelHash(rels);
if (!h) { plain += urls.length; return null; }
hashed += urls.length;
return h;
};
const walk = (v) => {
if (typeof v === 'string') return ART_URL.test(v) ? own(v) : v;
if (Array.isArray(v)) return v.map(walk);
if (!isObj(v)) return v;
if (name === 'assets.json' && isSpine(v)) {
const h = model(spineUrls(v));
const out = {};
for (const [k, x] of Object.entries(v)) {
if (h && k === 'skel') out[k] = hashedPath(x, h);
else if (h && k === 'atlas' && typeof x === 'string') out[k] = hashedPath(x, h);
else if (h && k === 'textures' && Array.isArray(x)) out[k] = x.map((u) => (typeof u === 'string' ? hashedPath(u, h) : u));
else out[k] = walk(x);
}
return out;
}
const out = {};
for (const [k, x] of Object.entries(v)) out[k] = walk(x);
return out;
};
if (name === 'local-assets.json' && isObj(doc) && isObj(doc.groups)) {
const groups = {};
for (const [g, entries] of Object.entries(doc.groups)) {
if (g.startsWith('spine/') && isObj(entries)) {
const names = Object.keys(entries).filter((n) => isObj(entries[n]) && typeof entries[n].path === 'string').sort();
const h = names.length ? model(names.map((n) => entries[n].path)) : null;
groups[g] = {};
for (const [n, e] of Object.entries(entries)) {
groups[g][n] = h && isObj(e) && typeof e.path === 'string' ? { ...e, path: hashedPath(e.path, h) } : walk(e);
}
} else groups[g] = walk(entries);
}
return { doc: { ...walk({ ...doc, groups: null }), groups }, models, hashed, plain };
}
return { doc: walk(doc), models, hashed, plain };
}
/**
* The views of one server.
* @param {{ dataDir: string, index: ReturnType<typeof import('./assetIndex.js').createAssetIndex>, log?: object }} opts
*/
export function createManifestViews({ dataDir, index, log = noopLog }) {
const dir = path.resolve(dataDir);
/** @type {Map<string, { key: string, view: { body: Buffer, hash: string, hashed: number, plain: number } | null, models: string[][] }>} */
const cache = new Map();
/** @type {Map<string, Promise<any>>} */
const pending = new Map();
const warned = new Set();
const pushModels = () => index.setModels([...cache.values()].flatMap((c) => c.models));
/** Build (or reuse) the view of `name`; null without the file, while the index is not ready or empty, or for a broken file. */
async function build(name) {
if (!index.ready || !index.size) return null;
const file = path.join(dir, name);
const st = await fsp.stat(file).catch(() => null);
if (!st || !st.isFile()) {
if (cache.delete(name)) pushModels();
return null;
}
const key = `${index.generation}|${st.size}|${st.mtimeMs}`;
const hit = cache.get(name);
if (hit && hit.key === key) return hit.view;
let doc;
try {
doc = JSON.parse(await fsp.readFile(file, 'utf8'));
} catch (e) {
if (!warned.has(name)) { warned.add(name); log.warn?.(`[assets] data/${name} unreadable (${e?.message || e}); served as it is`); }
cache.set(name, { key, view: null, models: [] });
pushModels();
return null;
}
// one synchronous step: the view and the model lists the index checks a model's URLs against
const { doc: out, models, hashed, plain } = rewriteManifest(doc, name, index);
const body = Buffer.from(JSON.stringify(out));
const view = { body, hash: hash16(body), hashed, plain };
cache.set(name, { key, view, models });
pushModels();
return view;
}
/** The view of `name` (one build at a time per manifest). */
function get(name) {
if (!VIEW_MANIFESTS.includes(name)) return Promise.resolve(null);
const running = pending.get(name);
if (running) return running;
const p = build(name).catch((e) => { log.error?.('[assets] manifest view failed', e); return null; }).finally(() => pending.delete(name));
pending.set(name, p);
return p;
}
const rebuildAll = () => Promise.all(VIEW_MANIFESTS.map(get));
// after every build that changed the hashes (and the first one): the views, and with them the index's model lists
index.onChange(() => { rebuildAll(); });
index.whenReady().then(() => rebuildAll(), () => {});
return {
get,
rebuildAll,
stats() {
const out = {};
for (const [name, c] of cache) out[name] = c.view ? { bytes: c.view.body.length, hashed: c.view.hashed, plain: c.view.plain } : null;
return out;
},
};
}
+21
View File
@@ -4,6 +4,8 @@
// * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private
// peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy;
// * DEBUG → the console logger's debug level;
// * SP_ASSET_INDEX=0 (or the `assetIndex: false` option) → no content-hash index: plain asset URLs, size-mtime ETags
// (assetIndex.js); the index of the default public/ persists in .cache/asset-hashes.json;
// * the served directories (public/, data/, shared/ and the content packs' packs/ of this repository unless the
// options name others), and which startServer() options are handed on to net.js Network and lobby.js Lobby.
@@ -64,6 +66,25 @@ export function serveDirs(opts) {
};
}
/**
* The asset index options out of the startServer() options and the environment, or null for none: `assetIndex: false`
* or SP_ASSET_INDEX=0 / off / false / no switches it off; `assetIndex: { cacheFile, delayMs }` overrides the defaults.
* The hashes persist in .cache/asset-hashes.json only for the repository's own public/ (a test's temporary folder keeps
* them in memory).
* @param {{ assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number } }} opts
* @param {string} publicDir
* @returns {{ cacheFile: string | null, delayMs?: number } | null}
*/
export function assetIndexOptionsFrom(opts, publicDir) {
if (opts.assetIndex === false) return null;
const env = String(process.env.SP_ASSET_INDEX ?? '').trim().toLowerCase();
if (opts.assetIndex == null && ['0', 'off', 'false', 'no'].includes(env)) return null;
const o = typeof opts.assetIndex === 'object' && opts.assetIndex ? opts.assetIndex : {};
const own = path.resolve(publicDir) === path.join(ROOT, 'public');
const cacheFile = o.cacheFile !== undefined ? o.cacheFile : (own ? path.join(ROOT, '.cache', 'asset-hashes.json') : null);
return { cacheFile, ...(Number.isFinite(o.delayMs) ? { delayMs: o.delayMs } : {}) };
}
/** net.js Network options out of the startServer() options; `trustProxy` falls back to TRUST_PROXY. */
export function netOptionsFrom(opts) {
const netOptions = {};
+145 -37
View File
@@ -1,9 +1,12 @@
// server/http/files.js — one file on disk → an HTTP response (static.js and media.js decide which file):
// server/http/files.js — one file on disk (serveFile) or one body in memory (serveBuffer, the served manifests of
// assetViews.js) → an HTTP response (static.js and media.js decide which file):
// MIME types incl. .mjs/.js text/javascript, .skel application/octet-stream, .atlas text/plain;
// gzip for text-like types, .skel and uncompressed fonts when the client accepts it (small files are
// compressed once and cached in memory); strong ETag + Last-Modified with 304s; Cache-Control
// (html & code/data: no-cache + revalidate; public/assets|fonts|vendor: 1 day; any `?v=` URL: immutable);
// single byte-range requests (206/416, used by <audio>).
// brotli (quality 5) for text-like types, .skel and uncompressed fonts when the client accepts it, else gzip
// (level 6) — browsers offer brotli on HTTPS pages and localhost only (small files are compressed once per
// encoding and cached in memory); strong ETag + Last-Modified with 304s — the ETag is the content hash
// when the caller has one (assetIndex.js), else size + mtime; Cache-Control (html & code/data: no-cache +
// revalidate; public/assets|fonts|vendor: 1 day; a URL whose content-hash segment matches the file (static.js,
// media.js): immutable for a year; any `?v=` URL: immutable); single byte-range requests (206/416, used by <audio>).
import fs from 'node:fs';
import fsp from 'node:fs/promises';
@@ -53,28 +56,60 @@ export const MIME = Object.freeze({
'.ttf': 'font/ttf',
});
/** Extensions worth gzipping (text-like, .skel, uncompressed fonts). */
/** Extensions worth compressing (text-like, .skel, uncompressed fonts). */
export const COMPRESSIBLE = new Set([
'.html', '.htm', '.js', '.mjs', '.css', '.json', '.map', '.webmanifest', '.txt', '.md', '.csv', '.xml',
'.atlas', '.skel', '.bin', '.wasm', '.svg', '.ico', '.otf', '.ttf', '.wav',
]);
/** Types brotli compresses in its text mode (the rest of COMPRESSIBLE: generic mode). */
const TEXT_LIKE = new Set(['.html', '.htm', '.js', '.mjs', '.css', '.json', '.map', '.webmanifest', '.txt', '.md', '.csv', '.xml', '.atlas', '.svg']);
const GZIP_MIN_BYTES = 512;
const GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are gzip-streamed on the fly
const GZIP_CACHE_MAX_TOTAL = 96 << 20;
// Asset URLs carry no content hash yet, and tools/fetch-assets.mjs / tools/vendor.mjs can rewrite files in
// place (atlas + png + skel must stay consistent), so "long" is one day; revalidation after that is a cheap 304.
const LONG_CACHE = 'public, max-age=86400'; // 1 day
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
const GZIP_CACHE_MAX_FILE = 8 << 20; // larger files are compressed as a stream on the fly
const GZIP_CACHE_MAX_TOTAL = 96 << 20; // both encodings together
/**
* Brotli quality of the on-the-fly compression: faster than gzip-6 and smaller — measured on this repository against
* gzip-6: .skel −27 %, data/*.json −26 %, vendor −14 %, public/js and server/sim −5 %, .atlas −7 %. Quality 11 would gain
* another ~10 % at ~100× the time, so it is not done per request.
*/
export const BROTLI_QUALITY = 5;
// A plain asset URL (no content-hash segment) names a file tools/fetch-assets.mjs / tools/vendor.mjs can rewrite in
// place (atlas + png + skel must stay consistent), so "long" is one day; revalidation after that is a cheap 304. A
// content-hashed URL whose hash names the file is immutable (static.js, media.js).
export const LONG_CACHE = 'public, max-age=86400'; // 1 day
export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
const LONG_CACHE_DIRS = ['assets', 'fonts', 'vendor']; // first path segment under public/
const gzipAsync = promisify(zlib.gzip);
const brotliAsync = promisify(zlib.brotliCompress);
/** zlib options of an encoding for a body of `size` bytes of type `ext`. */
function brotliParams(ext, size) {
return {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
[zlib.constants.BROTLI_PARAM_MODE]: TEXT_LIKE.has(ext) ? zlib.constants.BROTLI_MODE_TEXT : zlib.constants.BROTLI_MODE_GENERIC,
...(size > 0 ? { [zlib.constants.BROTLI_PARAM_SIZE_HINT]: size } : {}),
},
};
}
/** `raw` compressed with `encoding` ('br' | 'gzip'). */
function compress(raw, encoding, ext) {
return encoding === 'br' ? brotliAsync(raw, brotliParams(ext, raw.length)) : gzipAsync(raw, { level: 6 });
}
/** A streaming compressor for a file too large for the cache. */
function compressStream(encoding, ext, size) {
return encoding === 'br' ? zlib.createBrotliCompress(brotliParams(ext, size)) : zlib.createGzip();
}
// ---------------------------------------------------------------------------------------------------
// gzip cache (LRU by bytes)
// compression cache (LRU by bytes; one entry per file and encoding)
// ---------------------------------------------------------------------------------------------------
export class GzipCache {
export class CompressCache {
constructor(maxTotal = GZIP_CACHE_MAX_TOTAL) {
this.maxTotal = maxTotal;
this.total = 0;
@@ -82,20 +117,27 @@ export class GzipCache {
/** @type {Map<string, Promise<Buffer>>} */ this.inflight = new Map();
}
/** @returns {Promise<Buffer>} gzip of the file identified by (path, size, mtime) */
get(absPath, stat) {
const key = `${absPath}\0${stat.size}\0${stat.mtimeMs}`;
const hit = this.map.get(key);
if (hit) { this.map.delete(key); this.map.set(key, hit); return Promise.resolve(hit); }
const pending = this.inflight.get(key);
/** @returns {Promise<Buffer>} the file identified by (path, size, mtime) compressed with `encoding` ('gzip' by default) */
get(absPath, stat, encoding = 'gzip') {
return this.compressed(`${absPath}\0${stat.size}\0${stat.mtimeMs}`, () => fsp.readFile(absPath), encoding, path.extname(absPath).toLowerCase());
}
/**
* @returns {Promise<Buffer>} the body `read()` returns compressed with `encoding`, cached under `key` + encoding (one
* compression per entry at a time)
*/
compressed(key, read, encoding = 'gzip', ext = '') {
const k = `${encoding}\0${key}`;
const hit = this.map.get(k);
if (hit) { this.map.delete(k); this.map.set(k, hit); return Promise.resolve(hit); }
const pending = this.inflight.get(k);
if (pending) return pending;
const p = (async () => {
const raw = await fsp.readFile(absPath);
const gz = await gzipAsync(raw, { level: 6 });
this.store(key, gz);
return gz;
})().finally(() => this.inflight.delete(key));
this.inflight.set(key, p);
const out = await compress(await read(), encoding, ext);
this.store(k, out);
return out;
})().finally(() => this.inflight.delete(k));
this.inflight.set(k, p);
return p;
}
@@ -111,10 +153,46 @@ export class GzipCache {
}
}
/** The cache's former name (it held gzip only). */
export const GzipCache = CompressCache;
// ---------------------------------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------------------------------
/** Accept-Encoding → { token: q } (lower-case tokens; a malformed q counts as 0). */
function acceptedEncodings(header) {
const out = {};
if (!header || typeof header !== 'string') return out;
for (const part of header.split(',')) {
const [token, ...params] = part.trim().toLowerCase().split(';');
if (!token) continue;
let q = 1;
for (const p of params) {
const m = /^\s*q=([0-9.]+)\s*$/.exec(p);
if (m) q = Number(m[1]);
}
out[token.trim()] = Number.isFinite(q) ? q : 0;
}
return out;
}
/**
* The content coding to answer with: 'br' when the client names brotli (q > 0) at least as high as gzip, else 'gzip'
* when it accepts gzip (or `*`), else null (identity). `*` alone never picks brotli. Browsers name `br` on HTTPS pages
* and localhost only, so a LAN `http://` address gets gzip.
* @param {string | undefined} header
* @returns {'br' | 'gzip' | null}
*/
export function pickEncoding(header) {
const acc = acceptedEncodings(header);
const brQ = acc.br ?? 0;
const gzQ = acc.gzip ?? acc['x-gzip'] ?? acc['*'] ?? 0;
if (brQ > 0 && brQ >= gzQ) return 'br';
if (gzQ > 0) return 'gzip';
return null;
}
/** Does the Accept-Encoding header allow gzip (q > 0)? @param {string | undefined} header */
export function acceptsGzip(header) {
if (!header || typeof header !== 'string') return false;
@@ -190,20 +268,25 @@ function cacheControlFor(ext, mountName, segments, query) {
return 'no-cache';
}
export async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log) {
/**
* One file → the response.
* @param {{ contentHash?: string | null, cacheControl?: string | null }} [opts] contentHash: the file's content hash
* (assetIndex.js) — the ETag then; cacheControl: overrides the policy of its path (a content-hashed URL)
*/
export async function serveFile(req, res, absPath, stat, mountName, segments, query, gzipCache, log, opts = {}) {
const ext = path.extname(absPath).toLowerCase();
const type = MIME[ext] || 'application/octet-stream';
const compressible = COMPRESSIBLE.has(ext);
const rangeHeader = req.headers.range;
const useGzip = compressible && stat.size >= GZIP_MIN_BYTES && !rangeHeader && acceptsGzip(req.headers['accept-encoding']);
const baseTag = `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
const etag = `"${baseTag}${useGzip ? '-gz' : ''}"`;
const encoding = compressible && stat.size >= GZIP_MIN_BYTES && !rangeHeader ? pickEncoding(req.headers['accept-encoding']) : null;
const baseTag = opts.contentHash || `${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}`;
const etag = `"${baseTag}${encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : ''}"`;
const lastModified = stat.mtime.toUTCString();
const isHead = req.method === 'HEAD';
const headers = {
'Content-Type': type,
'Cache-Control': cacheControlFor(ext, mountName, segments, query),
'Cache-Control': opts.cacheControl || cacheControlFor(ext, mountName, segments, query),
ETag: etag,
'Last-Modified': lastModified,
};
@@ -215,18 +298,18 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
return;
}
if (useGzip) {
headers['Content-Encoding'] = 'gzip';
if (encoding) {
headers['Content-Encoding'] = encoding;
if (stat.size <= GZIP_CACHE_MAX_FILE) {
const gz = await gzipCache.get(absPath, stat);
headers['Content-Length'] = gz.length;
const body = await gzipCache.get(absPath, stat, encoding);
headers['Content-Length'] = body.length;
res.writeHead(200, headers);
res.end(isHead ? undefined : gz);
res.end(isHead ? undefined : body);
return;
}
res.writeHead(200, headers);
if (isHead) { res.end(); return; }
await streamTo(fs.createReadStream(absPath), res, log, zlib.createGzip());
await streamTo(fs.createReadStream(absPath), res, log, compressStream(encoding, ext, stat.size));
return;
}
@@ -253,6 +336,31 @@ export async function serveFile(req, res, absPath, stat, mountName, segments, qu
await streamTo(fs.createReadStream(absPath, { start, end }), res, log);
}
/**
* One body in memory → the response (the served manifests): its ETag from `hash`, brotli or gzip when accepted (cached
* under `key` + `hash`), 304 on a matching If-None-Match, no ranges.
* @param {{ body: Buffer, hash: string, type: string, cacheControl: string, key: string, cache: CompressCache }} o
*/
export async function serveBuffer(req, res, { body, hash, type, cacheControl, key, cache }) {
const encoding = body.length >= GZIP_MIN_BYTES ? pickEncoding(req.headers['accept-encoding']) : null;
const etag = `"${hash}${encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : ''}"`;
const headers = { 'Content-Type': type, 'Cache-Control': cacheControl, ETag: etag, Vary: 'Accept-Encoding' };
const inm = req.headers['if-none-match'];
if (typeof inm === 'string' && (inm.trim() === '*' || inm.split(',').some((t) => stripWeak(t) === etag))) {
res.writeHead(304, headers);
res.end();
return;
}
let out = body;
if (encoding) {
out = await cache.compressed(`${key}\0${hash}`, async () => body, encoding, path.extname(key).toLowerCase());
headers['Content-Encoding'] = encoding;
}
headers['Content-Length'] = out.length;
res.writeHead(200, headers);
res.end(req.method === 'HEAD' ? undefined : out);
}
async function streamTo(src, res, log, transform) {
try {
if (transform) await pipeline(src, transform, res);
+10 -3
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { AUDIO_EXTS } from '../../shared/media.js';
import { sendError } from './common.js';
import { serveFile } from './files.js';
import { hashedFileOpts } from './assetIndex.js';
/**
* Extension-less audio route: `/media/bgm/act1` → `public/assets/audio/bgm/act1.mp3`.
@@ -15,8 +16,12 @@ import { serveFile } from './files.js';
* Requests for the direct `/assets/audio/…` URLs keep working (they are the fallback for plain static hosts).
* `MEDIA_PREFIX` / `AUDIO_EXTS` live in `shared/media.js`: the browser decides which URLs to rewrite with the
* same two values, and they must not drift apart.
* `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js
* strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes
* (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it.
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null }} [hashing]
*/
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log) {
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) {
const root = path.join(path.resolve(publicDir), 'assets', 'audio');
const segments = String(rest || '').split('/').filter((s) => s.length > 0);
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
@@ -44,9 +49,11 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo
} catch { continue; }
if (!stat.isFile()) continue;
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day).
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day),
// or that of its content-hashed form.
const rel = ['assets', 'audio', ...segments.slice(0, -1), stem + ext].join('/');
// eslint-disable-next-line no-await-in-loop
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log);
await serveFile(req, res, absPath, stat, 'public', ['assets', 'audio', ...segments], query, gzipCache, log, hashedFileOpts(assetIndex, rel, stat, urlHash));
return;
}
sendError(req, res, 404, '页面不存在 · Not found');
+20 -7
View File
@@ -4,9 +4,11 @@
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
// rooms, matches), never cached;
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
// start, WebSocket wire and payload bytes);
// * everything else → the static files (static.js).
// A route that throws is logged and answers 500.
// Every response's body bytes count in the traffic counters (traffic.js meter). A route that throws is logged and
// answers 500.
import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
import { buildTag } from './buildTag.js';
@@ -15,20 +17,30 @@ import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
const MAX_URL_LENGTH = 4096;
/**
* The GET /healthz body.
* The GET /healthz body (`withTraffic`: plus the traffic counters and the asset index's state, `?traffic=1`).
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
* lobby: import('../lobby.js').Lobby }} health
* lobby: import('../lobby.js').Lobby, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter>,
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} health
* @param {boolean} [withTraffic]
*/
export function healthReport({ startedAt, network, registry, lobby }) {
return {
export function healthReport({ startedAt, network, registry, lobby, traffic, assetIndex, views }, withTraffic = false) {
const report = {
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
// the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is
// older than this reloads itself, so a deploy reaches clients that never reload
build: buildTag(),
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
};
if (withTraffic && traffic) report.traffic = traffic.snapshot();
// content-hashed asset URLs (assetIndex.js / assetViews.js): off, building, or ready with how many files and URLs
if (withTraffic) report.assetHashes = assetIndex ? { ...assetIndex.stats(), views: views ? views.stats() : null } : null;
return report;
}
/** Does the query ask for the traffic counters (`traffic`, `traffic=1`, `traffic=true`)? */
const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.test(query || '');
/**
* The request listener for `http.createServer`.
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
@@ -48,7 +60,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return;
}
if (parts.rawPath === '/healthz') {
sendJson(req, res, 200, healthReport(health));
sendJson(req, res, 200, healthReport(health, wantsTraffic(parts.query)));
return;
}
await serveStatic(req, res, parts.rawPath, parts.query);
@@ -56,6 +68,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return (req, res) => {
setSecurityHeaders(res);
if (health.traffic) health.traffic.meter(res, splitUrl(req.url || '/')?.rawPath || '/');
handleRequest(req, res).catch((e) => {
log.error('[http] request failed', e);
sendError(req, res, 500, '服务器内部错误 · Internal error');
+38 -9
View File
@@ -15,16 +15,27 @@
// /packs/index.json → the content packs of this server (server/packs.js: the language packs of
// public/i18n/ and the pack folders of packs/, re-read when they change; never cached)
// /packs/<id>/<file> → a file of a pack folder — only one its manifest names (server/packs.js servable)
// /assets/~<hash>/…, /fonts/~<hash>/…, /media/~<hash>/… → the same files: the content-hash segment
// (shared/assetPaths.js) is stripped. A hash that names the file's current bytes (assetIndex.js
// urlHashOk: its own hash, or its Spine model's) answers `immutable` for a year; any other hash —
// an old page, a URL derived from another file's — the current file with `no-cache`
// /data/assets.json, /data/local-assets.json → the served manifests (assetViews.js: every art URL with its
// content-hash segment; no-cache, the ETag of the view) once the asset index is ready, the files as
// they are before that or without an index
//
// Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent
// data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile.
// Files under public/assets/ and public/fonts/ get their content hash as ETag when the index has it (a file whose
// size or mtime changed since asks for a rescan), others size + mtime. Traversal & dotfile protection, a directory
// without its trailing slash → 301, 404 page; an absent data/local-assets.json is answered with an empty manifest.
// Files go out through files.js serveFile.
import fsp from 'node:fs/promises';
import path from 'node:path';
import { MEDIA_PREFIX } from '../../shared/media.js';
import { splitHashedPath } from '../../shared/assetPaths.js';
import { ROOT, noopLog } from './config.js';
import { sendError, sendJson } from './common.js';
import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
import { MIME, CompressCache, isNotModified, serveFile, serveBuffer } from './files.js';
import { ASSET_INDEX_ROOTS, hashedFileOpts } from './assetIndex.js';
import { serveMedia } from './media.js';
import { createPackRegistry } from '../packs.js';
import { PACKS_URL, PACK_INDEX_FILE } from '../../shared/packs.js';
@@ -51,11 +62,15 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
/**
* Create the static request handler.
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
* packs?: ReturnType<typeof createPackRegistry>, log?: object }} dirs
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs)
* packs?: ReturnType<typeof createPackRegistry>, log?: object,
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs);
* assetIndex / views: the content hashes and the served manifests (none: plain URLs, size-mtime ETags)
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
*/
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog }) {
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog,
assetIndex = null, views = null }) {
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
const mounts = [
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
@@ -66,7 +81,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
];
const shimBody = Buffer.from(DATA_SHIM_JS);
const shimTag = `"shim-${shimBody.length.toString(16)}"`;
const gzipCache = new GzipCache();
const gzipCache = new CompressCache(); // brotli and gzip bodies (files.js)
return async function serveStatic(req, res, rawPath, query) {
let decoded;
@@ -75,6 +90,10 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
sendError(req, res, 400, '请求地址无效 · Bad request');
return;
}
// /assets/~<hash>/x → /assets/x (the hash decides the cache policy below)
const hashed = splitHashedPath(decoded);
const urlHash = hashed.hash;
decoded = hashed.path;
if (decoded === '/data.js') {
const headers = { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-cache', ETag: shimTag, 'Content-Length': shimBody.length };
if (isNotModified(req, shimTag, new Date(0))) { delete headers['Content-Length']; res.writeHead(304, headers); res.end(); return; }
@@ -94,9 +113,17 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
}
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
if (decoded.startsWith(MEDIA_PREFIX)) {
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log);
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex });
return;
}
// The served manifests: every art URL with its content hash (assetViews.js), once the index is ready
if (views && (decoded === '/data/assets.json' || decoded === '/data/local-assets.json')) {
const view = await views.get(decoded.slice('/data/'.length));
if (view) {
await serveBuffer(req, res, { body: view.body, hash: view.hash, type: MIME['.json'], cacheControl: 'no-cache', key: `view:${decoded}`, cache: gzipCache });
return;
}
}
// Bare mount paths (e.g. "/data") → treat as the mount directory.
const mount = mounts.find((m) => decoded.startsWith(m.prefix) || decoded === m.prefix.slice(0, -1)) || mounts[mounts.length - 1];
const rest = decoded.length > mount.prefix.length ? decoded.slice(mount.prefix.length) : '';
@@ -154,6 +181,8 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
}
return;
}
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log);
const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]);
const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {});
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts);
};
}
+102
View File
@@ -0,0 +1,102 @@
// server/http/traffic.js — what this server has sent and received since it started (host bandwidth, layer 0):
//
// * HTTP: per category of shared/assetPaths.js trafficCategory (assets / audio / code / data / other) the responses
// and the body bytes actually written (after gzip / brotli; headers not counted), and how many were 304s;
// * WebSocket: the connections, the bytes on the wire both ways (the sockets' own counters, after permessage-deflate,
// frame headers included, the HTTP upgrade not) and the message payloads before compression — their ratio is what
// the compression saves.
//
// routes.js meters every response (`meter`), websocket.js every socket (`wsOpen` / `wsClose` / `wsSent` / `wsReceived`);
// `snapshot()` is the `traffic` object of GET /healthz?traffic=1 (docs/DEPLOY.md §5). Nothing is stored or sent
// anywhere else; the counters start at zero with the process.
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
/** Byte length of a chunk handed to res.write / res.end (a string in `encoding`, utf8 by default). */
function chunkBytes(chunk, encoding) {
if (chunk == null || typeof chunk === 'function') return 0;
if (typeof chunk === 'string') return Buffer.byteLength(chunk, typeof encoding === 'string' && Buffer.isEncoding(encoding) ? encoding : 'utf8');
return typeof chunk.byteLength === 'number' ? chunk.byteLength : 0;
}
/** Length of a ws 'message' payload (a Buffer, an ArrayBuffer or, fragmented, a Buffer list). */
function payloadBytes(data) {
if (Array.isArray(data)) return data.reduce((n, b) => n + payloadBytes(b), 0);
return data && typeof data.byteLength === 'number' ? data.byteLength : typeof data === 'string' ? Buffer.byteLength(data) : 0;
}
/**
* A traffic counter for one server.
* @param {{ now?: () => number }} [opts]
*/
export function createTrafficCounter({ now = Date.now } = {}) {
const startedAt = now();
const http = Object.fromEntries(TRAFFIC_CATEGORIES.map((c) => [c, { requests: 0, bytes: 0, notModified: 0 }]));
const ws = { connections: 0, wireOut: 0, wireIn: 0, payloadOut: 0, payloadIn: 0, messagesOut: 0, messagesIn: 0 };
/** @type {Map<import('node:net').Socket, { out: number, in: number }>} live sockets and their counters at the upgrade */
const live = new Map();
/** One finished HTTP response. */
function record(path, status, bytes) {
const c = http[trafficCategory(path)] || http.other;
c.requests++;
c.bytes += bytes;
if (status === 304) c.notModified++;
}
return {
/**
* Count the body bytes of `res` (every res.write / res.end) under the category of `path`, once the response is
* done or its connection gone.
* @param {import('node:http').ServerResponse} res
* @param {string} path the request's URL path
*/
meter(res, path) {
let bytes = 0;
const { write, end } = res;
res.write = function meteredWrite(chunk, encoding, cb) {
bytes += chunkBytes(chunk, encoding);
return write.call(this, chunk, encoding, cb);
};
res.end = function meteredEnd(chunk, encoding, cb) {
bytes += chunkBytes(chunk, encoding);
return end.call(this, chunk, encoding, cb);
};
res.once('close', () => record(path, res.statusCode, bytes));
},
/** A WebSocket connection opened on `socket` (after its upgrade answer was written). */
wsOpen(socket) {
ws.connections++;
if (socket && !live.has(socket)) live.set(socket, { out: socket.bytesWritten || 0, in: socket.bytesRead || 0 });
},
/** …and closed: its wire bytes move to the totals. */
wsClose(socket) {
const base = live.get(socket);
if (!base) return;
live.delete(socket);
ws.wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
ws.wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
},
/** A message handed to ws.send (its payload before compression). */
wsSent(data) { ws.messagesOut++; ws.payloadOut += payloadBytes(data); },
/** A message received (its payload after inflating). */
wsReceived(data) { ws.messagesIn++; ws.payloadIn += payloadBytes(data); },
/** The counters now (plain JSON). */
snapshot() {
let wireOut = ws.wireOut;
let wireIn = ws.wireIn;
for (const [socket, base] of live) {
wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
}
const byCat = Object.fromEntries(Object.entries(http).map(([k, v]) => [k, { ...v }]));
const total = Object.values(http).reduce((t, v) => ({ requests: t.requests + v.requests, bytes: t.bytes + v.bytes }), { requests: 0, bytes: 0 });
return {
since: new Date(startedAt).toISOString(),
seconds: Math.max(0, Math.round((now() - startedAt) / 1000)),
http: { ...byCat, total },
ws: { ...ws, open: live.size, wireOut, wireIn },
};
},
};
}
+61 -6
View File
@@ -2,8 +2,8 @@
//
// * session wiring: SessionRegistry (reconnect tokens) → Lobby (rooms, server/lobby.js) → Network (the socket
// protocol, server/net.js), built from the startServer() options (config.js decides which go where);
// * WebSocket (ws) at /ws, maxPayload 64 KB, no per-message deflate → Network.handleConnection. Refused at the
// upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
// * WebSocket (ws) at /ws, maxPayload 64 KB, permessage-deflate (WS_DEFLATE) → Network.handleConnection. Refused at
// the upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
// clientAddress; local/LAN peers are exempt) 429; server full (maxConnections) or shutting down 503.
import { WebSocketServer } from 'ws';
@@ -15,6 +15,38 @@ import { netOptionsFrom, lobbyOptionsFrom } from './config.js';
/** Inbound WebSocket frame limit (DESIGN §8). */
export const WS_MAX_PAYLOAD = 64 * 1024;
/**
* permessage-deflate (RFC 7692) for the JSON text frames. A message from 1 KB up (m.public, m.private, b.start, welcome
* …) is compressed on its own — no context is kept from one message to the next (`serverNoContextTakeover`) — with a
* 2 KB window and zlib memLevel 7, so a connection holds about 72 KB of zlib state; smaller frames (pong, ok) go out
* as they are. Measured on a solo session (rounds 3–5, the server's own counters, traffic.js): 173 KB of payload →
* 60 KB on the wire; replaying a session's frames with larger windows gained nothing on these messages. Browsers
* negotiate it by themselves; a client that offers none gets plain frames. The inflated size of a client frame still
* counts against WS_MAX_PAYLOAD.
*/
export const WS_DEFLATE = Object.freeze({
threshold: 1024,
serverNoContextTakeover: true,
serverMaxWindowBits: 11,
zlibDeflateOptions: Object.freeze({ memLevel: 7 }),
});
/**
* Does every permessage-deflate offer of this `Sec-WebSocket-Extensions` header cap the server window below
* WS_DEFLATE.serverMaxWindowBits? ws cannot accept such an offer and refuses the whole handshake (400), so the upgrade
* drops the header instead and the connection goes uncompressed. No browser sends `server_max_window_bits`.
* @param {string | string[] | undefined} header
*/
export function narrowDeflateOffer(header) {
if (typeof header !== 'string') return false;
const offers = header.split(',').map((s) => s.trim()).filter((s) => /^permessage-deflate\b/i.test(s));
if (!offers.length) return false;
return offers.every((offer) => {
const m = /(?:^|;)\s*server_max_window_bits\s*=\s*"?(\d+)"?/i.exec(offer);
return !!m && Number(m[1]) < WS_DEFLATE.serverMaxWindowBits;
});
}
/**
* The session stack of one server.
* @param {{ MatchClass?: Function, seedFn?: () => number, [option: string]: any }} opts startServer() options
@@ -30,15 +62,37 @@ export function createSessionStack(opts, { data, log }) {
return { registry, lobby, network };
}
/**
* Count a socket in the traffic counters (traffic.js): its wire bytes from now until it closes, the payload of every
* message it sends (ws.send wrapped) and receives.
* @param {import('ws').WebSocket} ws
* @param {import('node:http').IncomingMessage} req
* @param {ReturnType<typeof import('./traffic.js').createTrafficCounter>} traffic
*/
function meterSocket(ws, req, traffic) {
const socket = req.socket;
traffic.wsOpen(socket);
const send = ws.send;
ws.send = function meteredSend(data, ...rest) {
traffic.wsSent(data);
return send.call(this, data, ...rest);
};
ws.on('message', (data) => traffic.wsReceived(data));
ws.once('close', () => traffic.wsClose(socket));
}
/**
* Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
* @param {import('node:http').Server} server
* @param {{ network: Network, log: object }} deps
* @param {{ network: Network, log: object, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} deps
* @returns {WebSocketServer}
*/
export function attachWebSocket(server, { network, log }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: false, clientTracking: false });
wss.on('connection', (ws, req) => network.handleConnection(ws, req));
export function attachWebSocket(server, { network, log, traffic = null }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: { ...WS_DEFLATE }, clientTracking: false });
wss.on('connection', (ws, req) => {
if (traffic) meterSocket(ws, req, traffic);
network.handleConnection(ws, req);
});
wss.on('error', (e) => log.error('[ws] server error', e));
server.on('upgrade', (req, socket, head) => {
@@ -51,6 +105,7 @@ export function attachWebSocket(server, { network, log }) {
const refused = network.admission(req);
if (refused === 'per-address') { reject(429, 'Too Many Requests'); return; }
if (refused) { reject(503, 'Service Unavailable'); return; }
if (narrowDeflateOffer(req.headers['sec-websocket-extensions'])) delete req.headers['sec-websocket-extensions'];
try {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} catch (e) {
+31 -11
View File
@@ -3,14 +3,20 @@
//
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG),
// which startServer() options go to net.js / lobby.js, the console logger
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
// refused at upgrade with 404 / 429 per network / 503)
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB,
// permessage-deflate from 1 KB; refused at upgrade with 404 / 429 per network / 503)
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
// http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
// http/files.js one file → response: MIME, brotli / gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
// http/assetIndex.js the content hashes of public/assets and public/fonts (built in the background, cached in
// .cache/asset-hashes.json by path + size + mtime; Spine models share one hash)
// http/assetViews.js the served /data/assets.json and /data/local-assets.json: every art URL with its hash segment
// (/assets/~<hash>/…, immutable when the hash matches); the committed files stay unchanged
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
// http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
// WebSocket, since the start (GET /healthz?traffic=1)
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
@@ -24,20 +30,23 @@
import http from 'node:http';
import { getData, loadData } from './data.js';
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js';
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
import { createPackRegistry } from './packs.js';
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
import { MIME, COMPRESSIBLE, acceptsGzip, pickEncoding, parseRange } from './http/files.js';
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
import { createRequestHandler } from './http/routes.js';
import { createTrafficCounter } from './http/traffic.js';
import { createAssetIndex } from './http/assetIndex.js';
import { createManifestViews } from './http/assetViews.js';
import { answerClientError } from './http/common.js';
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
export {
ROOT, WS_MAX_PAYLOAD, DATA_SHIM_JS, MIME, COMPRESSIBLE, BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag,
acceptsGzip, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
acceptsGzip, pickEncoding, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
};
/**
@@ -50,11 +59,14 @@ export {
* ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number,
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
* assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number },
* }} [opts]
* assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom)
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
* close: () => Promise<void> }>}
* traffic: ReturnType<typeof createTrafficCounter>, assetIndex: ReturnType<typeof createAssetIndex> | null,
* views: ReturnType<typeof createManifestViews> | null, close: () => Promise<void> }>}
*/
export async function startServer(opts = {}) {
const { port, host } = listenAddress(opts);
@@ -67,15 +79,20 @@ export async function startServer(opts = {}) {
// content packs (docs/PACKS.md): scanned now — the start log names them — and again whenever their folders change
const packs = createPackRegistry({ publicDir, dataDir, packsDir }, { log });
packs.refresh(true);
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log });
// the content hashes of the art (built in the background once listening) and the manifests served with them
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views });
const startedAt = Date.now();
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
resetBuildTag();
buildTag();
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby }, log }));
const traffic = createTrafficCounter();
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log }));
server.on('clientError', answerClientError);
const wss = attachWebSocket(server, { network, log });
const wss = attachWebSocket(server, { network, log, traffic });
// The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
let boundHost;
@@ -108,9 +125,11 @@ export async function startServer(opts = {}) {
boundHost = bound;
} catch (e) {
network.close(); // stop heartbeat/sweep timers of the half-built server
assetIndex?.close();
throw e;
}
server.on('error', (e) => log.error('[http] server error', e));
assetIndex?.start();
const addr = server.address();
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
@@ -122,6 +141,7 @@ export async function startServer(opts = {}) {
closing = (async () => {
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
network.close();
assetIndex?.close();
await new Promise((resolve) => {
server.close(() => resolve());
server.closeIdleConnections?.();
@@ -132,7 +152,7 @@ export async function startServer(opts = {}) {
return closing;
}
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close };
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close };
}
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
+78
View File
@@ -0,0 +1,78 @@
// Asset URL paths shared by the server and the browser (host bandwidth, docs/ASSETS.md "The served manifests"):
//
// * the content-hash segment. The server hands out data/assets.json and data/local-assets.json with every file URL
// carrying the first 16 hex digits of the file's SHA-256 (of the model's files for a Spine model) as its first
// segment — `/assets/char/avatar/x.png` → `/assets/~0123456789abcdef/char/avatar/x.png`, audio at
// `/media/~<hash>/…` (shared/media.js) — so a browser may keep it for a year. The server strips the segment and
// serves the file at the plain path (server/http/static.js, media.js). A path segment, never `?v=`: several
// loaders look at the URL's ending (`.skel`, `.mp3`) and pixi-spine finds the atlas and its pages next to the
// skeleton, which a query string would not survive.
// * trafficCategory: the category a request path counts under in the traffic counters (server/http/traffic.js,
// /healthz?traffic=1; public/js/diag.js for the browser's own totals).
/** Hex digits of a content hash in a URL. */
export const ASSET_HASH_HEX = 16;
/** The content-hash segment: `~` + ASSET_HASH_HEX lower-case hex digits. */
export const HASH_SEGMENT_RE = /^~([0-9a-f]{16})$/;
/** URL roots whose first segment may be a content hash. */
export const HASHED_ROOTS = Object.freeze(['/assets/', '/fonts/', '/media/']);
/**
* Split the content-hash segment off a URL path.
* '/assets/~0123456789abcdef/x.png' → { hash: '0123456789abcdef', path: '/assets/x.png' }; any other path (no segment,
* a malformed one, another root) → { hash: null, path } unchanged.
* @param {string} path a URL path (no query)
* @returns {{ hash: string | null, path: string }}
*/
export function splitHashedPath(path) {
const p = String(path ?? '');
for (const root of HASHED_ROOTS) {
if (!p.startsWith(root)) continue;
const slash = p.indexOf('/', root.length);
if (slash < 0) break;
const m = HASH_SEGMENT_RE.exec(p.slice(root.length, slash));
return m ? { hash: m[1], path: root + p.slice(slash + 1) } : { hash: null, path: p };
}
return { hash: null, path: p };
}
/**
* The URL path with the content-hash segment after its root ('/assets/x.png', 'abc…' → '/assets/~abc…/x.png'). A
* path outside HASHED_ROOTS, one that already has a segment, or no valid hash → the path unchanged.
* @param {string} path
* @param {string | null | undefined} hash
*/
export function hashedPath(path, hash) {
const p = String(path ?? '');
if (typeof hash !== 'string' || !HASH_SEGMENT_RE.test(`~${hash}`)) return p;
const root = HASHED_ROOTS.find((r) => p.startsWith(r));
if (!root || p.length === root.length || splitHashedPath(p).hash) return p;
return `${root}~${hash}/${p.slice(root.length)}`;
}
/** The URL with its content-hash segment removed (a URL derived from a hashed one names another file). */
export const unhashedUrl = (url) => (typeof url === 'string' ? splitHashedPath(url).path : url);
/** Traffic categories, in report order. */
export const TRAFFIC_CATEGORIES = Object.freeze(['assets', 'audio', 'code', 'data', 'other']);
/**
* The traffic category of a request path (the content-hash segment does not matter): `audio` (/media/,
* /assets/audio/), `assets` (/assets/, /fonts/, /icons/, the art of a content pack), `data` (/data/*.json, the UI
* language packs /i18n/, the pack index, other JSON), `code` (the page, scripts, styles: /, *.js, *.css, *.html,
* /vendor/, /sim/, /shared/), `other` (/healthz, error pages for anything else).
* @param {string} path a URL path (no query)
* @returns {'assets'|'audio'|'code'|'data'|'other'}
*/
export function trafficCategory(path) {
const p = splitHashedPath(path).path;
if (p.startsWith('/media/') || p.startsWith('/assets/audio/')) return 'audio';
if (p.startsWith('/assets/') || p.startsWith('/fonts/') || p.startsWith('/icons/')) return 'assets';
if (p.startsWith('/packs/')) return /\.json$/i.test(p) ? 'data' : 'assets';
if (p.startsWith('/data/') || p.startsWith('/i18n/') || /\.json$/i.test(p)) return 'data';
if (p === '/' || p.endsWith('/') || /\.(?:m?js|css|html?|map|webmanifest)$/i.test(p)
|| p.startsWith('/vendor/') || p.startsWith('/sim/') || p.startsWith('/shared/')) return 'code';
return 'other';
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { N_ } from './i18n.js';
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.2.5';
export const APP_VERSION = '0.2.6-dev';
/** A development build (the public `dev` branch): the title screen, the boot banner and the README say so, and
* tools/package.mjs refuses to build a release zip from it without --allow-dev. */
export const DEV_BUILD = /-dev$/.test(APP_VERSION);
+3 -1
View File
@@ -7,7 +7,9 @@
// /assets/audio/bgm/act1.mp3 → /media/bgm/act1
//
// — and `server/http/media.js` resolves it back to the real file under public/assets/audio, still answering with
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved.
// `Content-Type: audio/mpeg`, Range support and the ETag of the file it resolved. The content-hash segment of a served
// manifest's URL (shared/assetPaths.js) comes along: `/assets/~<hash>/audio/bgm/act1.mp3` → `/media/~<hash>/bgm/act1`,
// cached by the browser for a year while the hash names the file.
//
// Both sides import this module: the browser to decide which URLs to rewrite (`public/js/media.js`), the server to
// decide which extensions a `/media/…` request may resolve to. A second copy of the list on one side would let them
+356
View File
@@ -0,0 +1,356 @@
// Content-hashed asset URLs (host bandwidth, layer 1; docs/ASSETS.md "The served manifests"):
// * server/http/assetIndex.js — SHA-256 (16 hex) of every file under public/assets and public/fonts, cached by path +
// size + mtime in a JSON file, never trusted for a file whose size or mtime changed; Spine models share one hash;
// * server/http/assetViews.js — the served data/assets.json / data/local-assets.json with every art URL carrying its
// hash segment, the committed files untouched;
// * the ~hash routes of static.js / media.js — the segment stripped, `immutable` only when it names the file's bytes,
// the current file with `no-cache` otherwise; content ETags; the plain URLs as before.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
import { createAssetIndex, hash16 } from '../server/http/assetIndex.js';
import { rewriteManifest, relOf } from '../server/http/assetViews.js';
import { startServer } from '../server/index.js';
const sha16 = (buf) => createHash('sha256').update(buf).digest('hex').slice(0, 16);
const IMMUTABLE = 'public, max-age=31536000, immutable';
function tree(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-hash-'));
for (const [rel, content] of Object.entries(files)) {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
}
return dir;
}
function get(port, p, headers = {}, method = 'GET') {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path: p, headers, method, agent: false }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
});
req.on('error', reject);
req.end();
});
}
const until = async (fn, ms = 3000) => { for (const t0 = Date.now(); Date.now() - t0 < ms;) { if (await fn()) return true; await new Promise((r) => setTimeout(r, 20)); } return false; };
describe('the asset index', () => {
test('hashes every file under assets/ and fonts/ (dot names skipped, a linked folder followed), persists them, rehashes only what changed', async () => {
const a = randomBytes(2000);
const b = Buffer.from('atlas text\n'.repeat(50));
const dir = tree({
'public/assets/char/a.png': a, 'public/assets/spine/x/x.atlas': b, 'public/assets/.hidden': 'h', 'public/assets/.git/obj': 'o',
'public/fonts/f.woff2': 'font', 'public/js/app.js': 'not indexed', 'elsewhere/linked/l.png': 'linked',
});
fs.symlinkSync(path.join(dir, 'elsewhere/linked'), path.join(dir, 'public/assets/link'), 'dir');
const cacheFile = path.join(dir, '.cache/asset-hashes.json');
try {
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), cacheFile, delayMs: 0 });
assert.equal(idx.ready, false);
idx.start();
await idx.whenReady();
assert.equal(idx.ready, true);
const st = (rel) => fs.statSync(path.join(dir, 'public', rel));
assert.equal(idx.hashOf('assets/char/a.png', st('assets/char/a.png')), sha16(a));
assert.equal(idx.hashOf('assets/spine/x/x.atlas'), sha16(b));
assert.equal(idx.hashOf('fonts/f.woff2'), sha16(Buffer.from('font')));
assert.equal(idx.hashOf('assets/link/l.png'), sha16(Buffer.from('linked')), 'a linked folder is followed');
assert.equal(idx.hashOf('assets/.hidden'), null, 'dot names are never served, never hashed');
assert.equal(idx.hashOf('assets/.git/obj'), null);
assert.equal(idx.hashOf('js/app.js'), null, 'only the asset folders');
assert.equal(idx.size, 4);
assert.equal(idx.stats().last.hashed, 4);
assert.equal(idx.hashOf('assets/CHAR/A.PNG'), sha16(a), 'a name that differs only in case finds the file');
// a stat that no longer matches: no hash (the caller serves the plain way)
assert.equal(idx.hashOf('assets/char/a.png', { size: 1, mtimeMs: st('assets/char/a.png').mtimeMs }), null);
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
assert.equal(cached.version, 1);
assert.deepEqual(cached.files['assets/char/a.png'], [a.length, st('assets/char/a.png').mtimeMs, sha16(a)]);
idx.close();
// a restart: the cache answers for the unchanged files; a changed file is hashed again; a removed one is dropped
const a2 = randomBytes(2100);
fs.writeFileSync(path.join(dir, 'public/assets/char/a.png'), a2);
fs.rmSync(path.join(dir, 'public/fonts/f.woff2'));
const idx2 = createAssetIndex({ publicDir: path.join(dir, 'public'), cacheFile, delayMs: 0 });
idx2.start();
await idx2.whenReady();
assert.equal(idx2.stats().last.hashed, 1, 'only the changed file was read');
assert.equal(idx2.stats().last.reused, 2);
assert.equal(idx2.hashOf('assets/char/a.png'), sha16(a2));
assert.equal(idx2.hashOf('fonts/f.woff2'), null);
assert.equal(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).files['fonts/f.woff2'], undefined);
idx2.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('models: one hash for a skeleton, its atlas and pages; urlHashOk accepts the own or the model hash, nothing else', async () => {
const dir = tree({ 'public/assets/s/x.skel': 'skel', 'public/assets/s/x.atlas': 'atlas', 'public/assets/s/x.png': 'png', 'public/assets/o.png': 'other' });
try {
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), delayMs: 0 });
idx.start();
await idx.whenReady();
const trio = ['assets/s/x.skel', 'assets/s/x.atlas', 'assets/s/x.png'];
const m = idx.modelHash(trio);
assert.equal(m, hash16(['skel', 'atlas', 'png'].map((s) => sha16(Buffer.from(s))).join('\n')));
assert.equal(idx.modelHash([...trio, 'assets/missing.png']), null, 'a member the index does not know: no model hash');
const st = (rel) => fs.statSync(path.join(dir, 'public', rel));
assert.equal(idx.urlHashOk('assets/s/x.atlas', st('assets/s/x.atlas'), m), false, 'not before the lists are known');
idx.setModels([trio]);
for (const rel of trio) assert.equal(idx.urlHashOk(rel, st(rel), m), true, rel);
assert.equal(idx.urlHashOk('assets/s/x.png', st('assets/s/x.png'), sha16(Buffer.from('png'))), true, 'the own hash too');
assert.equal(idx.urlHashOk('assets/o.png', st('assets/o.png'), m), false, 'not a member');
assert.equal(idx.urlHashOk('assets/s/x.png', st('assets/s/x.png'), '0000000000000000'), false);
assert.equal(idx.urlHashOk('assets/s/x.png', { size: 99, mtimeMs: 1 }, m), false, 'changed since: never immutable');
idx.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('a served file the index no longer matches asks for a rescan (rate-limited); the new hash follows', async () => {
const dir = tree({ 'public/assets/a.png': 'one' });
try {
let changes = 0;
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), delayMs: 0, rescanGapMs: 50 });
idx.onChange(() => changes++);
idx.start();
await idx.whenReady();
assert.equal(changes, 1);
const gen = idx.generation;
fs.writeFileSync(path.join(dir, 'public/assets/a.png'), 'two!');
assert.equal(idx.hashOf('assets/a.png', fs.statSync(path.join(dir, 'public/assets/a.png'))), null);
idx.noteMiss('assets/a.png');
idx.noteMiss('assets/a.png');
assert.ok(await until(() => idx.generation > gen), 'rescanned');
assert.equal(idx.hashOf('assets/a.png'), sha16(Buffer.from('two!')));
assert.equal(changes, 2, 'one rescan for both misses');
idx.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('the served manifests (rewriteManifest)', () => {
const H = { '/assets/char/a.png': 'aaaaaaaaaaaaaaaa', '/assets/audio/bgm/x.mp3': 'bbbbbbbbbbbbbbbb', '/fonts/f.woff2': 'cccccccccccccccc',
'/assets/spine/x/x.skel': '1111111111111111', '/assets/spine/x/x.atlas': '2222222222222222', '/assets/spine/x/x.png': '3333333333333333',
'/assets/local/spine/token/t/t.skel': '4444444444444444', '/assets/local/spine/token/t/t.atlas': '5555555555555555',
'/assets/local/spine/token/t/t.png': '6666666666666666', '/assets/local/ui/battle/b.png': '7777777777777777' };
const index = {
hashOf: (rel) => H[`/${rel}`] || null,
modelHash: (rels) => (rels.every((r) => H[`/${r}`]) ? hash16(rels.map((r) => H[`/${r}`]).join('\n')) : null),
};
test('data/assets.json: own hashes, one model hash per Spine object, unknown files and names left alone, the input untouched', () => {
const doc = {
version: 1, hash: 'a1b2c3d4e5f6', generator: 'tools/fetch-assets.mjs',
chars: { c: { avatar: '/assets/char/a.png', portrait: '/assets/char/missing.png',
spine: { front: { skel: '/assets/spine/x/x.skel', atlas: '/assets/spine/x/x.atlas', textures: ['/assets/spine/x/x.png'], pma: false, anims: { idle: 'Idle' } } } } },
enemies: { e: { icon: '/assets/char/a.png', spineLocal: { group: 'spine/enemy/e', skel: 'e.skel', atlas: 'e.atlas', textures: ['e.png'] } } },
audio: { bgm: { lobby: { loop: '/assets/audio/bgm/x.mp3' } } },
fonts: { css: '/fonts/fonts.css', faces: { f: { woff2: '/fonts/f.woff2', family: 'F' } } },
};
const copy = JSON.parse(JSON.stringify(doc));
const r = rewriteManifest(doc, 'assets.json', index);
assert.deepEqual(doc, copy, 'the committed manifest object is not changed');
const m = index.modelHash(['assets/spine/x/x.skel', 'assets/spine/x/x.atlas', 'assets/spine/x/x.png']);
assert.equal(r.doc.chars.c.avatar, '/assets/~aaaaaaaaaaaaaaaa/char/a.png');
assert.equal(r.doc.chars.c.portrait, '/assets/char/missing.png', 'no hash: the plain URL');
assert.deepEqual(r.doc.chars.c.spine.front, { skel: `/assets/~${m}/spine/x/x.skel`, atlas: `/assets/~${m}/spine/x/x.atlas`, textures: [`/assets/~${m}/spine/x/x.png`], pma: false, anims: { idle: 'Idle' } });
assert.deepEqual(r.doc.enemies.e.spineLocal, copy.enemies.e.spineLocal, 'spineLocal holds names, not URLs');
assert.equal(r.doc.audio.bgm.lobby.loop, '/assets/~bbbbbbbbbbbbbbbb/audio/bgm/x.mp3', 'audio keeps /assets/…/audio/….mp3 (media.js makes it /media/~hash/…)');
assert.equal(r.doc.fonts.faces.f.woff2, '/fonts/~cccccccccccccccc/f.woff2');
assert.equal(r.doc.fonts.css, '/fonts/fonts.css');
assert.equal(r.doc.hash, 'a1b2c3d4e5f6');
assert.deepEqual(r.models, [['assets/spine/x/x.skel', 'assets/spine/x/x.atlas', 'assets/spine/x/x.png']]);
assert.equal(r.hashed, 7);
assert.equal(r.plain, 2);
});
test('data/local-assets.json: a spine/ group is one model, other groups own hashes', () => {
const doc = { version: 1, source: 'local-client', count: 4, groups: {
'spine/token/t': { 't.atlas': { path: '/assets/local/spine/token/t/t.atlas', kind: 'TextAsset' }, 't.png': { path: '/assets/local/spine/token/t/t.png', w: 4, h: 4 }, 't.skel': { path: '/assets/local/spine/token/t/t.skel' } },
'ui/battle': { b: { path: '/assets/local/ui/battle/b.png', w: 2, h: 2, kind: 'Sprite' } },
} };
const r = rewriteManifest(doc, 'local-assets.json', index);
const m = index.modelHash(['assets/local/spine/token/t/t.atlas', 'assets/local/spine/token/t/t.png', 'assets/local/spine/token/t/t.skel']);
assert.ok(m);
for (const [n, e] of Object.entries(r.doc.groups['spine/token/t'])) assert.equal(e.path, doc.groups['spine/token/t'][n].path.replace('/assets/', `/assets/~${m}/`), n);
assert.equal(r.doc.groups['spine/token/t']['t.png'].w, 4);
assert.deepEqual(r.doc.groups['ui/battle'].b, { path: '/assets/~7777777777777777/local/ui/battle/b.png', w: 2, h: 2, kind: 'Sprite' });
assert.deepEqual([r.doc.version, r.doc.source, r.doc.count], [1, 'local-client', 4]);
assert.equal(r.models.length, 1);
});
test('relOf: URL path → index path, refusing what the static server refuses', () => {
assert.equal(relOf('/assets/a%20b.png'), 'assets/a b.png');
assert.equal(relOf('/fonts/f.woff2'), 'fonts/f.woff2');
for (const u of ['/data/x.json', '/assets/../x', '/assets//x', '/assets/x?v=1', '/assets/%E0%A4%A', 'assets/x', null]) assert.equal(relOf(u), null, String(u));
});
});
describe('content-hashed URLs through a real server', () => {
let srv;
let dir;
const png = randomBytes(3000);
const mp3 = randomBytes(6000);
const skel = Buffer.from('skel-binary'.repeat(80));
const atlas = Buffer.from('x.png\nsize: 64,64\n'.repeat(30));
const page = randomBytes(1500);
const manifest = {
version: 1, hash: 'abcdefabcdef',
chars: { c: { avatar: '/assets/char/a.png', spine: { front: { skel: '/assets/spine/x/x.skel', atlas: '/assets/spine/x/x.atlas', textures: ['/assets/spine/x/x.png'], anims: { idle: 'Idle' } } } } },
audio: { bgm: { lobby: { loop: '/assets/audio/bgm/lobby.mp3' } } },
fonts: { css: '/fonts/fonts.css', faces: { f: { woff2: '/fonts/f.woff2' } } },
stats: { note: 'a manifest large enough to be compressed '.repeat(40) },
};
const manifestText = JSON.stringify(manifest, null, 2);
before(async () => {
dir = tree({
'secret.txt': 'TOP-SECRET-CONTENT',
'public/index.html': '<p>hi</p>', 'public/assets/char/a.png': png, 'public/assets/audio/bgm/lobby.mp3': mp3,
'public/assets/spine/x/x.skel': skel, 'public/assets/spine/x/x.atlas': atlas, 'public/assets/spine/x/x.png': page,
'public/assets/spine/x/tiles.json': '{"t":1}', 'public/fonts/f.woff2': randomBytes(500), 'public/fonts/fonts.css': '@font-face{}',
'data/assets.json': manifestText, 'data/config.json': '{}', 'shared/x.js': 'export const y = 2;',
});
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(dir, 'public'), dataDir: path.join(dir, 'data'),
sharedDir: path.join(dir, 'shared'), assetIndex: { delayMs: 0 } });
await srv.assetIndex.whenReady();
});
after(async () => {
await srv?.close();
fs.rmSync(dir, { recursive: true, force: true });
});
const view = async () => JSON.parse((await get(srv.port, '/data/assets.json')).body);
test('/data/assets.json is served with hashed URLs (no-cache, its own ETag, 304, gzip, HEAD); the committed file stays as it was', async () => {
const r = await get(srv.port, '/data/assets.json');
assert.equal(r.status, 200);
assert.equal(r.headers['content-type'], 'application/json; charset=utf-8');
assert.equal(r.headers['cache-control'], 'no-cache');
assert.match(r.headers.etag, /^"[0-9a-f]{16}"$/);
const v = JSON.parse(r.body);
assert.equal(v.chars.c.avatar, `/assets/~${sha16(png)}/char/a.png`);
assert.equal(v.audio.bgm.lobby.loop, `/assets/~${sha16(mp3)}/audio/bgm/lobby.mp3`);
assert.match(v.fonts.faces.f.woff2, /^\/fonts\/~[0-9a-f]{16}\/f\.woff2$/);
const m = hash16([skel, atlas, page].map(sha16).join('\n'));
assert.deepEqual([v.chars.c.spine.front.skel, v.chars.c.spine.front.atlas, v.chars.c.spine.front.textures[0]],
[`/assets/~${m}/spine/x/x.skel`, `/assets/~${m}/spine/x/x.atlas`, `/assets/~${m}/spine/x/x.png`]);
assert.equal((await get(srv.port, '/data/assets.json', { 'if-none-match': r.headers.etag })).status, 304);
const gz = await get(srv.port, '/data/assets.json', { 'accept-encoding': 'gzip' });
assert.equal(gz.headers['content-encoding'], 'gzip');
assert.notEqual(gz.headers.etag, r.headers.etag);
const head = await get(srv.port, '/data/assets.json', {}, 'HEAD');
assert.equal(head.status, 200);
assert.equal(head.body.length, 0);
assert.equal(fs.readFileSync(path.join(dir, 'data/assets.json'), 'utf8'), manifestText, 'never written');
assert.equal((await get(srv.port, '/data/local-assets.json')).status, 200, 'an absent local manifest is still the empty stand-in');
assert.deepEqual(JSON.parse((await get(srv.port, '/data/local-assets.json')).body).groups, {});
});
test('a hashed URL: immutable with a content ETag when the hash names the file; the current file with no-cache otherwise; plain URLs as before', async () => {
const v = await view();
const ok = await get(srv.port, v.chars.c.avatar);
assert.equal(ok.status, 200);
assert.deepEqual(ok.body, png);
assert.equal(ok.headers['cache-control'], IMMUTABLE);
assert.equal(ok.headers.etag, `"${sha16(png)}"`);
const old = await get(srv.port, '/assets/~0000000000000000/char/a.png');
assert.deepEqual(old.body, png, 'an old page still gets the file');
assert.equal(old.headers['cache-control'], 'no-cache');
const plain = await get(srv.port, '/assets/char/a.png');
assert.equal(plain.headers['cache-control'], 'public, max-age=86400');
assert.equal(plain.headers.etag, `"${sha16(png)}"`, 'a content ETag on the plain URL too (an mtime change alone is a 304)');
assert.equal((await get(srv.port, '/assets/char/a.png', { 'if-none-match': `"${sha16(png)}"` })).status, 304);
const font = await get(srv.port, v.fonts.faces.f.woff2);
assert.equal(font.headers['cache-control'], IMMUTABLE);
const query = await get(srv.port, '/assets/char/a.png?v=1');
assert.match(query.headers['cache-control'], /immutable/, 'the old ?v= rule is kept');
});
test('a Spine model: skeleton, atlas, page and the atlas derived from the skeleton URL are immutable under the model hash; a file derived beside them is no-cache', async () => {
const sp = (await view()).chars.c.spine.front;
for (const u of [sp.skel, sp.atlas, sp.textures[0], sp.skel.replace(/\.skel$/, '.atlas')]) {
const r = await get(srv.port, u);
assert.equal(r.status, 200, u);
assert.equal(r.headers['cache-control'], IMMUTABLE, u);
}
const sk = await get(srv.port, sp.skel);
assert.deepEqual(sk.body, skel);
assert.equal(sk.headers.etag, `"${sha16(skel)}"`, 'the ETag is the file\'s own content hash');
const tiles = await get(srv.port, sp.skel.replace(/[^/]+$/, 'tiles.json'));
assert.equal(tiles.status, 200);
assert.equal(tiles.headers['cache-control'], 'no-cache', 'not a member of the model');
});
test('audio: /media/~hash/… resolves like /media/…, immutable when the hash matches, ranges and ETags as before', async () => {
const loop = (await view()).audio.bgm.lobby.loop;
const media = loop.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2');
assert.match(media, /^\/media\/~[0-9a-f]{16}\/bgm\/lobby$/);
const r = await get(srv.port, media);
assert.equal(r.status, 200);
assert.equal(r.headers['content-type'], 'audio/mpeg');
assert.equal(r.headers['cache-control'], IMMUTABLE);
assert.equal(r.headers.etag, `"${sha16(mp3)}"`);
assert.deepEqual(r.body, mp3);
const range = await get(srv.port, media, { range: 'bytes=0-9' });
assert.equal(range.status, 206);
assert.deepEqual(range.body, mp3.subarray(0, 10));
assert.equal((await get(srv.port, loop)).headers['cache-control'], IMMUTABLE, 'the .mp3 fallback URL too');
assert.equal((await get(srv.port, '/media/~0000000000000000/bgm/lobby')).headers['cache-control'], 'no-cache');
assert.equal((await get(srv.port, '/media/bgm/lobby')).headers['cache-control'], 'public, max-age=86400');
});
test('the segment never opens a way out of the served folders', async () => {
const h = sha16(png);
for (const p of [`/assets/~${h}/../../secret.txt`, `/assets/~${h}/%2e%2e/%2e%2e/secret.txt`, `/assets/~${h}/..%2f..%2fsecret.txt`,
`/media/~${h}/..%2f..%2f..%2fsecret.txt`, `/fonts/~${h}/../../secret.txt`, `/assets/~${h}/.hidden`, `/assets/~${h}`, '/assets/~/char/a.png']) {
const r = await get(srv.port, p);
assert.ok([301, 400, 403, 404].includes(r.status), `${p} → ${r.status}`);
assert.ok(!r.body.toString().includes('TOP-SECRET'), `${p} leaked`);
}
});
test('a file replaced on disk: its old hashed URL turns no-cache at once, and after the rescan the view names the new bytes', async () => {
const before = (await view()).chars.c.avatar;
const next = randomBytes(3100);
fs.writeFileSync(path.join(dir, 'public/assets/char/a.png'), next);
const stale = await get(srv.port, before);
assert.deepEqual(stale.body, next, 'the current file');
assert.equal(stale.headers['cache-control'], 'no-cache', 'the index no longer vouches for these bytes');
// the miss asked for a rescan (RESCAN_GAP_MS after the last build) — trigger it now instead of waiting
await srv.assetIndex.refresh();
assert.ok(await until(async () => (await view()).chars.c.avatar === `/assets/~${sha16(next)}/char/a.png`), 'the view follows');
assert.equal((await get(srv.port, `/assets/~${sha16(next)}/char/a.png`)).headers['cache-control'], IMMUTABLE);
});
});
test('without the index (assetIndex: false) the manifest goes out as committed and a hashed URL is served no-cache', async () => {
const dir = tree({ 'public/assets/a.png': 'png', 'data/assets.json': JSON.stringify({ chars: { c: { avatar: '/assets/a.png' } } }) });
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(dir, 'public'), dataDir: path.join(dir, 'data'), assetIndex: false });
try {
assert.equal(srv.assetIndex, null);
assert.equal(JSON.parse((await get(srv.port, '/data/assets.json')).body).chars.c.avatar, '/assets/a.png');
const r = await get(srv.port, `/assets/~${sha16(Buffer.from('png'))}/a.png`);
assert.equal(r.status, 200);
assert.equal(r.headers['cache-control'], 'no-cache');
assert.match(r.headers.etag, /^"[0-9a-f]+-[0-9a-f]+"$/, 'size-mtime ETag');
} finally {
await srv.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
+41
View File
@@ -0,0 +1,41 @@
// shared/assetPaths.js: the content-hash segment of asset URLs (split / insert / strip) and the traffic categories the
// server's counters and the browser's diagnostics share (host bandwidth, layers 0 and 1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { splitHashedPath, hashedPath, unhashedUrl, trafficCategory, TRAFFIC_CATEGORIES, ASSET_HASH_HEX, HASH_SEGMENT_RE } from '../shared/assetPaths.js';
const H = '0123456789abcdef';
test('the hash segment: split off, inserted after the root, removed again', () => {
assert.equal(ASSET_HASH_HEX, 16);
assert.ok(HASH_SEGMENT_RE.test(`~${H}`));
assert.deepEqual(splitHashedPath(`/assets/~${H}/char/avatar/a.png`), { hash: H, path: '/assets/char/avatar/a.png' });
assert.deepEqual(splitHashedPath(`/fonts/~${H}/f.woff2`), { hash: H, path: '/fonts/f.woff2' });
assert.deepEqual(splitHashedPath(`/media/~${H}/bgm/a`), { hash: H, path: '/media/bgm/a' });
for (const p of ['/assets/char/a.png', `/assets/~${H.toUpperCase()}/a.png`, `/assets/~${H}0/a.png`, `/assets/~${H}`, `/data/~${H}/x.json`,
`/assets/x/~${H}/a.png`, '/assets/~zz/a.png', '']) {
assert.deepEqual(splitHashedPath(p), { hash: null, path: p }, p);
}
assert.equal(hashedPath('/assets/char/a.png', H), `/assets/~${H}/char/a.png`);
assert.equal(hashedPath('/media/bgm/a', H), `/media/~${H}/bgm/a`);
assert.equal(hashedPath('/fonts/f.woff2', H), `/fonts/~${H}/f.woff2`);
assert.equal(hashedPath('/data/assets.json', H), '/data/assets.json', 'not a hashed root');
assert.equal(hashedPath('/assets/a.png', null), '/assets/a.png');
assert.equal(hashedPath('/assets/a.png', 'nothex'), '/assets/a.png');
assert.equal(hashedPath(`/assets/~${H}/a.png`, 'fedcba9876543210'), `/assets/~${H}/a.png`, 'never two segments');
assert.equal(hashedPath('/assets/', H), '/assets/');
assert.equal(unhashedUrl(`/assets/~${H}/audio/sfx/b_char_dead.mp3`), '/assets/audio/sfx/b_char_dead.mp3');
assert.equal(unhashedUrl(null), null);
});
test('traffic categories: audio, assets, code, data, other — the hash segment does not matter', () => {
assert.deepEqual([...TRAFFIC_CATEGORIES], ['assets', 'audio', 'code', 'data', 'other']);
const cases = {
'/media/bgm/act1': 'audio', [`/media/~${H}/bgm/act1`]: 'audio', '/assets/audio/bgm/a.mp3': 'audio', [`/assets/~${H}/audio/sfx/a.mp3`]: 'audio',
'/assets/char/avatar/a.png': 'assets', [`/assets/~${H}/spine/op/x/front/x.skel`]: 'assets', '/fonts/f.woff2': 'assets', '/icons/app.svg': 'assets',
'/packs/foo/art/a.png': 'assets', '/packs/index.json': 'data', '/data/assets.json': 'data', '/data/i18n/en.json': 'data', '/i18n/en.json': 'data',
'/manifest.json': 'data', '/': 'code', '/index.html': 'code', '/js/main.js': 'code', '/css/theme.css': 'code', '/vendor/pixi.min.js': 'code',
'/sim/Battle.js': 'code', '/shared/protocol.js': 'code', '/data.js': 'code', '/healthz': 'other', '/favicon.ico': 'other',
};
for (const [p, cat] of Object.entries(cases)) assert.equal(trafficCategory(p), cat, p);
});
+5
View File
@@ -111,6 +111,11 @@ test('#4 audio classes (official ON_UNIT_DEAD/BORN defaults): only operators pla
assert.equal(deathSfxUrl(manifest, bomb, { consumed: true }), null, 'used up ⇒ silent');
assert.equal(deathSfxUrl(manifest, mapChar), SFX.battle.charDie, 'band map characters are operators');
assert.equal(deathSfxUrl(manifest, crate), TOKEN_DEAD, 'act crate trap_1105 ⇒ b_char_tokendead');
// a served manifest's charDie carries charDie's content hash (host bandwidth layer 1): the sound derived beside it
// names another file, so it goes out without the segment (served plainly, cached a day) instead of a wrong hash
const hashed = { ...manifest, audio: { ...manifest.audio, sfx: { ...SFX, battle: { ...SFX.battle, charDie: SFX.battle.charDie.replace(/^\/assets\//, '/assets/~0123456789abcdef/') } } } };
assert.equal(deathSfxUrl(hashed, op), hashed.audio.sfx.battle.charDie);
assert.equal(deathSfxUrl(hashed, tok), TOKEN_DEAD, 'b_char_tokendead without charDie\'s hash');
assert.equal(deathSfxUrl(manifest, enemy), SFX.battle.enemyDie);
assert.equal(deathSfxUrl(manifest, boss), SFX.battle.enemyDieHeavy);
assert.equal(deathSfxUrl(manifest, op, { reason: 'retreat' }), null, 'a retreat is no knock-down');
+51 -1
View File
@@ -5,7 +5,7 @@ import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics,
setBattleSource, currentBattle, LOG_MAX, STACK_LINES,
setBattleSource, currentBattle, LOG_MAX, STACK_LINES, recordResourceEntry, trafficSummary, trafficLine, clearTraffic, installTrafficMeter,
} from '../public/js/diag.js';
import { APP_VERSION } from '../shared/constants.js';
@@ -184,3 +184,53 @@ describe('the report', () => {
setBattleSource(null);
});
});
describe('the page\'s traffic (host bandwidth layer 0)', () => {
beforeEach(() => clearTraffic());
const ORIGIN = 'http://192.168.1.5:3000';
test('Resource Timing entries sum by category: bytes on the wire, a cache hit counted as cached, other sites apart', () => {
recordResourceEntry({ name: `${ORIGIN}/assets/~0123456789abcdef/spine/op/x/front/x.skel`, transferSize: 120000, decodedBodySize: 400000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/assets/char/avatar/a.png`, transferSize: 0, decodedBodySize: 50000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/media/~0123456789abcdef/bgm/m_bat`, transferSize: 2000000, decodedBodySize: 2000000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 9000, decodedBodySize: 30000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/data/assets.json`, transferSize: 300, decodedBodySize: 1300000 }, ORIGIN);
recordResourceEntry({ name: 'https://fonts.gstatic.com/s/x.woff2', transferSize: 50000 }, ORIGIN);
recordResourceEntry({ name: 'data:image/png;base64,AAAA', transferSize: 0 }, ORIGIN);
recordResourceEntry({ name: 'blob:http://192.168.1.5:3000/1234', transferSize: 0 }, ORIGIN);
recordResourceEntry(null, ORIGIN);
const t = trafficSummary();
assert.deepEqual(t.assets, { requests: 2, bytes: 120000, cached: 1 });
assert.deepEqual(t.audio, { requests: 1, bytes: 2000000, cached: 0 });
assert.deepEqual(t.code, { requests: 1, bytes: 9000, cached: 0 });
assert.deepEqual(t.data, { requests: 1, bytes: 300, cached: 0 }, 'a 304 costs its headers only');
assert.deepEqual(t.external, { requests: 1, bytes: 50000, cached: 0 });
assert.deepEqual(t.total, { requests: 5, bytes: 2129300, cached: 1 }, 'the host total leaves other sites out');
assert.deepEqual(Object.keys(t), ['assets', 'audio', 'code', 'data', 'external', 'total']);
assert.equal(trafficLine(t), 'assets 120 KB (2, 1 cached) | audio 2.0 MB (1) | code 9 KB (1) | data 300 B (1) | external 50 KB (1) | host total 2.1 MB (5)');
});
test('the report carries the traffic line; nothing recorded → no line', () => {
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 1500 }, ORIGIN);
const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() });
assert.match(text, /\ntraffic: code 2 KB \(1\) \| host total 2 KB \(1\)\n/);
clearTraffic();
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() })));
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0 })));
});
test('installTrafficMeter reads the navigation entry and observes the resources (buffered), once; false without the API', () => {
let observed = null;
class PO {
constructor(fn) { this.fn = fn; }
observe(o) { observed = o; this.fn({ getEntries: () => [{ name: `${ORIGIN}/vendor/pixi.min.js`, transferSize: 140000 }] }); }
}
const win = { PerformanceObserver: PO, location: { origin: ORIGIN }, performance: { getEntriesByType: (t) => (t === 'navigation' ? [{ name: `${ORIGIN}/`, transferSize: 7000 }] : []) } };
assert.equal(installTrafficMeter({}), false);
assert.equal(installTrafficMeter(win), true);
assert.deepEqual(observed, { type: 'resource', buffered: true });
assert.deepEqual(trafficSummary().code, { requests: 2, bytes: 147000, cached: 0 });
assert.equal(installTrafficMeter(win), true, 'already installed');
assert.equal(trafficSummary().code.requests, 2, 'not twice');
});
});
+7
View File
@@ -109,6 +109,13 @@ export async function startRealServer(opts = {}) {
if (Date.now() - t0 > 20000) { child.kill('SIGKILL'); throw new Error(`server did not start:\n${logs.join('\n')}`); }
await sleep(150);
}
// the content-hash index (server/http/assetIndex.js, built ~1 s after the start): wait for it, so a page loads the
// served manifest's /assets/~<hash>/… URLs like a player's would (none: SP_ASSET_INDEX=0); bounded, never fatal
for (const t1 = Date.now(); Date.now() - t1 < 20000;) {
const h = await fetch(`${base}/healthz?traffic=1`).then((r) => r.json(), () => null);
if (!h || !h.assetHashes || h.assetHashes.ready) break;
await sleep(150);
}
return {
base, port, logs,
// `{ hard: true }`: SIGKILL at once — a crash: no room.closed 'shutdown' reaches the clients
+59 -12
View File
@@ -11,7 +11,7 @@ import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { startServer, parseRange, acceptsGzip, parseTrustProxy } from '../server/index.js';
import { startServer, parseRange, acceptsGzip, pickEncoding, parseTrustProxy } from '../server/index.js';
import { loadData, lookup, getChess, getBond, getBand, getMode, getConfig, INDEXED_FILES } from '../server/data.js';
import * as dataModule from '../server/data.js';
import { CODE_ALPHABET, BOT_NAMES } from '../server/lobby.js';
@@ -109,6 +109,8 @@ describe('static http server', () => {
const js = `// app\n${'export const x = 1;\n'.repeat(200)}`;
const mp3 = randomBytes(5000);
const skel = Buffer.from('skel'.repeat(400));
// over the 8 MB compression cache limit: compressed as a stream
const big = `// big\n${Array.from({ length: 360000 }, (_, i) => `export const v${i} = ${i % 97};`).join('\n')}\n`;
before(async () => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-static-'));
@@ -127,6 +129,7 @@ describe('static http server', () => {
w('public/assets/spine/x.skel', skel);
w('public/assets/spine/x.atlas', 'x.png\nsize: 64,64\n'.repeat(40));
w('public/assets/img/a.png', randomBytes(32));
w('public/js/huge.js', big);
w('public/assets/img/a.webp', randomBytes(32));
w('public/fonts/f.woff2', randomBytes(32));
w('public/fonts/f.ttf', randomBytes(32));
@@ -138,7 +141,10 @@ describe('static http server', () => {
srv = await startServer({
port: 0, host: '127.0.0.1', quiet: true,
publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared'),
assetIndex: { delayMs: 0 },
});
// the content hashes of public/assets + public/fonts (the ETags of those files): ready before the first request
await srv.assetIndex.whenReady();
});
after(async () => {
@@ -161,17 +167,20 @@ describe('static http server', () => {
assert.equal(redirect.headers.location, '/sub/?x=1');
});
test('javascript: correct MIME, gzip when accepted, identity otherwise', async () => {
const gz = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate, br' } });
assert.equal(gz.status, 200);
assert.equal(gz.headers['content-type'], 'text/javascript; charset=utf-8');
assert.equal(gz.headers['content-encoding'], 'gzip');
assert.equal(gz.headers.vary, 'Accept-Encoding');
assert.equal(Number(gz.headers['content-length']), gz.body.length);
test('javascript: correct MIME, brotli when named, gzip when accepted, identity otherwise', async () => {
const br = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate, br' } });
assert.equal(br.status, 200);
assert.equal(br.headers['content-type'], 'text/javascript; charset=utf-8');
assert.equal(br.headers['content-encoding'], 'br', 'a browser on an HTTPS page (or localhost) names br');
assert.equal(br.headers.vary, 'Accept-Encoding');
assert.equal(Number(br.headers['content-length']), br.body.length);
assert.equal(zlib.brotliDecompressSync(br.body).toString(), js);
const gz = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate' } });
assert.equal(gz.headers['content-encoding'], 'gzip', 'a LAN http:// page: gzip');
assert.equal(zlib.gunzipSync(gz.body).toString(), js);
// cached second response is identical
const gz2 = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } });
assert.deepEqual(gz2.body, gz.body);
// cached second responses are identical
assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).body, gz.body);
assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).body, br.body);
const plain = await httpReq(srv.port, '/js/app.js');
assert.equal(plain.headers['content-encoding'], undefined);
@@ -206,6 +215,27 @@ describe('static http server', () => {
}
const skelGz = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip' } });
assert.deepEqual(zlib.gunzipSync(skelGz.body), skel);
const skelBr = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip, br' } });
assert.equal(skelBr.headers['content-encoding'], 'br');
assert.deepEqual(zlib.brotliDecompressSync(skelBr.body), skel);
// media is never compressed, whatever the client names
assert.equal((await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { 'accept-encoding': 'br, gzip' } })).headers['content-encoding'], undefined);
assert.equal((await httpReq(srv.port, '/assets/img/a.png', { headers: { 'accept-encoding': 'br' } })).headers['content-encoding'], undefined);
});
test('a file over the compression cache limit is compressed as a stream (brotli and gzip); HEAD sends no body', async () => {
const br = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'br' } });
assert.equal(br.headers['content-encoding'], 'br');
assert.equal(br.headers['content-length'], undefined, 'streamed: no length up front');
assert.equal(zlib.brotliDecompressSync(br.body).toString(), big);
assert.ok(br.body.length < big.length / 4);
const gz = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'gzip' } });
assert.equal(gz.headers['content-encoding'], 'gzip');
assert.equal(zlib.gunzipSync(gz.body).toString(), big);
const head = await httpReq(srv.port, '/js/huge.js', { method: 'HEAD', headers: { 'accept-encoding': 'br' } });
assert.equal(head.status, 200);
assert.equal(head.body.length, 0);
assert.equal(head.headers['content-encoding'], 'br');
});
test('ETag / Last-Modified revalidation and Cache-Control policy', async () => {
@@ -225,10 +255,16 @@ describe('static http server', () => {
assert.equal(stale.status, 200);
const gzEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).headers.etag;
assert.notEqual(gzEtag, etag, 'gzip variant has its own ETag');
const brEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).headers.etag;
assert.equal(brEtag, etag.replace(/"$/, '-br"'), 'so has the brotli variant');
assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br', 'if-none-match': brEtag } })).status, 304);
assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip', 'if-none-match': brEtag } })).status, 200, 'not the gzip variant');
// unversioned art/fonts/vendor: cached for a day (files can be rewritten in place by the asset tools)
// unversioned art/fonts/vendor: cached for a day (files can be rewritten in place by the asset tools); the art and
// fonts carry their content hash as ETag (server/http/assetIndex.js), so a rewrite with the same bytes is a 304
const asset = await httpReq(srv.port, '/assets/img/a.png');
assert.equal(asset.headers['cache-control'], 'public, max-age=86400');
assert.match(asset.headers.etag, /^"[0-9a-f]{16}"$/);
const font = await httpReq(srv.port, '/fonts/f.woff2');
assert.equal(font.headers['cache-control'], 'public, max-age=86400');
const assetV = await httpReq(srv.port, '/assets/img/a.png?v=abc');
@@ -391,6 +427,17 @@ describe('static http server', () => {
assert.equal(acceptsGzip('*;q=0'), false);
assert.equal(acceptsGzip('br'), false);
assert.equal(acceptsGzip(undefined), false);
// brotli when named at least as high as gzip; `*` alone never picks it
assert.equal(pickEncoding('gzip, deflate, br, zstd'), 'br');
assert.equal(pickEncoding('gzip, deflate'), 'gzip');
assert.equal(pickEncoding('br;q=0, gzip'), 'gzip');
assert.equal(pickEncoding('br;q=0.5, gzip'), 'gzip');
assert.equal(pickEncoding('gzip;q=0.5, br'), 'br');
assert.equal(pickEncoding('br'), 'br');
assert.equal(pickEncoding('*'), 'gzip');
assert.equal(pickEncoding('*;q=0'), null);
assert.equal(pickEncoding('identity'), null);
assert.equal(pickEncoding(undefined), null);
});
});
+41
View File
@@ -209,6 +209,47 @@ print(json.dumps(e.prefab_node(go)))`);
const raw = spawnSync(PY, ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(TOOL)}); import extract as e, json; print(json.dumps(e.vec(type('V', (), {'x': -0.0, 'y': float('inf'), 'z': 1e-9})())))`], { encoding: 'utf8', env: ENV });
assert.equal(raw.stdout.trim(), '[0.0, 0, 0.0]');
});
test('guide pages (host bandwidth layer 0): --webp writes a lossless WebP copy of every page PNG, the manifest lists it, the PNG stays',
{ skip: spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status !== 0 && 'no Pillow with WebP' }, () => {
const jobs = JSON.parse(spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }).stdout);
assert.deepEqual(jobs.guideWebp.sub, 'guide');
assert.equal(jobs.guideWebp.mode, 'lossless', 'lossy pages wait for the maintainer\'s comparison');
assert.ok(!jobs.webp.some((w) => w.sub === 'guide'), 'the board texture table is unchanged');
const out = py(`
import tempfile, pathlib
from PIL import Image
root = pathlib.Path(tempfile.mkdtemp())
sub = root / 'guide'
sub.mkdir()
for i, name in enumerate(['autochess_home_1', 'autochess_shop_2']):
img = Image.new('RGBA', (48, 32), (10, 20, 30, 255))
for x in range(48):
for y in range(32): img.putpixel((x, y), ((x * 5 + i * 40) % 256, (y * 7) % 256, (x * y) % 256, 255 if x % 7 else 254))
img.save(sub / f'{name}.png')
groups = {'guide': {n: {'path': f'/assets/local/guide/{n}.png', 'w': 48, 'h': 32, 'kind': 'Sprite'} for n in ['autochess_home_1', 'autochess_shop_2']},
'ui/battle': {'x': {'path': '/assets/local/ui/battle/x.png', 'w': 2, 'h': 2, 'kind': 'Sprite'}}}
groups['guide']['autochess_handbook_9'] = {'path': '/assets/local/guide/autochess_handbook_9.png', 'kind': 'Sprite'} # no PNG on disk
mf = root / 'local-assets.json'
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 4, 'groups': groups}))
code = e.webp_only(root, mf, lambda *a: None)
doc = json.loads(mf.read_text())
g = doc['groups']['guide']
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
'other': doc['groups']['ui/battle']['x']['path'],
'same': all(px(sub / f'{n}.png') == px(sub / f'{n}.webp') for n in ['autochess_home_1', 'autochess_shop_2']),
'png': all((sub / f'{n}.png').exists() for n in ['autochess_home_1', 'autochess_shop_2']),
'again': e.run_webp(root, 'guide', doc['groups'], lambda *a: None)}))`);
assert.equal(out.code, 0);
const at = (n, ext) => `/assets/local/guide/${n}.${ext}`;
assert.deepEqual(out.paths, { autochess_home_1: at('autochess_home_1', 'webp'), autochess_shop_2: at('autochess_shop_2', 'webp'), autochess_handbook_9: at('autochess_handbook_9', 'png') });
assert.deepEqual(out.modes, { autochess_home_1: 'lossless', autochess_shop_2: 'lossless', autochess_handbook_9: null });
assert.equal(out.other, '/assets/local/ui/battle/x.png', 'other groups keep their PNGs');
assert.ok(out.same, 'lossless: the same pixels, alpha included');
assert.ok(out.png, 'the PNG stays beside the copy');
assert.equal(out.again, 2, 'a second run (another GUIDE_WEBP_MODE) rewrites the copies the manifest already lists');
});
});
describe('extracted board-scene files (when a local client was extracted)', { skip: !manifest && 'no data/local-assets.json' }, () => {
+10
View File
@@ -23,6 +23,16 @@ describe('mediaUrl: 音频地址去掉扩展名(躲开下载器嗅探)', ()
assert.equal(mediaUrl('/assets/audio/x/A.MP3', ORIGIN), '/media/x/A', '大小写不敏感');
});
test('服务器下发的清单带内容哈希段(/assets/~<hash>/audio/…):改写后保留这一段', () => {
const H = '0123456789abcdef';
assert.equal(mediaUrl(`/assets/~${H}/audio/bgm/m_sys_act1autochess_loop.mp3`, ORIGIN), `/media/~${H}/bgm/m_sys_act1autochess_loop`);
assert.equal(mediaUrl(`${ORIGIN}/assets/~${H}/audio/sfx/a.mp3`, ORIGIN), `/media/~${H}/sfx/a`);
assert.doesNotMatch(mediaUrl(`/assets/~${H}/audio/bgm/a.mp3`, ORIGIN), /\.(mp3|m4a|aac|ogg|oga|opus|wav)(\?|$)/i);
// 不是合法的哈希段(大写、长度不对)就不是我们的音频地址,原样返回
assert.equal(mediaUrl(`/assets/~${H.toUpperCase()}/audio/a.mp3`, ORIGIN), `/assets/~${H.toUpperCase()}/audio/a.mp3`);
assert.equal(mediaUrl(`/assets/~${H}/char/a.png`, ORIGIN), `/assets/~${H}/char/a.png`, '图片不改写');
});
test('改写后的地址里不再有任何媒体扩展名(下载器就是按这个嗅探的)', () => {
const out = mediaUrl('/assets/audio/bgm/m_bat_vtlionk_loop.mp3', ORIGIN);
assert.equal(out.startsWith(MEDIA_PREFIX), true);
+35
View File
@@ -23,6 +23,8 @@ describe('render engine in headless Chrome', { skip }, () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
// the content-hash index first: every page then loads the served manifest's /assets/~<hash>/… URLs (ASSETS.md)
await srv.assetIndex?.whenReady();
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
@@ -73,6 +75,39 @@ describe('render engine in headless Chrome', { skip }, () => {
});
}
test('Spine models load through the served manifest\'s content-hashed URLs, cached immutably (host bandwidth layer 1)', async (t) => {
if (!srv.assetIndex) { t.skip('SP_ASSET_INDEX=0: no content hashes'); return; }
const page = await browser.newPage();
const spine = [];
const problems = [];
page.on('response', (r) => {
const u = new URL(r.url());
if (/\.(skel|atlas)$/.test(u.pathname) || (/\/spine\//.test(u.pathname) && /\.png$/.test(u.pathname))) spine.push({ path: u.pathname, status: r.status(), cache: r.headers()['cache-control'] });
if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`);
});
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
try {
await page.setViewport({ width: 1600, height: 900 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&t=18&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await wait(3500);
const st = await page.evaluate(() => window.__demo.stats());
assert.ok(st.units > 0);
assert.equal(st.spine.failed, 0, 'no Spine load failures');
assert.ok(spine.length >= 6, `Spine files requested: ${spine.length}`);
for (const s of spine) {
assert.match(s.path, /^\/assets\/~[0-9a-f]{16}\//, `${s.path}: a content-hashed URL`);
assert.equal(s.status, 200, s.path);
assert.equal(s.cache, 'public, max-age=31536000, immutable', `${s.path}: skeleton, atlas and pages share the model hash`);
}
// the atlas pixi-spine derived from each skeleton came under the skeleton's segment
for (const sk of spine.filter((s) => s.path.endsWith('.skel'))) {
assert.ok(spine.some((s) => s.path === sk.path.replace(/\.skel$/, '.atlas')), `${sk.path}: its atlas`);
}
assert.deepEqual(problems, []);
} finally { await page.close(); }
});
test('Swire coins from a real snapshot remain separate from the elemental gauge', async () => {
const { makeBattle } = await import('../helpers/battleHarness.js');
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex: 1 }] });
+105
View File
@@ -0,0 +1,105 @@
// The server's traffic counters (server/http/traffic.js, GET /healthz?traffic=1; host bandwidth layer 0): the body
// bytes every HTTP response wrote, by category, 304s apart, and the WebSocket's wire and payload bytes both ways.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { startServer } from '../server/index.js';
import { createTrafficCounter } from '../server/http/traffic.js';
import { TestClient } from './helpers/wsClient.js';
function get(port, p, headers = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path: p, headers, agent: false }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
});
req.on('error', reject);
req.end();
});
}
test('HTTP: body bytes by category as written (compressed), 304s counted, /healthz reports them only when asked', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-traffic-'));
const w = (rel, content) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), content); };
const js = `// app\n${'export const x = 1;\n'.repeat(400)}`;
const png = randomBytes(3000);
const mp3 = randomBytes(5000);
w('public/index.html', '<!doctype html><p>hi</p>');
w('public/js/app.js', js);
w('public/assets/img/a.png', png);
w('public/assets/audio/bgm/a.mp3', mp3);
w('data/config.json', JSON.stringify({ a: 1 }));
w('shared/x.js', 'export const y = 2;');
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared') });
try {
const plain = await get(srv.port, '/health');
assert.equal(plain.status, 404);
const gz = await get(srv.port, '/js/app.js', { 'accept-encoding': 'gzip' });
assert.ok(gz.body.length < js.length, 'compressed');
await get(srv.port, '/js/app.js', { 'if-none-match': (await get(srv.port, '/js/app.js')).headers.etag });
await get(srv.port, '/assets/img/a.png');
await get(srv.port, '/media/bgm/a');
await get(srv.port, '/data/config.json');
const before = await get(srv.port, '/healthz');
assert.equal(JSON.parse(before.body).traffic, undefined, 'plain /healthz stays as it was');
const t = JSON.parse((await get(srv.port, '/healthz?traffic=1')).body).traffic;
assert.equal(t.http.code.requests, 3);
assert.equal(t.http.code.bytes, gz.body.length + js.length, 'what was written: the gzip body, the plain body, nothing for the 304');
assert.equal(t.http.code.notModified, 1);
assert.deepEqual(t.http.assets, { requests: 1, bytes: png.length, notModified: 0 });
assert.deepEqual(t.http.audio, { requests: 1, bytes: mp3.length, notModified: 0 });
assert.equal(t.http.data.requests, 1);
assert.equal(t.http.other.requests, 2, 'the 404 page and the first /healthz');
assert.ok(t.http.other.bytes > 0);
assert.equal(t.http.total.requests, 8);
assert.equal(typeof t.since, 'string');
assert.equal(srv.traffic.snapshot().http.total.requests, 9, 'the traffic request itself once it has finished');
} finally {
await srv.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('WebSocket: wire bytes both ways (after permessage-deflate) and the payloads, live and after the close', async () => {
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
try {
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
await c.hello('流量计数');
const live = srv.traffic.snapshot().ws;
assert.equal(live.connections, 1);
assert.equal(live.open, 1);
assert.ok(live.messagesOut >= 1 && live.payloadOut > 0, JSON.stringify(live));
assert.ok(live.messagesIn >= 1 && live.payloadIn > 0);
assert.ok(live.wireOut > 0 && live.wireIn > 0);
await c.close();
for (let i = 0; i < 50 && srv.traffic.snapshot().ws.open; i++) await new Promise((r) => setTimeout(r, 20));
const done = srv.traffic.snapshot().ws;
assert.equal(done.open, 0);
assert.ok(done.wireOut >= live.wireOut, 'the closed socket\'s bytes stay in the totals');
} finally {
await srv.close();
}
});
test('the counter on its own: a string body in another encoding, an aborted response, the payload of a fragmented message', () => {
const tc = createTrafficCounter({ now: () => 1000 });
const listeners = {};
const res = { statusCode: 200, write() { return true; }, end() {}, once(ev, fn) { listeners[ev] = fn; } };
tc.meter(res, '/data/x.json');
res.write('aGk=', 'base64');
res.end('é');
listeners.close();
const s = tc.snapshot();
assert.deepEqual(s.http.data, { requests: 1, bytes: 2 + 2, notModified: 0 });
tc.wsSent('{"t":"pong"}');
tc.wsReceived([Buffer.alloc(3), Buffer.alloc(4)]);
const ws = tc.snapshot().ws;
assert.equal(ws.payloadOut, 12);
assert.equal(ws.payloadIn, 7);
assert.equal(tc.snapshot().seconds, 0);
});
+78
View File
@@ -577,6 +577,63 @@ describe('AudioManager', () => {
globalThis.fetch = origFetch;
}
});
test('BGM is not downloaded while the music is muted or at volume 0; the wanted track starts once it is audible', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const tick = () => new Promise((r) => setTimeout(r, 10));
const lobby = manifest.audio.bgm.lobby.loop;
const prep = manifest.audio.bgm.prep.loop;
assert.notEqual(lobby, prep, 'premise: two different loops');
try {
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.setVolumes({ muted: true });
a.install();
a.playBgm('lobby');
fw.fire('pointerdown');
await tick();
assert.equal(a.unlocked, true);
assert.equal(a.bgmSilent, true);
assert.ok(!asked(urls, lobby) && !asked(urls, manifest.audio.bgm.lobby.intro), 'muted: nothing fetched at unlock');
a.playBgm('prep');
await tick();
assert.ok(!asked(urls, prep), 'muted: a phase change fetches nothing');
assert.equal(a.wantBgm, 'prep', 'the wanted track is remembered');
a.setVolumes({ muted: false });
await tick();
assert.ok(asked(urls, prep), 'unmuted: the track the screen wants now is fetched');
assert.ok(!asked(urls, lobby), 'never the track wanted before');
assert.equal(a.bgm?.loopUrl, prep);
// BGM volume 0 is silent too: another track stops and is not fetched; SFX keep playing
a.setVolumes({ bgm: 0 });
a.playBgm('lobby');
await tick();
assert.ok(!asked(urls, lobby), 'BGM volume 0: no download');
assert.equal(a.bgm, null, 'the other track stopped');
a.sfx('buy');
await tick();
assert.ok(asked(urls, manifest.audio.sfx.ui.buy), 'SFX are not gated by the BGM volume');
a.setVolumes({ bgm: 0.5 });
await tick();
assert.ok(asked(urls, lobby), 'audible again: fetched');
assert.equal(a.bgm?.loopUrl, lobby);
// the loop that already plays keeps playing through a silent phase change, and is not fetched again
a.setVolumes({ muted: true });
const n = urls.length;
const started = fw.made.started;
a.playBgm('lobby');
a.setVolumes({ muted: false });
await tick();
assert.equal(a.bgm?.loopUrl, lobby);
assert.equal(urls.length, n, 'no new request');
assert.equal(fw.made.started, started, 'no restart');
} finally {
globalThis.fetch = origFetch;
}
});
test('fetch failures are swallowed', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
@@ -598,6 +655,27 @@ describe('AudioManager', () => {
console.warn = origWarn;
}
});
test('a served manifest\'s content-hashed audio URL is fetched as /media/~<hash>/…, falling back to the hashed .mp3 URL', async () => {
const H = '0123456789abcdef';
const raw = manifest.audio.bgm.prep.loop.replace(/^\/assets\//, `/assets/~${H}/`);
const served = { ...manifest, audio: { ...manifest.audio, bgm: { ...manifest.audio.bgm, prep: { loop: raw } } } };
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return u.startsWith('/media/') ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
try {
const a = new AudioManager({ win: fw.win, getManifest: () => served });
a.install();
fw.fire('pointerdown');
a.playBgm('prep');
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(urls, [raw.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2'), raw]);
assert.match(urls[0], new RegExp(`^/media/~${H}/bgm/`));
assert.equal(a.bgm?.loopUrl, raw);
} finally {
globalThis.fetch = origFetch;
}
});
test('音频先走无扩展名的 /media/ 路由;只有它 404 才回退到带扩展名的原地址', async () => {
const raw = manifest.audio.bgm.prep.loop;
const media = mediaUrl(raw);
+13 -1
View File
@@ -11,7 +11,10 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
const srv = await startRealServer({ fast: { timerScale: 0.1, startRound: 2, kit: 0, shop: ['chess_char_1_02_a'] } });
const c = new Client(P, srv.base, 'bond-audio', { prefix: 'bond-audio' });
try {
await c.open(); await c.enter('叠层音效测试');
await c.open();
const mediaHits = [];
c.page.on('response', (r) => { const p = new URL(r.url()).pathname; if (p.startsWith('/media/')) mediaHits.push({ p, status: r.status(), cache: r.headers()['cache-control'] }); });
await c.enter('叠层音效测试');
await c.click('.mode-card', '独立模拟'); await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor(s => !!s.room, 'solo room');
@@ -41,6 +44,15 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
assert.deepEqual(result.frames.map(m => [m.bondId, m.add]).sort(), [['kjeragShip', 2], ['steadShip', 2]]);
assert.equal(result.cues.length, 2); assert.equal(result.playing, true); assert.equal(result.state, 'running');
assert.ok(result.cues.every(x => /act1autochess_b_ui_buffup/.test(x.url)));
// host bandwidth layer 1: the served manifest names the cue with its content hash and the client fetches it through
// the extension-less /media/~<hash>/… route, cached immutably (skipped with SP_ASSET_INDEX=0: plain URLs)
if (result.cues[0].url.includes('/~')) {
assert.ok(result.cues.every(x => /^\/assets\/~[0-9a-f]{16}\/audio\//.test(x.url)), JSON.stringify(result.cues));
const hit = mediaHits.find(h => /^\/media\/~[0-9a-f]{16}\/.*act1autochess_b_ui_buffup$/.test(h.p));
assert.ok(hit, mediaHits.map(h => h.p).join(' '));
assert.equal(hit.status, 200);
assert.equal(hit.cache, 'public, max-age=31536000, immutable');
}
const count = result.cues.length;
// g.watch on own prep asks the real server for state again, without a new gain.
await c.page.evaluate(() => globalThis.__SP__.net.request('g.watch', { fieldId: `n:${globalThis.__SP__.store.get().me.playerId}` }));
+13
View File
@@ -163,6 +163,19 @@ describe('emotes and 玩法说明 pages: local art first, then the mirror copy (
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
});
test('the guide preloads only the next page (host bandwidth layer 0): wraps around, skips a failed copy, never the previous page', async () => {
const { guidePreloadUrl } = await import('../../public/js/ui/guide.js');
const page = (k) => ({ urls: [`/assets/local/guide/${k}.webp`, `/assets/ui/guide/${k}.png`] });
const pages = ['autochess_home_1', 'autochess_home_2', 'autochess_home_3'].map(page);
assert.equal(guidePreloadUrl(pages, 0), '/assets/local/guide/autochess_home_2.webp');
assert.equal(guidePreloadUrl(pages, 2), '/assets/local/guide/autochess_home_1.webp', 'wraps to the first page');
assert.equal(guidePreloadUrl(pages, 1, new Set(['/assets/local/guide/autochess_home_3.webp'])), '/assets/ui/guide/autochess_home_3.png', 'the copy that has not failed');
assert.equal(guidePreloadUrl([page('autochess_home_1')], 0), null, 'a single page needs no preload');
assert.equal(guidePreloadUrl([], 0), null);
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
assert.deepEqual(src.match(/^\s+preload\(.*$/gm).map((l) => l.trim()), ['preload(guidePreloadUrl(pages, i, failed));'], 'one preload call in the viewer');
});
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));
+15 -2
View File
@@ -38,7 +38,7 @@ const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-use
*/
class Client {
constructor(puppeteer, base, label, { w = 1920, h = 1080 } = {}) {
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0 });
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0, hashed: { spine: 0, media: 0, other: 0 } });
}
async open(query = '') {
@@ -50,7 +50,14 @@ class Client {
page.on('console', (m) => { if (m.type() === 'error') this.problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
page.on('response', (r) => { if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`); });
page.on('response', (r) => {
if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`);
// the served manifest's content-hashed URLs (docs/ASSETS.md): Spine files, audio through /media/~<hash>/, the rest
const p = new URL(r.url()).pathname;
if (r.status() === 200 && /^\/(?:assets|media)\/~[0-9a-f]{16}\//.test(p)) {
this.hashed[/\.(?:skel|atlas)$|\/spine\//.test(p) ? 'spine' : p.startsWith('/media/') ? 'media' : 'other']++;
}
});
page.on('dialog', (d) => d.dismiss().catch(() => {}));
await page.goto(`${this.base}/${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
@@ -443,6 +450,8 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
const { startServer } = await import('../../server/index.js');
puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
// the content-hash index first, so the pages load the served manifests' /assets/~<hash>/… URLs like a player's
await srv.assetIndex?.whenReady();
base = `http://127.0.0.1:${srv.port}`;
mkdirSync(OUT, { recursive: true });
});
@@ -628,6 +637,10 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
assert.ok(did.inspected && did.bond, 'detail panel + bond popup');
assert.ok(did.emote, 'emote bubble reached the teammate');
assert.ok(did.enemies >= 0, 'enemy drawer opened');
if (srv.assetIndex) {
host.note(`content-hashed responses: ${JSON.stringify(host.hashed)}`);
assert.ok(host.hashed.spine > 0 && host.hashed.media > 0 && host.hashed.other > 0, `Spine, audio and art through hashed URLs: ${JSON.stringify(host.hashed)}`);
}
await assertClean(both);
} finally {
console.log([...host.log, ...guest.log].join('\n'));
+94
View File
@@ -0,0 +1,94 @@
// WebSocket permessage-deflate (server/http/websocket.js WS_DEFLATE, host bandwidth layer 0): a browser-like client
// negotiates it with no server context takeover and a 2 KB server window, a large JSON frame goes out compressed, a
// frame under the 1 KB threshold and a client without the extension get plain frames, and an offer that caps the server
// window below ours is answered without compression instead of a refused handshake.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import WebSocket from 'ws';
import { attachWebSocket, narrowDeflateOffer, WS_DEFLATE } from '../server/http/websocket.js';
import { startServer } from '../server/index.js';
const BIG = JSON.stringify({ t: 'm.public', players: Array.from({ length: 200 }, (_, i) => ({ playerId: `p_${i % 4}`, name: '阿米娅', lp: 30, phase: 'PREP' })) });
const SMALL = JSON.stringify({ t: 'pong', at: 12345 });
/** A bare http server whose /ws sends BIG then SMALL to every socket (a stand-in for net.js Network). */
async function fakeServer() {
const server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
const network = {
admission: () => null,
handleConnection: (ws) => { ws.send(BIG); ws.send(SMALL); },
};
const wss = attachWebSocket(server, { network, log: { error() {} } });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { url: `ws://127.0.0.1:${server.address().port}/ws`, close: () => new Promise((r) => { wss.close(); server.close(() => r()); }) };
}
/** Connect, collect two messages, report the server's Sec-WebSocket-Extensions answer and the bytes the socket read. */
function receive(url, opts) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, opts);
const msgs = [];
let negotiated = '';
ws.on('upgrade', (res) => { negotiated = res.headers['sec-websocket-extensions'] || ''; });
ws.on('error', reject);
ws.on('message', (data) => {
msgs.push(String(data));
if (msgs.length === 2) {
const read = ws._socket.bytesRead;
ws.close();
resolve({ msgs, read, extensions: negotiated });
}
});
});
}
describe('WebSocket permessage-deflate', () => {
let srv;
before(async () => { srv = await fakeServer(); });
after(async () => { await srv.close(); });
test('negotiated with no server context takeover and a small server window; a large frame is compressed', async () => {
const r = await receive(srv.url);
assert.match(r.extensions, /permessage-deflate/);
assert.match(r.extensions, /server_no_context_takeover/);
assert.match(r.extensions, new RegExp(`server_max_window_bits=${WS_DEFLATE.serverMaxWindowBits}`));
assert.deepEqual(r.msgs, [BIG, SMALL], 'the payloads arrive intact');
assert.ok(BIG.length > 10000);
assert.ok(r.read < BIG.length / 4, `compressed on the wire: ${r.read} bytes read for ${BIG.length} of payload`);
assert.equal(WS_DEFLATE.threshold, 1024);
});
test('a client without the extension gets the same messages uncompressed', async () => {
const r = await receive(srv.url, { perMessageDeflate: false });
assert.equal(r.extensions, '');
assert.deepEqual(r.msgs, [BIG, SMALL]);
assert.ok(r.read > BIG.length + SMALL.length, `plain frames: ${r.read}`);
});
test('an offer that caps the server window below ours connects uncompressed instead of a refused handshake', async () => {
const r = await receive(srv.url, { perMessageDeflate: { serverMaxWindowBits: 9 } });
assert.equal(r.extensions, '');
assert.deepEqual(r.msgs, [BIG, SMALL]);
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9'), true);
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=12; client_max_window_bits'), false);
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9, permessage-deflate'), false, 'another offer is acceptable');
assert.equal(narrowDeflateOffer('permessage-deflate; client_max_window_bits'), false);
assert.equal(narrowDeflateOffer(undefined), false);
});
});
test('the real server negotiates compression at /ws and keeps the 64 KB inflated frame limit', async () => {
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
try {
const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/ws`);
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
assert.match(ws.extensions, /permessage-deflate/);
// a compressed client frame whose inflated size is over the limit closes the socket (1009), like a plain one
const closed = new Promise((resolve) => ws.once('close', (code) => resolve(code)));
ws.send(JSON.stringify({ t: 'hello', name: 'x'.repeat(70 * 1024) }));
assert.equal(await closed, 1009);
} finally {
await srv.close();
}
});
+38 -11
View File
@@ -32,8 +32,8 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
DXT5nm in an older bundle: AG = XY, R = 1) and metallic/gloss with the smoothness in A; the board renderer needs
an RGB normal map (Z rebuilt) and a roughness map in G (1 − smoothness; metalness B = 0), written next to the
source as <name>_rgb.png / _rough.png
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
the manifest lists the copy
- WebP copies of the board textures every player downloads with the 3D board (WEBP) and of the 19 guidebook pages
(GUIDE_WEBP_*), written next to their PNGs; the manifest lists the copy
- the official battlefield scenes (scenes.py SCENES: one prebuilt scene per 卫戍协议 battlefield) → scene/<stageId>/
scene.json + scene.bin + lightmap pages and scene/tex/ (the materials' albedo textures). The scene bundles ship
only while the season runs: they are read from the client when it has them, else from --scenes <dir> (default
@@ -43,8 +43,9 @@ Usage:
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
.venv/bin/python tools/local-extract/extract.py [--game <AB root>] [--out public/assets/local] [--only <subdir prefix>]
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
e.g. the local art copied from a release bundle; needs Pillow)
python3 tools/local-extract/extract.py --webp (only the WebP copies of the board textures and the guide
pages, from the PNGs already extracted — e.g. the local art
copied from a release bundle; needs Pillow)
.venv/bin/python tools/local-extract/extract.py --only scene [--scenes <dir of in-season bundles>]
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
@@ -188,6 +189,19 @@ WEBP_OPTIONS = {
'lossless': {'lossless': True, 'quality': 100, 'method': 6, 'exact': True},
}
# The 19 玩法说明 pages (output subdir guide: 1024², opaque) get a WebP copy too — every page PNG of the group, each a
# 1.2–1.5 MB download whenever a player opens the guide (public/js/ui/guide.js shows the local page first and preloads
# the next one). Lossless by default: 25.6 MB → 17.4 MB on the CN client's pages. Lossy quality 90 would make them
# 4.2 MB; the maintainer decides on that after a side-by-side comparison (DESIGN §31.1, docs/history/0.2.6.md),
# and then only GUIDE_WEBP_MODE changes (`extract.py --webp` rewrites the copies). Method 4: method 6 spends about 8 s
# a page for 0.4 % less.
GUIDE_WEBP_SUB = 'guide'
GUIDE_WEBP_MODE = 'lossless'
GUIDE_WEBP_OPTIONS = {
'lossless': {'lossless': True, 'quality': 100, 'method': 4, 'exact': True},
'lossy': {'quality': 90, 'method': 4, 'alpha_quality': 100},
}
# Shader bundles loaded beside every job that exports Materials, only so that the materials' shader references
# (external CABs) resolve to a name in materials.json (e.g. Torappu/Scene/StandardDirectional); nothing is exported
# from them.
@@ -454,11 +468,22 @@ def run_derived(out_root, sub, manifest, log):
return n
def webp_targets(sub, manifest):
"""(name, mode, Pillow options) of every WebP copy output subdir `sub` gets: its WEBP rows and, for the guide, each
page of the group (an image entry: its path ends in .png, or in .webp after an earlier run)."""
out = [(name, mode, WEBP_OPTIONS[mode]) for wsub, name, mode in WEBP if wsub == sub]
if sub == GUIDE_WEBP_SUB:
out += [(name, GUIDE_WEBP_MODE, GUIDE_WEBP_OPTIONS[GUIDE_WEBP_MODE])
for name, entry in sorted(manifest.get(sub, {}).items())
if isinstance(entry, dict) and str(entry.get('path', '')).endswith(('.png', '.webp'))]
return out
def run_webp(out_root, sub, manifest, log):
"""Write the WEBP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
"""Write the WebP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
n = 0
for wsub, name, mode in WEBP:
entry = manifest.get(sub, {}).get(name) if wsub == sub else None
for name, mode, options in webp_targets(sub, manifest):
entry = manifest.get(sub, {}).get(name)
png = Path(out_root) / sub / f'{name}.png'
if not entry or not png.exists():
continue
@@ -466,7 +491,7 @@ def run_webp(out_root, sub, manifest, log):
try:
from PIL import Image
with Image.open(png) as img:
img.save(dst, 'WEBP', **WEBP_OPTIONS[mode])
img.save(dst, 'WEBP', **options)
except Exception as e: # no WebP support in this Pillow, or a broken PNG: the entry keeps the PNG
log(f' warn webp {name}: {e}')
continue
@@ -486,7 +511,7 @@ def webp_only(out_root, manifest_path, log):
if not isinstance(groups, dict):
print(f'{manifest_path} has no groups. Extract the local art first.', file=sys.stderr)
return 2
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys(s for s, _, _ in WEBP))
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys([*(s for s, _, _ in WEBP), GUIDE_WEBP_SUB]))
Path(manifest_path).write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
log(f'done: {n} WebP copies, manifest {manifest_path}')
return 0 if n else 1
@@ -846,8 +871,8 @@ def main():
'searched after --game and before .cache/official-scenes/*)')
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
ap.add_argument('--webp', action='store_true',
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
'them in the manifest (needs Pillow, not the client)')
help='only write the WebP copies of the board textures and the guide pages from the PNGs already '
'under --out and list them in the manifest (needs Pillow, not the client)')
args = ap.parse_args()
if args.print_jobs:
@@ -857,6 +882,8 @@ def main():
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived, 'webp': webp,
'guideWebp': {'sub': GUIDE_WEBP_SUB, 'mode': GUIDE_WEBP_MODE,
'options': GUIDE_WEBP_OPTIONS[GUIDE_WEBP_MODE]},
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES},
'scenes': {'sub': scenes.SCENE_SUB, 'tex': scenes.TEX_SUB,