feedback5: merge feedback4 (3af5b84) — PRs #186 / #134 / #185 / #24 / #183 ported to the split layout (terrainInfo / TERRAIN_TIPS into a new ui/gameLogic/terrain.js re-exported by the gameLogic.js barrel, closesOnFieldPress into ui/gameLogic/shortcuts.js, the rest in place; 42 strings through t(), #183's 设置 too); golden unchanged

Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new
  public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into
  ui/gameLogic/shortcuts.js.
- i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json
  (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands
  them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole
  msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN
  terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone,
  Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is
  byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages.
- public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo /
  fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is
  played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick
  handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only,
  so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name).
- public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers.
- public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear
  button's aria-label / title are t('设置') (en.json already has "Settings").
- tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's
  --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the
  voice plan unchanged.
- data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files
  6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash
  (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields.
- docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md,
  DEPLOY.md, PLAYING.md auto-merged.
- Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js,
  textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests.

Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15,
board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313,
docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283
scenarios match, nothing regenerated.
This commit is contained in:
sganggs committed 2026-10-06 14:41:40 +08:00
commit 7059528c47
40 files changed
+1944 -97

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"c01aa739a2d3","generator":"tools/fetch-assets.mjs","stats":{"files":6993,"bytes":462597208,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":120},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
{"version":1,"hash":"6a547fc5c937","generator":"tools/fetch-assets.mjs","stats":{"files":6994,"bytes":462620448,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":120},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
+2 -2
View File
@@ -131,7 +131,7 @@ 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.
- 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.
## Manifest schema (`data/assets.json`)
@@ -186,7 +186,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
defenceStart, defenceUnite, battleOverReduce, battleOverNoReduce, battleOverNormal, goFirst,
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, win, lose, killCoin },
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, leak, win, lose, killCoin },
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born?,
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
}
+27 -1
View File
@@ -36,6 +36,30 @@
等价的手动命令:`npm ci`、`node tools/setup.mjs`、`npm start`。
#### 国内镜像下载
Setup 默认使用「GitHub 原始源 → jsDelivr」,不查询公网 IP,也不请求 gh-proxy.com。GitHub 下载失败时会提示如何手动开启镜像;仅添加提示,不自动切换到第三方代理。
镜像方法是在完整 GitHub 链接前加 `https://gh-proxy.com/`,例如:
```text
https://gh-proxy.com/https://raw.githubusercontent.com/OWNER/REPO/BRANCH/file.png
```
手动开启后顺序为「前缀镜像 → 原始源 → jsDelivr」。索引、图片、Spine、音频和字体都使用此规则(音频 voice 分支跳过 jsDelivr)。镜像是第三方代理;当前只校验格式和大小,没有内容哈希校验,请自行决定是否信任并启用。npm / pip 依赖不使用 GitHub 前缀。
```powershell
node tools/setup.mjs --asset-source=mirror # 手动优先国内镜像
node tools/setup.mjs --asset-source=direct # 默认:仅原始源和 jsDelivr,不使用前缀代理
$env:SP_ASSET_SOURCE = 'mirror' # 也可用环境变量显式启用
```
`node tools/fetch-assets.mjs` 同样支持 `--asset-source=direct|mirror`。命令行优先于 `SP_ASSET_SOURCE`。默认镜像前缀为 `https://gh-proxy.com/`,可通过 `SP_GITHUB_PROXY` 指定其他 HTTPS 前缀;仅配置前缀不会启用镜像。将 `SP_GITHUB_PROXY` 设为空字符串(或全空格)可彻底禁用前缀代理,即使选择了 `mirror` 模式;未设置此变量与显式设空不同,前者使用默认前缀。Windows PowerShell 的某些版本会将空值视为删除变量,可设置 `$env:SP_GITHUB_PROXY = ' '` 或使用 `--asset-source=direct` 来明确禁用。前缀只处理 GitHub 下载链接,不重复添加。
镜像请求每个 URL 只尝试一次,响应头超时 8 秒,响应体有独立的空闲超时,失败即尝试原始源。连续 3 次网络错误、HTTP 错误或无效内容会在本次运行中关闭镜像,后续索引、素材和字体共享该状态;正在进行的镜像请求也会中止并回退。成功会清零连续失败次数;404 / 410 是资源不存在,不触发熔断。再次运行脚本会重新尝试手动启用的镜像。原始源的重试、已有文件跳过和 0.1.1 的清单缩减保护保持不变。
从历史下载记录派生的 Spine 补充贴图也按本次设置重新选择来源,禁用后不会沿用旧代理地址。
### 1.2 防火墙
- 第一次启动时 Windows 会弹出「Windows 安全中心警报」:勾选**专用网络**并点「允许访问」。
@@ -237,7 +261,7 @@ services:
| 任何问题 | `node tools/doctor.mjs`:Node 版本、依赖、素材完整性、端口、局域网地址、防火墙、网络类型 |
| `端口已被占用 / EADDRINUSE` | 已经有一个服务器在运行(自启任务?)或其他程序占用 3000:换端口 `scripts\start-windows.bat --port 3001` |
| 朋友打不开页面 | 防火墙规则 / 网络类型(1.2);确认用的是 `LAN` 地址而不是 `localhost`;访客 Wi-Fi 常开启「AP 隔离」;不在同一网络请看第 2 节 |
| 画面是占位图、没有声音 | 素材没下完:重新运行 `node tools/setup.mjs`(会续传);缺失明细在 `.cache/assets-report.json`。GitHub 原始地址访问失败时会自动改用 jsDelivr 镜像 |
| 画面是占位图、没有声音 | 素材没下完:重新运行 `node tools/setup.mjs`(会续传);缺失明细在 `.cache/assets-report.json`。默认仅原始源和 jsDelivr;可用 `--asset-source=mirror` 手动开启前缀镜像(见上文) |
| 素材下载很慢 / 失败 | 网络问题可随时中断,重新运行会跳过已完成的文件;`node tools/fetch-assets.mjs --concurrency=4` 降低并发。有文件没下载成功时,素材清单 `data/assets.json` 保持不变(脚本列出缺少的条目并以非零状态结束;游戏里缺的图片用占位图,缺的声音不播放),重新运行即可补齐 |
| 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` |
| 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
@@ -259,3 +283,5 @@ services:
表情(6 套 × 6 个)和「玩法说明」的 19 页教程图公开镜像也有:`node tools/setup.mjs` 会和其他素材一起下载(约 21 MB),不需要客户端;有本地素材时优先显示本地的。
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
+21 -1
View File
@@ -2172,7 +2172,7 @@ Decisions of the owner, 2026-10-04: a 联防 devour counts members who enter alr
## 24. Community reports after 0.1.3
Reports after the 0.1.3 release. Each was checked against the official data and PRTS; anything with no source is marked [ASSUMED]. Where each is handled: a skill clip with no Begin and no own Idle plays once (德克萨斯 S2 剑雨) → §24.1; 高台 for every melee chess whose trait reads 「可以放置于远程位」 (GitHub #153, PR #69) → §24.2; the 5-阿戈尔 revives go to the first 3 members knocked out (GitHub #105, #140) → §24.3; 联防 阿戈尔 devours the operator in front whoever owns it (GitHub #140) → §24.4. Merged pull requests keep their own notes where they changed the rules: deploy-timed skills as duration skills (PR #109, §5 skill contract), the teammate's hand / temp / equipment in prep scouting (PR #129), 灵巧's knocked-out aura and the hidden-layer bonds (PR #66, research 02).
Reports after the 0.1.3 release. Each was checked against the official data and PRTS; anything with no source is marked [ASSUMED]. Where each is handled: a skill clip with no Begin and no own Idle plays once (德克萨斯 S2 剑雨) → §24.1; 高台 for every melee chess whose trait reads 「可以放置于远程位」 (GitHub #153, PR #69) → §24.2; the 5-阿戈尔 revives go to the first 3 members knocked out (GitHub #105, #140) → §24.3; 联防 阿戈尔 devours the operator in front whoever owns it (GitHub #140) → §24.4; a special terrain tile explains itself on a tap (GitHub #184, PR #185) → §24.5; 活性源石 was two different materials on the two boards (GitHub #184, PR #185) → §24.6. Merged pull requests keep their own notes where they changed the rules: deploy-timed skills as duration skills (PR #109, §5 skill contract), the teammate's hand / temp / equipment in prep scouting (PR #129), 灵巧's knocked-out aura and the hidden-layer bonds (PR #66, research 02).
### 24.1 A skill clip with no Begin and no own Idle plays once — 德克萨斯 S2 剑雨 (player report) — `render/spine.js SpineActor.setSkill / update` (`SKILL_CLIP_MIN`, `skillClipOnce`, `skillEndPending`)
@@ -2342,3 +2342,23 @@ Reports after the 0.1.3 release. Each was checked against the official data and
through a teammate's 幽灵鲨 equals the own chain, and her knock-out takes her owner's 5-tier slot; the own-board case
through `uniteBattleOpts` (carry.down from a real own combat, the own combat's gains). The first three fail on the
previous code. `feedback3-egir-down`, `bonds_core`, `unitedown`, `feedback3-unitecarry` pass unchanged.
### 24.5 A special terrain tile explains itself on a tap (GitHub #184, player request 「建议加入对于特殊地形的单击信息提示」) — `render/app.js` (`tileClick`, `emitTileClick`), `ui/fallbackField.js` (the board tile), `ui/screens/game.js` (`tileClick` → `terrainInfo`, `live.terrainStage` / `terrainTile`), `ui/gameLogic.js` (`terrainInfo`, `TERRAIN_TIPS`), `ui/detailPanel.js` (`TerrainDetail`, `resolveDetail`)
- **Asked** (GitHub #184, after 0.1.3): a tap on a special terrain tile should say what it is. The mode draws 活性源石, 沼泽, 排气格栅, 深水区, the gates and the teleports, but nothing in the client names them — the legend lived in `docs/DATA.md` and the data only.
- **Why the ground was free**: a press on a tile that had nothing on it only closed what was open (a piece / unit card, the selection). `render/app.js` now emits `tileClick { row, col }` when neither a battle unit, a prep piece (the drag controller), a pen enemy nor the boss leader took the press, and `ui/fallbackField.js` emits the same from its own tile divs, so both field implementations behave alike.
- **What the card says**: `gameLogic.terrainInfo(stage, row, col)` reads the glyph from `stage.rows`, its legend entry from `stage.tiles` (tileKey / height / buildable / passability) and **that stage's own terrain parameters** from `stage.special[<terrain>]` — the values `sim/content/devices.js` runs on: 活性源石 `damage 70 / atk 0.2 / attack_speed 20 / duration 300` (PRTS 特殊地形 "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}真实伤害…"), 沼泽's stack rule (`intervalSec` 1, `aspdPerStack` −0.05, `moveMulPerStack` −0.05, `maxStacks` 10, `heavyWeight` 3, cleared on leaving — PRTS 沼泽控制), 深水区's drowning (`damage 40`, aspd −0.6, move ×0.6) plus 拒绝部署 (PRTS 深水区 地形信息; `grid.js DEPLOY_REFUSED_TILES`), 排气格栅's "operators on it cannot be targeted by enemy ranged attacks" — completed in review with the two halves that matter in play: the operator it blocks still gets attacked (the tile's buff is a 隐匿-like `stealth` flag, `devices.js enterTerrain`), and 深水区's drowning damage is sourceless damage that no 干员's 增伤 / 穿透 / 装备 applies to and is *not* 环境伤害 (`tickDeepsea`'s tags are `dot` / `periodic` / `deepsea`, deliberately not `terrain`). The gates and teleports carry no `special`, so they key on `tileKey` — 红门 (enemy gate) / 蓝门 (protection objective) / 传送入口 / 传送出口, in the words `docs/PLAYING.md` already uses. The card is a normal `ResultDialog`-less `DetailPanel` card: read-only, no protocol frame, closed by the next field press (`closesOnFieldPress` now lists `terrain`) or its own ✕.
- **An ordinary tile still says nothing** (road / floor / wall / fence / bench → null): the press keeps its other meanings there — deselecting a piece, closing a card — and a drag still starts only on a piece.
- **Coordinate spaces (corrected in review)**: a tap reports BOARD coordinates — `emitTileClick` picks with `pickBoardTile`, so the boss-prep transform (`prepXf.toBoard`) is applied once, in the renderer. (The first version reported the DRAWN tile from `groundTile` and the screen mapped it again, so on a Final Assault / Hidden Core prep the tap was converted twice and explained another tile.) Everywhere but that prep board the two spaces are the same: a 最终攻势 / 隐秘核心 battle renders the stage's own rows (`GEO.BOSS_RECT`), the normal and 联防 rects are stage rows. A boss PREP draws the player's half — stage rows 2–5 — as board rows 9–12, and the screen turns the board tile into the stage tile with `gameLogic.fieldTile` before the lookup (and reads the stage the board on screen is built from: the own one with the player's 机变 tile overrides, `effectiveStage`, or the plain stage while watching a teammate).
- **[ASSUMED]**: that a player wants the official's 地形信息 here (the official shows terrain descriptions in its own level / terrain UI, not in-match — research 09 records none) and the wording of the mechanism lines: the numbers are the data's, the prose is ours (marked as ours in `TERRAIN_TIPS`). A 机变 card that switches a terrain off (模拟战场演变 · 沙尘暴停止) is not reflected — the tip explains the TILE, not the round's modifier.
- **Tests**: `test/ui/gameLogic.test.js` (every terrain of the real stages with its own numbers: 活性源石 in 战场#04, 沼泽 in 战场#06, 排气格栅 in 战场#07, 深水区 in 战场#05; the four gate / teleport tiles; an ordinary glyph → null; out-of-range, fractional and legend-less input; the fact lines from the legend entry; a stage missing its parameters still explains the mechanism), `test/ui/terrain-detail.test.js` (the tap's target resolves into the card; a tip-less or malformed one does not), `test/ui/terrain-tip.e2e.test.js` (headless Chrome on the mock harness: a tap on 蓝门 / 红门 opens the card with its own line on BOTH field implementations — the engine canvas at the tile's own screen position and the DOM fallback's tile — while a tap on a floor tile opens nothing; and on a Final Assault PREP the tapped tile is the one the board tile maps to on the boss field, the regression the review asked for).
### 24.6 活性源石 is ONE material on both boards (GitHub #184, same report's second half) — `render/style.js` (`ORIGINIUM`, `linearToSrgb255` / `linearToHex`), `render/textures.js` (`originiumCanvas` / `originiumOverlay`, `MAT_DRAW.infection` / `infection2`, `PROC_LAYERS.originium` / `originium2`, `DEFAULT_ART_LAYERS.infection` / `infection2`), `render/tiles.js` (`variantMat`, the pulse tint), `render/board3d/materials.js` (`infectionMaterial`)
- **Reported** (GitHub #184): 「感觉源石地板的图片有点奇怪(有点简陋,另外两个源石地板的贴图不一致)」 — two originium floors that do not match. They were two different materials: with the board art installed (or `?board=2d`) the tile came from the 2D atlas cell — a beveled brick with seven randomly placed orange crystal clusters — while the 3D board drew a world-space crust with pulsing veins over the official concrete. And that 3D crust was multiplied by a per-tile fade (`edge = smoothstep(0, 0.1, min(vUv, 1 − vUv))`), so every tile of a 活性源石 area faded out at its own border and the area read as separate patches rather than one floor.
- **One palette**: `style.js ORIGINIUM` holds the crust's dark and lit sides, the vein and the crystal grains. The values are the 3D shader's own working colours (its fragment ends in `#include <colorspace_fragment>`, so they are linear); the canvas converts them with `linearToSrgb255` — without that the 2D tile came out darker than the 3D one, which is what the first version of this change did.
- **One recipe**: two octaves of noise make the crust mask (`smoothstep(0.38, 0.62, n·0.7 + n2·0.45)`), the ridge of the fine octave its veins (`smoothstep(0.86, 0.98, 1 − |2·n2 − 1|)`, brightened where the crust is), the rare top of a third octave the grains — the shader runs it over world-space noise, `textures.js originiumCanvas` over a *toroidal* field (the noise wraps, so a cell that is repeated over the board lines up with itself: the veins run through the tile's edge instead of stopping at it).
- **Both reach the tile's edges**: the 3D material no longer has the per-tile fade, and the 2D cell has no frame any more (the old `bevelTile` is gone). The mire / water / smog materials keep their own fades — this section is about 活性源石, the tile that was reported.
- **With the board art installed** the 2D tile is composed exactly like the 3D one: `DEFAULT_ART_LAYERS.infection` draws the official concrete slab (`board3d/atlas.js concrete`, the same atlas rect) and then the crust as a procedural layer; without the art the cell falls back to `MAT_DRAW.infection` (the procedural ground + the same crust). A second variant (`infection2`, the same art mirrored; `PROC_LAYERS.originium2` for the art path) is picked by tile (`tiles.js variantMat`), so a field of 活性源石 does not repeat one pattern.
- **Not [ASSUMED]** — this is our own art: the palette and the recipe are ours, the tile it dresses and its numbers are the data's (a 机变 card can switch the terrain off, §24.5).
- **Tests**: `test/render/originium-art.test.js` (the couplings: both renderers take the palette from `ORIGINIUM`, the canvas converts it, the recipe's thresholds agree, the 2D base slab is the same atlas rect the 3D board lays its crust on, the infection shader has no per-tile fade while the mire keeps its own, two variants are registered and picked, the pulse tint is the shared one), `test/render/originium-cell.e2e.test.js` (headless Chrome: the cell's veins hit the palette's displayed colour (`linearToHex(ORIGINIUM.vein)`), veins reach the border like the middle (no frame), the veins at opposite edges are alike (the cell joins itself), and the second variant is a different pattern).
+2
View File
@@ -20,6 +20,7 @@
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
@@ -94,6 +95,7 @@
- 联防中击杀悬赏敌人,资金归击杀者。
- 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。
- 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。
- **有敌人走进蓝门就会响一声**:不等它真的扣到目标生命值——一回合里超过 10 点上限的那部分、数据里标明不计数目的敌人(BOSS 部件之类),以及联防里漏过去、随后被队友清掉的那几只,目标生命值都不会再扣,但这一声照样会响。播的是**原版关卡里怪进蓝门那一声**(官方音效表里的 `ON_ENEMY_REACHED_EXIT`,任何一关都是它)。你正在看哪个战场就听哪个战场的,所以看队友的战场、以及漏怪后观看联防时同样听得到。这一声 1.44 秒,我们按官方的「同时只允许一个」做成 **1.5 秒内共用一声**:一波打穿防线只响一遍(不会叠成一片),过一会儿再漏会再响。
- 每个普通回合每人**最多扣 10 点 LP**。
## 6. 盟约
+1 -1
View File
@@ -103,7 +103,7 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
.fsbtn { display: grid; place-items: center; padding: 0; background: rgba(8, 11, 10, .85); border: 1px solid var(--line-2); color: var(--text-lo); transition: color var(--t-fast), border-color var(--t-fast); }
.fsbtn .icon { width: .26rem; height: .26rem; }
.fsbtn:hover, .fsbtn[aria-pressed="true"] { color: var(--mint-400); border-color: var(--mint-700); }
.title-fs { width: .4rem; height: .4rem; margin-left: .06rem; }
.title-fs, .title-settings { width: .4rem; height: .4rem; margin-left: .06rem; }
/* standalone / fullscreen: the browser bars are gone, nothing to reserve */
.sp-standalone .rotate-hint__micro::after { content: ' · PWA'; }
+42
View File
@@ -880,6 +880,48 @@
"{name}的替补": "Stand-in for {name}",
"未持有{name}:由替补干员 {standIn} 上场(盟约、特质、阶级与价格不变)": "{name} not owned: the stand-in {standIn} takes the field (Alliances, Attribute, tier and price unchanged)",
"未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。": "An Operator you do not own takes the field as its official stand-in: the shop, the Bench, the battlefield and the results show the stand-in (model, skills, talents, attack range), while Alliances, Attribute, tier, price and promotion follow the original Operator. Preset Operators always take the field themselves, owned or not, and are not listed.",
"活性源石": "Active Originium",
"特殊地形": "Special Terrain",
"攻击力 +{atk}%": "ATK +{atk}%",
"攻击速度 +{aspd}": "ASPD +{aspd}",
"部署于其上的我方单位、经过的敌方单位,每秒受到 {dmg} 点真实伤害(无来源)": "Friendly units deployed on it and enemies passing over it take {dmg} True damage per second (sourceless)",
"在其上的我方单位与经过的敌方单位持续受到伤害": "Friendly units on it and enemies passing over it take damage over time",
"同时获得:{mods}": "They also gain: {mods}",
"效果持续 {sec} 秒;离开地块后仍然保留,再次接触会重新计时": "The effect lasts {sec} {sec|second|seconds}; it stays after leaving the tile, and touching it again restarts the timer",
"沼泽": "Mire",
"留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd},敌方单位还有移动速度 {move}": "Units staying in the Mire gain 1 stack of Mired every {sec} {sec|second|seconds}: ASPD {aspd}, and for enemies also Movement Speed {move}",
"留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd}": "Units staying in the Mire gain 1 stack of Mired every {sec} {sec|second|seconds}: ASPD {aspd}",
"重量 ≥ {heavy} 的敌人一次获得 2 层": "Enemies with weight ≥ {heavy} gain 2 stacks at a time",
"最多 {max} 层;离开沼泽后解除": "Up to {max} {max|stack|stacks}; removed on leaving the Mire",
"排气格栅": "Exhaust Grille",
"站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)": "An Operator on an Exhaust Grille is not targeted by enemy ranged attacks (the same effect as Camouflage)",
"但挡住敌人的干员仍会被它攻击到": "An Operator blocking an enemy can still be attacked by that enemy",
"深水区": "Deep Water Zone",
"敌人每秒受到 {dmg} 点伤害": "Enemies take {dmg} damage per second",
"攻击速度 {aspd}、移动速度 ×{move}": "ASPD {aspd}, Movement Speed ×{move}",
"攻击速度 {aspd}": "ASPD {aspd}",
"移动速度 ×{move}": "Movement Speed ×{move}",
"溺水伤害属于无来源伤害(不吃干员的增伤、穿透与装备加成),也不归类为环境伤害": "Drowning damage is sourceless (no Operator damage bonus, penetration or Equipment bonus applies to it) and does not count as environmental damage",
"拒绝部署(特制水上平台可以让这一格变得可部署)": "Refuses deployment (a special water platform makes this tile deployable)",
"红门": "Red Gate",
"敌方入口": "Incursion Point",
"敌方单位从这里出场": "Enemy units enter the battlefield here",
"蓝门": "Blue Gate",
"保护目标": "Protection Objective",
"敌人走进这里会扣你的目标生命值(LP),一回合至多 10 点": "An enemy entering here costs you LP, at most 10 per round",
"传送入口": "Tunnel Entry",
"敌人走到这里会从场上消失": "Enemies reaching this tile vanish from the battlefield",
"传送出口": "Tunnel Exit",
"消失的敌人会从这里重新出现": "Vanished enemies reappear here",
"可部署": "Deployable",
"仅近战位可部署": "Melee deployment only",
"仅远程位可部署": "Ranged deployment only",
"不可部署": "Not deployable",
"高台": "High Ground",
"只有空中单位能通过": "Only aerial units can pass",
"地面单位可通过": "Ground units can pass",
"地形机制": "Terrain Mechanics",
"这一格": "This Tile",
"全部清空": "Clear All",
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
+19
View File
@@ -53,6 +53,15 @@ const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const MAX_PER_URL = 2;
/**
* 漏怪: the original Arknights exit alarm (`sfx.battle.leak`, `battle/b_ui/b_ui_alarmenter`) runs **1.44 s**, and the
* official bank is a one-shot: `battle.ON_ENEMY_REACHED_EXIT` carries `maxSoundAllowed: 1` with `popOldest: true` on the
* `Battle_UI_Important` mixer, i.e. **never two at once** (a new escape replaces the one still ringing). We keep the
* "never two at once" half and leave the rest of the cue alone: leaks closer together than the cue is long are the same
* disaster and share one alarm, so a line that breaks costs one clear ring per 1.5 s instead of a stutter of restarts.
* (The SFX limiter still applies on top.)
*/
const LEAK_SFX_GAP_MS = 1500;
const BUFFER_CACHE = 180;
/** Decoded-PCM budget of the buffer cache beside its entry count: a voice line decodes to 0.4–1.3 MB (see _buffer). */
const BUFFER_BYTES = 64 * 1024 * 1024;
@@ -977,6 +986,16 @@ export class AudioManager {
const mix = own ? m.audio.sfx.units[u.def].mix?.die : null;
if (!unitSoundPlays(mix, this.random())) continue;
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? unitGain(0.8, mix) : 0.7);
} else if (kind === 'leak') {
// 漏怪: an enemy reached its goal (Battle.leak emits the sim's own EV.LEAK — it is NOT a `die`, so until now a
// leak was completely silent, for the player's own field and for a 联防 the helpers could not hold alike).
// The cue is the ORIGINAL Arknights stage alarm — the one an enemy entering the exit plays in any normal
// stage (manifest `sfx.battle.leak`, bank battle.ON_ENEMY_REACHED_EXIT, file b_ui_alarmenter).
// `LEAK_SFX_GAP_MS` keeps it to one alarm at a time (the official bank's own maxSoundAllowed 1).
if (now - (this.lastLeakSfxAt ?? -Infinity) < LEAK_SFX_GAP_MS) continue;
if (typeof this.getManifest()?.audio?.sfx?.battle?.leak !== 'string') continue;
this.lastLeakSfxAt = now;
this.battle('leak', { unitKey: 'leak', volume: 0.85 });
} else if (kind === 'deploy') {
const u = this.units.get(e[1]);
if (!u || u.side === 'enemy') continue;
+23 -4
View File
@@ -35,6 +35,8 @@
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
// tileClick { row, col, x, y } — the ground itself was tapped and nothing stands there (GitHub issue #184:
// a special terrain tile's own tip; the screen resolves it with gameLogic.terrainInfo)
// tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
// Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
@@ -1099,6 +1101,21 @@ export async function createFieldView(host, options = {}) {
return hit ? hit.ref : null;
}
/**
* The ground itself was tapped: nothing stands there, so the TILE explains itself — a special terrain tile (活性源石,
* 沼泽, 排气格栅, 深水区, 红/蓝门, 传送) opens its own card (GitHub issue #184; screens/game.js `tileClick` →
* gameLogic.terrainInfo, which says nothing about an ordinary floor / road / wall tile).
* The tile is picked as a BOARD tile (`pickBoardTile`, i.e. through `prepXf.toBoard`): on a Final Assault / Hidden Core
* PREP the board draws the boss field's own rows (stage 2–5 as board 9–12), and the screen maps board → stage once more
* with `gameLogic.fieldTile` — reporting the DRAWN tile here would be converted twice and explain the wrong tile
* (review on #185).
*/
function emitTileClick(ev, e) {
const t = pickBoardTile(ev.x, ev.y);
if (!t || !(t.row >= 0) || !(t.col >= 0)) return; // outside the board this field draws
emit('tileClick', { row: t.row, col: t.col, button: e.button, clientX: e.clientX, clientY: e.clientY });
}
const onPointerDown = (e) => {
if (destroyed) return;
const ev = evPayload(e);
@@ -1109,18 +1126,20 @@ export async function createFieldView(host, options = {}) {
const payload = { unitId: v.id, uid: info?.uid ?? null, unit: info, button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY };
emit('pieceClick', payload);
if (e.button === 2) emit('pieceDetail', payload);
} else if (penViews.size) {
const pv = penUnitAt(ev.x, ev.y);
if (pv) emitPenClick(pv, e);
return;
}
const pv = penViews.size ? penUnitAt(ev.x, ev.y) : null;
if (pv) emitPenClick(pv, e);
else emitTileClick(ev, e);
return;
}
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
if (penViews.size && mode === 'prep') {
const pv = penUnitAt(ev.x, ev.y);
if (pv) emitPenClick(pv, e);
if (pv) { emitPenClick(pv, e); return; }
}
emitTileClick(ev, e);
};
const onPointerMove = (e) => {
if (destroyed) return;
+21 -7
View File
@@ -12,6 +12,8 @@
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
// geometry when the camera moves.
import { ORIGINIUM } from '../style.js';
/** Shared focus uniforms (one object for every lit material). */
export function focusUniforms(THREE) {
return {
@@ -275,7 +277,17 @@ export function mireMaterial(THREE, tex, focus) {
});
}
/** Infection (活性源石): dark originium crust patches with glowing, pulsing orange veins over the concrete. */
/**
* 活性源石 (infection): dark originium crust patches with glowing, pulsing orange veins over the concrete. The palette
* (`style.js ORIGINIUM`) and the recipe are the ones `render/textures.js originiumOverlay` draws on the 2D board —
* two octaves of noise for the crust, the ridge of the fine one for the veins, a third for the crystal grains — so both
* boards show ONE material (GitHub #184).
* The noise is world-space, so the crust carries on into the neighbouring tile: there is deliberately NO per-tile edge
* fade (the old `edge` term faded every tile out at its own border, which made a field of 活性源石 read as separate
* patches instead of one floor).
*/
const V3 = (c) => `vec3(${c.map((v) => Number(v).toFixed(3)).join(', ')})`;
export function infectionMaterial(THREE, tex, focus) {
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
return new THREE.ShaderMaterial({
@@ -288,15 +300,17 @@ export function infectionMaterial(THREE, tex, focus) {
'void main() {',
' float n = texture2D(uNoise, vWorld.xy * 0.55).r;',
' float n2 = texture2D(uNoise, vWorld.xy * 1.3 + 0.37).r;',
' float n3 = texture2D(uNoise, vWorld.xy * 3.1 + 0.11).r;',
' float crust = smoothstep(0.38, 0.62, n * 0.7 + n2 * 0.45);',
' float vein = smoothstep(0.86, 0.98, 1.0 - abs(n2 * 2.0 - 1.0)) * (0.35 + 0.65 * crust);',
' float grain = smoothstep(0.93, 1.0, n3) * crust;',
' float pulse = 0.6 + 0.4 * sin(uTime * 2.2 + n * 9.0);',
' vec2 e = min(vUv, 1.0 - vUv);',
' float edge = smoothstep(0.0, 0.1, min(e.x, e.y));',
' vec3 base = mix(vec3(0.16, 0.05, 0.05), vec3(0.3, 0.1, 0.07), n2);',
' vec3 glow = vec3(1.0, 0.46, 0.18) * (1.2 + 0.8 * pulse);',
' vec3 c = mix(base, glow, vein) * focusMask(vWorld.xy);',
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0) * edge;',
` vec3 base = mix(${V3(ORIGINIUM.base)}, ${V3(ORIGINIUM.crust)}, n2);`,
` vec3 glow = ${V3(ORIGINIUM.vein)} * (1.2 + 0.8 * pulse);`,
' vec3 c = mix(base, glow, vein);',
` c = mix(c, ${V3(ORIGINIUM.spec)} * (1.0 + 0.6 * pulse), grain);`,
' c *= focusMask(vWorld.xy);',
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0);',
' gl_FragColor = vec4(c, a);',
' #include <colorspace_fragment>',
'}',
+20 -2
View File
@@ -7,11 +7,29 @@
// `map/autochess → TX_autochessi_D`; its crop table `tiles.json` sits next to it (written by
// tools/crop-board-atlas.mjs). Every image referenced by the table is loaded through the shared image cache; a
// missing image drops only the materials that use it. Everything is cached per page (one load for every view).
// A table image is fetched at the URL the manifest lists for the same texture (sourceUrl), so the 3D board
// (board3d/load.js, which reads the manifest only) and this art share one download — also when the manifest lists
// the WebP copy tools/local-extract writes and the table still names the PNG.
let cached = null;
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** The manifest group of the board textures (and of their crop table). */
export const BOARD_GROUP = 'map/autochess';
/**
* URL of a crop-table image: the local-art manifest's entry for the same texture (group `BOARD_GROUP`, name = the
* file name without its extension), else the table's own path.
* @param {any} assets store of public/js/assets.js
* @param {string} path tiles.json `source[k].path`
*/
export function sourceUrl(assets, path) {
const name = path.replace(/^.*\//, '').replace(/\.[^.]*$/, '');
const listed = name && typeof assets?.localUrl === 'function' ? assets.localUrl(BOARD_GROUP, name) : null;
return typeof listed === 'string' && listed ? listed : path;
}
/** @param {any} assets store of public/js/assets.js (needs local(), localUrl(), image()) */
export function loadBoardArt(assets) {
if (cached) return cached;
@@ -19,7 +37,7 @@ export function loadBoardArt(assets) {
if (!assets || typeof assets.local !== 'function' || typeof assets.image !== 'function') return null;
const manifest = await assets.local().catch(() => null);
if (!manifest) return null;
const atlasUrl = assets.localUrl ? assets.localUrl('map/autochess', 'TX_autochessi_D') : null;
const atlasUrl = assets.localUrl ? assets.localUrl(BOARD_GROUP, 'TX_autochessi_D') : null;
if (!atlasUrl) return null;
const dir = atlasUrl.replace(/\/[^/]*$/, '');
let tiles = null;
@@ -30,7 +48,7 @@ export function loadBoardArt(assets) {
if (!isObj(tiles) || !isObj(tiles.materials) || !isObj(tiles.source)) return null;
const images = {};
await Promise.all(Object.entries(tiles.source).map(async ([k, s]) => {
const url = isObj(s) && typeof s.path === 'string' ? s.path : null;
const url = isObj(s) && typeof s.path === 'string' && s.path ? sourceUrl(assets, s.path) : null;
if (!url) return;
const img = await assets.image(url).catch(() => null);
if (img && img.width > 0) images[k] = img;
+31
View File
@@ -70,6 +70,37 @@ export const COLORS = Object.freeze({
export const TIER_COLORS = Object.freeze([0x9aa5a0, 0x9aa5a0, 0x7fd37a, 0x52b6ff, 0xb98cff, 0xffc600, 0xff6b3d]);
export const ENEMY_FRAME = Object.freeze({ normal: 0xc84a3c, elite: 0xff7a33, boss: 0xff2d55 });
/**
* 活性源石 (infection) — the ONE palette of that tile, shared by the two boards (GitHub #184: the 2D atlas cell and the
* 3D shader used to be two different materials — a beveled brick with crystal clusters against a world-space crust).
* The values are the 3D board's own working colours (`render/board3d/materials.js` writes them into its shaders, which
* end in `#include <colorspace_fragment>`, i.e. they are LINEAR); `textures.js` converts them for its canvas with
* `linearToHex`, so both renderers show the same colour.
* base — the crust's dark side, crust — its lit side, vein — the glowing veins, spec — the bright crystal grains.
*/
export const ORIGINIUM = Object.freeze({
base: Object.freeze([0.16, 0.05, 0.05]),
crust: Object.freeze([0.3, 0.1, 0.07]),
vein: Object.freeze([1.0, 0.46, 0.18]),
spec: Object.freeze([1.0, 0.78, 0.52]),
glow: 0xff6a3d, // the additive pulse the 2D board tints per tile
});
/**
* One channel of a working (linear) colour as the 0–255 sRGB value a canvas stores — the shaders' `#include
* <colorspace_fragment>` does this for the 3D board, the canvas has to do it itself.
*/
export function linearToSrgb255(v) {
const k = Number(v);
const s = k <= 0.0031308 ? k * 12.92 : 1.055 * Math.pow(Math.max(0, k), 1 / 2.4) - 0.055;
return Math.max(0, Math.min(255, Math.round(s * 255)));
}
/** A working (linear) `ORIGINIUM` colour as the `#rrggbb` sRGB string of the canvas (`textures.js originiumCanvas`). */
export function linearToHex(rgb) {
return `#${rgb.map((v) => linearToSrgb255(v).toString(16).padStart(2, '0')).join('')}`;
}
export const DMG_STYLE = Object.freeze({
phys: { font: 'sp-dmg-phys', fill: ['#fffbe8', '#ffb35c'], stroke: '#3b1400' },
arts: { font: 'sp-dmg-arts', fill: ['#fbe8ff', '#c77dff'], stroke: '#2a0b45' },
+103 -19
View File
@@ -11,7 +11,7 @@
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
// globalThis.PIXI and a DOM canvas at call time (never at import time).
import { statusIconKey } from './style.js';
import { statusIconKey, ORIGINIUM, linearToSrgb255 } from './style.js';
const PIXI = () => globalThis.PIXI;
@@ -50,7 +50,7 @@ const PROC_UNIT = 112;
export const MATERIALS = [
'road', 'road2', 'road3', 'roadN', 'roadN2', 'floor', 'floor2', 'preview', 'wall', 'wallSide', 'forbid', 'forbid2', 'forbidSide',
'sep', 'sepSide', 'fence', 'fenceSide', 'start', 'end', 'telin', 'telout', 'hand', 'temp', 'benchSide', 'benchSideTemp', 'mire',
'smog', 'deepsea', 'infection', 'cliff', 'blank', 'lowSide', 'margin',
'smog', 'deepsea', 'infection', 'infection2', 'cliff', 'blank', 'lowSide', 'margin',
// high-ground plates by connectivity (horizontal strip L/M/R, vertical strip B(near)/VM/T(far); 'wall' = single)
'wallL', 'wallM', 'wallR', 'wallB', 'wallVM', 'wallT',
// textured props
@@ -62,6 +62,11 @@ export const MATERIALS = [
/** Art layers of materials the crop table (tiles.json) may not describe yet: the board atlas' glass hatch + a glaze. */
const DEFAULT_ART_LAYERS = Object.freeze({
penGlass: Object.freeze([Object.freeze({ src: 'D', rect: [549, 1787, 255, 256] }), Object.freeze({ proc: 'glass' })]),
// 活性源石 (GitHub #184): the official concrete slab the 3D board lays its crust on (`board3d/atlas.js concrete`), plus
// the same crust as a procedural layer — so with the board art installed the 2D tile is composed exactly like the 3D
// one, and `MAT_DRAW.infection` is only the no-art fallback.
infection: Object.freeze([Object.freeze({ src: 'D', rect: [256, 512, 256, 256] }), Object.freeze({ proc: 'originium' })]),
infection2: Object.freeze([Object.freeze({ src: 'D', rect: [256, 512, 256, 256] }), Object.freeze({ proc: 'originium2' })]),
});
function speckle(ctx, x, y, w, h, r, n, colors, size = [0.6, 1.8]) {
@@ -91,6 +96,97 @@ function bevelTile(ctx, x, y, s, o) {
ctx.fillRect(x + s - m - bw, y + m, bw, s - 2 * m);
}
/**
* Toroidal value noise on an `n × n` grid: `(u, v) ∈ [0, 1)` wraps, so a cell that is repeated over the board joins
* itself without a seam (the knobs of 活性源石's crust line up across neighbouring tiles).
*/
function noiseField(n, seed) {
const r = rng(seed);
const g = new Float32Array(n * n);
for (let i = 0; i < g.length; i++) g[i] = r();
const smooth = (t) => t * t * (3 - 2 * t);
return (u, v) => {
const x = u * n, y = v * n;
const x0 = Math.floor(x), y0 = Math.floor(y);
const i0 = ((x0 % n) + n) % n, j0 = ((y0 % n) + n) % n;
const i1 = (i0 + 1) % n, j1 = (j0 + 1) % n;
const sx = smooth(x - x0), sy = smooth(y - y0);
const a = g[j0 * n + i0], b = g[j0 * n + i1], c = g[j1 * n + i0], d = g[j1 * n + i1];
return (a * (1 - sx) + b * sx) * (1 - sy) + (c * (1 - sx) + d * sx) * sy;
};
}
const ORIGINIUM_RES = 256; // the crust's own resolution (scaled into the atlas cell)
const smoothstep = (a, b, t) => {
const k = Math.max(0, Math.min(1, (t - a) / (b - a)));
return k * k * (3 - 2 * k);
};
/**
* 活性源石 (infection) — the dark originium crust with thin glowing veins, drawn with the palette and the recipe the 3D
* board's shader uses (`render/board3d/materials.js infectionMaterial`, the same octaves and thresholds over its own
* world-space noise), so the two boards show ONE material (GitHub #184). Two octaves make the crust mask, the ridge of
* the fine octave its veins (brightened where the crust is), the rare top of a third one the crystal grains; the crust
* reaches the tile's edges, so a field of 活性源石 carries on into the neighbouring tile instead of ending in a frame.
* `mirrored` draws the second variant (`infection2`; `render/tiles.js variantMat` picks between them by tile).
*/
const _originiumCells = new Map();
/**
* 活性源石's crust, rendered once per variant into `size × size` pixels (cached: the atlas rebuilds on every board art
* change, the pattern does not). `mirrored` is the second variant (see `originiumOverlay`).
*/
function originiumCanvas(size, mirrored) {
const key = `${size}:${mirrored ? 1 : 0}`;
const hit = _originiumCells.get(key);
if (hit) return hit;
const canvas = makeCanvas(size, size);
const ctx = canvas.getContext('2d');
const crustN = noiseField(16, 9137), fineN = noiseField(32, 4421), grainN = noiseField(64, 733);
const img = ctx.createImageData(size, size);
const d = img.data;
const base = ORIGINIUM.base, crust = ORIGINIUM.crust, vein = ORIGINIUM.vein, spec = ORIGINIUM.spec;
for (let py = 0; py < size; py++) {
const v = (py + 0.5) / size;
for (let px = 0; px < size; px++) {
const u = mirrored ? 1 - (px + 0.5) / size : (px + 0.5) / size;
const n1 = crustN(u, v), n2 = fineN(u, v), n3 = grainN(u, v);
const crustM = smoothstep(0.38, 0.62, n1 * 0.7 + n2 * 0.45);
const ridge = smoothstep(0.86, 0.98, 1 - Math.abs(n2 * 2 - 1)) * (0.35 + 0.65 * crustM);
const grain = smoothstep(0.93, 1, n3) * crustM;
const alpha = Math.min(1, crustM * 0.72 + ridge);
if (alpha <= 0.004 && grain <= 0.01) continue;
const i = (py * size + px) * 4;
for (let c = 0; c < 3; c++) {
const lit = base[c] * (1 - n2) + crust[c] * n2; // the crust's own shading
const vc = lit * (1 - ridge) + vein[c] * ridge; // …and the vein over it
// the mixing happens in the shaders' working (linear) space; the canvas stores sRGB (the 3D board's
// colorspace_fragment does the same conversion at the end of its fragment shader)
d[i + c] = linearToSrgb255(Math.max(0, Math.min(1, vc * (1 - grain) + spec[c] * grain)));
}
d[i + 3] = Math.round(alpha * 255);
}
}
ctx.putImageData(img, 0, 0);
_originiumCells.set(key, canvas);
return canvas;
}
/**
* 活性源石 (infection) — the dark originium crust with thin glowing veins, drawn with the palette and the recipe the 3D
* board's shader uses (`render/board3d/materials.js infectionMaterial`: the same octaves and thresholds over its own
* world-space noise), so the two boards show ONE material (GitHub #184: this cell used to be a beveled brick with random
* crystal clusters while the 3D quad carried the crust). Two octaves make the crust mask, the ridge of the fine octave
* its veins (brightened where the crust is), the rare top of a third one the crystal grains; the crust reaches the
* tile's edges, so a field of 活性源石 carries on into the neighbouring tile instead of ending in a frame — the noise
* wraps, so a repeated cell joins itself. Drawn through `drawImage`, so the callers' transform (the procedura draw path
* scales a 112 px cell up to the atlas cell) applies; `putImageData` would ignore it.
*/
function originiumOverlay(ctx, x, y, s, mirrored = false) {
ctx.drawImage(originiumCanvas(ORIGINIUM_RES, mirrored), x, y, s, s);
}
function cracks(ctx, x, y, s, r, n, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 1;
@@ -285,23 +381,8 @@ const MAT_DRAW = {
}
speckle(ctx, x, y, s, s, r, 60, ['rgba(200,255,255,0.12)']);
},
infection(ctx, x, y, s, r) {
bevelTile(ctx, x, y, s, { light: '#3a2a33', dark: '#241a20', seamColor: '#140e11', hi: 'rgba(255,140,90,0.14)' });
for (let i = 0; i < 7; i++) {
const cx = x + 16 + r() * (s - 32), cy = y + 16 + r() * (s - 32), rr = 5 + r() * 10, a = r() * Math.PI;
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rr * 2.2);
g.addColorStop(0, 'rgba(255,110,60,0.45)'); g.addColorStop(1, 'rgba(255,110,60,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, rr * 2.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = i % 2 ? '#ff7a45' : '#ffb070';
ctx.beginPath();
for (let k = 0; k < 5; k++) {
const aa = a + (k / 5) * Math.PI * 2, rad = k % 2 ? rr * 0.45 : rr;
ctx.lineTo(cx + Math.cos(aa) * rad, cy + Math.sin(aa) * rad);
}
ctx.closePath(); ctx.fill();
}
cracks(ctx, x, y, s, r, 4, 'rgba(255,120,70,0.45)');
},
infection(ctx, x, y, s, r) { floorBase(ctx, x, y, s, r); originiumOverlay(ctx, x, y, s, false); },
infection2(ctx, x, y, s, r) { floorBase(ctx, x, y, s, r); originiumOverlay(ctx, x, y, s, true); },
cliff(ctx, x, y, s) {
const g = ctx.createLinearGradient(x, y, x, y + s);
g.addColorStop(0, 'rgba(70,80,86,1)'); g.addColorStop(0.08, 'rgba(38,45,49,1)'); g.addColorStop(1, 'rgba(10,13,14,0)');
@@ -439,6 +520,9 @@ const _atlases = new Map(); // art key → atlas
/** Procedural finishing layers usable in tiles.json (`{ proc: 'rim' }`): a bevelled block edge. */
const PROC_LAYERS = {
/** 活性源石's crust over the official concrete (DEFAULT_ART_LAYERS.infection); the second variant is mirrored. */
originium(ctx, x, y, s) { originiumOverlay(ctx, x, y, s, false); },
originium2(ctx, x, y, s) { originiumOverlay(ctx, x, y, s, true); },
/** A glossy glass pane over the inner square of a hatch: sky-lit gradient, soft cloud reflections, a highlight. */
glass(ctx, x, y, s) {
const i = s * 0.135, w = s - i * 2;
+5 -2
View File
@@ -31,7 +31,7 @@
// are built; switching back (WebGL context lost, art missing) rebuilds the 2D board.
import { tileAtlas, fxAtlas, rng, groundTexture } from './textures.js';
import { GLYPH, TILEKEY_GLYPH, TILE_H, COLORS } from './style.js';
import { GLYPH, TILEKEY_GLYPH, TILE_H, COLORS, ORIGINIUM } from './style.js';
import { parsePenRect, PEN_RECT } from './pen.js';
/**
@@ -193,6 +193,9 @@ function variantMat(t) {
case 'roadN': return h < 4 ? 'roadN' : 'roadN2';
case 'floor': return h < 4 ? 'floor' : 'floor2';
case 'forbid': return h < 4 ? 'forbid' : 'forbid2';
// 活性源石: the same material twice (the second mirrored), so a field of it does not repeat one pattern
// (textures.js originiumOverlay — the palette and recipe match the 3D board's shader, GitHub #184)
case 'infection': return h < 4 ? 'infection' : 'infection2';
default: return t.mat;
}
}
@@ -650,7 +653,7 @@ export class TileField {
switch (t.glyph) {
case 'g': add('smog', 'smoke', P.BLEND_MODES.NORMAL, 0xb8c4c0, 2); break;
case 'd': add('sea', 'soft', P.BLEND_MODES.ADD, 0x5fe0ff, 1); break;
case 'i': add('infect', 'glow', P.BLEND_MODES.ADD, 0xff6a3d, 1); break;
case 'i': add('infect', 'glow', P.BLEND_MODES.ADD, ORIGINIUM.glow, 1); break;
case 'm': add('mire', 'dot', P.BLEND_MODES.ADD, 0xc8d890, 2); break;
case 'I': case 'O': add('tel', 'ring', P.BLEND_MODES.ADD, 0xb36bff, 1); break;
case 'S': if (r <= 12) add('gateGlow', 'glow', P.BLEND_MODES.ADD, COLORS.gateRed, 1); break;
+25 -1
View File
@@ -94,8 +94,9 @@ import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
terrainInfo,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
@@ -341,6 +342,16 @@ function MatchScreen() {
const st = fieldStage || (ownView ? ownStage : baseStage);
if (st) view.setStage(st);
}, [view, ownView, ownStage, baseStage, fieldStage]);
// the stage behind the board ON SCREEN — the one view.setStage was just given: a field's own map (联防, GitHub #41),
// else the own one (机变 overrides applied) or, while watching a teammate, the plain one — and how a tapped BOARD tile
// maps to it (GitHub issue #184: tileClick → gameLogic.terrainInfo). Everywhere but a boss-prep board the two spaces
// are the same: a 最终攻势 / 隐秘核心 battle renders the stage's own rows (GEO.BOSS_RECT), 联防 / normal rects are
// stage rows; the boss PREP draws the player's half (stage rows 2–5) as board rows 9–12 (render/prepfield.js toDisp),
// which is exactly gameLogic.fieldTile.
live.current.terrainStage = fieldStage || (ownView ? ownStage : baseStage);
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
? (row, col) => fieldTile(deployField, row, col)
: (row, col) => [row, col];
const staleFieldRef = useRef(null);
const enteredFieldRef = useRef(null);
const pressSel = useRef(null); // the selected piece when the current field press began
@@ -860,6 +871,19 @@ function MatchScreen() {
setSel(wasSel ? null : { uid: e.uid });
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
}),
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
view.on('tileClick', (t) => {
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
const L = live.current;
const [row, col] = L.terrainTile(t.row, t.col);
const info = terrainInfo(L.terrainStage, row, col);
if (!info) return;
audio.sfx('click', { volume: 0.4 });
setSel(null);
setDetail({ kind: 'terrain', terrain: info });
}),
];
return () => { moveOff?.(); for (const off of offs) { try { off?.(); } catch { /* ignore */ } } };
}, [view]);
+7
View File
@@ -20,6 +20,8 @@ import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
import { GIcon } from '../ui/gameComponents.js';
import { SettingsModal } from '../ui/settings.js';
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
// the client accepts is never rejected by the server's hello validation.
@@ -187,6 +189,7 @@ export function TitleScreen() {
const pendingJoin = useStore((s) => s.ui.pendingJoin);
const lang = useLang();
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const [settingsOpen, setSettingsOpen] = useState(false);
const assetsSettled = useData('assets');
const assets = data.get('assets');
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
@@ -265,11 +268,15 @@ export function TitleScreen() {
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
<button type="button" class="title-settings fsbtn tapx" aria-label=${t('设置')} title=${t('设置')}
onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
<${FullscreenButton} class="title-fs" />
</div>
</div>
</main>
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot">
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
+25 -1
View File
@@ -615,9 +615,30 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
${talents.length ? html`<${Section} title="天赋">${talents.map((t, i) => html`<p class="dtext" key=${i}><b>${t.name}</b> ${t.desc}</p>`)}<//>` : null}`;
}
/**
* A special terrain tile's tip (GitHub issue #184: 「建议加入对于特殊地形的单击信息提示」). Opened by a tap on the tile
* itself — the game screen resolves it with `gameLogic.terrainInfo` from the stage the board on screen is built from, so
* the mechanism lines carry that stage's own numbers (活性源石's damage / duration, 沼泽's stacks, 深水区's drowning …).
* @param {{ name:string, tag:string, lines:string[], facts:string[], row:number, col:number }} terrain
*/
function TerrainDetail({ terrain }) {
return html`
<div class="dhead">
<div class="dhead__icon"><${Icon} name="info" /></div>
<div class="dhead__info">
<div class="dhead__chips"><span class="dtag-kind">${terrain.tag}</span></div>
<h3 class="dhead__name">${terrain.name}</h3>
</div>
</div>
<${Section} title=${t('地形机制')} micro="TERRAIN">
${terrain.lines.map((line, i) => html`<p class="dtext" key=${i}>${line}</p>`)}
<//>
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${terrain.facts.join(' · ')}</p><//>` : null}`;
}
/**
* Resolve what a detail target shows.
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {Map<number, any>} pieces indexPieces(priv)
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
@@ -627,6 +648,8 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
*/
export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
if (!target) return null;
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
const ownSi = (c) => ownStandIn(c, priv, backups);
const dd = { chess: data.get('chess'), backups };
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
@@ -737,6 +760,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
</div>
</aside>`;
}
+3
View File
@@ -16,6 +16,8 @@
// Final Assault prep) keeps the own board layout here — every coordinate the UI exchanges is a board coordinate anyway —
// but draws the tiles of the player's half of the boss field (gameLogic fieldTile, the legality the highlights use: act2
// m01's fence tiles are floor there, not the normal field's walls; user playtest #5 item 7).
// A tap on the ground itself emits tileClick { row, col } like render/app.js does, so a special terrain tile
// explains itself on this board too (GitHub issue #184).
import { render } from '../../vendor/preact.module.js';
import { html, TierChip } from './components.js';
@@ -320,6 +322,7 @@ export function createFallbackView(host, opts = {}) {
const hov = st.hoverTarget?.area === 'board' && st.hoverTarget.row === row && st.hoverTarget.col === col ? dropState(st.hoverTarget) : null;
tiles.push(html`<div key=${k} class=${cx('ff-tile', `ff-tile--${tileClass(row, col)}`, hl && `is-${hl}`, hov && `is-hover-${hov}`)}
data-drop="board" data-row=${row} data-col=${col}
onPointerDown=${(e) => { if (e.button === 0) emit('tileClick', { row, col, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
style=${`transform:translate(${p.x}px,${p.y}px);width:${L.tile}px;height:${L.tile}px`}></div>`);
}
}
+1
View File
@@ -34,6 +34,7 @@ export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldL
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, canPlace, itemAttaches, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
+3 -1
View File
@@ -33,7 +33,9 @@ export function shortcutFor(e) {
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
* @param {{ kind?: string }|null|undefined} detail
*/
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit';
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
/**
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
+125
View File
@@ -0,0 +1,125 @@
// ui/gameLogic/terrain.js — special terrain tips (GitHub issue #184: 特殊地形的单击信息提示). Re-exported from
// ../gameLogic.js.
//
// The words go through t() (docs/I18N.md: the Chinese text is the msgid, public/i18n/en.json the English): the name /
// tag / fact tables are marked N_() and translated where terrainInfo hands them out, the mechanism lines are built
// with their numbers as params.
import { isObj } from './shared.js';
import { t, N_ } from '../../../../shared/i18n.js';
// ---- special terrain tips (GitHub issue #184: 特殊地形的单击信息提示) ----------------------------------------
/**
* What tapping a special tile says. `lines` are functions of the stage's own terrain parameters (`stage.special[<terrain>]`
* and the tile's `bb`, the very numbers the sim runs on — server/sim/content/devices.js), so a tip can never disagree with
* the battle; the prose is ours (docs/PLAYING.md wording, PRTS 特殊地形 / 沼泽控制 / 深水区 地形信息).
* `tag` is the chip above the name; `fact` needs the tile's own legend entry (see terrainInfo).
*/
const TERRAIN_TIPS = Object.freeze({
infection: {
name: N_('活性源石'), tag: N_('特殊地形'),
lines: (st) => {
const b = isObj(st?.infection?.bb) ? st.infection.bb : {};
const dmg = param(b.damage, 0);
const mods = [];
if (param(b.atk, 0)) mods.push(t('攻击力 +{atk}%', { atk: Math.round(param(b.atk, 0) * 100) }));
if (param(b.attack_speed, 0)) mods.push(t('攻击速度 +{aspd}', { aspd: param(b.attack_speed, 0) }));
return [
dmg ? t('部署于其上的我方单位、经过的敌方单位,每秒受到 {dmg} 点真实伤害(无来源)', { dmg }) : t('在其上的我方单位与经过的敌方单位持续受到伤害'),
mods.length ? t('同时获得:{mods}', { mods }) : null,
param(b.duration, 0) ? t('效果持续 {sec} 秒;离开地块后仍然保留,再次接触会重新计时', { sec: param(b.duration, 0) }) : null,
].filter(Boolean);
},
},
mire: {
name: N_('沼泽'), tag: N_('特殊地形'),
lines: (st) => {
const m = isObj(st?.mire) ? st.mire : {};
const per = param(m.aspdPerStack, -0.05);
const move = param(m.moveMulPerStack, -0.05);
const max = param(m.maxStacks, 10);
const heavy = param(m.heavyWeight, 3);
const sec = param(m.intervalSec, 1);
return [
move
? t('留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd},敌方单位还有移动速度 {move}', { sec, aspd: pctText(per), move: pctText(move) })
: t('留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd}', { sec, aspd: pctText(per) }),
heavy ? t('重量 ≥ {heavy} 的敌人一次获得 2 层', { heavy }) : null,
t('最多 {max} 层;离开沼泽后解除', { max }),
].filter(Boolean);
},
},
smog: {
name: N_('排气格栅'), tag: N_('特殊地形'),
// the sim gives the tile's buff `flags: { stealth: true }` (devices.js enterTerrain): enemy ranged targeting
// skips it like 隐匿 — and, like 隐匿, it does NOT stop the enemy it blocks from attacking it (PRTS 隐匿).
lines: () => [
t('站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)'),
t('但挡住敌人的干员仍会被它攻击到'),
],
},
deepsea: {
name: N_('深水区'), tag: N_('特殊地形'),
lines: (st) => {
const b = isObj(st?.deepsea?.bb) ? st.deepsea.bb : {};
const dmg = param(b['sea_drown[enemy].damage'], 0);
const aspd = param(b['sea_drown[enemy].attack_speed'], 0);
const move = param(b['sea_drown[enemy].move_speed'], 0);
const out = [];
if (dmg) out.push(t('敌人每秒受到 {dmg} 点伤害', { dmg }));
const slowed = move && move !== 1;
if (aspd && slowed) out.push(t('攻击速度 {aspd}、移动速度 ×{move}', { aspd: pctText(aspd), move }));
else if (aspd) out.push(t('攻击速度 {aspd}', { aspd: pctText(aspd) }));
else if (slowed) out.push(t('移动速度 ×{move}', { move }));
// devices.js tickDeepsea: sourceless true damage tagged 'dot' / 'periodic' / 'deepsea' — deliberately NOT 'terrain'
// (环境伤害, which is what 活性源石's tick is): it is nobody's damage, so no 干员's 增伤 / 穿透 / 装备 applies.
out.push(t('溺水伤害属于无来源伤害(不吃干员的增伤、穿透与装备加成),也不归类为环境伤害'));
out.push(t('拒绝部署(特制水上平台可以让这一格变得可部署)'));
return out;
},
},
start: { name: N_('红门'), tag: N_('敌方入口'), lines: () => [t('敌方单位从这里出场')] },
end: { name: N_('蓝门'), tag: N_('保护目标'), lines: () => [t('敌人走进这里会扣你的目标生命值(LP),一回合至多 10 点')] },
telin: { name: N_('传送入口'), tag: N_('特殊地形'), lines: () => [t('敌人走到这里会从场上消失')] },
telout: { name: N_('传送出口'), tag: N_('特殊地形'), lines: () => [t('消失的敌人会从这里重新出现')] },
});
/** Tile keys that carry a tip of their own although the legend gives them no `special` tag (gates, teleports). */
const TIP_BY_TILEKEY = Object.freeze({ tile_start: 'start', tile_end: 'end', tile_telin: 'telin', tile_telout: 'telout' });
/** 深水区's own legend entry is the one tile whose mechanism overrides the level's buildableType (grid.js DEPLOY_REFUSED_TILES). */
const BUILDABILITY = Object.freeze({ ALL: N_('可部署'), MELEE: N_('仅近战位可部署'), RANGED: N_('仅远程位可部署'), NONE: N_('不可部署') });
const param = (v, d) => (Number.isFinite(Number(v)) ? Number(v) : d);
const pctText = (v) => `${v > 0 ? '+' : '−'}${Math.abs(Math.round(param(v, 0) * 100))}%`;
/**
* The tip a tap on board tile (row, col) opens (GitHub issue #184 — "建议加入对于特殊地形的单击信息提示"), or null for an
* ordinary tile (road / floor / wall / fence …): those say nothing, so a tap on them still just closes what is open.
* The tile comes from the stage the board on screen is built from (`stage.rows` + `stage.tiles`, data/stages.json), and
* the numbers from that stage's own terrain parameters — the same values the sim runs. It reads the stage only, so a
* 补位 / 自选 board (whose pieces are other records) explains its tiles the same way. The text is in the current
* language (t()).
* @param {{ rows?: string[], tiles?: Record<string, any>, special?: any } | null | undefined} stage the shown field's stage
* @param {number} row board row (row 0 = the bottom row, DESIGN §1)
* @param {number} col
* @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[] } | null}
*/
export function terrainInfo(stage, row, col) {
const rows = Array.isArray(stage?.rows) ? stage.rows : null;
const line = rows && Number.isInteger(row) && row >= 0 ? rows[row] : null;
if (typeof line !== 'string' || !Number.isInteger(col) || col < 0 || col >= line.length) return null;
const tiles = isObj(stage.tiles) ? stage.tiles : null;
const tile = tiles ? tiles[line[col]] : null;
if (!isObj(tile)) return null;
const key = tile.special || TIP_BY_TILEKEY[tile.tileKey] || null;
const tip = key ? TERRAIN_TIPS[key] : null;
if (!tip) return null;
const facts = [];
const build = BUILDABILITY[tile.buildable];
if (build) facts.push(t(build));
if (tile.height === 'HIGH') facts.push(t('高台'));
if (tile.groundPassable === false) facts.push(t('只有空中单位能通过'));
else if (tile.groundPassable === true) facts.push(t('地面单位可通过'));
return { key, name: t(tip.name), tag: t(tip.tag), row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
}
+370
View File
@@ -0,0 +1,370 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { MirrorPolicy, selectDownloadSource } from '../tools/assets/network.mjs';
import { downloadUrls, githubProxyUrl, githubSourceUrl, normalizeProxyPrefix } from '../tools/assets/sources.mjs';
import { Downloader } from '../tools/assets/downloader.mjs';
import { cachedJson, loadIndexes } from '../tools/assets/cache.mjs';
import { resolveTemplate } from '../tools/assets/manifest.mjs';
import { processModels } from '../tools/assets/spine.mjs';
const RAW = 'https://raw.githubusercontent.com/o/r/main/a.json';
const PROXY = 'https://gh-proxy.com/' + RAW;
const CDN = 'https://cdn.jsdelivr.net/gh/o/r@main/a.json';
const quiet = () => {};
async function fixture(t) {
const dir = await mkdtemp(join(tmpdir(), 'sp-network-'));
t.after(() => rm(dir, { recursive: true, force: true }));
return dir;
}
test('default, manual source and offline mode perform no public IP lookup', async (t) => {
const fetch = t.mock.method(globalThis, 'fetch', async () => { assert.fail('unexpected network request'); });
assert.equal(await selectDownloadSource({ log: quiet }), 'direct');
for (const mode of ['direct', 'mirror']) assert.equal(await selectDownloadSource({ mode, log: quiet }), mode);
assert.equal(await selectDownloadSource({ offline: true }), 'direct');
await assert.rejects(selectDownloadSource({ mode: 'invalid' }), /unknown asset source/);
await assert.rejects(selectDownloadSource({ mode: 'auto' }), /unknown asset source/);
assert.equal(fetch.mock.callCount(), 0);
});
test('mirror startup log names its proxy and states the validation scope', async () => {
const logs = [];
assert.equal(await selectDownloadSource({ mode: 'mirror', proxyPrefix: 'https://mirror.example/p/', log: (msg) => logs.push(msg) }), 'mirror');
assert.match(logs[0], /https:\/\/mirror\.example\/p\//);
assert.match(logs[0], /仅校验格式和大小/);
});
test('URL order, voice support, custom prefix, and no double prefix', () => {
assert.deepEqual(downloadUrls(RAW, { source: 'mirror' }), [PROXY, RAW, CDN]);
assert.deepEqual(downloadUrls(RAW), [RAW, CDN]);
assert.deepEqual(downloadUrls(RAW, { source: 'direct', proxyPrefix: 'https://custom.example/' }), [RAW, CDN]);
const voice = 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/x.mp3';
assert.deepEqual(downloadUrls(voice, { source: 'mirror' }), ['https://gh-proxy.com/' + voice, voice]);
assert.deepEqual(downloadUrls(PROXY, { source: 'mirror' }), [PROXY, RAW, CDN]);
assert.deepEqual(downloadUrls(PROXY), [RAW, CDN]);
assert.deepEqual(downloadUrls('https://old-proxy.example/prefix/' + RAW, { source: 'mirror', proxyPrefix: '' }), [RAW, CDN]);
assert.deepEqual(downloadUrls(PROXY, { source: 'mirror', proxyPrefix: 'https://new-proxy.example/' }), ['https://new-proxy.example/' + RAW, RAW, CDN]);
assert.equal(githubSourceUrl('https://old-proxy.example/' + PROXY), RAW);
assert.equal(githubSourceUrl(RAW + '/https://example.com/path'), RAW + '/https://example.com/path');
for (const url of ['https://example.com/a', 'https://github.com.evil.example/a', 'https://user:secret@github.com/a']) {
assert.equal(githubProxyUrl(url), null);
}
assert.equal(githubProxyUrl(RAW, 'https://mirror.example/prefix'), 'https://mirror.example/prefix/' + RAW);
assert.equal(normalizeProxyPrefix(), 'https://gh-proxy.com/');
for (const proxyPrefix of ['', ' ']) {
assert.equal(normalizeProxyPrefix(proxyPrefix), '');
assert.equal(githubProxyUrl(RAW, proxyPrefix), null);
assert.deepEqual(downloadUrls(RAW, { source: 'mirror', proxyPrefix }), [RAW, CDN]);
}
for (const prefix of ['http://mirror.example/', 'https://mirror.example/?q=x', 'https://user:secret@mirror.example/']) {
assert.throws(() => normalizeProxyPrefix(prefix), /HTTPS URL/);
}
});
test('downloader uses proxy, validates payload, falls back, and skips existing files', async (t) => {
const dir = await fixture(t);
const calls = [];
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', retries: 1, backoffMs: 0, log: quiet,
fetchImpl: async (url) => {
calls.push(url);
return url === PROXY ? new Response('<html>bad gateway</html>') : Response.json({ ok: true });
} });
const job = { rel: 'a.json', urls: [RAW], kind: 'json' };
assert.equal((await dl.run([job])).get(job.rel).url, RAW);
assert.deepEqual(calls, [PROXY, RAW]);
assert.equal((await dl.run([job])).get(job.rel).status, 'skip');
assert.equal(calls.length, 2);
});
test('proxy provenance is recognized as the primary asset, not a fallback asset', async (t) => {
const dir = await fixture(t);
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', log: quiet,
fetchImpl: async () => Response.json({ ok: true }) });
const job = { rel: 'a.json', urls: [RAW], kind: 'json' };
assert.equal((await dl.run([job])).get(job.rel).url, PROXY);
for (const source of [PROXY, 'https://old-proxy.example/prefix/' + RAW]) {
const resolved = resolveTemplate({ icon: { alts: [job] } }, { root: dir, spine: new Map(), sourceOf: () => source });
assert.deepEqual(resolved.fallbacks, [], 'disabling/changing the proxy does not change default proxy provenance');
assert.equal(resolved.value.icon, '/assets/a.json');
}
});
test('Spine pages from old proxy ledger entries respect direct and explicitly disabled mirror mode', async (t) => {
const base = 'https://raw.githubusercontent.com/o/r/main/';
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXioAAAAASUVORK5CYII=', 'base64');
for (const prefix of ['https://gh-proxy.com/', 'https://old-proxy.example/prefix/']) {
for (const opts of [{ source: 'direct' }, { source: 'mirror', proxyPrefix: '' }]) {
const dir = await fixture(t);
const atlas = 'extra.png\nsize: 1,1\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\n';
await writeFile(join(dir, 'model.atlas'), atlas);
const calls = [];
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), log: quiet, ...opts,
fetchImpl: async (url) => {
calls.push(url);
return url === base + 'extra.png' ? new Response(png) : new Response('', { status: 404 });
} });
dl.ledger.files['model.atlas'] = { url: prefix + base + 'model.atlas', bytes: Buffer.byteLength(atlas) };
const model = { key: 'model', dir: '', baseUrl: base, pngs: [],
skel: { rel: 'model.skel', urls: [base + 'model.skel'], kind: 'skel' },
atlas: { rel: 'model.atlas', urls: [base + 'model.atlas'], kind: 'atlas' } };
await processModels(new Map([['model', model]]), { root: dir, dl, cachePath: join(dir, 'spine.json'), log: quiet });
assert.ok(calls.includes(base + 'extra.png'));
assert.ok(calls.every((url) => url.startsWith(base) || url.startsWith('https://cdn.jsdelivr.net/')));
assert.equal(dl.ledger.files['extra.png'].url, base + 'extra.png');
}
}
});
test('all three indexes use the selected proxy and cached/offline reads make no request', async (t) => {
const dir = await fixture(t);
const calls = [];
const opts = { source: 'mirror', log: quiet, fetchImpl: async (url) => { calls.push(url); return Response.json({ index: true }); } };
const result = await loadIndexes(dir, opts);
assert.deepEqual(result, { audioData: { index: true }, modelsData: { index: true }, charword: { index: true } });
assert.equal(calls.length, 3);
assert.ok(calls.some((url) => url.endsWith('/charword_table.json')));
assert.ok(calls.every((url) => url.startsWith('https://gh-proxy.com/https://raw.githubusercontent.com/')));
await loadIndexes(dir, { ...opts, offline: true, refresh: true });
assert.equal(calls.length, 3);
assert.deepEqual(JSON.parse(await readFile(join(dir, '.cache', 'ark-models', 'models_data.json'), 'utf8')), { index: true });
});
test('index proxy miss falls back to raw', async (t) => {
const dir = await fixture(t);
const calls = [];
assert.deepEqual(await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, source: 'mirror', log: quiet,
fetchImpl: async (url) => { calls.push(url); return url === PROXY ? new Response('', { status: 404 }) : Response.json({ ok: true }); } }), { ok: true });
assert.deepEqual(calls, [PROXY, RAW]);
});
test('direct retries that recover do not print a failure hint or contact the prefix proxy', async (t) => {
const dir = await fixture(t);
const calls = [];
const logs = [];
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0,
log: (msg) => logs.push(msg), fetchImpl: async (url) => {
calls.push(url);
return url.startsWith('https://raw.githubusercontent.com/') ? new Response('', { status: 503 }) : Response.json({ ok: true });
} });
await dl.run([1, 2].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
assert.equal(calls.length, 4);
assert.ok(calls.every((url) => url.startsWith('https://raw.githubusercontent.com/') || url.startsWith('https://cdn.jsdelivr.net/')));
assert.equal(logs.filter((msg) => msg.includes('--asset-source=mirror')).length, 0);
});
test('default index retries that fall back successfully do not print a failure hint', async (t) => {
const dir = await fixture(t);
const calls = [];
const logs = [];
const result = await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, backoffMs: 0,
log: (msg) => logs.push(msg), fetchImpl: async (url) => {
calls.push(url);
return url === RAW ? new Response('', { status: 503 }) : Response.json({ ok: true });
} });
assert.deepEqual(result, { ok: true });
assert.deepEqual(calls, [RAW, RAW, RAW, CDN]);
assert.equal(logs.filter((msg) => msg.includes('--asset-source=mirror')).length, 0);
});
test('empty proxy setting disables every downloader and index proxy request in mirror mode', async (t) => {
const dir = await fixture(t);
const calls = [];
const opts = { source: 'mirror', proxyPrefix: '', log: quiet, fetchImpl: async (url) => { calls.push(url); return Response.json({ ok: true }); } };
await loadIndexes(dir, opts);
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), ...opts });
await dl.run([{ rel: 'a.json', urls: [RAW], kind: 'json' }]);
assert.equal(calls.length, 4);
assert.ok(calls.every((url) => url.startsWith('https://raw.githubusercontent.com/')));
});
test('one circuit spans indexes, assets and fonts and stops after three failed proxy attempts', async (t) => {
const dir = await fixture(t);
const calls = [];
const log = [];
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', log: (msg) => log.push(msg) });
const opts = { source: 'mirror', mirrorPolicy, log: quiet, fetchImpl: async (url) => {
calls.push(url);
return url.startsWith('https://gh-proxy.com/') ? new Response('', { status: 503 }) : Response.json({ ok: true });
} };
await loadIndexes(dir, opts); // three consecutive proxy failures across the indexes
assert.equal(mirrorPolicy.disabled, true);
const dl = new Downloader({ root: join(dir, 'assets'), ledgerPath: join(dir, 'ledger.json'), concurrency: 1, ...opts });
await dl.run([1, 2, 3].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
const fonts = new Downloader({ root: join(dir, 'fonts'), ledgerPath: join(dir, 'fonts-ledger.json'), ...opts });
await fonts.run([{ rel: 'font.json', urls: [RAW + '/font'], kind: 'json' }]);
assert.equal(calls.filter((url) => url.startsWith('https://gh-proxy.com/')).length, 3, 'no retries on the proxy, no later proxy requests');
assert.equal(dl.totals.ok, 3);
assert.equal(fonts.totals.ok, 1);
assert.equal(log.length, 1, 'one circuit-open warning for the shared invocation');
});
test('successful and not-found proxy responses reset the failure streak, opened circuit stays off', async (t) => {
const dir = await fixture(t);
const statuses = [503, 503, 200, 503, 404, 503, 503, 503];
let proxyCalls = 0;
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', concurrency: 1, log: quiet,
fetchImpl: async (url) => {
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
const status = statuses[proxyCalls++] ?? 200;
return status === 200 ? Response.json({ ok: true }) : new Response('', { status });
} });
await dl.run(Array.from({ length: 10 }, (_, i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
assert.equal(proxyCalls, 8);
assert.equal(dl.totals.ok, 10);
});
test('invalid payloads trip the same circuit as HTTP failures', async (t) => {
const dir = await fixture(t);
let proxyCalls = 0;
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', concurrency: 1, log: quiet,
fetchImpl: async (url) => {
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
proxyCalls++;
return new Response('<html>upstream error</html>');
} });
await dl.run([1, 2, 3, 4].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
assert.equal(proxyCalls, 3);
assert.equal(dl.totals.ok, 4);
});
test('slow proxy requests use a short deadline, abort, and fall back without repeated waits', async (t) => {
const dir = await fixture(t);
let proxyCalls = 0;
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', timeoutMs: 10, failureLimit: 2, log: quiet });
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', mirrorPolicy, concurrency: 1, log: quiet,
fetchImpl: async (url, { signal }) => {
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
proxyCalls++;
return new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }));
} });
const keepAlive = setInterval(quiet, 1000);
const started = Date.now();
try { await dl.run([1, 2, 3].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' }))); }
finally { clearInterval(keepAlive); }
assert.equal(proxyCalls, 2);
assert.equal(dl.totals.ok, 3);
assert.ok(Date.now() - started < 3000, 'uses the mirror deadline, not 120 seconds times three');
});
test('opening the circuit aborts concurrent proxy work and later jobs go direct', async (t) => {
const dir = await fixture(t);
let proxyCalls = 0;
let aborted = 0;
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', failureLimit: 1, log: quiet });
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', mirrorPolicy, concurrency: 3, log: quiet,
fetchImpl: async (url, { signal }) => {
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
proxyCalls++;
if (proxyCalls === 3) return new Response('', { status: 503 });
return new Promise((_, reject) => signal.addEventListener('abort', () => { aborted++; reject(signal.reason); }, { once: true }));
} });
await dl.run([1, 2, 3, 4, 5].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
assert.equal(proxyCalls, 3);
assert.equal(aborted, 2);
assert.equal(dl.totals.ok, 5);
});
test('the mirror body idle deadline covers stalled response bodies for indexes and files', async (t) => {
const dir = await fixture(t);
const server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.write('{"ok":'); // Headers arrive, but the body never finishes.
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(() => new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); }));
let proxyCalls = 0;
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', timeoutMs: 100, failureLimit: 2, log: quiet });
const opts = { mirrorPolicy, log: quiet, fetchImpl: async (url, options) => {
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
proxyCalls++;
return fetch(`http://127.0.0.1:${server.address().port}`, options);
} };
const started = Date.now();
assert.deepEqual(await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, ...opts }), { ok: true });
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), concurrency: 1, ...opts });
await dl.run([1, 2].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
assert.equal(proxyCalls, 2);
assert.equal(dl.totals.ok, 2);
assert.ok(Date.now() - started < 4000, 'stalled body reads time out instead of waiting indefinitely');
});
test('mirror header timeout does not cap a steadily streaming index or asset body', async (t) => {
const dir = await fixture(t);
const payload = Buffer.from('{"streamed":true}');
const delayedResponse = () => {
let offset = 0;
let timer;
return new Response(new ReadableStream({
start(controller) {
timer = setInterval(() => {
if (offset >= payload.length) {
clearInterval(timer);
controller.close();
return;
}
controller.enqueue(payload.subarray(offset, ++offset));
}, 20);
},
cancel() { clearInterval(timer); },
}), { headers: { 'content-type': 'application/json', 'content-length': String(payload.length) } });
};
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', timeoutMs: 100, log: quiet });
const opts = { mirrorPolicy, log: quiet, fetchImpl: async (url) => url.startsWith('https://gh-proxy.com/') ? delayedResponse() : Response.json({ direct: true }) };
const started = Date.now();
assert.deepEqual(await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, ...opts }), { streamed: true });
const dl = new Downloader({ root: join(dir, 'assets'), ledgerPath: join(dir, 'ledger.json'), ...opts });
const result = await dl.run([{ rel: 'slow.json', urls: [RAW], kind: 'json' }]);
assert.equal(result.get('slow.json').status, 'ok');
assert.ok(Date.now() - started > 100, 'the body takes longer than the header deadline while making progress');
});
test('CLI source defaults to direct and retains the 0.1.1 shrink/local-spine flags', async () => {
const { parseArgs } = await import('../tools/fetch-assets.mjs');
const old = process.env.SP_ASSET_SOURCE;
try {
delete process.env.SP_ASSET_SOURCE;
assert.equal(parseArgs([]).source, 'direct');
process.env.SP_ASSET_SOURCE = 'mirror';
assert.equal(parseArgs([]).source, 'mirror');
assert.equal(parseArgs([]).voiceLang, 'cn');
assert.equal(parseArgs([]).voiceAll, false);
const opts = parseArgs(['--asset-source=direct', '--allow-shrink', '--local-spines', '--voice-lang=jp', '--voice-all']);
assert.equal(opts.source, 'direct');
assert.equal(opts.allowShrink, true);
assert.equal(opts.localSpines, true);
assert.equal(opts.voiceLang, 'jp');
assert.equal(opts.voiceAll, true);
} finally {
if (old === undefined) delete process.env.SP_ASSET_SOURCE;
else process.env.SP_ASSET_SOURCE = old;
}
});
test('SP_GITHUB_PROXY is parsed only for online mirror mode', async () => {
const { resolveProxyPrefix } = await import('../tools/fetch-assets.mjs');
assert.equal(resolveProxyPrefix('direct', false, 'not-an-https-url'), '');
assert.equal(resolveProxyPrefix('mirror', true, 'not-an-https-url'), '');
assert.equal(resolveProxyPrefix('mirror', false, ''), '');
assert.throws(() => resolveProxyPrefix('mirror', false, 'not-an-https-url'), /Invalid URL/);
});
test('setup and asset CLI document and validate source options before doing work', () => {
for (const script of ['tools/setup.mjs', 'tools/fetch-assets.mjs']) {
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
assert.equal(help.status, 0, help.stderr);
assert.match(help.stdout, /--asset-source/);
assert.match(help.stdout, /SP_GITHUB_PROXY/);
const invalid = spawnSync(process.execPath, [script, '--asset-source=invalid'], { encoding: 'utf8' });
assert.notEqual(invalid.status, 0);
assert.match(invalid.stderr, /unknown asset source/);
}
});
+7
View File
@@ -968,6 +968,13 @@ describe('screen helpers', () => {
assert.equal(findUiAsset({ ui: {} }, ['x']), null);
});
test('title exposes the shared settings modal', () => {
const source = readFileSync(path.join(PUBLIC, 'js/screens/title.js'), 'utf8');
assert.match(source, /import \{ SettingsModal \} from '\.\.\/ui\/settings\.js'/);
assert.match(source, /class="title-settings fsbtn tapx"/, 'title screen includes the settings control');
assert.match(source, /<\$\{SettingsModal\} open=\$\{settingsOpen\}/, 'settings control opens the shared modal');
});
test('lobby: normalizeCode / parseRoomParam / difficultyInfo', async () => {
const { normalizeCode, parseRoomParam, difficultyInfo, MODE_TEXT } = await mod('screens/lobby.js');
assert.equal(normalizeCode('ab-c d9'), 'ABCD');
+81 -4
View File
@@ -1,12 +1,14 @@
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
// the renderer downloads (extract.py WEBP), the prefab mesh keys, and —
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decodePng } from '../../tools/crop-board-atlas.mjs';
@@ -18,6 +20,7 @@ const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
const HAS_WEBP = HAS_PIL && spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status === 0;
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
function py(code) {
@@ -62,6 +65,68 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
});
test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { webp } = JSON.parse(r.stdout);
assert.deepEqual(webp.map((w) => `${w.sub}/${w.name}`).sort(), Object.values(PACK_IMAGES).map(([g, n]) => `${g}/${n}`).sort());
const mode = Object.fromEntries(webp.map((w) => [w.name, w.mode]));
for (const n of ['TX_autochessi_N_rgb', '[ucp]TX_water_normal', 'TX_autochessi_M_rough', 'T_noise_clouds_01']) assert.equal(mode[n], 'lossless', n);
for (const n of ['TX_autochessi_D', 'TX_autochessi_BG', 'TX_autochessi_common_D']) assert.equal(mode[n], 'lossy', n);
});
test('run_webp / --webp: a copy beside the PNG that the manifest lists; normals exact, alpha and hidden RGB kept', { skip: !HAS_WEBP && 'no Pillow with WebP' }, () => {
const dir = mkdtempSync(path.join(tmpdir(), 'sp-webp-'));
try {
const out = py(`
from pathlib import Path
from PIL import Image
root = Path(${JSON.stringify(dir)})
sub = root / 'map' / 'autochess'
sub.mkdir(parents=True)
d = Image.new('RGBA', (64, 64), (200, 40, 40, 0)) # transparent texels whose colour the opaque board material shows
for x in range(32):
for y in range(64): d.putpixel((x, y), (x * 8, y * 4, 90, 255))
d.save(sub / 'TX_autochessi_D.png')
n = Image.new('RGB', (64, 64))
for x in range(64):
for y in range(64): n.putpixel((x, y), ((x * 37) % 256, (y * 53) % 256, 200 + (x + y) % 56))
n.save(sub / 'TX_autochessi_N_rgb.png')
entry = lambda name, kind: {'path': f'/assets/local/map/autochess/{name}.png', 'w': 64, 'h': 64, 'kind': kind}
groups = {'map/autochess': {'TX_autochessi_D': entry('TX_autochessi_D', 'Texture2D'),
'TX_autochessi_N_rgb': entry('TX_autochessi_N_rgb', 'Derived'),
'TX_autochessi_E': entry('TX_autochessi_E', 'Texture2D')}}
mf = root / 'local-assets.json'
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 3, 'groups': groups}))
logs = []
code = e.webp_only(root, mf, logs.append)
doc = json.loads(mf.read_text())
g = doc['groups']['map/autochess']
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
a, b = px(sub / 'TX_autochessi_D.png'), px(sub / 'TX_autochessi_D.webp')
hidden = [q for p, q in zip(a, b) if p[3] == 0]
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()},
'count': doc['count'], 'pngKept': (sub / 'TX_autochessi_D.png').exists(),
'alphaSame': all(p[3] == q[3] for p, q in zip(a, b)),
'normalSame': px(sub / 'TX_autochessi_N_rgb.png') == px(sub / 'TX_autochessi_N_rgb.webp'),
'hidden': [sum(q[i] for q in hidden) / len(hidden) for i in range(3)],
'again': e.run_webp(root, 'map/autochess', doc['groups'], logs.append)}))`);
assert.equal(out.code, 0);
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
// TX_autochessi_E is listed without a PNG on disk: its entry stays as it was
assert.deepEqual(out.paths, { TX_autochessi_D: at('TX_autochessi_D', 'webp'), TX_autochessi_N_rgb: at('TX_autochessi_N_rgb', 'webp'), TX_autochessi_E: at('TX_autochessi_E', 'png') });
assert.deepEqual(out.modes, { TX_autochessi_D: 'lossy', TX_autochessi_N_rgb: 'lossless', TX_autochessi_E: null });
assert.equal(out.count, 3);
assert.ok(out.pngKept, 'the PNG stays beside the copy (crop tool, setup)');
assert.ok(out.alphaSame, 'alpha lossless');
assert.ok(out.normalSame, 'normal map lossless');
out.hidden.forEach((v, i) => assert.ok(Math.abs(v - [200, 40, 40][i]) < 12, `RGB under alpha 0 kept: ${out.hidden}`));
assert.equal(out.again, 2, 'a second run rewrites both copies');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => {
const out = py(`
from types import SimpleNamespace as NS
@@ -92,6 +157,7 @@ describe('vendored three.js', () => {
describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => {
const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p));
const pngOf = (p) => p.replace(/\.webp$/, '.png'); // a WebP copy (extract.py WEBP) keeps its PNG beside it
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx'));
test('every pack slot the renderer loads is in the manifest (and on disk)', () => {
@@ -113,13 +179,24 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => {
const g = manifest.groups['map/autochess'];
const n = decodePng(readFileSync(onDisk(g.TX_autochessi_N_rgb.path)));
const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path))));
let zSum = 0, cnt = 0;
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.path)));
const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path))));
assert.deepEqual([r.w, r.h], [m.w, m.h]);
for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]);
});
test('the WebP copies the manifest lists are on disk with their PNGs beside them', { skip: !hasFiles && 'files not extracted' }, () => {
for (const [g, entries] of Object.entries(manifest.groups)) {
for (const [n, e] of Object.entries(entries)) {
if (!/\.webp$/.test(e?.path || '')) continue;
assert.ok(existsSync(onDisk(e.path)), `${g}/${n}`);
assert.ok(existsSync(onDisk(pngOf(e.path))), `${g}/${n}: PNG`);
assert.ok(e.webp === 'lossy' || e.webp === 'lossless', `${g}/${n}: mode ${e.webp}`);
}
}
});
});
+23
View File
@@ -18,6 +18,7 @@ import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
import { statusIconKey } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
import { loadBoardArt, resetBoardArt, sourceUrl } from '../../public/js/render/boardArt.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
@@ -311,4 +312,26 @@ describe('local-art manifest helpers', () => {
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
assert.equal(await broken.local(), null);
});
test('board art loads the crop-table textures at the URLs the manifest lists (one download with the 3D board)', async () => {
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: at('TX_autochessi_D', 'webp') }, TX_autochessi_BG: { path: at('TX_autochessi_BG', 'webp') } } } };
const asked = [];
const store = { local: async () => m, localUrl: (g, n) => localAssetUrl(m, g, n), image: async (u) => { asked.push(u); return { width: 4 }; } };
assert.equal(sourceUrl(store, at('TX_autochessi_D', 'png')), at('TX_autochessi_D', 'webp'));
assert.equal(sourceUrl(store, at('TX_autochessi_common_D', 'png')), at('TX_autochessi_common_D', 'png'), 'not in the manifest: the table path');
assert.equal(sourceUrl({}, '/x/y.png'), '/x/y.png');
const tiles = { version: 2, materials: {}, source: { D: { path: at('TX_autochessi_D', 'png') }, common: { path: at('TX_autochessi_common_D', 'png') }, BG: { path: at('TX_autochessi_BG', 'png') } } };
const realFetch = globalThis.fetch;
globalThis.fetch = async (u) => (u === '/assets/local/map/autochess/tiles.json' ? { ok: true, json: async () => tiles } : { ok: false, status: 404 });
try {
resetBoardArt();
const art = await loadBoardArt(store);
assert.ok(art?.images.D && art.images.common && art.images.BG);
assert.deepEqual(asked.sort(), [at('TX_autochessi_BG', 'webp'), at('TX_autochessi_D', 'webp'), at('TX_autochessi_common_D', 'png')]);
} finally {
globalThis.fetch = realFetch;
resetBoardArt();
}
});
});
+79
View File
@@ -0,0 +1,79 @@
// GitHub #184 (second half): 活性源石 was drawn as two different materials — the 2D atlas cell was a beveled brick with
// random crystal clusters, the 3D board's quad a world-space crust with pulsing veins — so the same tile read as two
// different floors. These are the couplings that keep it ONE material: the palette of `render/style.js ORIGINIUM`, the
// concrete slab it lies on, and no per-tile frame (which made a field of 活性源石 look like separate patches).
// The pixels themselves are checked in test/render/originium-cell.e2e.test.js (it needs a real canvas).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const style = read('public/js/render/style.js');
const textures = read('public/js/render/textures.js');
const materials = read('public/js/render/board3d/materials.js');
const tiles = read('public/js/render/tiles.js');
/** The source of one exported function of a module (its JSDoc through the closing brace at column 0). */
function fnSource(src, name) {
const at = src.indexOf(`export function ${name}(`);
assert.ok(at > 0, `${name} not found`);
const end = src.indexOf('\n}\n', at);
return src.slice(src.lastIndexOf('/**', at), end + 2);
}
test('活性源石: one palette for both boards, and the canvas converts the working space', () => {
assert.match(style, /export const ORIGINIUM = Object\.freeze\(\{/, 'the palette lives in style.js');
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(style, new RegExp(`${k}: Object\\.freeze\\(\\[`), k);
assert.match(style, /export function linearToSrgb255/, 'the sRGB conversion the canvas needs');
// the 2D drawer takes its colours from the palette (no private copy) and converts them for the canvas
assert.match(textures, /import \{[^}]*ORIGINIUM[^}]*\} from '\.\/style\.js'/);
assert.match(textures, /ORIGINIUM\.vein/);
assert.match(textures, /linearToSrgb255\(/, 'the canvas stores sRGB, the palette is the shaders\' linear space');
// so does the 3D shader, through the vec3 helper
assert.match(materials, /import \{ ORIGINIUM \} from '\.\.\/style\.js'/);
assert.match(materials, /const V3 = \(c\) => `vec3\(/);
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(materials, new RegExp(`V3\\(ORIGINIUM\\.${k}\\)`), k);
// the identical recipe (the shader's own thresholds, mirrored in the drawer's smoothstep calls)
assert.match(materials, /smoothstep\(0\.38, 0\.62, n \* 0\.7 \+ n2 \* 0\.45\)/);
assert.match(textures, /smoothstep\(0\.38, 0\.62, n1 \* 0\.7 \+ n2 \* 0\.45\)/);
assert.match(materials, /smoothstep\(0\.86, 0\.98, 1\.0 - abs\(n2 \* 2\.0 - 1\.0\)\)/);
assert.match(textures, /smoothstep\(0\.86, 0\.98, 1 - Math\.abs\(n2 \* 2 - 1\)\)/);
});
test('活性源石: the 2D tile lies on the same concrete the 3D board lays its crust on', () => {
// board3d/atlas.js `concrete` and textures.js DEFAULT_ART_LAYERS.infection must name the same atlas rect: with the
// board art installed the 2D tile is composed exactly like the 3D one (official slab + our crust).
const atlas = read('public/js/render/board3d/atlas.js');
const m2 = atlas.match(/concrete: S\('D', \[([\d, ]+)\]\)/);
const m1 = textures.match(/infection: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[([\d, ]+)\] \}\)/);
assert.ok(m1 && m2, 'both define the slab');
assert.equal(m1[1].replace(/\s/g, ''), m2[1].replace(/\s/g, ''), 'the same concrete cell');
});
test('活性源石: neither board fades the tile out at its own border', () => {
// the 3D crust used to be multiplied by a per-tile `edge` fade (min(vUv, 1 − vUv)), which left a transparent band
// between neighbouring tiles; the drawer must reach the cell's edges the same way. Its siblings (mire / water) keep
// their own fade — this is about 活性源石 only.
const inf = fnSource(materials, 'infectionMaterial');
assert.ok(!/min\(e\.x, e\.y\)|float edge/.test(inf), 'no per-tile edge fade in the infection shader');
assert.match(inf, /float a = clamp\(crust \* 0\.72 \+ vein, 0\.0, 1\.0\)/);
const mire = fnSource(materials, 'mireMaterial');
assert.match(mire, /float edge = smoothstep/, 'the mire keeps its own fade');
// the 2D cell has no frame either: no bevelTile in the drawer's two variants
const draw = textures.slice(textures.indexOf(' infection(ctx, x, y, s, r) {'), textures.indexOf(' cliff(ctx, x, y, s) {'));
assert.ok(!/bevelTile/.test(draw), 'the old beveled brick is gone');
assert.match(draw, /floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, false\);/);
assert.match(draw, /infection2\(ctx, x, y, s, r\) \{ floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, true\); \}/);
});
test('活性源石: two variants are registered, picked by tile, and the pulse uses the shared tint', () => {
assert.match(textures, /'deepsea', 'infection', 'infection2', 'cliff'/);
assert.match(textures, /originium2\(ctx, x, y, s\) \{ originiumOverlay\(ctx, x, y, s, true\); \}/, 'the proc layer for the art path');
assert.match(textures, /infection2: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[256, 512, 256, 256\] \}\), Object\.freeze\(\{ proc: 'originium2' \}\)\]\)/);
assert.match(tiles, /case 'infection': return h < 4 \? 'infection' : 'infection2';/);
assert.match(tiles, /add\('infect', 'glow', P\.BLEND_MODES\.ADD, ORIGINIUM\.glow, 1\)/);
});
+133
View File
@@ -0,0 +1,133 @@
// GitHub #184 (second half), the pixels: the 2D board's 活性源石 cell must be the material the 3D board's shader draws
// (`render/board3d/materials.js infectionMaterial`) — the palette of `render/style.js ORIGINIUM`, the crust reaching the
// tile's edges (no per-tile frame: a field of 活性源石 must read as ONE floor, not as separate patches), and the cell
// joining itself without a seam (the noise wraps, so a repeated cell lines up).
// SP_E2E=1 CHROME_PATH=… node --test test/render/originium-cell.e2e.test.js
// Both configurations are checked: the atlas a deployment WITH the board art renders (the official concrete slab + our
// crust, `DEFAULT_ART_LAYERS.infection`) and the procedural one upstream players without the art see (`MAT_DRAW.infection`).
// The couplings between the two renderers' sources are in test/render/originium-art.test.js (no browser needed).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const f3 = (v) => Math.round(v * 1000) / 1000;
describe('活性源石 cell (2D board)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
let configs = []; // [{ name, art, infection, infection2, road, palette }]
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
const page = await browser.newPage();
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'domcontentloaded' });
await page.addScriptTag({ url: '/vendor/pixi.min.js' }); // textures.js builds its atlas with PIXI
configs = await page.evaluate(async () => {
const m = await import('/js/render/textures.js');
const st = await import('/js/render/style.js');
const { assets } = await import('/js/assets.js');
const { loadBoardArt } = await import('/js/render/boardArt.js');
// the atlas a deployment with the board art renders, and the procedural one (no art) upstream sees
await assets.load?.().catch?.(() => null);
const art = await loadBoardArt(assets).catch(() => null);
const read = (atlas, name) => {
const uv = atlas.uv[name];
if (!uv) return null;
const W = atlas.canvas.width, H = atlas.canvas.height;
const x0 = Math.round(uv[0] * W), y0 = Math.round(uv[1] * H);
const w = Math.round((uv[2] - uv[0]) * W), h = Math.round((uv[3] - uv[1]) * H);
const img = atlas.canvas.getContext('2d').getImageData(x0, y0, w, h);
const px = [];
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const k = (y * w + x) * 4;
px.push({ x, y, r: img.data[k] / 255, g: img.data[k + 1] / 255, b: img.data[k + 2] / 255, a: img.data[k + 3] / 255 });
}
return { w, h, px, data: Array.from(img.data) };
};
const cells = (atlas, name) => ({ name, art: atlas.art, artCount: atlas.artCount,
infection: read(atlas, 'infection'), infection2: read(atlas, 'infection2'), road: read(atlas, 'road') });
// the vein's DISPLAYED colour (what a canvas stores): the palette converted out of the shaders' working space
const hex = st.linearToHex(st.ORIGINIUM.vein);
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const palette = { veinHex: hex, gr: rgb[1] / rgb[0], br: rgb[2] / rgb[0] };
const out = [cells(m.tileAtlas(art || null), art ? 'board art' : 'no art installed')];
if (art) out.push(cells(m.tileAtlas(null), 'procedural'));
return out.map((c) => ({ ...c, palette }));
});
await page.close();
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** The numbers of one cell: veins, edges, ring vs middle, and the difference to another cell. */
function stats(c) {
const veinish = (p) => p.a > 0.6 && p.r > 0.55 && p.g > 0.2 && p.g < 0.8 && p.b < 0.5;
const mean = (arr, k) => arr.reduce((n, p) => n + p[k], 0) / Math.max(1, arr.length);
const hot = c.px.filter(veinish);
const ring = c.px.filter((p) => Math.min(p.x, p.y, c.w - 1 - p.x, c.h - 1 - p.y) < c.w * 0.08);
const lum = (arr) => arr.reduce((n, p) => n + (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b) * p.a, 0) / Math.max(1, arr.length);
const band = (pred) => { const a = c.px.filter(pred); return f3(a.filter(veinish).length / Math.max(1, a.length)); };
// the vein CORES are opaque, so their colour does not depend on the slab underneath (the art config draws one):
// the brightest few percent are exactly those, whichever base the cell has
const core = [...c.px].sort((p, q) => (q.r + q.g + q.b) - (p.r + p.g + p.b)).slice(0, Math.max(1, Math.round(c.px.length * 0.03)));
return {
veinPct: f3(hot.length / c.px.length), ringVeinPct: f3(ring.filter(veinish).length / ring.length),
hotGR: f3(mean(hot, 'g') / mean(hot, 'r')), hotBR: f3(mean(hot, 'b') / mean(hot, 'r')), borderLum: f3(lum(ring)),
coreGR: f3(mean(core, 'g') / mean(core, 'r')), coreBR: f3(mean(core, 'b') / mean(core, 'r')),
// our own crust at the tile's borders (the base slab may be official art with edges of its own, so the veins — the
// layer this change drew — are what shows whether the tile joins the next one)
veinsL: band((p) => p.x < 3), veinsR: band((p) => p.x >= c.w - 3),
veinsT: band((p) => p.y < 3), veinsB: band((p) => p.y >= c.h - 3),
};
}
const diff = (a, b) => f3(a.data.reduce((n, v, i) => n + Math.abs(v - b.data[i]), 0) / a.data.length);
test('the cell wears the shared palette: the veins hit the colour the 3D shader writes', () => {
for (const cfg of configs) {
const s = stats(cfg.infection);
const { gr, br, veinHex } = cfg.palette;
assert.ok(s.veinPct >= 0.05, `${cfg.name}: the crust carries veins (${s.veinPct})`);
assert.ok(Math.abs(s.coreGR - gr) <= 0.06, `${cfg.name}: the veins wear the palette's colour (g/r ${s.coreGR} vs ${f3(gr)} for ${veinHex})`);
assert.ok(Math.abs(s.coreBR - br) <= 0.06, `${cfg.name}: …and its blue (b/r ${s.coreBR} vs ${f3(br)})`);
// the whole cell is that material, not the bare slab the atlas holds for an ordinary tile
assert.ok(diff(cfg.infection, cfg.road) > 8, `${cfg.name}: the overlay really draws over the slab (${diff(cfg.infection, cfg.road)})`);
}
});
test('no per-tile frame — the crust reaches the edges, and the cell joins itself', () => {
for (const cfg of configs) {
const s = stats(cfg.infection);
// veins at the border like anywhere else: the old beveled brick had a frame and none at the very edge
assert.ok(s.ringVeinPct >= s.veinPct * 0.6, `${cfg.name}: veins reach the tile's border (${s.ringVeinPct} at the ring vs ${s.veinPct})`);
// a repeated cell must line up: the noise wraps, so opposite edges sample the same field — the veins run through
// the edge on both sides instead of stopping at it. Compared exactly on the cell WE compose (floor + crust); with
// the official slab under the crust the vein/not-vein classification of the composite pixels follows the slab's
// own shading (its edges are the official art's, on every tile of the board), so there only the ring rule above
// applies — an edge band is 3 of 240 columns, and one slab of the noise is not the cell's average.
if (!cfg.art) {
for (const [a, b, where] of [[s.veinsL, s.veinsR, 'left/right'], [s.veinsT, s.veinsB, 'top/bottom']]) {
assert.ok(Math.abs(a - b) <= 0.06, `${cfg.name}: ${where} edge veins ${a} vs ${b}`);
}
}
}
});
test('two variants, so a field of 活性源石 does not repeat one pattern', () => {
for (const cfg of configs) {
assert.ok(cfg.infection2, `${cfg.name}: infection2 is an atlas cell`);
const a = stats(cfg.infection), b = stats(cfg.infection2);
assert.ok(Math.abs(a.veinPct - b.veinPct) <= 0.02, `${cfg.name}: the same recipe (${a.veinPct} vs ${b.veinPct})`);
assert.ok(diff(cfg.infection, cfg.infection2) > 6, `${cfg.name}: the second variant is a different pattern (${diff(cfg.infection, cfg.infection2)})`);
}
});
});
+59
View File
@@ -664,3 +664,62 @@ describe('impact sounds (user playtest #4 item 6)', () => {
} finally { globalThis.performance = perf; restore(); }
});
});
// =====================================================================================================================
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
// silent, for the player's own field and for a 联防 the helpers could not hold alike.
//
// The cue is the ORIGINAL Arknights stage alarm an enemy entering the exit plays in any normal stage: the manifest's
// `sfx.battle.leak`, bank `battle.ON_ENEMY_REACHED_EXIT`, file `Battle/b_ui/b_ui_alarmenter`. (The autochess banks
// have nothing named for an escape — all 13,948 SFX banks searched — but the stage itself does.) The official bank is
// a one-shot: `maxSoundAllowed: 1` with `popOldest: true` on the `Battle_UI_Important` mixer.
describe('漏怪 sound', () => {
async function rig() {
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.install();
fw.fire('pointerdown');
const settle = () => new Promise((r) => setTimeout(r, 10));
return { a, fw, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
}
test('an escaped enemy plays the original stage exit alarm — and no death sound (a leak is not a `die`)', async () => {
const { a, urls, settle, restore } = await rig();
try {
const url = manifest.audio.sfx.battle.leak;
assert.ok(url, '前提:清单里有 sfx.battle.leak');
assert.match(url, /b_ui_alarmenter\.mp3$/, '就是原版关卡里怪进蓝门那一声');
a.handleBattleEvents([['leak', 7]]);
await settle();
assert.ok(asked(urls, url), `漏怪 plays ${url}`);
assert.equal(askedCount(urls, manifest.audio.sfx.battle.enemyDie), 0, 'a leak is not a death — no death sound');
} finally { restore(); }
});
test('leaks of one disaster are ONE alarm (the cue is 1.44 s long), a later one rings again', async () => {
const { a, fw, urls, settle, restore } = await rig();
try {
const url = manifest.audio.sfx.battle.leak;
a.handleBattleEvents([['leak', 1]]);
await settle();
assert.equal(askedCount(urls, url), 1, 'the first escape rings');
// the plays themselves, not the fetches: the buffer is cached after the first one
const before = fw.made.started;
// six more at once — a wiped board, or a 联防 the helpers could not hold
a.handleBattleEvents([['leak', 2], ['leak', 3], ['leak', 4], ['leak', 5], ['leak', 6], ['leak', 7]]);
await settle();
assert.equal(fw.made.started - before, 0, 'one disaster never stacks alarms (the official bank allows 1)');
// a genuine later leak is a new disaster and rings again, once the cue (1.44 s) has finished
await new Promise((r) => setTimeout(r, 1600));
a.handleBattleEvents([['leak', 8]]);
await settle();
assert.equal(fw.made.started - before, 1, 'a later leak rings again');
assert.ok(a.limiter.active <= a.limiter.maxVoices);
} finally { restore(); }
});
});
+109 -1
View File
@@ -12,7 +12,7 @@ import {
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
} from '../../public/js/ui/gameLogic.js';
import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js';
@@ -585,3 +585,111 @@ describe('equipment dropped on a tile goes to the unit on it', () => {
assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
});
});
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: tapping a special terrain tile explains it — in the stage's own
// numbers (the same bb / special parameters the sim runs on), while an ordinary tile says nothing at all.
describe('special terrain tip', () => {
/** The first (row, col) of `glyph` in a real stage of data/stages.json. */
const at = (stageId, glyph) => {
const st = stages[stageId];
for (let row = 0; row < st.rows.length; row++) {
const col = st.rows[row].indexOf(glyph);
if (col >= 0) return { st, row, col };
}
throw new Error(`no ${glyph} in ${stageId}`);
};
test('each terrain of the mode, with the numbers of the stage it stands on', () => {
// 活性源石 (战场#04): the official tile parameters (damage 70/s, +20% ATK, +20 ASPD, 300 s)
const inf = at('act1autochess_m04', 'i');
const tip = terrainInfo(inf.st, inf.row, inf.col);
assert.equal(tip.key, 'infection');
assert.equal(tip.name, '活性源石');
assert.equal(tip.tag, '特殊地形');
assert.deepEqual([tip.row, tip.col], [inf.row, inf.col]);
assert.match(tip.lines[0], /每秒受到 70 点真实伤害/, 'damage from the stage\'s own bb');
assert.match(tip.lines[1], /攻击力 \+20%、攻击速度 \+20/);
assert.match(tip.lines[2], /300 秒/);
assert.deepEqual(tip.facts, ['可部署', '地面单位可通过']);
// 沼泽 (战场#06): one 陷入沼泽 layer a second, −5% ASPD (−5% move for enemies), 2 layers at 重量 ≥ 3, 10 at most
const mire = terrainInfo(...(() => { const g = at('act2autochess_m02', 'm'); return [g.st, g.row, g.col]; })());
assert.equal(mire.name, '沼泽');
assert.ok(mire.lines.some((l) => /攻击速度 −5%/.test(l) && /移动速度 −5%/.test(l)));
assert.ok(mire.lines.some((l) => /重量 ≥ 3 的敌人一次获得 2 层/.test(l)));
assert.ok(mire.lines.some((l) => /最多 10 层/.test(l)));
// 排气格栅 (战场#07): the 隐匿-like rule and its limit — the enemy it blocks still hits it (review on #185)
const smog = at('act2autochess_m03', 'g');
const smogTip = terrainInfo(smog.st, smog.row, smog.col);
assert.equal(smogTip.name, '排气格栅');
assert.match(smogTip.lines[0], /不会被敌方的远程攻击选中(效果相当于隐匿)/);
assert.match(smogTip.lines[1], /挡住敌人的干员仍会被它攻击到/);
// 深水区 (战场#05): drowning numbers, and 拒绝部署 although the level's own buildableType is ALL (grid.js)
const sea = at('act1autochess_m05', 'd');
const seaTip = terrainInfo(sea.st, sea.row, sea.col);
assert.equal(seaTip.name, '深水区');
assert.ok(seaTip.lines.some((l) => /每秒受到 40 点伤害/.test(l)));
assert.ok(seaTip.lines.some((l) => /攻击速度 −60%/.test(l) && /移动速度 ×0.6/.test(l)));
// …and what that damage IS: sourceless, and not 环境伤害 (devices.js tickDeepsea; review on #185)
assert.ok(seaTip.lines.some((l) => /无来源伤害/.test(l) && /不归类为环境伤害/.test(l)));
assert.ok(seaTip.lines.some((l) => /拒绝部署/.test(l)));
assert.ok(seaTip.facts.includes('不可部署'));
// the gates and teleports every stage carries (tile_start / tile_end / tile_telin / tile_telout)
for (const [glyph, name] of [['S', '红门'], ['E', '蓝门'], ['I', '传送入口'], ['O', '传送出口']]) {
const g = at('act1autochess_m04', glyph);
const t = terrainInfo(g.st, g.row, g.col);
assert.equal(t.name, name, glyph);
assert.ok(t.lines.length >= 1);
}
});
test('an ordinary tile says nothing (the press keeps its other meanings); nonsense input is safe', () => {
const st = stages['act1autochess_m04'];
const inf = at('act1autochess_m04', 'i');
assert.ok(terrainInfo(inf.st, inf.row, inf.col), 'the stage does carry one tile that answers');
// every non-special glyph the stage uses: the bench, the blocked rows, road / floor, the fence, the separator…
for (const glyph of ['a', 'A', '#', 'X', 'r', 'f', 'b', 'h', 'p']) {
let found = null;
for (let row = 0; row < st.rows.length && !found; row++) {
const col = st.rows[row].indexOf(glyph);
if (col >= 0) found = { row, col };
}
if (!found) continue; // a glyph this stage does not use says nothing to test
assert.equal(terrainInfo(st, found.row, found.col), null, glyph);
}
assert.equal(terrainInfo(inf.st, -1, inf.col), null);
assert.equal(terrainInfo(inf.st, inf.st.rows.length, 0), null);
assert.equal(terrainInfo(inf.st, inf.row, 999), null);
assert.equal(terrainInfo(inf.st, 1.5, 2), null);
assert.equal(terrainInfo(inf.st, '3', 2), null);
assert.equal(terrainInfo(null, 0, 0), null);
assert.equal(terrainInfo(undefined, 0, 0), null);
assert.equal(terrainInfo({}, 0, 0), null);
// a stage without the tile legend (or without that glyph) explains nothing rather than guessing
assert.equal(terrainInfo({ rows: ['i'] }, 0, 0), null);
assert.equal(terrainInfo({ rows: ['i'], tiles: {} }, 0, 0), null);
assert.equal(terrainInfo({ rows: ['i'], tiles: { i: null } }, 0, 0), null);
// …and a legend entry the mode never gave a tip (its own ordinary floor) is not a tip either
assert.equal(terrainInfo({ rows: ['z'], tiles: { z: { tileKey: 'tile_floor', special: null } } }, 0, 0), null);
});
test('the facts come from the tile\'s own legend entry; missing terrain parameters never crash', () => {
const stage = {
rows: ['ih'],
tiles: {
i: { tileKey: 'tile_infection', height: 'LOW', buildable: 'RANGED', groundPassable: false, special: 'infection' },
h: { tileKey: 'tile_smog', height: 'HIGH', buildable: 'NONE', groundPassable: false, special: 'smog' },
},
special: {},
};
assert.deepEqual(terrainInfo(stage, 0, 0).facts, ['仅远程位可部署', '只有空中单位能通过']);
assert.deepEqual(terrainInfo(stage, 0, 1).facts, ['不可部署', '高台', '只有空中单位能通过']);
// a stage whose `special` is missing: the mechanism is still explained, just without the stage's numbers
const bare = { rows: ['ih'], tiles: stage.tiles };
assert.deepEqual(terrainInfo(bare, 0, 0).lines, ['在其上的我方单位与经过的敌方单位持续受到伤害']);
assert.deepEqual(terrainInfo(bare, 0, 1).lines, ['站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)', '但挡住敌人的干员仍会被它攻击到']);
// 沼泽 without its parameters falls back to the official template numbers
const mire = { rows: ['m'], tiles: { m: { tileKey: 'tile_mire', height: 'LOW', buildable: 'ALL', groundPassable: true, special: 'mire' } } };
assert.match(terrainInfo(mire, 0, 0).lines[0], /每 1 秒获得 1 层/);
assert.match(terrainInfo(mire, 0, 0).lines[2], /最多 10 层/);
});
});
+41
View File
@@ -0,0 +1,41 @@
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: the tap's target (`screens/game.js` tileClick hands over
// `{ kind: 'terrain', terrain }`, resolved from the stage the board on screen is built from) turns into the panel's
// terrain card — and a target that carries no tip resolves to nothing rather than opening an empty card.
// The words / numbers themselves are covered in test/ui/gameLogic.test.js (terrainInfo) and the tap itself reaches them
// in the browser (test/ui/terrain-tip.e2e.test.js); this is the link between the two.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { terrainInfo } = await import('../../public/js/ui/gameLogic.js');
/** The first (row, col) of `glyph` in a stage of data/stages.json. */
function at(stage, glyph) {
for (let row = 0; row < stage.rows.length; row++) {
const col = stage.rows[row].indexOf(glyph);
if (col >= 0) return { row, col };
}
throw new Error(`no ${glyph} in ${stage.id}`);
}
test('a terrain target resolves into the panel card; a tip-less target resolves to nothing', () => {
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
const st = stages['act1autochess_m04'];
const p = at(st, 'i');
const info = terrainInfo(st, p.row, p.col);
assert.equal(info.name, '活性源石');
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: info }, new Map()), { type: 'terrain', terrain: info });
// the screen never hands a tip-less tile over (terrainInfo returns null there), but a stray target must not throw or
// open an empty card
assert.equal(resolveDetail({ kind: 'terrain' }, new Map()), null);
assert.equal(resolveDetail({ kind: 'terrain', terrain: null }, new Map()), null);
assert.equal(resolveDetail({ kind: 'terrain', terrain: '活性源石' }, new Map()), null);
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: {} }, new Map()), { type: 'terrain', terrain: {} },
'a well-formed but empty tip still resolves (the screen filtered it before)');
assert.equal(resolveDetail(null, new Map()), null);
});
+172
View File
@@ -0,0 +1,172 @@
// Browser checks of the special-terrain tip (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) on the in-match mock
// harness (public/dev/game-mock.html):
// SP_E2E=1 CHROME_PATH=… node --test test/ui/terrain-tip.e2e.test.js
//
// A tap on the GROUND — nothing stands there — makes the tile explain itself, on both field implementations: the engine
// canvas (render/app.js `tileClick`, reached by clicking the tile's own screen position) and the DOM fallback
// (ui/fallbackField.js, the tile div). Asserted: the mock's own stage (act2autochess_m01) carries gates, a tap on 蓝门 /
// 红门 opens the card with its mechanism line, a tap on an ordinary floor tile opens nothing (and closes the card that was
// open), and no scenario logs a console error. The words and the numbers are unit-tested in test/ui/gameLogic.test.js
// (terrainInfo, against every terrain of the real stages) — this file is about the tap reaching them.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Tiles of the mock's stage (act2autochess_m01) the tip must answer for, and one ordinary tile it must not. */
const GATE = { row: 9, col: 10, name: '红门' };
const GOAL = { row: 9, col: 2, name: '蓝门' };
const FLOOR = { row: 10, col: 5, name: null };
describe('special terrain tip in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(url, { w = 1600, h = 900, waitUntil = 'networkidle0' } = {}) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
// the engine keeps fetching art (and SwiftShader is slow to build the board), so it waits on __SP_VIEW__ instead
await page.goto(`${base}${url}`, { waitUntil, timeout: 60000 });
return { page, problems };
}
/** The open detail card's text (null while none is open). */
const card = (page) => page.evaluate(() => {
const el = document.querySelector('.dpanel');
return el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
});
test('the fallback (DOM) board: a tap on 蓝门 / 红门 opens the card, an ordinary tile opens nothing', async () => {
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=fallback');
await page.waitForSelector('.ff-tile', { timeout: 15000 });
await sleep(400);
const tap = (row, col) => page.evaluate(([r, c]) => {
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
if (!el) return false;
const b = el.getBoundingClientRect();
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
return true;
}, [row, col]);
assert.ok(await tap(GOAL.row, GOAL.col), 'the mock board draws the 蓝门 tile');
await page.waitForSelector('.dpanel', { timeout: 5000 });
let text = await card(page);
assert.match(text, /蓝门/, text);
assert.match(text, /目标生命值/, text);
assert.match(text, /保护目标/, text);
// the 红门 (the enemies' own entrance) says its own thing
assert.ok(await tap(GATE.row, GATE.col));
await sleep(200);
text = await card(page);
assert.match(text, /红门/, text);
assert.match(text, /从这里出场/, text);
// an ordinary floor tile: nothing to explain, and the card that was open closes with the press
assert.ok(await tap(FLOOR.row, FLOOR.col));
await sleep(200);
assert.equal(await card(page), null, 'an ordinary tile opens nothing');
assert.deepEqual(problems, []);
await page.close();
});
test('the engine canvas: the same tap on a tile\'s own screen position opens the card', async () => {
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=engine', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
await sleep(1200); // models / camera
const at = (row, col) => page.evaluate(([r, c]) => {
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
return t && { x: t.x, y: t.y };
}, [row, col]);
const pt = await at(GOAL.row, GOAL.col);
assert.ok(pt, 'the 蓝门 tile has a screen position');
await page.mouse.click(pt.x, pt.y);
await page.waitForSelector('.dpanel', { timeout: 5000 });
let text = await card(page);
assert.match(text, /蓝门/, text);
assert.match(text, /目标生命值/, text);
const floor = await at(FLOOR.row, FLOOR.col);
await page.mouse.click(floor.x, floor.y);
await sleep(250);
assert.equal(await card(page), null, 'the floor tile closes it again');
assert.deepEqual(problems, []);
await page.close();
});
test('Final Assault prep: the tap reports the BOARD tile, so the boss field explains ITS tile (review on #185)', async () => {
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&variant=boss&render=engine', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
await page.waitForFunction(() => globalThis.__SP_VIEW__.raw.prepField?.().kind === 'bossPrep', { timeout: 15000 });
await sleep(1200); // camera / models (a deployment with the local board art also builds the 3D board here)
// A board tile of the player's half + the stage tile it draws (stage 2–5 shown as board 9–12): the card must be about
// the stage tile. Reporting the drawn tile instead (the old groundTile) made the screen convert a second time, which
// on this board explains a DIFFERENT tile — or none at all.
const target = await page.evaluate(async () => {
const { data } = await import('/js/data.js');
const gl = await import('/js/ui/gameLogic.js');
const S = globalThis.__MOCK__.S();
const stage = data.lookup('stages', S.pub.stageId);
for (let row = 9; row <= 12; row++) {
for (let col = 0; col <= 10; col++) {
const [sr, sc] = gl.fieldTile('bossL', row, col);
const info = gl.terrainInfo(stage, sr, sc);
if (!info) continue;
const t = globalThis.__SP_VIEW__.tileScreen(row, col);
if (t) return { row, col, sr, sc, name: info.name, x: t.x, y: t.y };
}
}
return null;
});
assert.ok(target, 'the boss field has a special tile on the player half');
// The boss-prep camera is still settling when the board appears (and building the 3D board shifts a machine's timing),
// so wait for the tile's screen position to stop moving, and re-read it before each try.
const at = () => page.evaluate(([r, c]) => {
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
return t && { x: t.x, y: t.y };
}, [target.row, target.col]);
let settled = null;
for (let i = 0; i < 40; i++) {
const p = await at();
if (p && settled && Math.abs(p.x - settled.x) < 1 && Math.abs(p.y - settled.y) < 1) break;
settled = p;
await sleep(150);
}
assert.ok(settled, 'the boss-field tile still has a screen position');
let text = null;
for (let i = 0; i < 3 && text === null; i++) {
const p = (await at()) || settled;
await page.mouse.click(p.x, p.y);
try { await page.waitForSelector('.dpanel', { timeout: 3000 }); } catch { continue; } // a tap that missed: retry
text = await card(page);
}
assert.ok(text !== null, 'the tap opened the terrain card');
assert.match(text, new RegExp(target.name), `board (${target.row},${target.col}) → stage (${target.sr},${target.sc}) is ${target.name}, card: ${text}`);
assert.deepEqual(problems, []);
await page.close();
});
});
+5
View File
@@ -252,6 +252,11 @@ export const BATTLE_SFX = Object.freeze({
enemyDieHeavy: { path: 'battle/b_enemy/b_enemy_dead_h.mp3' },
enemyHit: { path: 'enemy/e_imp/e_imp_general_w.mp3' },
heal: { bank: 'battle.ON_MODIFIER_HEAL' },
// 漏怪: an enemy reached the exit. This is the ORIGINAL Arknights stage cue (`battle.ON_ENEMY_REACHED_EXIT` ->
// `Battle/b_ui/b_ui_alarmenter`), not an autochess one — the mode itself has no bank named for an escape (all 13,948
// SFX banks of audio_data.json searched). The official treats it as a one-shot alarm: `maxSoundAllowed: 1` with
// `popOldest: true` on the `Battle_UI_Important` mixer, so a new escape replaces the one still playing.
leak: { bank: 'battle.ON_ENEMY_REACHED_EXIT' },
win: { path: 'battle/b_ui/b_ui_win.mp3' },
lose: { path: 'battle/b_ui/b_ui_lose.mp3' },
killCoin: { bank: 'battle.ON_CUSTOM_TRIGGER.autochess_kill_gain_coin' },
+45 -14
View File
@@ -6,7 +6,8 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { RAW, mirrorUrl } from './sources.mjs';
import { RAW } from './sources.mjs';
import { MirrorPolicy } from './network.mjs';
/**
* Read a cached JSON file, downloading it first when missing or unparsable.
@@ -16,44 +17,70 @@ import { RAW, mirrorUrl } from './sources.mjs';
* @param {boolean} [o.refresh] force re-download
* @param {boolean} [o.offline] never download (throw when the cache is missing)
* @param {(m:string)=>void} [o.log]
* @param {'direct'|'mirror'} [o.source]
* @param {string} [o.proxyPrefix]
* @param {typeof fetch} [o.fetchImpl]
* @param {MirrorPolicy} [o.mirrorPolicy] shared invocation-wide mirror circuit breaker
* @param {number} [o.timeoutMs]
* @param {number} [o.backoffMs]
* @returns {Promise<any>} parsed JSON
*/
export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log }) {
export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log, source = 'direct', proxyPrefix, fetchImpl = globalThis.fetch, mirrorPolicy, timeoutMs = 180000, backoffMs = 500 }) {
if (!refresh || offline) {
try { return JSON.parse(await readFile(cacheFile, 'utf8')); } catch (e) {
if (offline) throw new Error(`--offline: cached index ${cacheFile} is missing or corrupt (${e.message}); run once online`);
}
}
const network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log });
let lastErr = null;
for (const src of [url, mirrorUrl(url)].filter(Boolean)) {
for (let attempt = 1; attempt <= 3; attempt++) {
for (const src of network.urls(url)) {
if (network.skip(src)) continue;
const attempts = network.isProxy(src) ? 1 : 3;
for (let attempt = 1; attempt <= attempts; attempt++) {
let text, json;
try {
log(`[cache] downloading ${src}`);
const res = await fetch(src, { signal: AbortSignal.timeout(180000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const json = JSON.parse(text);
await mkdir(dirname(cacheFile), { recursive: true });
await writeFile(cacheFile + '.tmp', text);
await rename(cacheFile + '.tmp', cacheFile);
return json;
const res = await network.request(src, fetchImpl, {}, timeoutMs);
if (!res.ok) {
await res.body?.cancel();
if (res.status === 404 || res.status === 410) {
network.succeeded(src); // missing index, not a proxy outage
lastErr = new Error(`HTTP ${res.status}`);
break;
}
throw new Error(`HTTP ${res.status}`);
}
text = (await network.readBody(src, res)).toString('utf8');
json = JSON.parse(text);
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, 500 * attempt));
network.failed(src);
if (attempt < attempts && backoffMs) await new Promise((r) => setTimeout(r, backoffMs * attempt));
continue;
}
network.succeeded(src);
// A local disk failure is not evidence that the mirror is unhealthy.
await mkdir(dirname(cacheFile), { recursive: true });
await writeFile(cacheFile + '.tmp', text);
await rename(cacheFile + '.tmp', cacheFile);
return json;
}
}
if (lastErr) network.directFailureHint();
throw new Error(`cannot fetch ${url}: ${lastErr?.message}`);
}
/**
* Load audio_data.json (official), charword_table.json (official voice slots) and Ark-Models models_data.json.
* @param {string} root project root
* @param {{refresh?:boolean, offline?:boolean, log?:(m:string)=>void}} [opts]
* @param {{refresh?:boolean, offline?:boolean, log?:(m:string)=>void, source?:'direct'|'mirror', proxyPrefix?:string, fetchImpl?:typeof fetch, mirrorPolicy?:MirrorPolicy}} [opts]
* @returns {Promise<{ audioData: any, modelsData: any, charword: any }>}
*/
export async function loadIndexes(root, opts = {}) {
const mirrorPolicy = opts.mirrorPolicy ?? new MirrorPolicy(opts);
const audioData = await cachedJson({
...opts,
mirrorPolicy,
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'audio_data.json'),
url: RAW.gamedata + 'excel/audio_data.json',
refresh: opts.refresh,
@@ -63,6 +90,8 @@ export async function loadIndexes(root, opts = {}) {
// 11 MB, and raw.githubusercontent stalls on it often: the jsDelivr mirror cachedJson() falls back to is the
// reliable path (the operator battle voice needs it, plan.mjs indexVoice).
const charword = await cachedJson({
...opts,
mirrorPolicy,
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'charword_table.json'),
url: RAW.gamedata + 'excel/charword_table.json',
refresh: opts.refresh,
@@ -70,6 +99,8 @@ export async function loadIndexes(root, opts = {}) {
log: opts.log,
});
const modelsData = await cachedJson({
...opts,
mirrorPolicy,
cacheFile: join(root, '.cache', 'ark-models', 'models_data.json'),
url: RAW.arkModels + 'models_data.json',
refresh: opts.refresh,
+23 -13
View File
@@ -5,8 +5,9 @@
// download never appears at the final path.
// - Per source: up to `retries` attempts with exponential backoff on network
// errors, timeouts, 403/429/5xx and payloads that fail format validation;
// a 404/410 moves on immediately. Sources: each candidate URL, then its
// jsDelivr mirror (sources.mirrorUrl).
// a 404/410 moves on immediately. Each candidate uses its opt-in prefix proxy
// first (when enabled), then the original URL and jsDelivr fallback. The proxy gets one short attempt
// per file and shares a run-wide circuit breaker with the index/font fetchers.
// - Idempotent: an existing file is kept when its size matches the ledger entry
// of a previous download or the expected byte count from research, or (when
// neither is known) when it passes format validation. The ledger lives in
@@ -15,7 +16,7 @@
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { mirrorUrl } from './sources.mjs';
import { MirrorPolicy } from './network.mjs';
import { validate } from './formats.mjs';
/**
@@ -44,8 +45,11 @@ export class Downloader {
* @param {(msg:string)=>void} [o.log]
* @param {typeof fetch} [o.fetchImpl]
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
* @param {'direct'|'mirror'} [o.source] preferred download source
* @param {string} [o.proxyPrefix] HTTPS prefix for GitHub downloads
* @param {MirrorPolicy} [o.mirrorPolicy] invocation-wide policy and mirror circuit breaker
*/
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400, source = 'direct', proxyPrefix, mirrorPolicy }) {
this.root = root;
this.ledgerPath = ledgerPath;
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
@@ -57,6 +61,7 @@ export class Downloader {
this.written = new Set();
this.log = log;
this.fetch = fetchImpl;
this.network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log });
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
this.ledger = { files: {} };
this.totals = { ok: 0, skip: 0, miss: 0, error: 0, bytesDownloaded: 0, sizeChanged: 0 };
@@ -107,36 +112,40 @@ export class Downloader {
/**
* Fetch one URL with retries.
* @returns {Promise<{buf:Buffer}|{notFound:true}|{error:string}>}
* @returns {Promise<{buf:Buffer}|{notFound:true}|{skipped:true}|{error:string}>}
*/
async fetchWithRetries(url, kind) {
if (this.network.skip(url)) return { skipped: true };
let lastErr = 'unknown error';
for (let attempt = 1; attempt <= this.retries; attempt++) {
const attempts = this.network.isProxy(url) ? 1 : this.retries;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const res = await this.fetch(url, {
const res = await this.network.request(url, this.fetch, {
headers: { 'user-agent': 'stronghold-protocol-fetch-assets/1.0' },
signal: AbortSignal.timeout(this.timeoutMs),
redirect: 'follow',
});
}, this.timeoutMs);
if (res.status === 404 || res.status === 410) {
try { await res.body?.cancel(); } catch { /* ignore */ }
this.network.succeeded(url); // a missing asset is not a proxy outage
return { notFound: true };
}
if (!res.ok) {
try { await res.body?.cancel(); } catch { /* ignore */ }
throw new HttpError(res.status, url);
}
const buf = Buffer.from(await res.arrayBuffer());
const buf = await this.network.readBody(url, res);
const len = Number(res.headers.get('content-length'));
const enc = res.headers.get('content-encoding');
if (!enc && Number.isFinite(len) && len > 0 && len !== buf.length) {
throw new Error(`truncated body ${buf.length}/${len} ${url}`);
}
if (!validate(kind, buf)) throw new Error(`invalid ${kind} payload (${buf.length} B) ${url}`);
this.network.succeeded(url);
return { buf };
} catch (e) {
lastErr = e?.message || String(e);
if (attempt < this.retries && this.backoffMs) await sleep(this.backoffMs * 2 ** (attempt - 1) + Math.floor(Math.random() * this.backoffMs));
this.network.failed(url);
if (attempt < attempts && this.backoffMs) await sleep(this.backoffMs * 2 ** (attempt - 1) + Math.floor(Math.random() * this.backoffMs));
}
}
return { error: lastErr };
@@ -152,10 +161,10 @@ export class Downloader {
if (kept >= 0) return { status: 'skip', bytes: kept };
let lastError = null;
for (const url of job.urls) {
const sources = [url, mirrorUrl(url)].filter(Boolean);
const sources = this.network.urls(url);
for (const src of sources) {
const r = await this.fetchWithRetries(src, job.kind);
if (r.notFound) continue;
if (r.notFound || r.skipped) continue;
if (r.error) { lastError = r.error; continue; }
const abs = join(this.root, job.rel);
await mkdir(dirname(abs), { recursive: true });
@@ -174,6 +183,7 @@ export class Downloader {
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
}
}
if (lastError) this.network.directFailureHint();
return lastError ? { status: 'error', bytes: 0, error: lastError } : { status: 'miss', bytes: 0, error: 'not found (404) on all sources' };
}
+4 -2
View File
@@ -7,7 +7,7 @@
import { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { assetUrl, mirrorUrl } from './sources.mjs';
import { assetUrl, githubSourceUrl, mirrorUrl } from './sources.mjs';
/** Manifest schema version (bump on breaking shape changes). */
export const MANIFEST_VERSION = 1;
@@ -94,9 +94,11 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
const a = node.alts[i];
if (existsSync(join(root, a.rel))) {
const src = sourceOf(a.rel);
// Provenance only: disabling/changing a proxy does not change the
// identity of a previously downloaded asset.
const primary = node.alts[0].urls.flatMap((u) => [u, mirrorUrl(u)]);
if (i > 0) fallbacks.push(`${path} ← ${src || a.urls[0]}`);
else if (src && !primary.includes(src)) fallbacks.push(`${path} ← ${src}`);
else if (src && !primary.includes(githubSourceUrl(src))) fallbacks.push(`${path} ← ${src}`);
files.add(a.rel);
return assetUrl(a.rel);
}
+115
View File
@@ -0,0 +1,115 @@
// Explicit download-source selection and one shared policy per asset invocation.
// Selecting a source never looks up the user's public IP or makes a request.
import { downloadUrls, normalizeProxyPrefix } from './sources.mjs';
export function validateSource(mode) {
if (!['direct', 'mirror'].includes(mode)) throw new Error(`unknown asset source: ${mode} (direct|mirror)`);
return mode;
}
export async function selectDownloadSource({ mode = 'direct', offline = false, proxyPrefix, log = console.log } = {}) {
validateSource(mode);
if (offline) return 'direct';
const normalizedProxy = mode === 'mirror' ? normalizeProxyPrefix(proxyPrefix) : '';
log(mode === 'mirror'
? `[network] 手动开启 GitHub 镜像:${normalizedProxy || '代理已禁用'};文件仅校验格式和大小。`
: '[network] 原始源(未开启 GitHub 镜像)');
return mode;
}
/** Shared by indexes, files, Spine follow-ups and fonts; a tripped mirror stays off. */
export class MirrorPolicy {
constructor({ source = 'direct', proxyPrefix, failureLimit = 3, timeoutMs = 8000, log = console.log } = {}) {
this.source = validateSource(source);
this.proxyPrefix = this.source === 'mirror' ? normalizeProxyPrefix(proxyPrefix) : '';
this.failureLimit = Math.max(1, Number(failureLimit) || 3);
this.timeoutMs = Math.max(1, Number(timeoutMs) || 8000);
this.log = log;
this.failures = 0;
this.disabled = false;
this.hintShown = false;
this.abortMirror = new AbortController();
}
urls(url) {
return downloadUrls(url, { source: this.disabled ? 'direct' : this.source, proxyPrefix: this.proxyPrefix });
}
isProxy(url) {
return this.source === 'mirror' && !!this.proxyPrefix && url.startsWith(this.proxyPrefix);
}
skip(url) { return this.isProxy(url) && this.disabled; }
async request(url, fetchImpl, options = {}, timeoutMs = 120000) {
if (!this.isProxy(url)) {
return fetchImpl(url, { ...options, signal: options.signal ?? AbortSignal.timeout(timeoutMs) });
}
const headerDeadline = new AbortController();
const signal = AbortSignal.any([headerDeadline.signal, this.abortMirror.signal]);
const timer = setTimeout(() => headerDeadline.abort(new Error('GitHub mirror response headers timed out')), Math.min(timeoutMs, this.timeoutMs));
try {
return await fetchImpl(url, { ...options, signal });
} finally {
clearTimeout(timer);
}
}
async readBody(url, response) {
if (!this.isProxy(url)) return Buffer.from(await response.arrayBuffer());
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (true) {
const { value, done } = await readChunk(reader, this.timeoutMs, this.abortMirror.signal);
if (done) break;
const chunk = Buffer.from(value);
chunks.push(chunk);
size += chunk.length;
}
} catch (error) {
try { await reader.cancel(error); } catch { /* ignore */ }
throw error;
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, size);
}
succeeded(url) {
// A success from a request already in flight must not reopen a tripped circuit.
if (this.isProxy(url) && !this.disabled) this.failures = 0;
}
failed(url) {
if (this.isProxy(url)) {
if (this.disabled || ++this.failures < this.failureLimit) return;
this.disabled = true;
this.log(`[network] GitHub 镜像连续失败 ${this.failures} 次:本次运行停用镜像,继续使用原始源 / jsDelivr。`);
this.abortMirror.abort(new Error('GitHub mirror disabled for this run'));
}
}
directFailureHint() {
if (this.source !== 'direct' || this.hintShown) return;
this.hintShown = true;
this.log('[network] GitHub 下载失败;如需使用第三方镜像,可重新运行并加 --asset-source=mirror(或 SP_ASSET_SOURCE=mirror)。');
}
}
function readChunk(reader, timeoutMs, signal) {
return new Promise((resolve, reject) => {
let timer;
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = () => { cleanup(); reject(signal.reason ?? new Error('GitHub mirror request aborted')); };
if (signal?.aborted) return onAbort();
timer = setTimeout(() => { cleanup(); reject(new Error(`GitHub mirror response body idle for ${timeoutMs} ms`)); }, timeoutMs);
signal?.addEventListener('abort', onAbort, { once: true });
reader.read().then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
});
}
+53 -4
View File
@@ -1,10 +1,59 @@
// Upstream asset sources (GitHub dumps of the official client), URL helpers and
// local-path helpers shared by the asset pipeline (tools/fetch-assets.mjs).
//
// Every URL we download from is a raw.githubusercontent.com URL; mirrorUrl()
// maps it to the equivalent jsDelivr URL used as a fallback when GitHub raw
// fails. jsDelivr 404s on the ArknightsAssets2 `voice` branch, so no mirror is
// offered for audio. See docs/ASSETS.md for the full source list and credits.
// GitHub URLs support a prefix proxy and a jsDelivr fallback. jsDelivr does
// not serve the voice branch, but the prefix proxy can still be tried there.
// See docs/ASSETS.md for the full source list and credits.
export const DEFAULT_GITHUB_PROXY = 'https://gh-proxy.com/';
export function normalizeProxyPrefix(prefix = DEFAULT_GITHUB_PROXY) {
if (prefix.trim() === '') return '';
const url = new URL(prefix);
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
throw new Error('GitHub proxy prefix must be an HTTPS URL without credentials, query or fragment');
}
return url.href.endsWith('/') ? url.href : url.href + '/';
}
function isGithubDownloadUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' && !parsed.username && !parsed.password &&
['github.com', 'raw.githubusercontent.com', 'objects.githubusercontent.com'].includes(parsed.host);
} catch { return false; }
}
/**
* Legacy ledgers store the successful proxy URL. Spine page jobs derived from
* those URLs must use today's policy, not implicitly opt in to the old proxy.
* Recognize embedded public GitHub URLs even when the old custom prefix is no
* longer configured. An ordinary GitHub URL (including its path) stays intact.
*/
export function githubSourceUrl(url) {
let candidate = String(url);
while (candidate.startsWith('https://')) {
if (isGithubDownloadUrl(candidate)) return candidate;
const marker = candidate.indexOf('/https://', 8);
if (marker < 0) break;
candidate = candidate.slice(marker + 1);
}
return url;
}
/** Only public GitHub download URLs are sent to the proxy; never prefix twice. */
export function githubProxyUrl(url, prefix = DEFAULT_GITHUB_PROXY) {
const normalized = normalizeProxyPrefix(prefix);
if (!normalized || !isGithubDownloadUrl(url)) return null;
return normalized + url;
}
/** Ordered transport alternatives; direct mode retains the existing jsDelivr order. */
export function downloadUrls(url, { source = 'direct', proxyPrefix = DEFAULT_GITHUB_PROXY } = {}) {
url = githubSourceUrl(url);
const proxy = source === 'mirror' ? githubProxyUrl(url, proxyPrefix) : null;
return [...new Set([proxy, url, mirrorUrl(url)].filter(Boolean))];
}
/** Raw base URLs (always end with '/'). */
export const RAW = Object.freeze({
+27 -9
View File
@@ -13,9 +13,10 @@
// that file from the extracted models (after a game update).
//
// Idempotent: existing files with the right size are skipped, so re-running is
// cheap. Downloads use ~16 parallel connections, 3 retries per source and a
// jsDelivr mirror fallback. Spine atlases get `size:` (and `pma: true` for
// enemies); every skeleton is parsed to resolve animation roles.
// cheap. Downloads use ~16 parallel connections, 3 retries per direct source,
// a jsDelivr fallback and an opt-in GitHub proxy (one short attempt per URL).
// Spine atlases get `size:` (and `pma: true` for enemies); every skeleton is
// parsed to resolve animation roles.
//
// The committed data/assets.json never shrinks by accident: an entry whose files
// are missing here is left out of a rebuilt manifest, so a run on a machine where
@@ -45,6 +46,8 @@ import { existsSync, realpathSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Downloader } from './assets/downloader.mjs';
import { MirrorPolicy, selectDownloadSource, validateSource } from './assets/network.mjs';
import { normalizeProxyPrefix } from './assets/sources.mjs';
import { loadIndexes } from './assets/cache.mjs';
import { indexAudio, VOICE_DIRS } from './assets/audio.mjs';
import { buildPlan } from './assets/plan.mjs';
@@ -63,6 +66,7 @@ const LOCAL_SPINES = join(ROOT, LOCAL_ENEMY_SPINES_FILE);
const HELP = `Usage: node tools/fetch-assets.mjs [options]
--concurrency=N parallel downloads (default 16)
--asset-source=M direct (default) or mirror (opt-in; no public-IP lookup)
--force re-download files even when present
--offline no network: post-process what is on disk and rebuild data/assets.json
--dry-run print the plan and exit
@@ -78,18 +82,23 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
file, no font rebuild (a worktree sharing public/assets and public/fonts)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
--help this text`;
--help this text
Environment: SP_ASSET_SOURCE sets the default source; SP_GITHUB_PROXY sets the
HTTPS mirror prefix (default https://gh-proxy.com/; empty disables the proxy).
Mirror attempts have an 8 s response header timeout; response body has a separate idle timeout. Stops for this run after 3 consecutive
failures. Only explicitly enabled GitHub downloads use the third-party proxy.`;
/**
* Parse CLI flags.
* @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, addOnly:boolean, localSpines:boolean, voiceLang:string, voiceAll:boolean, help:boolean}}
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, addOnly:boolean, localSpines:boolean, voiceLang:string, voiceAll:boolean, help:boolean, source:string}}
*/
export function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, addOnly: false, localSpines: false, voiceLang: 'cn', voiceAll: false, help: false };
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, addOnly: false, localSpines: false, voiceLang: 'cn', voiceAll: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
for (const a of argv) {
const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
else if (k === '--asset-source') o.source = v;
else if (k === '--force') o.force = true;
else if (k === '--offline') o.offline = true;
else if (k === '--dry-run') o.dryRun = true;
@@ -104,9 +113,14 @@ export function parseArgs(argv) {
else throw new Error(`unknown option ${a}\n${HELP}`);
}
if (o.addOnly && (o.prune || o.force)) throw new Error(`--add-only never deletes or rewrites files: not with --prune / --force\n${HELP}`);
if (!o.help) validateSource(o.source);
return o;
}
export function resolveProxyPrefix(source, offline = false, value = process.env.SP_GITHUB_PROXY) {
return offline || source !== 'mirror' ? '' : normalizeProxyPrefix(value);
}
/**
* What data/backups.json and data/chess.json add to the asset plan: `extraOperators` — every unit of backups.json
* (`{ name, subProfessionId, nationId, skills: [{ index, skillId, iconId }] }`; buildPlan plans those research 07 lacks,
@@ -264,7 +278,11 @@ async function main() {
readJson('docs/research/05-enemies.json'),
readJson('docs/research/05-maps.json'),
]);
const { audioData, modelsData, charword } = await loadIndexes(ROOT, { refresh: opts.refreshIndex && !opts.offline, offline: opts.offline, log });
const proxyPrefix = resolveProxyPrefix(opts.source, opts.offline);
const source = await selectDownloadSource({ mode: opts.source, offline: opts.offline, proxyPrefix, log });
const mirrorPolicy = new MirrorPolicy({ source, proxyPrefix, log });
const network = { source, proxyPrefix, mirrorPolicy };
const { audioData, modelsData, charword } = await loadIndexes(ROOT, { refresh: opts.refreshIndex && !opts.offline, offline: opts.offline, log, ...network });
const audio = indexAudio(audioData);
// The game data built by tools/build-data.mjs (when present) may reference more
// spawnable enemies/tokens than research lists (e.g. 机变 enemy swaps): cover them too.
@@ -298,7 +316,7 @@ async function main() {
const dl = new Downloader({
root: ASSETS, ledgerPath: join(CACHE, 'assets-ledger.json'),
concurrency: opts.concurrency, force: opts.force, keepExisting: opts.addOnly, log,
concurrency: opts.concurrency, force: opts.force, keepExisting: opts.addOnly, log, ...network,
});
await dl.loadLedger();
const downloadErrors = opts.offline ? [] : await downloadLeaves(leaves, dl, ASSETS, 'files');
@@ -311,7 +329,7 @@ async function main() {
// Fonts (--add-only: not rebuilt — public/fonts may be another checkout's; the manifest keeps its current entry)
let fontErrors = [];
if (!opts.offline && !opts.addOnly) {
const fdl = new Downloader({ root: FONTS, ledgerPath: join(CACHE, 'fonts-ledger.json'), concurrency: 4, force: opts.force, log });
const fdl = new Downloader({ root: FONTS, ledgerPath: join(CACHE, 'fonts-ledger.json'), concurrency: 4, force: opts.force, log, ...network });
await fdl.loadLedger();
await fdl.run(fontJobs(), 'fonts');
dl.totals.bytesDownloaded += fdl.totals.bytesDownloaded;
+82 -5
View File
@@ -26,15 +26,19 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
- derived PBR maps for three.js (DERIVED): Unity stores the theme's normal map as two channels (BC5: RG = XY, B = 0)
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
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)
Writes <out>/**.png|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just the
jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing manifest
(every other group is kept as is).
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
manifest (every other group is kept as is).
Everything is (c) Hypergryph; for private, non-commercial fan use only.
"""
import argparse
@@ -118,6 +122,32 @@ DERIVED = [
('map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'),
]
# The board textures every player downloads when a match shows the 3D board (public/js/render/board3d/load.js
# PACK_IMAGES; D / common_D / BG also feed the 2D board art, render/boardArt.js): (output subdir, name, mode). A WebP
# copy is written next to the PNG and the manifest lists the copy instead (≈ 6.7 MB → 2.0 MB per cold start); the PNG
# stays for tools/crop-board-atlas.mjs and setup's check. 'lossy' = colour maps at quality 95 with the alpha lossless
# and the RGB under transparent texels kept (`exact`: the board material is opaque and samples it); 'lossless' =
# normal and data maps, whose channels hold independent values that lossy WebP's chroma subsampling would mix (a
# normal map ends up tens of degrees off). A Pillow without WebP support keeps the PNG.
WEBP = [
('map/autochess', 'TX_autochessi_D', 'lossy'),
('map/autochess', 'TX_autochessi_BG', 'lossy'),
('map/autochess', 'TX_autochessi_common_D', 'lossy'),
('map/autochess', 'TX_autochessi_N_rgb', 'lossless'),
('map/autochess', 'TX_autochessi_M_rough', 'lossless'),
('map/autochess', 'TX_autochessi_E', 'lossless'),
('map/autochess', 'TX_autochessi_common_E', 'lossless'),
('map/common', 'TX_wind_device', 'lossy'),
('map/fx', '[opt]merged_textures', 'lossless'),
('map/water', '[ucp]TX_water_normal', 'lossless'),
('map/water', 'TX_Caustics256', 'lossy'),
('map/water', 'T_noise_clouds_01', 'lossless'),
]
WEBP_OPTIONS = {
'lossy': {'quality': 95, 'method': 6, 'alpha_quality': 100, 'exact': True},
'lossless': {'lossless': True, 'quality': 100, 'method': 6, 'exact': True},
}
# 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.
@@ -314,6 +344,44 @@ def run_derived(out_root, sub, manifest, log):
return n
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."""
n = 0
for wsub, name, mode in WEBP:
entry = manifest.get(sub, {}).get(name) if wsub == sub else None
png = Path(out_root) / sub / f'{name}.png'
if not entry or not png.exists():
continue
dst = png.with_name(f'{name}.webp')
try:
from PIL import Image
with Image.open(png) as img:
img.save(dst, 'WEBP', **WEBP_OPTIONS[mode])
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
entry.update(path=f'/assets/local/{sub}/{dst.name}', webp=mode)
n += 1
return n
def webp_only(out_root, manifest_path, log):
"""--webp: the WEBP copies of an existing extraction (e.g. the local art copied from a release bundle)."""
try:
doc = json.loads(Path(manifest_path).read_text(encoding='utf-8'))
except (OSError, ValueError) as e:
print(f'No readable manifest at {manifest_path} ({e}). Extract the local art first.', file=sys.stderr)
return 2
groups = doc.get('groups') if isinstance(doc, dict) else None
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))
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
def merge_manifest(old_groups, new_groups, ran_subs):
"""Groups of the previous manifest minus the subdirs that were re-extracted, plus the new ones (sorted keys)."""
out = {g: v for g, v in (old_groups or {}).items() if g not in ran_subs}
@@ -415,6 +483,9 @@ def export_bundle(ab_root, job, out_root, manifest, log):
n += 1
n += run_derived(out_root, sub, manifest, log)
log(f'{rel}: {n} files -> {sub}')
k = run_webp(out_root, sub, manifest, log)
if k:
log(f' {k} WebP copies -> {sub}')
return n
@@ -555,16 +626,22 @@ def main():
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
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)')
args = ap.parse_args()
if args.print_jobs:
jobs = [{'bundle': rel, 'sub': sub, 'kinds': sorted(kinds), 'keep': keep.pattern if keep else None}
for rel, sub, kinds, keep in map(job_parts, JOBS)]
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
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, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
'ids': ENEMY_SPINES}}, ensure_ascii=False))
'derived': derived, 'webp': webp, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
'ids': ENEMY_SPINES}}, ensure_ascii=False))
return 0
if args.webp:
return webp_only(Path(args.out), Path(args.manifest), print)
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
if not ab_root or not ab_root.exists():
+9 -2
View File
@@ -19,6 +19,9 @@
// Options:
// --check report only, change nothing (exit 1 when something essential is missing)
// --no-assets skip the art/audio download
// --asset-source=M direct (default) or mirror (opt-in; no public-IP lookup)
// SP_ASSET_SOURCE sets the default; SP_GITHUB_PROXY sets the HTTPS prefix (https://gh-proxy.com/)
// An empty SP_GITHUB_PROXY disables the proxy, including in mirror mode.
// --no-local skip the local-client detection and extraction
// --local extract from the local client without asking (re-extracts when already done)
// --game <dir> AssetBundle root of the local client (…/StreamingAssets/AB/Windows or PlayCover …/Documents/Bundles)
@@ -35,6 +38,7 @@ import path from 'node:path';
import readline from 'node:readline';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { validateSource } from './assets/network.mjs';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const MIN_NODE = 22;
@@ -328,11 +332,13 @@ function ensureVenv(py, log) {
// ---------------------------------------------------------------------------------------------------
function parseArgs(argv) {
const o = { check: false, assets: true, local: 'ask', game: null, yes: false, quiet: false, help: false };
const o = { check: false, assets: true, local: 'ask', game: null, yes: false, quiet: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--check') o.check = true;
else if (a === '--no-assets') o.assets = false;
else if (a === '--asset-source') o.source = argv[++i];
else if (a.startsWith('--asset-source=')) o.source = a.slice('--asset-source='.length);
else if (a === '--no-local') o.local = 'no';
else if (a === '--local') o.local = 'force';
else if (a === '--game') { o.game = argv[++i] || null; if (o.local !== 'no') o.local = 'force'; }
@@ -342,6 +348,7 @@ function parseArgs(argv) {
else if (a === '-h' || a === '--help') o.help = true;
else throw new Error(`未知参数 / unknown option: ${a}(--help 查看用法)`);
}
if (!o.help) validateSource(o.source);
return o;
}
@@ -413,7 +420,7 @@ async function main() {
const what = !assets.present ? `首次下载约 ${assets.bytes ? mb(assets.bytes) : '270 MB'},可随时中断,重新运行会续传`
: `补全缺失的 ${assets.missing} 个文件`;
log(`\n${c.cyan('▶')} 下载美术与音频素材(${what})…`);
const r = run(process.execPath, [path.join(ROOT, 'tools', 'fetch-assets.mjs')]);
const r = run(process.execPath, [path.join(ROOT, 'tools', 'fetch-assets.mjs'), `--asset-source=${opts.source}`]);
assets = checkAssets();
if (!r.ok && !assets.ok) log(c.warn(' 素材下载未完成(网络问题?)。游戏仍可运行(使用占位图),稍后重新运行 setup 即可续传。'));
}