卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# Keep the build context small and free of machine-specific state (see Dockerfile).
.git
.github
.claude
node_modules
.cache
.venv*
**/__pycache__
test
logs
*.log
docs/img
# game art is mounted at runtime or downloaded with --build-arg FETCH_ASSETS=1, never sent from the host
public/assets
# generated from node_modules inside the image
public/vendor
scripts/service.env.cmd
.DS_Store
Thumbs.db
.env
.env.*
+20
View File
@@ -0,0 +1,20 @@
# Normalise line endings in the repository; check out Windows scripts with CRLF (cmd.exe needs it for labels/goto)
# and shell scripts with LF, whatever core.autocrlf says on the cloning machine.
* text=auto
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.sh text eol=lf
*.mjs text eol=lf
*.js text eol=lf
*.py text eol=lf
# binary assets
*.png binary
*.jpg binary
*.webp binary
*.skel binary
*.woff2 binary
*.otf binary
*.ttf binary
*.mp3 binary
*.ogg binary
+67
View File
@@ -0,0 +1,67 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }}, node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
# 22 and 24 = the maintained LTS lines (24 is what `winget install OpenJS.NodeJS.LTS` installs today)
node: [22, 24]
env:
# Browser/E2E suites gate themselves on these (and on a local Chrome + downloaded game assets, which CI never
# has): keep them off explicitly. Suites that need .cache/gamedata, public/assets or data/local-assets.json skip.
SP_E2E: '0'
SP_REAL_E2E: '0'
RENDER_E2E: '0'
NO_COLOR: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install (npm ci → postinstall copies public/vendor)
run: npm ci --no-audit --no-fund
- name: Setup check (no downloads)
run: node tools/setup.mjs --check --no-local
- name: Unit & integration tests
run: node --test
# Linux is the reference platform; Windows results are informative until the suite is proven there.
continue-on-error: ${{ matrix.os == 'windows-latest' }}
- name: Server smoke test (boot, /healthz, index.html, doctor)
shell: bash
run: |
node server/index.js > server.log 2>&1 &
pid=$!
ok=
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3000/healthz; then ok=1; break; fi
sleep 1
done
echo
[ -n "$ok" ] || { cat server.log; exit 1; }
curl -fsS http://127.0.0.1:3000/ | grep -qi '<html'
curl -fsS -o /dev/null http://127.0.0.1:3000/vendor/pixi.min.js
node tools/doctor.mjs || true
kill $pid || true
+39
View File
@@ -0,0 +1,39 @@
# --- dependencies & generated client libs -------------------------------------------------------
node_modules/
# copied from node_modules by tools/vendor.mjs (postinstall); never commit third-party builds
public/vendor/
# --- game assets: (c) Hypergryph / Yostar, fetched or extracted locally, NEVER committed ----------
# tools/fetch-assets.mjs → public/assets/** (~250 MB) and public/fonts/** (+ fonts.css)
public/assets/
public/fonts/
# tools/local-extract/extract.py → public/assets/local/** and this per-machine manifest
# (optional: the client and the tests work without it)
data/local-assets.json
# download caches, upstream tables, ledgers, reports, setup state
.cache/
# --- Python (optional local-client extraction) ----------------------------------------------------
.venv*/
venv/
__pycache__/
*.py[cod]
# --- tests & logs ----------------------------------------------------------------------------------
test/e2e/out/
logs/
*.log
# --- per-machine config (scripts/install-service-windows.ps1) ------------------------------------
scripts/service.env.cmd
.env
.env.*
# --- OS / editor clutter ---------------------------------------------------------------------------
.DS_Store
Thumbs.db
desktop.ini
*.swp
.idea/
.vscode/
.claude/
+60
View File
@@ -0,0 +1,60 @@
# syntax=docker/dockerfile:1
# 卫戍协议:盟约 · production image (server + static client). Docs: docs/DEPLOY.md「Docker」.
#
# Game art/audio is © Hypergryph and never part of the repository. Two ways to get it into a container:
# A) download it while building (~250 MB, needs internet during the build):
# docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
# B) build without it and mount the host's copy (prepared with `node tools/setup.mjs` on the host):
# docker build -t stronghold-protocol .
# docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \
# -v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol
# (public/fonts, data/assets.json and data/local-assets.json are copied from the build context when present)
# Without any art the game still runs with placeholder visuals.
#
# Run: docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
# Env: PORT (3000), HOST (0.0.0.0), SP_COMBAT (client|server), SP_VERIFY (off|sample|all), TRUST_PROXY (auto|1|0), DEBUG
ARG NODE_IMAGE=node:22-alpine
# ---- 1. production dependencies (cached while package*.json are unchanged) -------------------------
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# --ignore-scripts: the postinstall (tools/vendor.mjs) runs in the next stage, once the sources are there
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
# ---- 2. vendor libs + optional art download ---------------------------------------------------------
FROM deps AS build
ARG FETCH_ASSETS=0
COPY shared ./shared
COPY server ./server
COPY tools ./tools
COPY data ./data
COPY public ./public
COPY docs/research ./docs/research
RUN node tools/vendor.mjs \
&& if [ "$FETCH_ASSETS" = "1" ]; then \
node tools/fetch-assets.mjs || echo "WARNING: art download incomplete; the image falls back to placeholder art"; \
fi \
&& rm -rf .cache
# ---- 3. runtime ---------------------------------------------------------------------------------------
FROM ${NODE_IMAGE}
ENV NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0
WORKDIR /app
COPY --from=deps /app/package.json ./package.json
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/shared ./shared
COPY --from=build /app/server ./server
COPY --from=build /app/data ./data
COPY --from=build /app/public ./public
# research tables: read by server/sim/nodeData.js as a fallback
COPY --from=build /app/docs/research ./docs/research
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT}/healthz" || exit 1
CMD ["node", "server/index.js"]
+151
View File
@@ -0,0 +1,151 @@
# 卫戍协议:盟约 · Stronghold Protocol: Covenant
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**同人联机网页复刻**。浏览器即开即玩:独立模拟(单人)与同盟模拟(1–4 人合作,可用 AI 队友补位),局域网 / 公网联机,一台小主机就能开服。
> 非官方同人项目,仅供学习交流,**禁止商用**。游戏素材版权归上海鹰角网络 / Yostar,**不包含在本仓库中**(在你自己的电脑上下载或提取;`docs/img/` 里只有几张说明用的游戏截图)。详见 [致谢与声明](#致谢与声明)。
> English summary: [below](#english).
| 同盟房间 | 策略轮选 | 休整期(商店 / 盟约) |
|---|---|---|
| ![房间](docs/img/room.jpg) | ![策略](docs/img/band-draft.jpg) | ![休整期](docs/img/prep.jpg) |
| **部署方向轮盘** | **作战** | **最终攻势** |
| ![方向](docs/img/facing-wheel.jpg) | ![作战](docs/img/combat.jpg) | ![最终攻势](docs/img/final-assault.jpg) |
## 特性
- 完整的一局流程:确认本局信息 → 策略轮选(40 种策略)→ 14 回合(+ 隐秘核心)→ 结算称号。
- 4 种难度(标准 / 险境 / 绝境 / 终极),独立与同盟两套参数,数据来自官方表。
- 112 名可招募干员(+ 精锐)及技能天赋、23 个盟约与层数、特质、装备与法术、机变、10 个敌方领袖、地形装置。
- **干员调配**(大厅 / 房间 / 本局信息确认时):和官方一样不能调整等阶,但可为每名干员选择携带的技能(283 个技能全部手工实现)与精锐的模组(或不装备)。
- 联防、最终攻势(两人共享战场与领袖血池)、隐秘核心;同盟模拟断线 10 分钟内可重连(掉线期间按原阵容自动作战,也可「暂离」交给 AI 托管),独立模拟 24 小时内可回来继续。
- 真实 Spine 小人、官方 BGM / 音效、表情(6 套 × 6 个);可选的官方 3D 棋盘(需从本机客户端提取贴图)。
- **战斗在各玩家浏览器里模拟**(和官方一样),服务器只管经济与回合:一台低功耗 Windows 小主机即可开服。
## 快速开始
需要 **Node.js 18+**(推荐 22 或 24 LTS)和约 **400 MB** 磁盘空间。第一次启动会自动安装依赖并下载约 250 MB 素材(可中断,再次启动会续传)。
### Windows
1. 安装 Node.js:在 PowerShell 里运行 `winget install OpenJS.NodeJS.LTS`,或到 <https://nodejs.org/zh-cn/download> 下载安装包。
2. 获取代码:`git clone <本仓库地址>`,或在 GitHub 页面 *Code → Download ZIP* 后解压。
3. 双击 **`scripts\start-windows.bat`**(ZIP 下载的若弹出「安全警告」,点「运行」)。准备完成后会自动打开浏览器,窗口里会列出朋友可用的局域网地址。
也可以用 PowerShell:`powershell -ExecutionPolicy Bypass -File scripts\start-windows.ps1`(没有 Node.js 时会提示用 winget 安装)。
开机自动在后台运行、防火墙设置、固定 IP 等见 **[docs/DEPLOY.md](docs/DEPLOY.md)**。
### macOS / Linux
```bash
git clone <本仓库地址> Stronghold-Protocol && cd Stronghold-Protocol
scripts/start.sh # 首次:npm ci + 下载素材;然后启动服务器并打开浏览器
```
### 手动(任何系统)
```bash
npm ci # 安装依赖(postinstall 会把 pixi / preact / three 复制到 public/vendor)
node tools/setup.mjs # 检查环境、下载/补全素材、(可选)从本机客户端提取官方贴图
npm start # http://localhost:3000
```
`node tools/doctor.mjs` 可随时诊断:Node 版本、素材是否完整、端口占用、局域网地址、防火墙提示。
## 和朋友一起玩
1. 打开页面 → 输入昵称 → **同盟模拟** → 创建房间。房主选择难度,可添加 / 移除 AI 队友。
2. 把 4 位字母的**同盟密钥**,或「复制链接」得到的 `http://<地址>:3000/?room=密钥` 发给朋友。
3. 所有人点「准备就绪」后房主开始。刷新页面 / 断线后 10 分钟内重新打开即可回到原座位(独立模拟:24 小时内,用同一个浏览器)。
**局域网**:服务器启动时会打印 `LAN: http://192.168.x.x:3000`,同一 Wi-Fi / 路由器下的朋友直接打开。打不开时多半是防火墙:Windows 首次启动时在弹窗中允许「专用网络」,或运行 `node tools/doctor.mjs` 查看具体命令。
**公网**(朋友不在同一个网络):
| 方式 | 适合 | 做法 |
|---|---|---|
| **Tailscale / ZeroTier**(推荐) | 家里常开的小主机、固定几个朋友 | 主机和朋友都安装并加入同一个网络,朋友打开 `http://<主机的 100.x / ZeroTier IP>:3000`。无需公网 IP、不暴露到互联网、延迟低。 |
| **cloudflared 临时隧道** | 临时开一局,朋友什么都不用装 | `cloudflared tunnel --url http://localhost:3000`,把输出的 `https://xxxx.trycloudflare.com` 发给朋友。页面会自动使用 `wss://`。每次启动地址都会变。 |
| **路由器端口转发** | 有公网 IPv4 | 把路由器的 TCP 3000 转发到主机的局域网 IP,朋友访问 `http://<公网 IP>:3000`。注意安全(见 DEPLOY)。 |
详细步骤(含 Nginx / Caddy 反向代理与 HTTPS)见 [docs/DEPLOY.md](docs/DEPLOY.md)。玩法说明见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内也有「玩法说明」)。
## 操作
| 操作 | 方法 |
|---|---|
| 购买 / 升级调度中心 | 点一次选中,再点一次确认(`D` 升级) |
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上/右/下/左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消 |
| 调整朝向 | 把干员拖回它自己的格子,再选方向 |
| 出售 / 撤退 / 销毁装备 | 点击单位 → 底部按钮「出售 +1」「撤退」;整备区里的装备与法术只能「销毁」,已配发的装备锁定在干员身上(干员出售或合成精锐时退回整备区)。也可以把棋盘上的干员拖回整备区撤退 |
| 装备 | 把装备拖到干员身上(每人 2 件;满了会弹出替换窗口,选择要替换的一件——它会被销毁);法术拖到地块上并选方向 |
| 查看详情 | 右键或长按单位 / 卡牌 |
| 快捷键 | `R` 刷新 · `F` 冻结 · `D` 升级 · `Space` 准备就绪 · `Esc` 取消 / 关闭 |
| 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 |
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」 |
手机和平板也能玩(触摸拖拽、长按查看),推荐横屏;电脑推荐 Chrome / Edge / Firefox / Safari 最新版。显卡较弱时可在「设置」里调低画质,或在网址后加 `?board=2d`(强制 2D 棋盘)/ `?render=fallback`(不用 WebGL 的简化 DOM 画面)。
## 配置
| 环境变量 | 默认 | 说明 |
|---|---|---|
| `PORT` | `3000` | 监听端口 |
| `HOST` | `0.0.0.0` | 监听地址(`127.0.0.1` = 只允许本机,放在反向代理后面时使用) |
| `SP_COMBAT` | `client` | `client`:各玩家浏览器模拟自己的战斗(官方模式,服务器负载极低);`server`:旧的服务器模拟 + 推流模式 |
| `SP_VERIFY` | `off` | 服务器复算客户端上报的战斗结果:`off` / `sample`(约 1/8 抽查并记录)/ `all`(全部复算,不一致时以服务器为准,更耗 CPU) |
| `TRUST_PROXY` | `auto` | 是否信任 `X-Forwarded-For` 等转发头:`auto` 只信任来自本机/内网的代理(如 cloudflared、Nginx);`1` 总是;`0` 从不 |
| `DEBUG` | 空 | 设为任意值输出详细日志 |
| `SP_NO_BROWSER` | 空 | 设为 `1` 时启动脚本不自动打开浏览器 |
设置方式:macOS / Linux `PORT=8080 npm start`;PowerShell `$env:PORT=8080; npm start`;cmd `set "PORT=8080" && npm start`;启动脚本也接受 `--port 8080`。健康检查:`GET /healthz`。
## 可选:从本机客户端提取官方贴图
如果这台电脑装有《明日方舟》PC 客户端(Windows 原生、macOS CrossOver 或 PlayCover),`node tools/setup.mjs` 会检测到并询问是否提取官方棋盘贴图、UI、表情、模组类型图标和教程图(需要 Python 3.8+,依赖安装在项目内的 `.venv-extract`,不影响系统)。之后可随时 `node tools/setup.mjs --local` 重新提取,或 `--game "<…\StreamingAssets\AB\Windows>"` 指定路径。提取结果只在本机(`public/assets/local/`、`data/local-assets.json`,均已被 git 忽略);没有它游戏一样能玩,只是棋盘为 2D、部分 UI 用替代图(模组类型图标改为文字)。
## 测试
```bash
node --test # 单元 + 集成测试(约 2300 项;缺少素材 / 浏览器的用例会自动跳过)
SP_E2E=1 node --test test/ui/mock.e2e.test.js # 需要本机 Chrome(CHROME_PATH 可指定路径)
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载素材
```
GitHub Actions([.github/workflows/ci.yml](.github/workflows/ci.yml))在 Ubuntu 与 Windows 上运行 `npm ci`、`node --test` 和服务器冒烟测试。
## 项目结构
| 路径 | 内容 |
|---|---|
| `server/` | Node HTTP 静态服务 + WebSocket(`/ws`)、大厅、对局引擎(`match/`)、战斗模拟(`sim/`,浏览器与服务器共用) |
| `shared/` | 前后端共用的常量与网络协议 |
| `public/` | 浏览器客户端(原生 ES 模块,PixiJS + pixi-spine、three.js 3D 棋盘、Preact + htm UI) |
| `data/` | 由官方数据表生成的游戏数据(`tools/build-data.mjs`)与素材清单 `assets.json` |
| `tools/` | `setup.mjs` / `doctor.mjs`、素材下载 `fetch-assets.mjs`、数据构建、本地提取 `local-extract/` |
| `scripts/` | 启动脚本(Windows / macOS / Linux)、Windows 开机自启 |
| `docs/` | [DESIGN](docs/DESIGN.md)(架构与契约)· [DEPLOY](docs/DEPLOY.md) · [PLAYING](docs/PLAYING.md) · [ASSETS](docs/ASSETS.md) · [DATA](docs/DATA.md) · `research/` |
| `test/` | `node:test` 测试 |
## 致谢与声明
- 《明日方舟》及「卫戍协议」相关的全部名称、角色、美术、音频与数据版权归 **上海鹰角网络科技有限公司(Hypergryph)** 及其授权方(Yostar 等)所有。本项目为非官方、非商业的爱好者作品,与官方无任何关联;如有侵权请联系删除。
- **本仓库不包含游戏素材文件**(`docs/img/` 中的几张游戏截图仅用于说明):美术 / 音频 / 字体由 `tools/fetch-assets.mjs` 在使用者本机下载,本地客户端贴图由使用者自行提取,均被 `.gitignore` 排除。请勿把 `public/assets/` 等目录上传或再分发。
- 游戏数据:[Kengxxiao/ArknightsGameData](https://github.com/Kengxxiao/ArknightsGameData)。
- 素材来源:[yuanyan3060/ArknightsGameResource](https://github.com/yuanyan3060/ArknightsGameResource)、[fexli/ArknightsResource](https://github.com/fexli/ArknightsResource)、[isHarryh/Ark-Models](https://github.com/isHarryh/Ark-Models)、[ArknightsAssets/ArknightsAssets2](https://github.com/ArknightsAssets/ArknightsAssets2);字体来自 [TimWangZi/The-font-of-Arknights](https://github.com/TimWangZi/The-font-of-Arknights) 与 Google Fonts(Noto Sans SC)。详见 [docs/ASSETS.md](docs/ASSETS.md)。
- LZ4AK 解包:`tools/local-extract/aklz4.py` 的算法来自 [isHarryh/Ark-Unpacker](https://github.com/isHarryh/Ark-Unpacker)(BSD-3-Clause,经 MooncellWiki/UnityPy);解析 Unity 资源使用 [UnityPy](https://github.com/K0lb3/UnityPy)(MIT)。
- 库:[PixiJS](https://pixijs.com/)(MIT)、[pixi-spine](https://github.com/pixijs/spine)(MIT;其中包含的 Spine Runtime 代码另受 [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license) 约束,其中有关于 Spine 编辑器授权的条款,公开发布前请自行阅读)、[three.js](https://threejs.org/)(MIT)、[Preact](https://preactjs.com/) + [htm](https://github.com/developit/htm)(MIT)、[ws](https://github.com/websockets/ws)(MIT)。这些库通过 npm 安装,不随仓库提交。
- **代码许可证:TODO(由仓库所有者选择,例如 MIT / GPL-3.0;在此之前仓库中的代码保留所有权利)。** 无论选择哪种,上面的游戏素材与第三方内容都不在其范围内。
---
## English
A fan-made, online co-op browser remake of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Covenant*. Solo and 1–4 player co-op (AI teammates fill seats), played over LAN or the internet. Combat is simulated in each player's browser (like the official client), so a low-power mini PC can host.
- **Run:** Node.js 18+ (22 or 24 LTS recommended). Windows: double-click `scripts\start-windows.bat`. macOS/Linux: `scripts/start.sh`. Manual: `npm ci && node tools/setup.mjs && npm start` → <http://localhost:3000>. First start downloads ~250 MB of game art (resumable). `node tools/doctor.mjs` diagnoses ports, assets, LAN addresses and firewall.
- **Friends:** create a co-op room and share the 4-letter code or the `?room=CODE` link. A dropped player can rejoin within 10 minutes (co-op) or resume a solo run within 24 hours (same browser; the server keeps everything in memory, so a restart ends all runs). LAN: use the printed `LAN:` URL. Internet: Tailscale/ZeroTier (recommended), `cloudflared tunnel --url http://localhost:3000` (wss handled automatically) or port forwarding — see [docs/DEPLOY.md](docs/DEPLOY.md).
- **Env:** `PORT`, `HOST`, `SP_COMBAT=client|server`, `SP_VERIFY=off|sample|all`, `TRUST_PROXY=auto|1|0`, `DEBUG`.
- **Disclaimer:** unofficial and non-commercial. Game names, art, audio and data © Hypergryph / Yostar; **no game art/audio/font files are in this repository** (only a few screenshots in `docs/img/`) — they are downloaded or extracted locally and git-ignored. Code licence: TODO (owner to choose).
+1
View File
@@ -0,0 +1 @@
{"version":1,"hash":"4135541dd0d3","generator":"tools/fetch-assets.mjs","stats":{"files":3927,"bytes":259646135,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
+1
View File
@@ -0,0 +1 @@
{"band_bldsk":{"bandId":"band_bldsk","sortId":1,"name":"华法琳","iconId":"icon_bldsk","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_21","effectName":"重点监护","desc":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","descRaw":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","buffs":[{"key":"prep_finish_char_bond_add_layer","bb":{"layer":2},"bbStr":{}}],"params":{"layer":2},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_amiya":{"bandId":"band_amiya","sortId":2,"name":"阿米娅","iconId":"icon_amiya","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":25,"effectId":"aceffect_band_22","effectName":"众志合一","desc":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","descRaw":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5},"bbStr":{"key":"act1autochess_band2_buff"}}],"params":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5,"key":"act1autochess_band2_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_duyaoy":{"bandId":"band_duyaoy","sortId":3,"name":"杜遥夜","iconId":"icon_duyaoy","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_28","effectName":"广交豪杰","desc":"【广交豪杰】每回合前2次主动刷新为特殊刷新:优先刷新出1名<炎>干员\n在<炎>部分干员缺席时体验可能不完整","descRaw":"【广交豪杰】每回合前2次主动刷新为特殊刷新:优先刷新出1名<炎>干员\n<@ba.vdown>在<炎>部分干员缺席时体验可能不完整</>","buffs":[{"key":"band_first_self_refresh_present_char","bb":{"count":1},"bbStr":{"bond":"yanShip"}}],"params":{"count":1,"bond":"yanShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_sarkazb":{"bandId":"band_sarkazb","sortId":4,"name":"歌利亚","iconId":"icon_sarkazb","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":45,"effectId":"aceffect_band_44","effectName":"坚不可摧","desc":"【坚不可摧】初始目标生命值为45点","descRaw":"【坚不可摧】初始目标生命值为45点","buffs":[],"params":{},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_orchid":{"bandId":"band_orchid","sortId":5,"name":"梓兰","iconId":"icon_orchid","modeTypeList":["SINGLE","MULTI"],"totalHp":30,"effectId":"aceffect_band_23","effectName":"猎头顾问","desc":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","descRaw":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<@ba.acrem><寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)</>","buffs":[{"key":"band_shop_refresh_copy_max_lv_char","bb":{"count":1},"bbStr":{}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}}],"params":{"count":1,"round":10,"type":"equip","chess":"chess_item_4_01_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【标准模拟】中通过第9回合后解锁"},"band_justin":{"bandId":"band_justin","sortId":6,"name":"小贾斯汀","iconId":"icon_justin","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_47","effectName":"私募基金","desc":"【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐>\n<骑士储蓄罐>:装备时随机获得1~6资金并销毁","descRaw":"【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐>\n<@ba.acrem><骑士储蓄罐>:装备时随机获得1~6资金并销毁</>","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":4,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":7,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}}],"params":{"round":1,"count":1,"type":"equip","chLine truncated
+1
View File
@@ -0,0 +1 @@
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
+1
View File
@@ -0,0 +1 @@
{"boss_1":{"bossId":"boss_1","enemyKey":"enemy_9013_acstmk","handbookId":"enemy_9013_acstmk","name":"假想敌:胄","sortId":1,"weight":6,"hidden":false,"bloodPoint":{"FUNNY":247500,"NORMAL":675000,"HARD":1800000,"ABYSS":3600000},"lpr":2,"templates":{"mode_single_funny":{"round":9,"template":"act1autochess_h07_01_s"},"mode_single_normal":{"round":14,"template":"act1autochess_h07_01_s"},"mode_single_hard":{"round":14,"template":"act1autochess_h07_01_s"},"mode_single_abyss":{"round":14,"template":"act1autochess_h07_01_s"},"mode_multi_funny":{"round":14,"template":"act1autochess_h07_01"},"mode_multi_normal":{"round":14,"template":"act1autochess_h07_01"},"mode_multi_hard":{"round":14,"template":"act1autochess_h07_01"},"mode_multi_abyss":{"round":14,"template":"act1autochess_h07_01"}},"escortsByTemplate":{"act1autochess_h07_01_s":[{"key":"enemy_1427_lrnazg","slot":"E","count":2},{"key":"enemy_1042_frostd","slot":"EF","count":3},{"key":"enemy_1006_shield_2","slot":null,"count":9}],"act1autochess_h07_01":[{"key":"enemy_1427_lrnazg","slot":"E","count":2},{"key":"enemy_1042_frostd","slot":"EF","count":3},{"key":"enemy_1006_shield_2","slot":null,"count":14}]},"parts":[],"abilities":["随机选择目标发射射线造成法术伤害","【灭顶之灾】选择攻击力最高的干员对其发射一枚<刺胄之弹>,<刺胄之弹>到达目标位置时爆炸使目标及周围8格单位晕眩较长时间且每秒受到物理伤害;<刺胄之弹>飞行时对其造成数次攻击可将其击破","生命值低于一定比例时,受到的物理与法术伤害降低,【灭顶之灾】额外发射<刺胄之弹>","【死亡集群】周期性召唤无人机单位","隐秘核心","【旧日的剑与锤】在模拟中达成特定条件并进入隐秘核心回合时,<假想敌:胄>将获得<“斩胄之剑”>和<“破胄之锤”>以完全形态登场","<“斩胄之剑”>和<“破胄之锤”>初始无敌;被击落时受到伤害以一定比例传递给<假想敌:胄>","<“斩胄之剑”>和<“破胄之锤”>周期性飞向场上攻击力最低的一名干员并自爆,使目标及其周围8格的干员晕眩,每秒受到物理伤害,随后返回<假想敌:胄>身边;飞行途中失去无敌且受到一定次数伤害后会被击落;被击落后,视为地面单位且受到的伤害增加"]},"boss_2":{"bossId":"boss_2","enemyKey":"enemy_9017_achunt","handbookId":"enemy_9017_achunt","name":"假想敌:铳","sortId":2,"weight":6,"hidden":false,"bloodPoint":{"FUNNY":225000,"NORMAL":400000,"HARD":800000,"ABYSS":3000000},"lpr":2,"templates":{"mode_single_funny":{"round":9,"template":"act1autochess_h07_02_s"},"mode_single_normal":{"round":14,"template":"act1autochess_h07_02_s"},"mode_single_hard":{"round":14,"template":"act1autochess_h07_02_s"},"mode_single_abyss":{"round":14,"template":"act1autochess_h07_02_s"},"mode_multi_funny":{"round":14,"template":"act1autochess_h07_02"},"mode_multi_normal":{"round":14,"template":"act1autochess_h07_02"},"mode_multi_hard":{"round":14,"template":"act1autochess_h07_02"},"mode_multi_abyss":{"round":14,"template":"act1autochess_h07_02"}},"escortsByTemplate":{"act1autochess_h07_02_s":[{"key":"enemy_1427_lrnazg","slot":"E","count":2},{"key":"enemy_1430_lrrook","slot":null,"count":2},{"key":"enemy_1042_frostd","slot":"EF","count":3}],"act1autochess_h07_02":[{"key":"enemy_1427_lrnazg","slot":"E","count":2},{"key":"enemy_1430_lrrook","slot":null,"count":2},{"key":"enemy_1042_frostd","slot":"EF","count":3}]},"parts":[],"abilities":["优先攻击防御力最高的单位","攻击时附带侵蚀损伤","无法被阻挡","对同一个目标进行攻击时,攻击速度逐渐提升","【最终之罚】停止攻击,移动速度大幅提升,向全场防御力最高的单位冲锋。对经过的单位造成物理伤害","隐秘核心","【未尽的告解】在模拟中达成特定条件并进入隐秘核心回合时,场上出现<“碎铳之簧”>,同时<假想敌:铳>受到的物理与法术伤害大幅降低","【盲信之誓】与场上所有<“碎铳之簧”>连结,<“碎铳之簧”>受到伤害时以一定比例传递给<假想敌:铳>,处于【盲信之誓】连结上的我方单位每秒受到物理伤害","【末日布道】持续召唤场上所有<“碎铳之簧”>向自身移动,<“碎铳之簧”>移动速度大幅提升并免疫伤害,对经过的我方单位造成物理伤害"]},"boss_3":{"bossId":"boss_3","enemyKey":"enemy_9021_acduml","handbookId":"enemy_9021_acduml","name":"假想敌:管","sortId":3,"weight":6,"hidden":false,"bloodPoint":{"FUNNY":285000,"NORMAL":708750,"HARD":2000000,"ABYSS":4000000},"lpr":2,"templates":{"mode_single_funny":{"round":9,"template":"act1autochess_h07_03_s"},"mode_single_normal":{"round":14,"template":"act1autochess_h07_03_s"},"mode_single_hard":{"round":14,"template":"act1autochess_h07_03_s"},"mode_single_abyss":{"round":14,"template":"act1autochess_h07_03_s"},"mode_multi_funny":{"round":14,"template"Line truncated
+1
View File
@@ -0,0 +1 @@
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":399,"def":140,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}]},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garriLine truncated
+1
View File
@@ -0,0 +1 @@
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
+1
View File
@@ -0,0 +1 @@
{"season":"act2autochess","seasonName":"卫戍协议:盟约","modes":{"mode_training_1":{"modeId":"mode_training_1","name":"入门协议","code":"AC-TR-1","sortId":1,"type":"LOCAL","difficulty":"TRAINING","inScope":false,"color":"#4ed8af","iconId":"mode_training_icon","backgroundId":"background_training","desc":"用以熟悉协议内容的模式","effectDescList":["·仅可使用预置的调配干员","·无奖励","·无法用于解锁策略"],"unlockText":null,"specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","lateranoShip","egirShip","siracusaShip","kazimierzShip","preciShip","swiftShip","skillfulShip","arcaneShip","steadShip","deputShip","visiShip","miraShip","investShip","raidShip","indomShip","maniShip","emptyShip","soloShip","suntShip"],"inactiveBondIds":[],"inactiveEnemyKeys":[],"lastRound":4,"bossRound":4,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_tr01","bossTemplates":null,"combatTimeLimit":40,"levelMaxPlayTime":40,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":true,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":null,"bossTemplates":{"boss_7":"act1autochess_tr04"},"combatTimeLimit":null,"levelMaxPlayTime":180,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":true,"isHidden":false,"bossOvertimeAfter":150}},"spRounds":[3],"combatTimeLimit":{"1":40,"2":45,"3":45,"4":null},"enemyScale":{"1":{"atk":1,"hp":1,"speed":1,"assumed":true},"2":{"atk":1,"hp":1,"speed":1,"assumed":true},"3":{"atk":1,"hp":1,"speed":1,"assumed":true},"4":{"atk":1,"hp":1,"speed":1,"assumed":true}},"bossHpScale":{"bloodPointKey":null,"coop":1,"aliveScaling":false,"unaffectedByEnemyScale":true},"upgradePrices":[4,4,6,9,12],"maxShopLevel":6,"shopSlots":{"1":{"chess":3,"item":0},"2":{"chess":3,"item":0},"3":{"chess":3,"item":0},"4":{"chess":3,"item":0},"5":{"chess":3,"item":1},"6":{"chess":3,"item":1}},"levelTagColors":{"1":"#434343","2":"#626654","3":"#445760","4":"#615B74","5":"#6C5E41","6":"#5d341e"},"stages":["act1autochess_m01"],"bossWeights":{"boss_7":5},"hiddenBossWeights":{}},"mode_single_funny":{"modeId":"mode_single_funny","name":"标准模拟","code":"AC-1","sortId":2,"type":"SINGLE","difficulty":"FUNNY","inScope":true,"color":"#f6a329","iconId":"mode_funny_icon","backgroundId":"background_funny","desc":"时长较短的模拟训练","effectDescList":["·可以快速完成作战","·常规奖励"],"unlockText":"通关【入门协议】后解锁","specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","siracusaShip","preciShip","swiftShip","steadShip","deputShip","visiShip","indomShip","maniShip","emptyShip"],"inactiveBondIds":["lateranoShip","egirShip","kazimierzShip","skillfulShip","arcaneShip","miraShip","investShip","raidShip","soloShip","suntShip"],"inactiveEnemyKeys":["enemy_1112_emppnt","enemy_1207_sfji","enemy_1275_dwlock_2","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_9008_acbunn","enemy_1174_duholy_2","enemy_1355_mrfly_2","enemy_1197_sfshu_2","enemy_10067_ftsjc","enemy_1270_nhstlk_2","enemy_1283_sgkill","enemy_1175_dushdo_2","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1072_dlancer"],"lastRound":9,"bossRound":9,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_01","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_03","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":"act1autochess_04","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"5":{"template":"act1autochess_05","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"6":{"template":"act1autochess_06","bossTemplates":null,"combatTimeLimit":70,"levelMaxPlayTime":70,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"7":{"template":"act1auLine truncated
+1
View File
@@ -0,0 +1 @@
{"aceffect_band_21":{"effectId":"aceffect_band_21","effectType":"BAND_INITIAL","name":"重点监护","desc":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","descRaw":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","counterType":"NONE","continuedRound":-1,"decoIconId":null,"enemyPrice":0,"buffs":[{"key":"prep_finish_char_bond_add_layer","countType":"NONE","bb":{"layer":2},"bbStr":{}}],"params":{"layer":2}},"aceffect_band_22":{"effectId":"aceffect_band_22","effectType":"BAND_INITIAL","name":"众志合一","desc":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","descRaw":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","counterType":"NONE","continuedRound":-1,"decoIconId":null,"enemyPrice":0,"buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5},"bbStr":{"key":"act1autochess_band2_buff"}}],"params":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5,"key":"act1autochess_band2_buff"}},"aceffect_band_23":{"effectId":"aceffect_band_23","effectType":"BAND_INITIAL","name":"猎头顾问","desc":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","descRaw":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<@ba.acrem><寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)</>","counterType":"NONE","continuedRound":-1,"decoIconId":null,"enemyPrice":0,"buffs":[{"key":"band_shop_refresh_copy_max_lv_char","countType":"NONE","bb":{"count":1},"bbStr":{}},{"key":"preparation_start_gain_chess_from_round","countType":"NONE","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}}],"params":{"count":1,"round":10,"type":"equip","chess":"chess_item_4_01_e_a"}},"aceffect_band_24":{"effectId":"aceffect_band_24","effectType":"BAND_INITIAL","name":"通关奖励","desc":"【通关奖励】每使用20资金,获得一名不高于当前调度中心等级的随机干员","descRaw":"【通关奖励】每使用20资金,获得一名不高于当前调度中心等级的随机干员","counterType":"NONE","continuedRound":-1,"decoIconId":null,"enemyPrice":0,"buffs":[{"key":"band_coin_cost_gain_random_char_by_shop_level","countType":"NONE","bb":{"coin_cnt":20,"count":1},"bbStr":{}}],"params":{"coin_cnt":20,"count":1}},"aceffect_band_25":{"effectId":"aceffect_band_25","effectType":"BAND_INITIAL","name":"加练!","desc":"【加练!】每2个回合获得1个特殊法术<教鞭>\n<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金","descRaw":"【加练!】每2个回合获得1个特殊法术<教鞭>\n<@ba.acrem><教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金</>","counterType":"NONE","continuedRound":-1,"decoIconId":null,"enemyPrice":0,"buffs":[{"key":"preparation_start_gain_chess_every_n_round","countType":"NONE","bb":{"round":2,"count":1},"bbStr":{"type":"trap","chess":"chess_item_6_03_m"}}],"params":{"round":2,"count":1,"type":"trap","chess":"chess_item_6_03_m"}},"aceffect_band_26":{"effectId":"aceffect_band_26","effectType":"BAND_INITIAL","name":"定向投放","desc":"【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件","descRaw":"【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件","counterType":"NONE","continuedRound":-1,"decoIconId":null,"enemyPrice":0,"buffs":[{"key":"up_shop_add_special_goods","countType":"NONE","bb":{"count":3,"choice":1},"bbStr":{"pool":"pool_equip_kathe"}}],"params":{"count":3,"choice":1,"pool":"pool_equip_kathe"}},"aceffect_band_27":{"effectId":"aceffect_band_27","effectType":"BAND_INITIAL","name":"商业包装","desc":"【商业包装】初始获得1个<商业包装方案>\n<商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员","descRaw":"【商业包装】初始获得1个<商业包装方案>\n<@ba.acrem><商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员</>","counterType":"NONE","continuedRound":-1,"decoIconId":null,"enemyPrLine truncated
+52
View File
@@ -0,0 +1,52 @@
{
"version": 1,
"source": "display_meta_table.emoticonData (type AUTOCHESS_BATTLE) × activity_table autoChessData.enabledEmoticonThemeIdList",
"chatCD": 1,
"chatTime": 3,
"themes": [
{"themeId":"emoticon_autochess_basic","dir":"basic","sortId":100000,"isBasic":true,"name":"表情套组:卫戍协议","emotes":["autochess_battle_happy","autochess_battle_scared","autochess_battle_sorry","autochess_battle_thanks","autochess_battle_thinking","autochess_battle_nice_cooperate"]},
{"themeId":"emoticon_originium_slug","dir":"slug","sortId":1001,"isBasic":false,"name":"表情套组:虫动","emotes":["slug_autochess_battle_nice_work","slug_autochess_battle_thanks","slug_autochess_battle_sorry","slug_autochess_battle_bye","slug_autochess_battle_distrust","slug_autochess_battle_very_soon"]},
{"themeId":"emoticon_autochess_basic_2","dir":"basic_2","sortId":100001,"isBasic":true,"name":"表情套组:卫戍协议","emotes":["autochess_battle_noproblem","autochess_battle_respect","autochess_battle_call","autochess_battle_playingcool","autochess_battle_sad","autochess_battle_dying"]},
{"themeId":"emoticon_foolsday_doctor","dir":"fooldoctor","sortId":1002,"isBasic":false,"name":"表情套组:博士士","emotes":["autochess_battle_fooldoctor_01","autochess_battle_fooldoctor_02","autochess_battle_fooldoctor_03","autochess_battle_fooldoctor_04","autochess_battle_fooldoctor_05","autochess_battle_fooldoctor_06"]},
{"themeId":"emoticon_foolsday_amiya","dir":"foolamiya","sortId":1003,"isBasic":false,"name":"表情套组:米米子","emotes":["autochess_battle_foolamiya_01","autochess_battle_foolamiya_02","autochess_battle_foolamiya_03","autochess_battle_foolamiya_04","autochess_battle_foolamiya_05","autochess_battle_foolamiya_06"]},
{"themeId":"emoticon_foolsday_wisdel","dir":"foolwisdel","sortId":1004,"isBasic":false,"name":"表情套组:维维美","emotes":["autochess_battle_foolwisdel_01","autochess_battle_foolwisdel_02","autochess_battle_foolwisdel_03","autochess_battle_foolwisdel_04","autochess_battle_foolwisdel_05","autochess_battle_foolwisdel_06"]}
],
"emotes": [
{"id":"autochess_battle_happy","themeId":"emoticon_autochess_basic","sortId":1001,"picId":"pic_happy_battle","art":"/assets/local/emoticon/basic/pic_happy_battle.png","label":"开心"},
{"id":"autochess_battle_scared","themeId":"emoticon_autochess_basic","sortId":1002,"picId":"pic_scared_battle","art":"/assets/local/emoticon/basic/pic_scared_battle.png","label":"害怕"},
{"id":"autochess_battle_sorry","themeId":"emoticon_autochess_basic","sortId":1003,"picId":"pic_sorry_battle","art":"/assets/local/emoticon/basic/pic_sorry_battle.png","label":"对不起"},
{"id":"autochess_battle_thanks","themeId":"emoticon_autochess_basic","sortId":1004,"picId":"pic_thanks_battle","art":"/assets/local/emoticon/basic/pic_thanks_battle.png","label":"谢谢"},
{"id":"autochess_battle_thinking","themeId":"emoticon_autochess_basic","sortId":1005,"picId":"pic_thinking_battle","art":"/assets/local/emoticon/basic/pic_thinking_battle.png","label":"思考"},
{"id":"autochess_battle_nice_cooperate","themeId":"emoticon_autochess_basic","sortId":1006,"picId":"pic_cooperate_battle","art":"/assets/local/emoticon/basic/pic_cooperate_battle.png","label":"合作愉快"},
{"id":"slug_autochess_battle_nice_work","themeId":"emoticon_originium_slug","sortId":2001,"picId":"pic_nice_work_battle","art":"/assets/local/emoticon/slug/pic_nice_work_battle.png","label":"合作愉快!"},
{"id":"slug_autochess_battle_thanks","themeId":"emoticon_originium_slug","sortId":2002,"picId":"pic_thanks_battle","art":"/assets/local/emoticon/slug/pic_thanks_battle.png","label":"谢谢!"},
{"id":"slug_autochess_battle_sorry","themeId":"emoticon_originium_slug","sortId":2003,"picId":"pic_sorry_battle","art":"/assets/local/emoticon/slug/pic_sorry_battle.png","label":"对不起!"},
{"id":"slug_autochess_battle_bye","themeId":"emoticon_originium_slug","sortId":2004,"picId":"pic_bye_battle","art":"/assets/local/emoticon/slug/pic_bye_battle.png","label":"再见!"},
{"id":"slug_autochess_battle_distrust","themeId":"emoticon_originium_slug","sortId":2005,"picId":"pic_distrust_battle","art":"/assets/local/emoticon/slug/pic_distrust_battle.png","label":"???"},
{"id":"slug_autochess_battle_very_soon","themeId":"emoticon_originium_slug","sortId":2006,"picId":"pic_very_soon_battle","art":"/assets/local/emoticon/slug/pic_very_soon_battle.png","label":"很快就好!"},
{"id":"autochess_battle_noproblem","themeId":"emoticon_autochess_basic_2","sortId":1007,"picId":"pic_noproblem_battle","art":"/assets/local/emoticon/basic_2/pic_noproblem_battle.png","label":"没问题!"},
{"id":"autochess_battle_respect","themeId":"emoticon_autochess_basic_2","sortId":1008,"picId":"pic_respect_battle","art":"/assets/local/emoticon/basic_2/pic_respect_battle.png","label":"敬礼!"},
{"id":"autochess_battle_call","themeId":"emoticon_autochess_basic_2","sortId":1009,"picId":"pic_call_battle","art":"/assets/local/emoticon/basic_2/pic_call_battle.png","label":"欢呼!"},
{"id":"autochess_battle_playingcool","themeId":"emoticon_autochess_basic_2","sortId":1010,"picId":"pic_playingcool_battle","art":"/assets/local/emoticon/basic_2/pic_playingcool_battle.png","label":"酷!"},
{"id":"autochess_battle_sad","themeId":"emoticon_autochess_basic_2","sortId":1011,"picId":"pic_sad_battle","art":"/assets/local/emoticon/basic_2/pic_sad_battle.png","label":"伤心"},
{"id":"autochess_battle_dying","themeId":"emoticon_autochess_basic_2","sortId":1012,"picId":"pic_dying_battle","art":"/assets/local/emoticon/basic_2/pic_dying_battle.png","label":"快死了"},
{"id":"autochess_battle_fooldoctor_01","themeId":"emoticon_foolsday_doctor","sortId":1020,"picId":"pic_fooldoctor_01_battle","art":"/assets/local/emoticon/fooldoctor/pic_fooldoctor_01_battle.png","label":"博士士 1"},
{"id":"autochess_battle_fooldoctor_02","themeId":"emoticon_foolsday_doctor","sortId":1021,"picId":"pic_fooldoctor_02_battle","art":"/assets/local/emoticon/fooldoctor/pic_fooldoctor_02_battle.png","label":"博士士 2"},
{"id":"autochess_battle_fooldoctor_03","themeId":"emoticon_foolsday_doctor","sortId":1022,"picId":"pic_fooldoctor_04_battle","art":"/assets/local/emoticon/fooldoctor/pic_fooldoctor_04_battle.png","label":"博士士 3"},
{"id":"autochess_battle_fooldoctor_04","themeId":"emoticon_foolsday_doctor","sortId":1023,"picId":"pic_fooldoctor_05_battle","art":"/assets/local/emoticon/fooldoctor/pic_fooldoctor_05_battle.png","label":"博士士 4"},
{"id":"autochess_battle_fooldoctor_05","themeId":"emoticon_foolsday_doctor","sortId":1024,"picId":"pic_fooldoctor_06_battle","art":"/assets/local/emoticon/fooldoctor/pic_fooldoctor_06_battle.png","label":"博士士 5"},
{"id":"autochess_battle_fooldoctor_06","themeId":"emoticon_foolsday_doctor","sortId":1025,"picId":"pic_fooldoctor_08_battle","art":"/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png","label":"博士士 6"},
{"id":"autochess_battle_foolamiya_01","themeId":"emoticon_foolsday_amiya","sortId":1040,"picId":"pic_foolamiya_01_battle","art":"/assets/local/emoticon/foolamiya/pic_foolamiya_01_battle.png","label":"米米子 1"},
{"id":"autochess_battle_foolamiya_02","themeId":"emoticon_foolsday_amiya","sortId":1041,"picId":"pic_foolamiya_02_battle","art":"/assets/local/emoticon/foolamiya/pic_foolamiya_02_battle.png","label":"米米子 2"},
{"id":"autochess_battle_foolamiya_03","themeId":"emoticon_foolsday_amiya","sortId":1042,"picId":"pic_foolamiya_03_battle","art":"/assets/local/emoticon/foolamiya/pic_foolamiya_03_battle.png","label":"米米子 3"},
{"id":"autochess_battle_foolamiya_04","themeId":"emoticon_foolsday_amiya","sortId":1043,"picId":"pic_foolamiya_04_battle","art":"/assets/local/emoticon/foolamiya/pic_foolamiya_04_battle.png","label":"米米子 4"},
{"id":"autochess_battle_foolamiya_05","themeId":"emoticon_foolsday_amiya","sortId":1044,"picId":"pic_foolamiya_05_battle","art":"/assets/local/emoticon/foolamiya/pic_foolamiya_05_battle.png","label":"米米子 5"},
{"id":"autochess_battle_foolamiya_06","themeId":"emoticon_foolsday_amiya","sortId":1045,"picId":"pic_foolamiya_06_battle","art":"/assets/local/emoticon/foolamiya/pic_foolamiya_06_battle.png","label":"米米子 6"},
{"id":"autochess_battle_foolwisdel_01","themeId":"emoticon_foolsday_wisdel","sortId":1060,"picId":"pic_foolwisdel_01_battle","art":"/assets/local/emoticon/foolwisdel/pic_foolwisdel_01_battle.png","label":"维维美 1"},
{"id":"autochess_battle_foolwisdel_02","themeId":"emoticon_foolsday_wisdel","sortId":1061,"picId":"pic_foolwisdel_02_battle","art":"/assets/local/emoticon/foolwisdel/pic_foolwisdel_02_battle.png","label":"维维美 2"},
{"id":"autochess_battle_foolwisdel_03","themeId":"emoticon_foolsday_wisdel","sortId":1062,"picId":"pic_foolwisdel_03_battle","art":"/assets/local/emoticon/foolwisdel/pic_foolwisdel_03_battle.png","label":"维维美 3"},
{"id":"autochess_battle_foolwisdel_04","themeId":"emoticon_foolsday_wisdel","sortId":1063,"picId":"pic_foolwisdel_04_battle","art":"/assets/local/emoticon/foolwisdel/pic_foolwisdel_04_battle.png","label":"维维美 4"},
{"id":"autochess_battle_foolwisdel_05","themeId":"emoticon_foolsday_wisdel","sortId":1064,"picId":"pic_foolwisdel_05_battle","art":"/assets/local/emoticon/foolwisdel/pic_foolwisdel_05_battle.png","label":"维维美 5"},
{"id":"autochess_battle_foolwisdel_06","themeId":"emoticon_foolsday_wisdel","sortId":1065,"picId":"pic_foolwisdel_06_battle","art":"/assets/local/emoticon/foolwisdel/pic_foolwisdel_06_battle.png","label":"维维美 6"}
]
}
+1
View File
@@ -0,0 +1 @@
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2"},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo"},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai"},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2"},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerLine truncated
+1
View File
@@ -0,0 +1 @@
{"templateSlots":{"N":"enemy_1422_lrsldr","E":"enemy_1427_lrnazg","S":"enemy_1425_lrcmra","NF":"enemy_1005_yokai","EF":"enemy_1042_frostd","SF":"enemy_1040_bombd","T":"enemy_1000_gopro_2","TF":"enemy_1041_lazerd"},"types":{"SPECIAL":{"type":"SPECIAL","name":"特训敌人·特异","desc":"此类敌人拥有优秀的造成伤害和承受伤害能力","icon":"special_icon","sortId":1,"typeIdentifier":1,"involveRandom":false,"count":-1,"weight":-1,"pool":["enemy_1010_demon_2","enemy_1071_dftman","enemy_1010_demon","enemy_1045_hammer","enemy_1043_zomsbr","enemy_1046_agent","enemy_1118_lidbox_2","enemy_1116_liprr","enemy_1116_liprr_2","enemy_1325_cbgpro_2","enemy_1000_gopro_2","enemy_1325_cbgpro","enemy_1121_lifbos","enemy_1006_shield_3","enemy_1402_tgshd_2","enemy_1329_cbshld","enemy_1251_lysyta","enemy_1249_lysdb_2","enemy_1045_hammer_2","enemy_1061_zomshd","enemy_1387_winshd","enemy_1381_winman","enemy_1381_winman_2","enemy_1121_lifbos_2","enemy_1119_vofsd","enemy_1320_wdrrl_2","enemy_1422_lrsldr_2","enemy_10124_uashld_2","enemy_1328_cbjedi","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1251_lysyta_2","enemy_1252_lysytb_2","enemy_1072_dlancer"],"entries":["enemy_1006_shield_3","enemy_1010_demon_2","enemy_1045_hammer","enemy_1045_hammer_2","enemy_1072_dlancer","enemy_1118_lidbox_2","enemy_1121_lifbos","enemy_1121_lifbos_2","enemy_1254_lypa_2","enemy_1320_wdrrl_2","enemy_1325_cbgpro_2","enemy_1328_cbjedi","enemy_1329_cbshld","enemy_1329_cbshld_2","enemy_1387_winshd","enemy_1425_lrcmra_2","enemy_10124_uashld_2"]},"FLY":{"type":"FLY","name":"特训敌人·飞行","desc":"此类敌人包含大量空中单位","icon":"fly_icon","sortId":2,"typeIdentifier":2,"involveRandom":true,"count":3,"weight":3,"pool":["enemy_1041_lazerd","enemy_10083_hlbird","enemy_1005_yokai_2","enemy_1355_mrfly","enemy_1005_yokai","enemy_1017_defdrn","enemy_10084_hlegle","enemy_1407_hummbd","enemy_1005_yokai_3","enemy_1112_emppnt","enemy_1041_lazerd_2","enemy_1042_frostd","enemy_9009_acfort","enemy_1355_mrfly_2"],"entries":["enemy_1005_yokai_3","enemy_1017_defdrn","enemy_1041_lazerd","enemy_1041_lazerd_2","enemy_1112_emppnt","enemy_1355_mrfly","enemy_1355_mrfly_2","enemy_1407_hummbd","enemy_9009_acfort","enemy_10084_hlegle"]},"TIMES":{"type":"TIMES","name":"特训敌人·频次","desc":"此类敌人需要一定攻击次数击破","icon":"times_icon","sortId":3,"typeIdentifier":3,"involveRandom":true,"count":3,"weight":3,"pool":["enemy_1195_sfyin_2","enemy_1195_sfyin","enemy_1197_sfshu","enemy_1288_duskls_2","enemy_1288_duskls","enemy_1292_duskld","enemy_1207_sfji","enemy_1199_sfjin","enemy_9010_acpupp","enemy_1197_sfshu_2"],"entries":["enemy_1195_sfyin_2","enemy_1197_sfshu","enemy_1197_sfshu_2","enemy_1199_sfjin","enemy_1207_sfji","enemy_1288_duskls_2","enemy_1292_duskld","enemy_9010_acpupp"]},"ELEMENT":{"type":"ELEMENT","name":"特训敌人·元素","desc":"此类敌人拥有造成元素损伤的能力","icon":"element_icon","sortId":4,"typeIdentifier":4,"involveRandom":true,"count":3,"weight":3,"pool":["enemy_1161_tidmag","enemy_1158_divman","enemy_1160_hvyslr","enemy_1305_mhslim_2","enemy_1305_mhslim","enemy_2025_syufo","enemy_2021_syfish","enemy_1161_tidmag_2","enemy_1160_hvyslr_2","enemy_1439_dslntf","enemy_1148_dssbr","enemy_1148_dssbr_2","enemy_1275_dwlock_2","enemy_1229_darmy","enemy_1229_darmy_2","enemy_9007_acelem","enemy_10067_ftsjc","enemy_10065_ftzlc"],"entries":["enemy_1161_tidmag","enemy_1161_tidmag_2","enemy_1275_dwlock_2","enemy_1305_mhslim_2","enemy_1439_dslntf","enemy_2025_syufo","enemy_9007_acelem","enemy_10067_ftsjc"]},"DOT":{"type":"DOT","name":"特训敌人·持续","desc":"此类敌人擅长造成持续性伤害","icon":"dot_icon","sortId":5,"typeIdentifier":5,"involveRandom":true,"count":3,"weight":3,"pool":["enemy_1234_dsubrl","enemy_1433_dsbasi","enemy_1433_dsbasi_2","enemy_1272_nhtank","enemy_1267_nhpbr","enemy_1267_nhpbr_2","enemy_1270_nhstlk","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_1270_nhstlk_2","enemy_9006_actoxi"],"entries":["enemy_1234_dsubrl","enemy_1234_dsubrl_2","enemy_1270_nhstlk","enemy_1270_nhstlk_2","enemy_1272_nhtank","enemy_1272_nhtank_2","enemy_9006_actoxi","enemy_10122_uacann_2"]},"INVISIBLE":{"type":"INVISIBLE","name":"特训敌人·隐匿","desc":"此类敌人拥有隐匿","icon":"invisible_icon","sortId":6,"typeIdentifier":6,"involveRandom":true,"count":3,"weight":3,"pool":["enemy_1299_ymkilr","enemy_1043_zomsbr","enemy_1019_jshoot","enemy_10034_cnvsax","enemy_10042_prtrop_2","enemy_10043_sailor","enemy_10042_prtrop","enemy_1389_winbab_2","enemy_1381_winman","enemy_1019_jshoot_2","enemy_1299_ymkilr_2","enemy_1422_lrsldr_2","enemy_1404_msnip","enemy_1009_lurker","enemy_9008_acbunn","enemy_10031_cnvsld","enemy_1283_sgkill"],"entries":["enemy_1283_sgkill","enemy_1299_ymkilr","enemy_1299_ymkilr_2","enemy_1389_winbab_2","enemy_1404_msnip","enemy_9008_acbunn","enemy_10034_cnvsax","enemy_10042_prtrop_2"]},"REFLECTION":{"type":"REFLECTION","namLine truncated
+1
View File
@@ -0,0 +1 @@
{"garrison_01_a":{"garrisonId":"garrison_01_a","desc":"<战斗中>造成的伤害变为弱点伤害(基于敌人的防御力和法术抗性变换物理和法术伤害)","descRaw":"<战斗中>造成的伤害变为弱点伤害(基于敌人的防御力和法术抗性变换物理和法术伤害)","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"act1autochess_gar_eff_chaos","battleRuneKey":"env_gbuff_new_with_verify","charLevel":0,"bb":{},"bbStr":{"key":"act1autochess_gar_eff_chaos"},"owners":["chess_char_1_18_a","chess_char_3_17_a","chess_char_3_19_a"]},"garrison_01_b":{"garrisonId":"garrison_01_b","desc":"<战斗中>造成的伤害变为弱点伤害(基于敌人的防御力和法术抗性变换物理和法术伤害)","descRaw":"<战斗中>造成的伤害变为弱点伤害(基于敌人的防御力和法术抗性变换物理和法术伤害)","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"act1autochess_gar_eff_chaos","battleRuneKey":"env_gbuff_new_with_verify","charLevel":0,"bb":{},"bbStr":{"key":"act1autochess_gar_eff_chaos"},"owners":["chess_char_1_18_b","chess_char_3_17_b","chess_char_3_19_b"]},"garrison_03_a":{"garrisonId":"garrison_03_a","desc":"攻击力和生命值+20%","descRaw":"攻击力和生命值+<@ba.vup>20%</>","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"GAIN_BUFF","battleRuneKey":"char_attribute_mul","charLevel":0,"bb":{"atk":1.2,"max_hp":1.2},"bbStr":{},"owners":["chess_char_1_15_a","chess_char_2_05_a","chess_char_2_19_a","chess_char_6_12_a"]},"garrison_03_b":{"garrisonId":"garrison_03_b","desc":"攻击力和生命值+40%","descRaw":"攻击力和生命值+<@ba.vup>40%</>","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"GAIN_BUFF","battleRuneKey":"char_attribute_mul","charLevel":0,"bb":{"atk":1.4,"max_hp":1.4},"bbStr":{},"owners":["chess_char_1_15_b","chess_char_2_05_b","chess_char_2_19_b","chess_char_6_12_b"]},"garrison_04_a":{"garrisonId":"garrison_04_a","desc":"购买价格为1","descRaw":"购买价格为<@ba.vup>1</>","eventType":"SERVER_PRICE","eventTypeDesc":"整备能力","eventTypeIcon":"icon_gold","effectType":"SERVER_CHESS_PRICE","effectKey":"SERVER_CHESS_PRICE","battleRuneKey":null,"charLevel":0,"bb":{"price":1},"bbStr":{},"owners":["chess_char_1_05_a"]},"garrison_04_b":{"garrisonId":"garrison_04_b","desc":"购买价格为1","descRaw":"购买价格为<@ba.vup>1</>","eventType":"SERVER_PRICE","eventTypeDesc":"整备能力","eventTypeIcon":"icon_gold","effectType":"SERVER_CHESS_PRICE","effectKey":"SERVER_CHESS_PRICE","battleRuneKey":null,"charLevel":0,"bb":{"price":1},"bbStr":{},"owners":["chess_char_1_05_b"]},"garrison_05_a":{"garrisonId":"garrison_05_a","desc":"【谢拉格】【奥术】每叠加3层,本干员攻击力+1%","descRaw":"【谢拉格】【奥术】每叠加3层,本干员攻击力<@ba.vup>+1%</>","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"act1autochess_gar_eff_attrByBond","battleRuneKey":"env_gbuff_new_with_verify","charLevel":0,"bb":{"divide_num":3,"atk":0.01},"bbStr":{"key":"act1autochess_gar_eff_attrByBond","bond_id":"kjeragShip,arcaneShip"},"owners":["chess_char_6_02_a"]},"garrison_05_b":{"garrisonId":"garrison_05_b","desc":"【谢拉格】【奥术】每叠加3层,本干员攻击力+2%","descRaw":"【谢拉格】【奥术】每叠加3层,本干员攻击力<@ba.vup>+2%</>","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"act1autochess_gar_eff_attrByBond","battleRuneKey":"env_gbuff_new_with_verify","charLevel":0,"bb":{"divide_num":3,"atk":0.02},"bbStr":{"key":"act1autochess_gar_eff_attrByBond","bond_id":"kjeragShip,arcaneShip"},"owners":["chess_char_6_02_b"]},"garrison_07_a":{"garrisonId":"garrison_07_a","desc":"【阿戈尔】每叠加3层,本干员攻击力+1%","descRaw":"【阿戈尔】每叠加3层,本干员攻击力<@ba.vup>+1%</>","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"act1autochess_gar_eff_attrByBond","battleRuneKey":"env_gbuff_new_with_verify","charLevel":0,"bb":{"divide_num":3,"atk":0.01},"bbStr":{"key":"act1autochess_gar_eff_attrByBond","bond_id":"egirShip"},"owners":["chess_char_4_09_a"]},"garrison_07_b":{"garrisonId":"garrison_07_b","desc":"【阿戈尔】每叠加3层,本干员攻击力+2%","descRaw":"【阿戈尔】每叠加3层,本干员攻击力<@ba.vup>+2%</>","eventType":"IN_BATTLE","eventTypeDesc":"作战能力","eventTypeIcon":"icon_battle","effectType":"GAIN_BUFF","effectKey":"act1autochess_gar_eff_attrByBond","battleRuneKey":"env_gbuff_new_with_verify","charLevel":0,"bb":{"divide_num":Line truncated
+1
View File
@@ -0,0 +1 @@
{"chess_item_1_01_e_a":{"id":"chess_item_1_01_e_a","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":false,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":267,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":1,"hideInShop":false,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_01_e_b","duration":-1,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm041","effectName":"维式重锤","desc":"攻击力+15%","descRaw":"攻击力<@ba.vup>+15%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.15},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.15,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_01_e_b":{"id":"chess_item_1_01_e_b","baseId":"chess_item_1_01_e_a","goldenId":"chess_item_1_01_e_b","isGolden":true,"trapId":"trap_1041_acarm041","iconId":"trap_1041_acarm041","identifier":326,"name":"维式重锤","itemType":"EQUIP","tier":1,"shopSortId":1,"price":5,"hideInShop":false,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"victoriaShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm041","effectName":"维式重锤","desc":"攻击力+25%","descRaw":"攻击力<@ba.vup>+25%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.25},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"atk":0.25,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"ATK% += atk (additive with other ATK% buffs)","rangeGrid":[[0,0]],"flavor":"一柄曾在维多利亚的工厂内铸造和平的重锤。\n“用同样的铁与火,去终结那场战争。”"},"chess_item_1_02_e_a":{"id":"chess_item_1_02_e_a","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":false,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":273,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":1,"hideInShop":false,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_02_e_b","duration":-1,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm047","effectName":"坚守盾牌","desc":"防御力+20%","descRaw":"防御力<@ba.vup>+20%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.2},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.2,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_02_e_b":{"id":"chess_item_1_02_e_b","baseId":"chess_item_1_02_e_a","goldenId":"chess_item_1_02_e_b","isGolden":true,"trapId":"trap_1047_acarm047","iconId":"trap_1047_acarm047","identifier":332,"name":"坚守盾牌","itemType":"EQUIP","tier":1,"shopSortId":2,"price":5,"hideInShop":false,"mergeable":false,"upgradeNum":0,"upgradeChessId":null,"duration":0,"giveBondId":"steadShip","givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acgarm047","effectName":"坚守盾牌","desc":"防御力+35%","descRaw":"防御力<@ba.vup>+35%</>","buffs":[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"def":0.35},"bbStr":{"key":"attr_common_global_buff"}}],"params":{"def":0.35,"key":"attr_common_global_buff"},"category":"STAT","kind":"passive","family":null,"implFormula":"DEF% += def","rangeGrid":[[0,0]],"flavor":"一面属于坚守之人的盾牌。\n“推击、拨挡、压制,主动防御的基础在于对盾的运用。”"},"chess_item_1_03_e_a":{"id":"chess_item_1_03_e_a","baseId":"chess_item_1_03_e_a","goldenId":"chess_item_1_03_e_b","isGolden":false,"trapId":"trap_1066_acarm066","iconId":"trap_1066_acarm066","identifier":292,"name":"盟约之币","itemType":"EQUIP","tier":1,"shopSortId":3,"price":1,"hideInShop":false,"mergeable":true,"upgradeNum":2,"upgradeChessId":"chess_item_1_03_e_b","duration":-1,"giveBondId":null,"givePowerId":null,"canGiveBond":false,"requiresBondId":null,"effectId":"eff_acarm066","effectName":"盟约之币","desc":"装备时销毁,并获得1资金","descRaw":"装备时销毁,并获得<@ba.vup>1</>资金","buffs":[{"key":"equip_destory_gain_random_coin","countType":"NONE","bb":{"min":1,"max":1},"bbStr":{}}],"params":{"min":1,"max":1},"category":"ECONOMY","kind":"consume_on_equip","family":null,"implFormula":"On equip: destroy item, player.funds += randInt(min,max) (normal 1, golden 2). Does not use an equip slot.","rangeGrid":[[0,0]],"flavor":"“推动协议模拟Line truncated
+1
View File
@@ -0,0 +1 @@
{"act1autochess_m01":{"id":"act1autochess_m01","name":"战场#01","weight":50,"active":true,"modes":["mode_training_1","mode_single_normal","mode_single_funny","mode_multi_normal","mode_multi_funny"],"size":[19,21],"rows":["aaaaaaaaaa#aaaaaaaaaa","###IAAAAA###AAAAAI###","##Err###rrOrr###rrE##","###brbbbrfffrbbbrb###","###brbbbrfffrbbbrb###","###brrrrrfOfrrrrrb###","XXXXXXXXXXXXXXXXXXXXX","aaaaaaaaaa###########","####AAAAA##I#########","##Err###rrSrr###rrS##","###brbbbrf#brbbbrf###","###brbbbrf#brbbbrf###","###brrrrrfSbrrrrrfS##","XXXXXXXXXXXXXXXXXXXXX","#######fffffff#######","#######Sffffff#######","#######ppppppp#######","#######fffffff#######","#######Sffffff#######"],"tiles":{"a":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{"isValidHand":1}},"#":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"I":{"tileKey":"tile_telin","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"A":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"E":{"tileKey":"tile_end","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"r":{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"O":{"tileKey":"tile_telout","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"b":{"tileKey":"tile_fence_bound","height":"LOW","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"f":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"X":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"NONE","groundPassable":false,"flyPassable":false,"special":null,"bb":{}},"S":{"tileKey":"tile_start","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"p":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{"previewNotAlloed":1}}},"devices":[{"key":"trap_1112_acblzd","name":"盟约寒风","alias":null,"pos":[18,0],"dir":"UP","hidden":false,"role":"coldWind","stats":{"maxHp":100,"atk":0,"def":0,"res":0,"bat":1,"aspd":100,"blockCnt":0},"rangeGrid":null,"rangeTiles":null,"skill":{"skillId":"sktok_acblzd","name":"谢拉格寒风","level":1,"desc":"每隔一定时间对全场敌人造成一定时间的寒冷","descRaw":"每隔一定时间对全场敌人造成一定时间的<$ba.cold>寒冷</>","bb":{},"bbStr":{}},"desc":null,"active":true},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#1","pos":[9,7],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,6],[10,6],[11,6],[12,6],[13,6],[9,7],[10,7],[11,7],[12,7],[13,7],[9,8],[10,8],[11,8],[12,8],[13,8]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"damage_scale_per_stack":0.001,"max_attack_speed":300,"max_damage_scale":1.3},"bbStr":{}},"desc":null,"active":false},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#2","pos":[9,15],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,14],[10,14],[11,14],[12,14],[13,14],[9,15],[10,15],[11,15],[12,15],[13,15],[9,16],[10,16],[11,16],[12,16],[13,16]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"daLine truncated
+1
View File
@@ -0,0 +1 @@
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon","name":"诅咒娃娃","appellatiLine truncated
+11
View File
@@ -0,0 +1,11 @@
{
"_doc": "Hand-maintained overrides layered on the generated data/config.json by server/match/gamedata.js (schema: docs/META.md §3.1). Enemy and leader numbers are the OFFICIAL ones (config.json enemyScale = the PRTS table, leader pool = bloodPoint): the custom enemyHpMul / enemyAtkMul / bossHpMul / flyPlaceholders balance was removed (research 08 §6, docs/BALANCE.md). Only result-title rules remain.",
"version": 2,
"titles": {
"comment_3": {
"stat": "lpLost",
"rule": "min",
"_why": "坚若磐石 text is 目标生命值损失最少 (least LP lost), not most LP remaining"
}
}
}
+1
View File
@@ -0,0 +1 @@
{"act1autochess_01":{"id":"act1autochess_01","kind":"normal","solo":false,"bossId":null,"maxPlayTime":45,"dp":{"init":10,"perSec":1,"max":99},"characterLimit":8,"moveMultiplier":0.5,"bgm":"bat_kazimierz2_2","routes":[{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,9],[12,9],[12,7],[9,7],[9,5],[12,5],[12,4],[9,4]]}],"extraRoutes":[{"motion":"FLY","start":[11,7],"end":[9,2],"checkpoints":[]}],"spawns":[{"time":3,"key":"enemy_1007_slime","count":2,"interval":5,"routeIndex":0},{"time":12,"key":"enemy_1422_lrsldr","count":2,"interval":3,"routeIndex":1,"slot":"N"},{"time":12,"key":"enemy_1005_yokai","count":2,"interval":3,"routeIndex":2,"slot":"NF"}],"branches":{"dragon":[[{"time":0,"key":"enemy_9012_acloon","count":1,"interval":1,"routeIndex":0}]]},"overrides":{},"devices":[],"totalCount":6,"slotCounts":{"N":2,"NF":2},"usedBy":[{"modeId":"mode_multi_abyss","round":1,"bossId":null},{"modeId":"mode_multi_funny","round":1,"bossId":null},{"modeId":"mode_multi_hard","round":1,"bossId":null},{"modeId":"mode_multi_normal","round":1,"bossId":null},{"modeId":"mode_single_abyss","round":1,"bossId":null},{"modeId":"mode_single_funny","round":1,"bossId":null},{"modeId":"mode_single_hard","round":1,"bossId":null},{"modeId":"mode_single_normal","round":1,"bossId":null}]},"act1autochess_02":{"id":"act1autochess_02","kind":"normal","solo":false,"bossId":null,"maxPlayTime":45,"dp":{"init":10,"perSec":1,"max":99},"characterLimit":8,"moveMultiplier":0.5,"bgm":"bat_kazimierz2_2","routes":[{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,9],[12,9],[12,7],[9,7],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,9],[12,9],[12,7],[9,7],[9,5],[12,5],[12,4],[9,4]]}],"extraRoutes":[{"motion":"FLY","start":[11,7],"end":[9,2],"checkpoints":[]}],"spawns":[{"time":3,"key":"enemy_1422_lrsldr","count":3,"interval":5,"routeIndex":0,"slot":"N"},{"time":15,"key":"enemy_1422_lrsldr","count":3,"interval":7,"routeIndex":1,"slot":"N"},{"time":3,"key":"enemy_1005_yokai","count":4,"interval":7,"routeIndex":2,"slot":"NF"},{"time":15,"key":"enemy_1005_yokai","count":5,"interval":7,"routeIndex":3,"slot":"NF"}],"branches":{"dragon":[[{"time":0,"key":"enemy_9012_acloon","count":1,"interval":1,"routeIndex":0}]]},"overrides":{},"devices":[],"totalCount":15,"slotCounts":{"N":6,"NF":9},"usedBy":[{"modeId":"mode_multi_abyss","round":2,"bossId":null},{"modeId":"mode_multi_funny","round":2,"bossId":null},{"modeId":"mode_multi_hard","round":2,"bossId":null},{"modeId":"mode_multi_normal","round":2,"bossId":null},{"modeId":"mode_single_abyss","round":2,"bossId":null},{"modeId":"mode_single_funny","round":2,"bossId":null},{"modeId":"mode_single_hard","round":2,"bossId":null},{"modeId":"mode_single_normal","round":2,"bossId":null}]},"act1autochess_03":{"id":"act1autochess_03","kind":"normal","solo":false,"bossId":null,"maxPlayTime":55,"dp":{"init":10,"perSec":1,"max":99},"characterLimit":8,"moveMultiplier":0.5,"bgm":"bat_kazimierz2_2","routes":[{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,9],[12,9],[12,7],[9,7],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,9],[12,9],[12,7],[9,7],[9,5],[12,5],[12,4],[9,4]]}],"extraRoutes":[{"motion":"FLY","start":[11,7],"end":[9,2],"checkpoints":[]}],"spawns":[{"time":6,"key":"enemy_1422_lrsldr","count":10,"interval":6,"routeIndex":0,"slot":"N"},{"time":18,"key":"enemy_1427_lrnazg","count":1,"interval":10,"routeIndex":1,"slot":"E"},{"time":6,"key":"enemy_1005_yokai","count":9,"interval":7,"routeIndex":2,"slot":"NF"},{"time":10,"key":"enemy_1042_frostd","count":2,"interval":10,"routeIndex":3,"slot":"EF"}],"branches":{"dragon":[[{"time":0,"key":"enemy_9012_acloon","count":1,"interval":1,"routeIndex":0}]]},"overrides":{},"devices":[],"totalCount":22,"slotCounts":{"N":10,"E":1,"NF":9,"EF":2},"usedBy":[{"modeId":"mode_multi_abyss","round":3,"bossId":null},{"modeId":"mode_multi_funny","round":3,"bossId":null},{"modeId":"mode_multi_hard","round":3,"bossId":null},{"modeId":"mode_multi_normal","round":3,"bossId":null},{"modeId":"mode_single_abyss","round":3,"bossId":null},{"modeId":"mode_single_funny","round":3,"bossId":null},{"modeId":"mode_single_hard","round":3,"bossId":null},{"modeId":"mode_single_normal","round":3,"bossId":null}]},"act1autochess_04":{"id":"act1autochess_04","kind":"normal","solo":false,"bossId":null,"maxPlayTime":55,"dp":{"init":10,"perSec":1,"max":99},"characterLimit":8,"moveMultiplier":0.5,"bgm":"bat_kazimierz2_2","routes":[{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoiLine truncated
+263
View File
@@ -0,0 +1,263 @@
# Assets: sources, layout, manifest, credits
Owner: `tools/fetch-assets.mjs` and `tools/assets/*`. Research background: `docs/research/07-assets.md`.
All art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored.
Everything the client needs is listed in **`data/assets.json`**. The client should only request URLs that appear in that manifest.
## Running
```bash
npm install # also vendors pixi / pixi-spine / preact / three (tools/vendor.mjs; three.js is fetched by
# browsers only when data/local-assets.json lists the board atlas — the 3D board, DESIGN §15)
npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
```
| Option | Effect |
|---|---|
| `--concurrency=N` | Parallel downloads (default 16). |
| `--force` | Re-download everything. |
| `--offline` | No network. Re-runs post-processing (atlas fixes, skeleton parsing, WOFF2) on what is already on disk, then rebuilds `data/assets.json`. |
| `--dry-run` | Print the plan (file and model counts, alias notes) and exit. |
| `--refresh-index` | Re-download the two upstream indexes: `audio_data.json` and `models_data.json`. |
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. |
The script is **idempotent**. A file on disk is kept, not re-downloaded, when any one of these holds:
- its size matches the ledger entry from a previous download (`.cache/assets-ledger.json`);
- its size matches the byte count recorded in research;
- no size is known, but the file passes format validation. Validation checks the PNG signature and `IEND`, the MP3 header, the sfnt header, the atlas page line, and that a skel is not an HTML error page.
Every download is written to a temp file, then renamed into place. A partial download therefore never reaches its final path.
How downloads are fetched:
- 16 connections at a time.
- Each source gets 3 attempts with exponential backoff.
- If `raw.githubusercontent.com` fails, the jsDelivr mirror (`cdn.jsdelivr.net/gh/…`) is tried.
- There is no mirror for the ArknightsAssets2 `voice` branch, because jsDelivr returns 404 for it.
- A manifest entry with fallbacks (for example an enemy icon that falls back to its base enemy's icon) only moves on to the next alternative after a **definitive 404**. When the primary fails transiently (network error, 5xx or an invalid payload after all retries), no fallback is fetched. The path is listed under `downloadErrors` in the report, and the next run retries the primary.
- A skeleton that fails to parse is deleted and removed from the ledger, so the next online run downloads it again.
The first run downloads about **242 MiB in about 3,700 files**. It took 134 s on a ~3 MB/s link. A re-run takes about 1 s.
Outputs:
- `data/assets.json`: the manifest (committed).
- `public/assets/**`: art and audio (git-ignored).
- `public/fonts/*`: fonts and `fonts.css`.
- `.cache/assets-report.json`: misses, fallbacks and notes from the last run.
- `.cache/spine-info.json`: skeleton parse cache.
The run exits with code 1 if any pool operator is missing its avatar, its portrait or its Front Spine.
Upstream indexes are cached under `.cache/`. They are downloaded when missing:
- `.cache/gamedata/excel/audio_data.json`, from `Kengxxiao/ArknightsGameData` (zh_CN).
- `.cache/ark-models/models_data.json`, from `isHarryh/Ark-Models`.
The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are needed.
## What is downloaded
| Class | Source | Local path (under `public/assets/`) |
|---|---|---|
| Operator avatars, 180×180. Base, plus E2 when it exists. | yuanyan3060/ArknightsGameResource `avatar/` | `char/avatar/{charId}.png`, `char/avatar/{charId}_2.png` |
| Operator half-body portraits, 180×360 | yuanyan `portrait/` | `char/portrait/{charId}_1.png`, `_2.png` |
| Default-skill icons, including backup operators' skills | yuanyan `skill/` | `skill/{iconId sanitized}.png` |
| Enemy icons | yuanyan `enemy/`. Fallbacks: the handbook id, then the base id. | `enemy/icon/{enemyId}.png` |
| Token avatars | yuanyan `avatar/` | `token/avatar/{tokenId}.png` |
| Bond icons (the real autochess glyphs) | ArknightsAssets2 `cn` `ui/autochess/[uc]autochesscommon/arts/bondicon/`. Fallback: the camp logo. | `bond/{bondId}.png` |
| Shop item icons | AA2 `…/arts/shopitemicon/` | `item/{trapId}.png` |
| Band (strategy) icons | AA2 `…/arts/bandicon/` | `band/{bandId}.png` |
| Profession, sub-profession and battle-card icons | AA2 `arts/profession_hub`, `arts/ui/subprofessionicon`, `ui_battle_new/battlecard` | `prof/icon_{p}.png`, `prof/large_{p}.png`, `prof/battlecard_{p}.png`, `prof/sub/{subProfessionId}.png` |
| UI sprites (see below) | AA2 `ui/autochess/**`, `arts/**`, `activity/[uc]act2autochess/**`, `battle/[pack]common/sprites` | `ui/{group}/{key}.png` |
| Operator battle Spine (Front, Back) | fexli/ArknightsResource `spine/{id}/{id}/{Front,Back}/` | `spine/op/{charId}/{front,back}/{stem}.{skel,atlas,png}` |
| Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` |
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` | `audio/bgm/{file}.mp3` |
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
| Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | `public/fonts/*.{otf,ttf,woff2}`, `public/fonts/fonts.css` |
The `stem` of a Spine model is the upstream file name. Two examples: `char_107_liskam` has the stem `char_107_liskarm`, and `enemy_9032_aclionk` uses `enemy_1559_vtlionk`. The skel and atlas of a model always share one stem. pixi-spine locates the atlas by swapping the extension, so this matters.
### Id scope
- **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators.
- **Tokens:** the 20 pool tokens.
- **Enemies:** 253 ids planned, 252 in the manifest (心烛 has no assets). The set is the union of:
- the 07 enemy list;
- every enemy in the `act1autochess_*` wave, boss and 联防 levels that act2 modes use (from `05-maps.json`; the tutorial is excluded);
- the bosses (`boss_1..10`);
- the closure of their summons (`randomEnemyAttribute` spawns and blackboard `enemy_key` references);
- every key of `data/enemies.json` (built by `tools/build-data.mjs`, when present), which adds for example the enemies swapped in by 机变 effects;
- the bosses' handbook/model ids, such as `enemy_1559_vtlionk`;
- enemy units spawned by operator kits (research 03 skills/talents). For example, 隐德来希's default S3 summons 心烛 `enemy_5601_entlec` through the talent key `take_extra_enemy_key`. 心烛 has no icon and no Spine in any dump, so it has no manifest entry: it is reported as a miss, and the client must draw a glyph.
- **Extra tokens:** any `token_*` key of `data/tokens.json` that research does not list gets the default avatar and Spine locations.
- The non-token summons in that file (`enemy_9012_acloon` 炎佑, `char_605_cmedic`, `char_613_acmedc`) are found under `enemies` and `chars`.
- **Skill icons:** the default skill of every chess (`defaultSkillIndex`), plus each backup operator's `skillIndex`. That makes 144 icons.
- **UI:**
- every group from `07-assets.json → autochessUi`: rarity, elite and chess-level sprites, the shop panel and cards, HUD, bond board, equip slot, round dialog, band choose, settlement, prepare backdrop;
- `arts` (rarity stars, elite icons, the camp logos of pool nations, the loading illustrations used by the act2 modes, battle common sprites, act2 entry backdrops and season logo, item rarity frames);
- extras: mode choice art, battle-ready backdrops, battle UI (speed, pause, HP slider, attack range, boss avatar frame, skill ready), `empty_skill`, the 机变 panel and cards, the equip-replace dialog, the bond detail dialog, the prep-ready panel, stage-info titles.
## Post-processing
- **Atlases** (research 07 §5.3):
- Insert `size: W,H` right after each page name. fexli atlases omit it; the value is read from the PNG header. An existing size that disagrees with the PNG is corrected.
- Ark-Models enemy atlases get `pma: true`, because their textures are premultiplied. pixi-spine reads this and sets `ALPHA_MODES.PMA`.
- Page names are sanitized to safe file names.
- All of this is idempotent.
- **Skeletons:** every `.skel` (Spine 3.8.99 binary) is parsed in Node with `@pixi-spine/runtime-3.8`, the parser the client ships.
- The parse extracts animation names and durations, event names, `OnAttack` times per animation, and bounds.
- Attachment paths are checked against the atlas regions.
- Then the animation-role resolver runs. See `tools/assets/anim-roles.mjs` and research 07 §5.4.
- **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.
## Manifest schema (`data/assets.json`)
All paths are URL paths relative to the site root, for example `/assets/char/avatar/char_002_amiya.png`. **Only entries whose files exist on disk are emitted.** When a key is missing, the client should use its fallback (research 07 §5.6).
```js
{
version: 1, // schema version
hash: 'a1b2c3d4e5f6', // content hash (cache busting)
generator: 'tools/fetch-assets.mjs',
stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine,
spineModels, bonds, items, bands, skills, ui, sfxUnits },
chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } },
enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId } },
tokens: { [tokenId]: { owner: charId|null, avatar?, spine?: Spine, spineVariant?: string } },
bonds: { [bondId]: url }, // white glyphs; tint in CSS/canvas
items: { [trapId]: url },
bands: { [bandId]: url },
skills: { [iconId]: url }, // iconId = skill_table iconId ?? skillId
skillsById: { [skillId]: iconId },
ui: { ['group/key']: url }, // e.g. 'hudPanel/icon_hp', 'shopCard/frame_lv1', 'loading/loading_ac_core'
prof: { icon: {caster…warrior}, large: {…}, battlecard: {…, token}, sub: {[subProfessionId]: url} },
audio: {
bgm: { lobby, prep, combat, boss: { intro?, loop } }, // intro then crossfade to loop (1 s)
bossBgm: { [bossId]: { intro?, loop } }, // per-boss track of its R14/R15 level
sfx: {
ui: { click, back, confirm, tab, pick, drop, error, buy, sell, income, refresh, freeze, levelup,
merge, equip, itemMerge, bondUp, artPlace, ready, timer, draft, yourTurn, yourTurnCircle,
target, broadcast, danger, emote, roundStart, rest, battleStart, battleStartBoss,
bossRoundTeam, bossRoundSingle, bossRoundSecret, killBoss, killBossAll, killBossNormal,
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 },
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born? } }
}
},
fonts: { css: '/fonts/fonts.css', faces: { [name]: { family, weight, woff2?, original } } }
}
```
### The `Spine` object
```js
{
skel, atlas, textures: [url], // load with PIXI.Assets.load(skel); atlas/png sit next to it
pma: boolean, // true for enemies (atlas already carries `pma: true`)
anims: Roles, // resolved roles, below
animations: { [name]: seconds }, // every animation with its duration
events: [name], // e.g. ['OnAttack', 'OnStart']
hits: { [animName]: [seconds] }, // OnAttack event times (apply damage / spawn projectile)
bounds: { x, y, width, height } | null // skeleton AABB (setup pose, skeleton units)
}
```
### The `Roles` object
```js
Clip = { begin: string|null, loop: string, end: string|null, via?: 'combat'|'attackAny'|'skill'|'idle'|'attack' }
SkillClip = Clip & { index: number /* 0-based skill index */, idle: string|null /* Skill_n_Idle */ }
Roles = {
idle: string, deploy: string,
attack: Clip, // play begin once, loop per attack, end when stopping; `via:'idle'` ⇒ add a flash
attackDown: Clip|null, // _Down variants (target below the unit)
skill: SkillClip|null, // for the chess's default skill (primary index)
skills?: { [index]: SkillClip }, // when the char is used with several default skills (backups)
die: string|null, // null ⇒ use the Front model's Die (Back lacks it) or fade out 0.5 s
move: Clip|null, // enemies: Move_Begin|Move_Start + Move_Loop|Move + Move_End → Run_*
stun: Clip|null // null ⇒ freeze the track (timeScale 0)
}
```
`via` marks a fallback:
| `via` | The attack is actually… |
|---|---|
| `combat` | `Combat` |
| `attackAny` | a numbered attack such as `Attack_01`, possibly framed by `Attack_Begin`/`Attack_End` (`char_1045_svash2`) |
| `skill` | `Skill_1_Loop` (pure supporters) |
| `idle` | `Idle` (no attack animation at all) |
On a skill clip, `via: 'attack'` means the model has no skill animation, so the attack clip is reused.
A skill clip may also come from directional-only animations when a model has no undirected ones: for example `Skill_Right_Loop` (`char_279_excu`) or `Skill_Loop_Up` (the Back model of `char_431_ashlok`).
The resolver's full precedence list is in the header of `tools/assets/anim-roles.mjs`.
Other renderer rules from research 07 §5.4–5.5:
- **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up.
- **Attack speed:** set the attack `timeScale` to `duration / attackInterval`.
- **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases:
- `enemy_1305_mhslim` has no Spine anywhere, so it uses the slime model. Tint it.
- `_2` boss variants reuse the base model.
### Other fallbacks
- **Tokens:**
- Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token`.
- Without a Spine, draw the avatar sprite with a bob tween.
- `spineVariant` names the skin-variant model that stands in for the missing default model.
- **Enemies without a spine** (for example `enemy_9016_acstmr`): draw `icon` in a diamond. Enemies with no manifest entry at all (`enemy_5601_entlec` 心烛): draw a procedural glyph.
### Looking up `data/chess.json` asset ids
`data/chess.json` stores asset **ids** in `chess[*].assets` (docs/DATA.md). They map onto this manifest as follows (checked for all 258 chess):
| `assets` field | Example id | Manifest URL |
|---|---|---|
| `avatar` | `char_498_inside` / `char_498_inside_2` | `chars[id].avatar` / `chars[id minus "_2"].avatarE2` |
| `portrait` | `char_498_inside_1` / `char_498_inside_2` | `chars[id minus "_1"].portrait` / `chars[id minus "_2"].portraitE2` |
| `spine` | `char_498_inside` | `chars[id].spine.front` (and `.back`) |
| `skillIcon` | `skchr_inside_2` | `skills[id]` (the id is the skill's `iconId`) |
| `subProfIcon` | `sub_fastshot_icon` | `prof.sub[id minus "sub_" and "_icon"]` |
- **Missing unit SFX:** use `audio.sfx.battle.enemyHit` or a WebAudio blip (research 07 §6.4).
- **Bond icons:** if the real glyph ever fails, the stored file is the nation camp logo.
## Verification
`node --test test/assets.test.js` covers the pure helpers: the resolver, the atlas normalizer, the format sniffers, WOFF2, audio banks, the plan id sets, the downloader against a fake network, and the self-heal of corrupt skeletons.
When `public/assets` exists, the same file also checks the generated output:
- Every manifest path exists on disk.
- Every pool operator has an avatar, a portrait and a Front model.
- Every atlas has `size:` lines, plus `pma: true` for enemies.
- Every Spine model loads the way the client loads it. That means pixi-spine's atlas reader with real page sizes, where a region outside its page throws, then `SkeletonBinary` with `AtlasAttachmentLoader`, where a missing region throws. Every resolved role is then posed.
The 2026-09-27 verification pass also checked:
- **PNG:** all 2,211 PNGs pass a full CRC and inflate check.
- **Audio:** all 467 MP3s decode with ffmpeg without errors.
- **Browser:** headless Chrome loads and animates all 529 Spine models with the vendored PixiJS 7.4.2 and pixi-spine 4.0.6, with no console errors. Chrome's font sanitizer also accepts the three WOFF2 files.
- **Official data:** 771 provenance checks against the official data (`activity_table`, `skill_table`, `models_data.json`) all match: avatars, portraits, E2 art, bond, band and item icons, and enemy skeleton files.
## Licensing and credits
- **Game assets.** All images, Spine models, audio and game data are © **Hypergryph (上海鹰角网络)**. The overseas publisher is **Yostar**.
- This is an **unofficial, non-commercial fan project**: no ads, donations or paywall.
- Assets are fetched from public community dumps at install time and are not redistributed in this repository.
- Assets will be removed on request from the rights holders.
- The client must show a credits screen: "Arknights © Hypergryph / Yostar. This is an unofficial fan project; all game assets belong to their owners."
- **Asset dumps and tools.** Credit to:
- [yuanyan3060/ArknightsGameResource](https://github.com/yuanyan3060/ArknightsGameResource)
- [fexli/ArknightsResource](https://github.com/fexli/ArknightsResource) (ArkResourceAutoUpdateBot)
- [isHarryh/Ark-Models](https://github.com/isHarryh/Ark-Models) (ArkUnpacker). Not for commercial use.
- [ArknightsAssets/ArknightsAssets2](https://github.com/ArknightsAssets/ArknightsAssets2) (ArknightsStudio)
- [Kengxxiao/ArknightsGameData](https://github.com/Kengxxiao/ArknightsGameData)
- **Spine runtime.** pixi-spine is MIT-licensed. It embeds Spine Runtime code under the [Spine Runtimes License](http://esotericsoftware.com/spine-runtimes-license), which formally expects a Spine Editor licence. This is commonly tolerated for non-commercial fan tools, and the Spine Runtimes License must be credited.
- **Fonts.**
- **Bender:** © Jovanny Lemonad (Oleg Zhuravlev, Ivan Gladkikh). Free for personal and commercial use.
- **Novecento Wide:** © Jan Tonellato / Synthview. Free licence.
- Both are mirrored from TimWangZi/The-font-of-Arknights.
- Noto Sans SC and Noto Serif SC (SIL OFL) are loaded from Google Fonts, not self-hosted.
+230
View File
@@ -0,0 +1,230 @@
# BALANCE.md — difficulty model and measurement (official numbers, no custom tuning)
Owner: match / balance. Tools: `tools/balance.mjs` (competent-board model — now a **measuring** tool only),
`tools/matchrun.mjs` (bot matches). Tests: `test/match/balance.test.js`, `test/match/waves-official.test.js`,
`test/match/waves.test.js`, `test/sim/pathing.test.js`.
**Every enemy number is the official one; the custom balance layer was removed** (user request: rules, numbers, enemy
kinds / counts and routes must follow the official game; research 08 §6–§7, DESIGN §14 corrections):
* waves = the client's `RandomEnemyGenerater` (research 08 §2): a 15-slot type schedule per match (3 types × 3 slots +
6 SPECIAL, shuffled), one weighted special entry per round, each placeholder ACTION replaced by
`clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window, the other movement class not spawned — no
`k` copies, no kept fly placeholders; verified against all 429 official entry × round compositions and the official
count distribution (同盟 险境 R3 ≤ 10 enemies, R13 ≈ 37 — the old generator averaged 28 / 81);
* stats = the PRTS per-round `enemyScale` table only (`data/config.json`, 终极 ×1.15 speed from R3);
* leader pool = `bloodPoint[difficulty]` (co-op; one pool for the split leaders, no alive-player factor — solo keeps
×0.25, flagged [ASSUMED] in data);
* bounties: no extra ×0.7 in solo (the 70 % base is already in `enemyScale`), spawned with the template's first normal
action (research 08 §5);
* routes = the official 4-direction flow field with crates at cost 1000 (research 08 §3), act1 m02 without crates.
**Removed:** every `enemyHpMul` / `enemyAtkMul` / `enemySpeedMul` / `bossHpMul` of `data/tuning.json` (it now holds only
the `titles.comment_3` rule) and the `flyPlaceholders` knob. `gamedata.js` ignores those keys if an old file carries them.
**Measured with the official numbers** (`node tools/balance.mjs --mode all --difficulty ALL --tuning off`, 2026-09-28):
average capped leaks per board and round R1–R13 (solo 标准 R1–R8) of the competent board of §1.1 —
| mode | old generator, research numbers | old generator, tuned (shipped before) | **official (now)** | LP/rd after 联防 |
|---|---|---|---|---|
| 独立 标准 | 1.3 | 0.6 | **0.17** | (= leaks) |
| 独立 险境 | 1.4 | 0.9 | **0.19** | (= leaks) |
| 独立 绝境 | 1.8 | 1.8 | **0.22** | (= leaks) |
| 独立 终极 | 4.1 | 3.1 | **0.65** | (= leaks) |
| 同盟 标准 | 1.4 | 0.5 | **0.31** | 0.13 |
| 同盟 险境 | 2.3 | 1.3 | **0.49** | 0.01 |
| 同盟 绝境 | 5.0 | 2.7 | **0.80** | 0.10 |
| 同盟 终极 | 6.3 | 4.3 | **1.96** | 0.93 |
The old generator spawned 2–4× the official enemy count (every template enemy × k, plus the other class's
placeholders); the tuning of the previous pass only compensated for that. With the official counts the difficulty
comes out below the old targets — which is the official game's difficulty as far as this model can tell (the model is a
competent, layer-rich board; see §1.2 for what it does not capture). Nothing is tuned from these numbers.
---
## 1. Method — `tools/balance.mjs`
For every mode (独立 solo / 同盟 multi) × difficulty × round the tool samples whole matches (stage, the 3 factions, bans,
boss, band and lineups all vary with the seed), builds a **competent board for that round**, generates the round's
real wave (`waves.js`: template, the official per-action replacement of the round's pick, enemy scaling, bounties off)
and runs the real
`Battle` with full content (kits, talents, bonds with layers, IN_BATTLE 特质, equipment, bands, summons). Co-op rounds
field **4 boards** against the same wave and then run **联防** exactly like the match (`unite.js`: ≤ 2 perfect helpers,
only the survivors cost their source LP). Boss rounds build the Final Assault fields exactly like `Match.startFinalAssault`
(pairs / `_s` for a lone player, shared pool `GameData.bossPoolHp` = `bloodPoint` (solo ×0.25), merged team LP of 15 per player, leaks' `lpr`,
−1 LP/s after 150 s) and record the pool damage by 150 s, the kill time and the win rate.
Metrics: **capped leaks** = min(leaks, 10) per board and round (what that board alone would lose; the targets below
use it — raw means are dominated by swarm rounds that cap at 10 anyway), **LP/rd after 联防** (co-op), perfect-round
share, the enemies that leak most, clear time; bosses: share of the pool dealt by 150 s, win rate, kill time.
### 1.1 The competent board of round r
| R | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| shop level | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 4 | 5 | 5 | 6 | 6 | 6 | 6 |
| units | 2 | 4 | 5 | 7 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
| elites (精锐) | 0 | 0 | 0 | 0.5 | 1 | 1.5 | 2 | 3 | 3.5 | 4 | 4.5 | 5 | 6 | 6.5 | 7 |
| equipment | 0 | 0.5 | 1 | 1.5 | 2 | 3 | 3.5 | 4 | 5 | 5.5 | 6 | 7 | 8 | 9 | 9 |
| core bond members | – | – | 3 | 3 | 3 | 3 | 3 | 3 | 4 | 5 | 6 | 6 | 6 | 6 | 6 |
| core bond layers | 0 | 2 | 6 | 12 | 20 | 28 | 38 | 50 | 65 | 80 | 95 | 110 | 130 | 150 | 170 |
| add-on layers (each) | 0 | 1 | 4 | 8 | 12 | 17 | 23 | 30 | 38 | 46 | 55 | 65 | 75 | 90 | 100 |
(fractional values: that many on average.) Sources: income 4, 5, 6 … min(3 + r, 12) and upgrade prices 5 / 8 / 11 /
12 / 13 with −1 per round (research 00-INDEX §3) → L2 at R3, L3 at R5, L4 at R7, L5 at R10, L6 at R12; the board is
full from R5; elites ≈ 0 at R3 → 3 at R8 → 6+ at R13; 1–2 add-on bonds; the layer pace reaches Σ active layers ≈ 330
at R14 — the solo Hidden Core threshold is > 350 (research 02 §2.3), i.e. these are boards of players who *regularly*
reach the Hidden Core. Units are drawn from the match's pool (bans respected) with a tier mix per shop level, around a
core bond with enough members, plus the roles a player reads from the wave preview: 2 blockers, anti-air by the share of
flyers, arts dealers by the armour of the enemies (arts anti-air when the flyers are armoured, e.g. 寒霜 DEF 600), a
medic from R5. Equipment: passive combat items of tier ≤ shop level (golden from R7) on the damage dealers. Layers: the
core curve on the core bond, the add-on curve on every other bond the lineup activates. Placement: the bot's layout
planner with rehearsal of its 5 variants (a player adapts the layout to the previewed wave). Not modeled: bounties, 机变
cards, prep-only effects (their value is folded into the curves). `--profile weak|strong` scales elites, items and
layers ×0.6 / ×1.4.
### 1.2 Reference levels (informative — nothing is tuned against them any more)
The previous pass tuned the waves toward: 标准 ≤ 1 capped leak per board and round, 险境 ≤ 2, 绝境 ≈ 2–3.5, 终极 ≈ 3–5.
They are kept only to read the measurement. What the model does NOT capture (so real matches are harder than the
table): imperfect economy and placement, bans hitting the planned bonds, 机变 / bounty choices (bounties add enemies),
the preview being read by a human in 90 s, and losing LP early (fewer rounds to build layers).
---
## 2. What the numbers were checked against (research first)
| Suspect | Research | Verdict |
|---|---|---|
| Enemy multipliers (`config.modes[m].enemyScale`, `k` exponents) | A3 table (PRTS 下半, user-sourced) — 01-core-rules.md A3 / `_criticAddendum.enemyStatMultipliers` | **match exactly** (every mode, every round incl. ABYSS HP k and ×1.08); kept as the base |
| Wave templates / counts | level data (`maxPlayTime`, spawns, routes) — DATA | match the official levels; kept |
| Faction replacement | was: each template enemy × `k` [ASSUMED] | **replaced by the official per-action rule** (research 08 §2.3, client code) |
| Fly placeholders without a FLY faction | was: kept (寒霜 / 暴鸰 / 妖怪, the #1 leak source) [ASSUMED] | **official: the other movement class is not spawned** (research 08 §2.3 + the PRTS screenshots) |
| Faction mixing | was: every round a ground entry + a FLY entry | **official: one entry per round from a 15-slot type schedule** |
| `MOVE_SCALE` 0.5 | every level's `moveMultiplier` 0.5 (05 §2.2) | kept |
| **Combat time limit unit** | `maxPlayTime` read as game seconds [ASSUMED] | **wrong — real seconds; fixed** (§2.1) |
| Boss pool | bloodPoint DATA; co-op "敌方领袖的总生命值不变" | **co-op = bloodPoint, no alive scaling**; solo ×0.25 kept [ASSUMED]; the solo 标准 ×0.6 removed |
| Solo bounties ×0.7 | 上半 11/18 note = the global solo base | **removed** (already in enemyScale) |
| Bounty spawn timing | was: the bounty units appended after the host action's own units | **official: inserted among them at floor((i+1)·len/(n+1)), then the list spread over the window** (client `_InsertSpActionToNormal` + `_CalculateActionPredelay`, decoded; `waves.js withBounties`) |
| 联防 spawn timing | was: owner k at +min(0.5·k, 5) s, each owner's units over the whole window [ASSUMED] | **official (decoded `_CalculateActionPredelayConsiderUid`): owner k at +0.5·k s, unit step min(max(W/M, 0.05·W), 5 s), M = largest owner group** — a lone leaker's units now come 5 s apart instead of up to 40 s |
| Leader / leader-part multipliers | was: leaders and their parts never scaled | **official: the round multipliers are the ENEMY effects 攻坚装备 / II / III / 补给线 / 急行军 (`enemy_attribute_mul`; `enemy_exclude` = 炎佑 — plus the TIMES tokens for 补给线 — never a leader) ⇒ parts take HP/ATK/speed, the leader ATK/speed; only the leader's HP (the server pool) is exempt** ("领袖单位于服务器的生命值加成不受上述加成影响"). Co-op 终极 R14: leader ATK ×2.14; solo 标准: ×0.7 |
| Ground routes | was: 8-dir A*, m02 crates active | **official 4-dir SPFA + smoothing, crates cost 1000, m02 without crates** (research 08 §3) |
| Income / upgrade curve | R1–R3 VERIFIED, +1/round cap 12 ASSUMED | kept (the model's level curve follows it) |
| Operator stats per tier | chess status T1 E1 L55 … T6 E2 L1, elites E2 + skill 7 (03) — DATA | faithful: power comes from layers, elites and skills, which is why the model carries them |
| Player power in the bots | bots fielded T1-heavy boards with 0–3 elites and 20–70 layers per bond at R13 | bot fixes (§5) |
### 2.1 Combat time limits are real seconds (fidelity fix)
`maxPlayTime` counts **real** seconds of the forced 2× battle. Read as game seconds (the old assumption) the rounds' own
spawn schedules do not fit: R2 spawns its last flyer at 43 s of a 45 s limit, R3 its last enemies at 60–62 s of a 55 s
limit (they could never be killed, or never even spawned). As real seconds (× 2 in game seconds) every limit is ≈ the
last spawn + one flyer crossing of the board (≈ 44 s serpentine at 0.45 tiles/s): R2 43 + 44 ≈ 90, R3 62 + 44 ≈ 110,
R5 38 + 67 ≈ 110 (暴鸰 at 0.3 tiles/s).
| R | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| `maxPlayTime` (real s) | 45 | 45 | 55 | 55 | 55 | 70 | 85 | 85 | 95 | 95 | 115 | 115 | 115 |
| last spawn (game s) | 15 | 43 | 62 | 39 | 38 | 42 | 56 | 67 | 75 | 59 | 68 | 60 | 73 |
| slack, old reading | 30 | **2** | **−7** | 16 | 17 | 28 | 29 | 18 | 20 | 36 | 47 | 55 | 42 |
| slack, fixed (× 2) | 75 | 47 | 48 | 71 | 72 | 98 | 114 | 103 | 115 | 131 | 162 | 170 | 157 |
Before the fix 90–100 % of the early "leaks" were enemies still alive (often just spawned) at the limit, not enemies that
reached the objective. `gd.combatTimeLimit(r)` now returns game seconds (`config.combatTimeScale`, default 2); the
player-facing combat countdown is unchanged (= `maxPlayTime` real seconds). Research 00-INDEX §8 #10 should be updated
by its owner.
### 2.2 The fly placeholders (resolved)
The previous pass measured that the kept fly placeholders (寒霜 DEF 600, 暴鸰) were the most frequent leakers and left a
`flyPlaceholders` knob for experiments. The official client settles it: a placeholder whose class differs from the
round's pick is `isValid = false` — not spawned, not sent, not previewed (research 08 §2.3). The knob is gone.
---
## 3. Measurement per round (official numbers)
Capped leaks per board (16 solo samples / 6 co-op matches × 4 boards per round), `--tuning off` (identical: the
tuning file holds only titles), seed 1, rehearsal 5. Leader columns: win rate, share of the pool dealt by 150 s, mean
kill time (8 samples). Re-measured after the review fixes (decoded 联防 timing; leaders take the round ATK / speed
multipliers, their parts all of them): normal rounds unchanged, leader rounds and LP/rd as below.
| mode | R1 | R2 | R3 | R4 | R5 | R6 | R7 | R8 | R9 | R10 | R11 | R12 | R13 | avg | LP/rd |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 独立 标准 | 0.13 | 0.06 | 0.00 | 0.13 | 0.63 | 0.00 | 0.25 | 0.19 | – | – | – | – | – | 0.17 | 0.17 |
| 独立 险境 | 0.00 | 0.00 | 0.00 | 0.31 | 0.00 | 0.00 | 0.63 | 0.19 | 0.06 | 0.00 | 0.00 | 0.63 | 0.63 | 0.19 | 0.19 |
| 独立 绝境 | 0.00 | 0.00 | 0.00 | 0.00 | 0.38 | 0.00 | 0.00 | 0.25 | 0.63 | 0.50 | 0.44 | 0.63 | 0.00 | 0.22 | 0.22 |
| 独立 终极 | 0.13 | 0.00 | 0.19 | 0.25 | 1.00 | 0.88 | 0.44 | 0.81 | 1.50 | 0.94 | 1.13 | 0.06 | 1.19 | 0.65 | 0.65 |
| 同盟 标准 | 0.00 | 0.54 | 0.00 | 0.00 | 1.13 | 0.00 | 1.25 | 0.13 | 0.88 | 0.04 | 0.00 | 0.04 | 0.00 | 0.31 | 0.13 |
| 同盟 险境 | 0.00 | 0.00 | 0.83 | 0.17 | 0.00 | 0.00 | 0.17 | 0.25 | 1.58 | 0.04 | 0.46 | 2.21 | 0.71 | 0.49 | 0.01 |
| 同盟 绝境 | 0.00 | 0.00 | 0.38 | 1.00 | 0.71 | 1.17 | 0.50 | 0.92 | 1.88 | 2.50 | 0.63 | 0.71 | 0.00 | 0.80 | 0.10 |
| 同盟 终极 | 0.00 | 0.04 | 0.21 | 0.92 | 0.83 | 2.79 | 4.00 | 6.00 | 3.83 | 1.29 | 3.71 | 0.83 | 0.96 | 1.96 | 0.93 |
| mode | 最终攻势 (R14; solo 标准 R9) | 隐秘核心 (R15) |
|---|---|---|
| 独立 标准 | pool 64k · win 50 % · 80 % by 150 s · 89.8 s | – |
| 独立 险境 | pool 169k · win 88 % · 97 % · 19.5 s | pool 237k · win 100 % · 100 % · 22.2 s |
| 独立 绝境 | pool 456k · win 63 % · 86 % · 57.0 s | pool 869k · win 50 % · 70 % · 71.1 s |
| 独立 终极 | pool 899k · win 38 % · 79 % · 42.0 s | pool 1.63M · win 25 % · 48 % · 44.9 s |
| 同盟 标准 | pool 224k · win 100 % · 100 % · 18.0 s | – |
| 同盟 险境 | pool 550k · win 100 % · 100 % · 22.0 s | pool 919k · win 100 % · 100 % · 37.8 s |
| 同盟 绝境 | pool 1.68M · win 88 % · 99 % · 32.6 s | pool 3.42M · win 88 % · 94 % · 100.9 s |
| 同盟 终极 | pool 3.70M · win 88 % · 95 % · 41.9 s | pool 6.08M · win 75 % · 87 % · 92.6 s |
Reading: the early game is easy everywhere (official R1–R3 bring 3–10 enemies); leaks concentrate on the second-half
specials (深池逐火 TIMES embers, stealth 隐形弩手组长 / 重弩突袭者, 疯狂的逐腐兽, 掠海漂移体) and 终极 R6–R11, where co-op
boards still leak 3–6 and 联防 halves the LP cost. Solo leaders (bloodPoint × 0.25 [ASSUMED]) are the hard part of the
solo modes; the solo pool factor is the one open number here (research 08 §8 #2).
Reproduce: `node tools/balance.mjs --mode all --difficulty ALL --tuning off [--bots 5] [--json]`.
---
## 4. The tuning — removed
The previous pass layered per-round enemy HP × s / ATK × √s (all 8 modes, e.g. 同盟 险境 R13 HP × 0.55) and the solo
标准 leader pool × 0.6 on top of the research numbers, to compensate for the old generator's 2–4× enemy counts. Research
08 decoded the official generator, so the compensation is gone together with its cause: `data/tuning.json` keeps only
`titles.comment_3` (坚若磐石 = least LP lost), `gamedata.js` has no multiplier layer (`enemyScale(r)` = the config table,
`bossHpMul()` = 1, `bossPoolHp()` = the official pool). Difficulty questions are now answered by measuring (§3), not by
tuning. `tools/balance.mjs --tuning` remains accepted and has no effect on the numbers.
---
## 5. Bots (server/match/bot.js)
As found the bots lost every merge reward made while buying (the free pick-one offer queued by a merge expires at prep
end; they only looked at offers when the prep opened), levelled late (L2 at R4 … L6 at R13), valued a T6 barely above a
T1 and ignored the 特质 that produce layers — at R13 they fielded T1-heavy boards with 0–3 elites and 20–70 layers per
bond (model: 6 elites, 130 / 75). Changes: offers are taken after every buy loop, the level curve is the competent one
(early levels only with a full board), tier power 10 → 25 (was 19.5 for T6), recurring layer 特质 (every prep /
refresh) and 获得时 layer / economy 特质 add value, and boss rounds are planned against the boss field (the leader
counts as 10 tough enemies with a 30 s dwell on its first tiles, so stationary leaders such as 阿利斯泰尔 get hit).
| mode | as found (before the fixes*) | research + time fix | **tuned** | **official waves (now, seeds 1–5)** |
|---|---|---|---|---|
| 独立 标准 (1 AI) | 6.7 rounds · 3/10 wins · [5 5 9 9 5 7 6 8 4 9] | 8.1 rounds · 5/10 wins · [7 7 9 9 6 8 9 9 8 9] | **8.9 rounds · 9/10 wins · [9 9 9 9 8 9 9 9 9 9]** | **8.8 · 4/5 · [9 9 9 9 8]** |
| 独立 险境 (1 AI) | 6.5 rounds · 1/10 wins · [5 4 11 4 5 9 3 7 3 14] | 8.4 rounds · 1/10 wins · [7 6 13 6 8 11 6 8 5 14] | **10.1 rounds · 3/10 wins · [9 14 14 7 9 11 9 9 5 14]** | **13.2 · 3/5 · [14 14 13 14 11]** |
| 独立 绝境 (1 AI) | 6.9 rounds · 1/10 wins · [5 5 11 5 5 7 3 14 3 11] | 8.6 rounds · 1/10 wins · [7 5 12 8 13 8 6 9 4 14] | **8.6 rounds · 1/10 wins · [7 5 12 8 13 8 6 9 4 14]** | **13.2 · 1/5 · [13 14 13 13 13]** |
| 独立 终极 (1 AI) | 5.0 rounds · 0/10 wins · [4 4 7 3 3 7 3 7 3 9] | 5.9 rounds · 0/10 wins · [5 4 8 5 5 7 4 7 4 10] | **6.1 rounds · 0/10 wins · [5 4 10 5 4 7 4 7 4 11]** | **11.4 · 0/5 · [13 8 13 11 12]** |
| 同盟 标准 (4 AI) | 9.1 rounds · 2/10 wins · [9 4 14 11 9 11 6 8 5 14] | 11.6 rounds · 3/10 wins · [11 8 14 12 13 14 8 11 11 14] | **14.0 rounds · 10/10 wins · [14 14 14 14 14 14 14 14 14 14]** | **14.0 · 5/5 · [14 14 14 14 14]** |
| 同盟 险境 (4 AI) | 6.6 rounds · 0/10 wins · [9 4 8 7 8 6 5 6 4 9] | 9.3 rounds · 0/10 wins · [10 6 12 11 10 7 7 9 9 12] | **11.1 rounds · 2/10 wins · [11 8 14 11 12 7 13 10 11 14]** | **14.0 · 5/5 · [14 14 14 14 14]** |
| 同盟 绝境 (4 AI) | 4.9 rounds · 0/10 wins · [6 4 6 4 6 6 5 4 3 5] | 7.0 rounds · 0/10 wins · [7 5 8 7 7 7 6 8 5 10] | **8.9 rounds · 0/10 wins · [9 6 10 9 9 8 8 11 7 12]** | **11.2 · 0/5 · [12 9 13 10 12]** |
| 同盟 终极 (4 AI) | 5.2 rounds · 0/10 wins · [6 4 6 5 5 6 5 5 4 6] | 6.3 rounds · 0/10 wins · [6 5 7 6 6 7 6 6 5 9] | **7.2 rounds · 0/10 wins · [6 5 9 6 8 7 7 8 6 10]** | **9.8 · 0/5 · [10 7 13 8 11]** |
Official-waves column: `node tools/balance.mjs --mode all --difficulty ALL --bots 5` (rounds passed avg · wins · per
seed), measured before the review's leader-multiplier / 联防-timing fixes (§2; not re-run); the other columns are the
previous pass (old generator), kept for history. \* the bot code of this pass (§5) with the old time reading and no tuning; the originally reported bots (old bot code) survived ≈ 6.5 rounds on 险境 and won 1/20 on 标准.
---
## 6. Match follow-ups shipped with this pass
* Multi-round bounties ("之后的每场作战") also spawn in the Final Assault / Hidden Core, on the owner's half (route to
its goal), are shown in the boss-round preview, and the boss battle uses up one of their battles (kill coins →
pending funds for the Hidden Core prep).
* A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned).
* docs/META.md §2.6 example guards `ctx.source.kind === 'choice'` (its EffectRef reuses the handler's key).
* 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports
`rule: 'min'` among the players still alive).
* 教鞭 / “神秘顾客” stay a random bounty, documented in docs/META.md §2.5.
+540
View File
@@ -0,0 +1,540 @@
# DATA.md — generated game data (`data/*.json`)
All files in `data/` except `data/assets.json` are produced by **`node tools/build-data.mjs`** (task F1) from the
official zh_CN client data ([Kengxxiao/ArknightsGameData](https://github.com/Kengxxiao/ArknightsGameData)) joined
with `docs/research/*.json`. Do not edit them by hand — change the build script and rebuild.
```
node tools/build-data.mjs # build (downloads missing official files into .cache/gamedata/)
node tools/build-data.mjs --offline # never download; fail if a cached file is missing
node tools/build-data.mjs --refresh # re-download every official file
node tools/build-data.mjs --no-research --out /tmp/x # research-free build (fallback defaults), other dir
node tools/build-data.mjs --cache <dir> --report <file> # other cache dir / report file (tests, CI)
node tools/build-data.mjs --force # write data/ even when integrity checks fail (debugging only)
node --test test/data.test.js # integrity tests (+ raw-data cross-check and rebuild check when .cache/gamedata exists)
```
Unknown options or a missing option value are errors (exit code 2); `--refresh` and `--offline` are exclusive.
- **Cache.** Official files live under `.cache/gamedata/<repo path>` (`excel/activity_table.json`,
`levels/enemydata/enemy_database.json`, `levels/activities/act1autochess/level_*.json`, …) and are fetched from
`https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/<path>` when missing.
Downloads are validated (must parse as JSON) and written atomically.
- **Report.** Every run writes `.cache/build-data-report.json` (counts, byte sizes, warnings (each once), errors, `written`).
Integrity errors (see §17) make the exit code 1 **and leave the previous output untouched** (unless `--force`);
warnings never do. Each output file is written atomically (temp file + rename).
- **Determinism.** Same inputs ⇒ byte-identical outputs (stable key order, no timestamps, no randomness).
- **Size.** ≈3.5 MB total (limit 6 MB; `chess.json` ≈1.65 MB with the loadout choices), compact JSON (no indentation).
- **Derived paths.** `stages.json groundPaths*` come from the sim's own `server/sim/grid.js` pathing: a change there
needs a rebuild (the offline-rebuild test catches a stale `data/`).
## 0. Conventions used by every file
| Convention | Meaning |
|---|---|
| ids | Always the official ids: `chess_char_1_01_a` (normal) / `_b` (精锐 golden), `chess_item_1_01_e_a`, `garrison_16_a`, `bondeffect_yan`, `enemy_1422_lrsldr`, `band_bldsk`, `boss_1`, `act1autochess_03` (wave template = level file name, lower-case), `act2autochess_m01` (stage). |
| `desc` / `descRaw` | `descRaw` keeps the official rich-text markup (`<@ba.vup>+15%</>`, `<$ba.sluggish>停顿</>`), `desc` is plain text. Literal trigger labels such as `<获得时>`, `<在场6名不同【炎】干员>` are part of the text and kept in both. `\n` escapes are real newlines. Placeholders (`{atk:0%}`, `{-x}`, `{x:0.0}`) are already **resolved** at the record's level, except bond `effectDesc` (see bonds). |
| `bb` / `bbStr` | Flattened official blackboard. `bb[key]` = number; `bbStr[key]` = the official `valueStr` when present. A string-valued entry (value 0 + valueStr) appears **only** in `bbStr`. Duplicate keys get `_1`, `_2` suffixes. Numbers are float-noise-cleaned (6 decimals). |
| `params` | Effects/items/bands: `{…bb, …bbStr}` of all buffs merged, first occurrence wins (convenience; use `buffs` for exact semantics). |
| positions | `[row, col]`, **row 0 = bottom**, col 0 = left, 19 × 21 grid (DESIGN §3). |
| `rangeGrid` | `[[dRow, dCol], …]` relative to the unit facing **right** (`[0,0]` = own tile). Rotated for the unit's direction (DESIGN §3, `server/sim/dir.js rotateOffset`): RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)`; `rangeExtend` grows +dCol before rotating. (Every grid in the data is symmetric top-to-bottom, so LEFT equals mirroring `dCol`.) |
| round-keyed maps | JSON object keys are strings: `rounds["14"]`. |
| `[ASSUMED]` / `assumed: true` | Value is a documented reconstruction (research), not client data. Keep it tunable. |
---
## 1. `config.json` — modes, economy and all global tunables
Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAfter, bossOvertimeDrainPerSec, bossHpScale, hiddenCore, dp, unite, finalAssault, timers, bans, bandDraft, titles, titleRule, tips, broadcasts, trophies, roundScores, rewards, constants }`.
### 1.1 `modes[modeId]` (9 modes; `modeId = mode_${single|multi}_${difficulty}` + `mode_training_1`)
| Field | Example | Meaning |
|---|---|---|
| `modeId`, `name`, `code`, `sortId` | `"mode_multi_abyss"`, `"终极模拟"`, `"AC-4"`, `5` | official |
| `type` | `"MULTI"` | `SINGLE` 独立 / `MULTI` 同盟 / `LOCAL` training |
| `difficulty` | `"ABYSS"` | `FUNNY` `NORMAL` `HARD` `ABYSS` (`TRAINING`) |
| `inScope` | `true` | false for training (out of scope v1) |
| `color`, `iconId`, `backgroundId` | `"#ff0024"`, `"mode_abyss_icon"` | UI |
| `desc`, `effectDescList`, `unlockText` | | official texts |
| `specialPhaseTime` | `16` | official 机变 phase time field (multi 16 s per picker, solo 150) |
| `activeBondIds` / `inactiveBondIds` | FUNNY: 13 / 10 | static bond availability |
| `inactiveEnemyKeys` | 18 keys (FUNNY/NORMAL) | enemies never used in this mode |
| `lastRound` | `14` (solo FUNNY `9`) | last regular round (boss round) |
| `bossRound` | `14` | round of the Final Assault |
| `hiddenRound` | `15` or `null` | 隐秘核心 round (NORMAL+) |
| `rounds[r]` | see below | per-round schedule |
| `spRounds` | `[3,9,11]` | rounds whose prep opens with a 机变 draft |
| `combatTimeLimit[r]` | `{"1":45,…,"14":null}` | = `rounds[r].combatTimeLimit`: **real** seconds of the forced-2× battle (DESIGN §4) |
| `enemyScale[r]` | `{"atk":1.1,"hp":1.2,"speed":1,"kAtk":1,"kHp":1}` | non-boss enemy multipliers (research 01 A3): `atk = atkBase·1.1^kAtk`, `hp = hpBase·1.2^kHp·extra`, `speed` 1.15 on ABYSS from R3. Apply to level-0 stats after special-enemy replacement, also to bounty/special enemies. **Leader HP pools excluded.** |
| `bossHpScale` | multi `{"bloodPointKey":"bloodPointAbyss","coop":1,"aliveScaling":false,…}` · solo `{"bloodPointKey":"bloodPoint","solo":0.25,"soloAssumed":true,…}` | co-op pool = `bosses[id].bloodPoint[difficulty]` unscaled (split leaders share one pool, "敌方领袖的总生命值不变", no alive-player factor — research 08 §6 #7); solo = × `solo` [ASSUMED, flagged]. `GameData.bossPoolHp(bossId)` implements it |
| `upgradePrices` | `[5,8,11,12,13]` | base price L1→2 … L5→6 (−1 per round start, floor 0, reset after upgrade) |
| `maxShopLevel` | `6` | |
| `shopSlots[level]` | `{"1":{"chess":3,"item":1},…}` | operator + item slots per shop level |
| `levelTagColors[level]` | `"#434343"` | UI |
| `stages` | `["act1autochess_m02",…]` | active stages allowed (pick by `stages[id].weight`, 50 each): 标准 FUNNY (and training) = `act1autochess_m01` 战场#01 only; 险境 NORMAL = the 8 active stages; 绝境 HARD / 终极 ABYSS = 7 (no 战场#01) — official mode data |
| `bossWeights` / `hiddenBossWeights` | `{"boss_1":6,…}` / `{"boss_8":50,…}` | weighted boss picks |
`rounds[r]`:
| Field | Example | Meaning |
|---|---|---|
| `template` | `"act1autochess_03"` | wave template (`waves.json`) for non-boss rounds; `null` for boss rounds |
| `bossTemplates` | `{"boss_1":"act1autochess_h07_01",…}` | boss rounds: template per bossId (solo uses `_s`) |
| `combatTimeLimit` | `55` | **real** seconds (= template `maxPlayTime`) of the forced-2× battle: the Battle / 联防 limit is 2 × this in game seconds (`gamedata.js combatTimeLimit`, `config.combatTimeScale` default 2; docs/BALANCE.md §2.1); `null` for boss rounds (no hard stop) |
| `levelMaxPlayTime` | `120` | raw `maxPlayTime` of the (first) template in real seconds; boss rounds: the Final Assault / Hidden Core countdown (`m.public.deadline`, `gamedata.js bossLevelTime`) — not a hard stop, the battle goes on |
| `prepTime` | `65` | multi/training prep timer (real seconds); **`null` for solo** (untimed in 下半) |
| `prepTimeData` | `300` | official `normalPhaseTime` (optional "timed solo") |
| `isSpPrepare`, `isBoss`, `isHidden` | | 机变 before prep / leader round / 隐秘核心 |
| `bossOvertimeAfter` | `150` | boss rounds: team LP −1 per **real** second after this many real seconds (= 300 game s on the 2× field clock; `m.public.overtimeAt`) |
### 1.2 `economy`
| Field | Example | Meaning |
|---|---|---|
| `income` | `[0,4,5,6,…,12]` | index = round (index 0 unused): `min(3+r, 12)`; `incomeCap` 12, `incomeCapAlternative` 10, `incomeAssumedAfterRound` 3 |
| `leftoverFundsLost`, `leftoverFundsKeptByBands` | `true`, `["band_cannot"]` | |
| `chessPrice[tier]` / `chessSell[tier]` | `{"normal":2,"golden":2}` / `{"normal":1,"golden":1}` | |
| `chessStatus[tier]` | `{"normal":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0,…},"golden":{…}}` | training status per tier |
| `refreshPrice` | `1` | |
| `freeze` | `{"scope":"allUnsoldSlots","price":0,"consumedAtRoundStart":true,"refreshWhileFrozenRerollsAll":true,…}` | one toggle freezes all unsold slots |
| `shopClearedAtCombatStart` | `"unfrozenSlots"` | |
| `itemSellable`, `itemDestroyRefund` | `false`, `0` | items can only be destroyed |
| `benchSize`, `tempSize`, `deployCap`, `storeCntMax` | `10`, `5`, `8`, `6` | |
| `equipPerChess`, `maxArtsPerRound` | `2`, `2` | |
| `poolCopies[tier]` + `poolCopiesOverrides` | `{"1":12,"2":14,"3":18,"4":16,"5":8,"6":5}`, `{"chess_char_6_11_a":4}` (缪尔赛思) | shared pool copies per base chess |
| `goldenCopies`, `mergeCount`, `mergeCountOverrides`, `itemMergeCount` | `3`, `3`, `{"chess_char_2_11_a":2}` (风丸), `2` | |
| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 free chess of tier `min(shopLevel+1,6)` |
| `handFillOrder` | `"rightToLeft"` | |
| `shopOdds` | `{"model":"copyWeighted",…}` | [ASSUMED] roll model |
| `borrowCount`, `fallbackBondId`, `defaultBandId`, `defaultStartLp` | `20`, `"emptyShip"`, `"band_bldsk"`, `28` | |
### 1.3 Other global keys
| Key | Example | Meaning |
|---|---|---|
| `lpCapPerRound` | `10` | max LP lost per player per normal round |
| `bossOvertimeAfter`, `bossOvertimeDrainPerSec` | `150`, `1` | official `bossTurnHpReduceTime`: from 150 **real** s of a boss round the merged team LP loses 1 per whole real second (`gamedata.js bossOvertimeDue`; first point at 151 s) |
| `bossHpScale` | `{"formula":"co-op: bloodPoint[difficulty] …","coop":1,"solo":0.25,"soloAssumed":true,"aliveScaling":false,…}` | global form of the mode rule |
| `hiddenCore` | `{"single":350,"multi":1200,"minTeamLpExclusive":1,"difficulties":["NORMAL","HARD","ABYSS"],"checkedAfterRound":14}` | Σ activated layers must be **>** threshold and LP **>** 1 |
| `dp` | `{"init":10,"perSec":1,"max":99}` | |
| `unite` | `{"maxHelpers":2,"helperOrder":"unitsOnField>activeBond>undownedUnits; pair: unitsOnField>activeBond>activeLayers>undownedUnits, first = right field (PRTS 帮助)","layerGainsEnabled":false,"templates":{"1":"act1autochess_escaped_single","2":"act1autochess_escaped_multi"},…}` | 联防; `helperOrder` documents the rule `server/match/unite.js helperOrder` implements (research 08 §5; ties → seat) |
| `finalAssault` | `{"pairing":"seatOrderPairs","oddPlayerAlone":true,"movableBossPerAlivePlayerSide":true,"layerGainsEnabled":false}` | |
| `timers` | `{"infoCheck":25,"infoCheckHint":5,"bandDraft":50,"bandDraftHint":15,"bandTurn":12,"battleCheck":3,"spFirst":30,"spTurn":16,"soloPrepTimeData":300,"soloSpTimeData":150,"chatCd":1,"chatBubble":3,"broadcastDelay":1,"enterSteps":[…]}` | seconds |
| `bans[difficulty]` | `{"core":3,"addon":4}` (FUNNY `{0,1}`) + `rule` | per-match disabled-bond draw (research 01 A2) |
| `bandDraft` | `{"skipsPerPlayer":1,"order":"random","duplicatesAllowed":true,"timeoutBandId":"band_bldsk"}` | |
| `titles[]` | `{"id":"comment_1","picId":"comment_icon_1","name":"卫戍之星","stat":"bossDamage","rule":"max","onlyOnWin":true,"text":"…"}` | 评语 + [ASSUMED] criteria; `titleRule` explains assignment |
| `tips[]` | `{"tip":"只有在联合模拟中才能获得奖杯","weight":50}` | gameTipsList |
| `broadcasts[]` | `{"id":"comment_boss_hit_1","type":"BOSS_HIT","priority":30,"text":"{0}博士对敌方领袖造成的伤害超过20%!","textRaw":"<@ba.vup>{0}博士</>…","params":["0.2"]}` | `{0}` player, `{1}` operator/level, `{2}` amount |
| `trophies` | `{"byRoundsPassed":[{"maxRound":4,"FUNNY":0,…},…],"hiddenCore":{…},"multiOnly":true,"medals":[{"count":0,"iconId":"trophy_level1_icon"},…]}` | |
| `roundScores[]` | `{"round":4,"score":50}` | |
| `rewards` | `{"itemId":"act2autochess_token_chess","baseByRoundsPassed":[{"round":1,"count":10,"dailyPoint":10},…],"formula":"…","difficultyFactor":{…},"modeFactor":{…}}` | 卫戍认证 |
| `constants` | `{"maxLevelCnt":15,"bossTrailerStartRound":3,"singleReconnectTime":86400,…,"pingConds":[…]}` | misc official constData; `singleReconnectTime` (s) = how long a dropped **solo** run stays resumable (server/lobby.js; co-op keeps the 10-minute window) |
---
## 2. `chess.json` — `{ [chessId]: Chess }` (266 = 133 normal + 133 golden)
112 are `visible` (non-hidden, non-DIY) normal chess: per tier 16/17/19/22/19/19. 17 are `isHidden` (retired
上半 entries or effect-only such as `chess_char_1_15_a` 盟约·辅助干员 from band Pith); 4 are DIY (甄选) slots.
| Field | Example | Meaning |
|---|---|---|
| `chessId`, `baseId`, `goldenId`, `isGolden` | `"chess_char_1_01_b"`, `"chess_char_1_01_a"`, `"chess_char_1_01_b"`, `true` | normal/golden pair |
| `tier` | `1` | 阶 I–VI (shop tier) |
| `identifier`, `shopSortId` | `133`, `1` | official ordering |
| `isHidden`, `isDiy`, `visible` | | `visible = !isHidden && !isDiy` (only visible chess enter the shop pool) |
| `chessType` | `"PRESET"` | `PRESET` / `NORMAL` / `DIY` |
| `charId` | `"char_498_inside"` | operator (null for DIY) |
| `name`, `appellation` | `"隐现"`, `"Insider"` | |
| `rarity` | `5` | stars 1–6 |
| `profession`, `subProfessionId`, `subProfessionName`, `position` | `"SNIPER"`, `"fastshot"`, `"速射手"`, `"RANGED"` | |
| `nationId` | `"laterano"` | |
| `bonds[]` | `["lateranoShip","swiftShip"]` | bondIds (→ `bonds.json`) |
| `garrisonIds[]` | `["garrison_16_b"]` | 特质 (→ `garrisons.json`); first = displayed trait |
| `price`, `sellPrice` | `2`, `1` | |
| `upgradeNum`, `upgradeChessId` | `3`, `"chess_char_1_01_b"` | copies needed to merge (风丸 2; golden 0) |
| `status` | `{"phase":2,"level":50,"skillLevel":7,"equipLevel":1}` | training status used for all numbers |
| `stats` | `{"maxHp":1421,"atk":508,"def":167,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0}` | keyframes linearly interpolated at `status`, hp/atk/def/cost/block/respawn rounded; **golden includes the module attribute bonus**. `aspd` 100 = base (module attack_speed added). Talent stat boosts are NOT folded in. |
| `immunities` | `{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false}` | |
| `rangeId`, `rangeGrid` | `"3-3"`, `[[1,0],[1,1],…]` | attack range at `status` |
| `dmgType` | `"phys"` | `phys` / `arts` / `heal` (derived, see §2.1) |
| `attackKind` | `"ranged"` | `melee` / `ranged` / `heal` / `none` |
| `projectile` | `"arrow"` | `arrow` (phys ranged) / `bolt` (arts ranged) / `orb` (heal) / `none` |
| `canHitFly` | `true` | |
| `targetPriority` | `"fly"` | from trait text: `fly` (优先攻击空中单位), `lowestDef` (防御力最低), else `null` |
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range) |
| `skill` | see below | default skill at `status.skillLevel` (normal 4, golden 7) |
| `skills[]` | `[{…skill record…, "index":0, "isDefault":false}, {…, "index":1, "isDefault":true}]` | **loadout choices** (DESIGN §16): every skill unlocked at `status` (E1 ⇒ S1–S2, E2 ⇒ S1–S3; the default is always listed), same shape as `skill` + `isDefault`, at the chess skill level, `trigger` resolved **for that skill index** (§2.2). The `isDefault` entry equals `skill` |
| `modules[]`, `statsBase`, `traitBase`, `talentsBase` | see §2.2 | golden chess with `equipLevel > 0` only: selectable modules + the no-module base they apply to |
| `talents[]` | `{"index":0,"name":"火力支援","desc":"…","descRaw":"…","bb":{"self_ammo":3,"duration":20,"ally_ammo":1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}` | best unlocked candidate at `status` (potential 0); golden: module talent upgrades applied (`fromModule`); module data-only talents have `name:null, hidden:true`. A module upgrade of an existing talent **merges** blackboards (module keys win, base keys it does not restate are kept — e.g. 宴 keeps `min_attack_speed`; 仇白's upgrade adds `atk_scale_t` next to the old `atk_scale`: prefer the key the text uses). Module parts flagged `isToken` are **not** applied to the operator; they upgrade its summons (tokens.json variants). `containerTokenKey`: the official talent token id when it is a container missing from character_table (凛御银灰), `tokenKey` then holds the default skill's token |
| `tokens[]` | `["token_10028_vigil_wolf"]` | summons (→ `tokens.json`): displayTokenDict + default-skill `overrideTokenKey` + talent `tokenKey`; `tokens.json → variants[chessId].sources` tells which (a `display`-only token is not produced by this chess's default skill or talents) |
| `module` | `{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":1,"active":true}` | active only on golden chess |
| `assets` | `{"avatar":"char_498_inside_2","portrait":"char_498_inside_2","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"}` | asset **ids** (URLs in `data/assets.json`); golden uses the E2 art when it exists |
| `diyRequirement` | `"TIER_6"` | DIY only |
`skill`:
| Field | Example | Meaning |
|---|---|---|
| `skillId`, `iconId`, `name`, `level`, `index` | `"skchr_inside_2"`, `"skchr_inside_2"`, `"“解决麻烦”"`, `7`, `1` | `index` = character skill slot |
| `desc`, `descRaw` | `"攻击力+100%,…攻击装有14发弹药…"` | placeholders resolved |
| `skillType` | `"MANUAL"` | `MANUAL` / `AUTO` / `PASSIVE` |
| `durationType` | `"AMMO"` | `NONE` / `AMMO` |
| `duration` | `0` | seconds (0 = instant/ammo) |
| `spType` | `"INCREASE_WITH_TIME"` | also `INCREASE_WHEN_ATTACK`, `INCREASE_WHEN_TAKEN_DAMAGE`, `ON_DEPLOY` (passive, no SP) |
| `spCost`, `initSp`, `maxChargeTime`, `increment` | `20`, `10`, `1`, `1` | `maxChargeTime > 1` ⇒ charges |
| `bb`, `bbStr` | `{"atk":1,"base_attack_time":-0.3,"attack@trigger_time":14}` | kit input (DESIGN §5.6) |
| `rangeId`, `rangeGrid` | `null` | skill range override |
| `prefabId`, `overrideTokenKey` | | |
| `trigger` | `{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null}` | auto-cast rule. `rule` ∈ `DEFAULT`, `TAKE_DAMAGE` (古米/灰毫 S1 only), `SP_FULL` (official `ALWAYS`), `CUSTOM_RANGE` (official `CUSTOM_RANGE_SEARCH_ENEMY`, uses `customRangeGrid`), `SEARCH` (安洁莉娜), `MLYSS_WTRMAN` (缪尔赛思), `GDGLOW_SKILL_2` (荒芜拉普兰德, 纯烬艾雅法拉). Resolution per research 03 Addendum C1. |
### 2.1 Combat classification heuristic (`dmgType` / `attackKind` / `projectile` / `canHitFly`)
- `dmgType`: MEDIC (except `incantationmedic`) and `bard` → `heal`; trait text containing 法术伤害 or profession CASTER → `arts`; else `phys`.
- `attackKind`: `bard`, `phalanx`, `librator` → `none` (no normal attack); heal → `heal`; position RANGED or melee sub-professions with a ranged normal attack (`lord`, `fortress`, `shotprotector`, `agent`, `hookmaster`) → `ranged`; else `melee`.
- `canHitFly`: ranged attackers unless the trait says 地面敌人 (投掷手); `skywalker` (蒂比) true.
- These are defaults for the generic engine; kits may override.
### 2.2 Loadout choices: skills & modules (DESIGN §16)
Before a match the player may pick, per base chess, the equipped **skill** (any index legal for both the normal and the
elite record — `shared/protocol.js loadoutOptions`) and, for the elite, the **module** (or none). The data carries
everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simdata resolveLoadout / loadoutRecord`,
`DataSource.getChess(id, loadout)`):
- `skills[]` — see the table above. Triggers per skill: charId rows (exact `skillIndex`, or −1 = every skill) →
subProfession rows → profession rows with that exact index (official `skillIndex 0` = skill 1 only: every defender's
S1 is `TAKE_DAMAGE`, their S2/S3 `DEFAULT`); `customRangeGrid` from `skillRangeDict[skillId]`.
- `modules[]` (golden, `equipLevel > 0`): every **ADVANCED** `uniequip` of the character (the INITIAL `uniequip_001_*`
is "no module" = choice `'none'`), in official order, at the chess `equipLevel` (1, T6: 3):
| Field | Example (`chess_char_6_11_b`) | Meaning |
|---|---|---|
| `uniEquipId`, `name`, `typeName`, `typeIcon`, `icon` | `"uniequip_003_mlyss"`, `"落叶四季"`, `"TAC-Y"`, `"tac-y"`, `"uniequip_003_mlyss"` | |
| `isDefault`, `level` | `false`, `3` | default = `defaultUniEquipId` (= `module.id`) |
| `attr` | `{"maxHp":170,"atk":28,"def":28}` | flat stat additions (stat field names): `stats = statsBase[f] + attr[f]` (float-noise cleaned) |
| `traitOverride` | `{"desc":"…提升至165%","descRaw":…,"bb":{"atk_scale":1.65},"bbStr":{},"rangeGrid":null,"moduleDesc"?:…}` \| `null` | the full trait with this module; `null` ⇒ `traitBase` |
| `talentChanges[]` | `{"talentIndex":1,"name":"开源节流","desc":…,"descRaw":…,"bb":{"cost":-2,"runtime_cost":-1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false}` | talent additions/overrides (`talentIndex` −1 = new hidden data-only talent); applied to `talentsBase` with the build's merge rule (`simdata composeTalents`: override of an existing index merges blackboards, module keys win) |
- `statsBase` / `traitBase` / `talentsBase` — the golden record **without** any module (`stats` / `trait` / `talents`
keep the default module, unchanged). `test/data.test.js` proves that composing the default module onto the base
reproduces `stats`, `trait` and `talents` exactly for every golden chess.
- Module-less goldens (蒂比, 凛御银灰): `modules: []`, base = own values. Normal chess have none of these fields.
- Module parts flagged `isToken` and the per-token attribute blackboards go to the summons: `tokens.json →
variants[owner].byModule` (§14). Module choices never change the combat classification (`dmgType`…; the build warns
if one ever would).
---
## 3. `bonds.json` — `{ [bondId]: Bond }` (23 bonds, 8 core)
| Field | Example (`deputShip`) | Meaning |
|---|---|---|
| `bondId`, `name`, `identifier` | `"deputShip"`, `"助力"`, `14` | |
| `isCore`, `bondType`, `bondOrder`, `powerIdList` | `false`, `"REGULAR"`, `2`, `[]` | core = `isPower` (SEASON); `powerIdList` = nation/group ids for DIY bond derivation |
| `iconId` | `"icon_deputShip"` | |
| `activeCount` | `2` | official activation count |
| `thresholds` | `[2,3]` | ascending member counts that raise the tier (tier = number of thresholds reached). yan `[3,6,9]`, egir `[3,5]`, sunt `[2,5]`, solo `[1]` |
| `maxCount` | `null` (solo `1`) | `count_threshold_downward`: active only while `count ≤ maxCount` |
| `thresholdTemplate` | `"count_threshold_upward"` | also `_downward` (独行), `_upward_golden` (绝技) |
| `countMode` | `"BOARD"` | `BOARD` (distinct base chess on board), `BOARD_AND_DECK` (+hand: 远见/奇迹/投资人), `BOARD_ALL_CHESS` (绝技: every golden chess on board, duplicates count) |
| `countsHand`, `countsGoldenOnly` | `false`, `false` | convenience flags |
| `activeType`, `isActiveInDeck`, `noStack`, `weight`, `maxInactiveBondCount` | `"BATTLE"`, `false`, `false`, `10`, `-1` | `weight` 0 ⇒ never drawn for per-match bans |
| `layerMilestones[]` | `[{"layer":25,"mode":"every","effect":"bond_layer_added_reward_equip"}]` | layer-based powers: `reach` (while L ≥ layer), `every` (each multiple), `first` (latched once) |
| `desc`, `descRaw` | | bond panel text |
| `effectId`, `effectName`, `effectDesc`, `effectDescRaw` | `"bondeffect_deput"`, …, `"所有干员防御力+{0:0%}(受层数影响)…"` | in-battle text with **positional** placeholders |
| `effectDescParams[]` | `[{"index":0,"format":"0%","base":"base_def","perStack":"def_per_stack"}]` | `{i:fmt}` = `bb[base] + bb[perStack] × layers` |
| `bb`, `bbStr` | `{"base_def":0.15,"def_per_stack":0.012,"respawn_time":-0.3,"layer":2,"count":3,"more_layer":4}` | all buffs merged (first wins) |
| `buffs[]` | `[{"key":"env_gbuff_new","bb":{…},"bbStr":{"key":"act1autochess_bond_eff_deput"}}, …]` | exact official buffs (e.g. 萨尔贡 has a second buff valid only with `band_narant`) |
| `baseParams`, `perStackParams` | `["base_def"]`, `["def_per_stack"]` | official descParamBaseList / descParamPerStackList |
| `members[]`, `visibleMembers[]` | `["chess_char_1_06_a",…]` | base (normal) chessIds incl. hidden / only visible |
| `spec` | `{"tiers":[{"need":"…","effect":"…"}],"formulas":{…},"layerGain":"…",…}` | research 02 implementer spec (null without research) |
## 4. `garrisons.json` — `{ [garrisonId]: Garrison }` (249; 43 distinct `effectKey`s)
| Field | Example | Meaning |
|---|---|---|
| `garrisonId` | `"garrison_30_a"` | `_a` normal, `_b` golden version |
| `desc`, `descRaw` | `"<获得时>获得等于当前调度中心等级的【炎】层数(无需激活盟约)"` | |
| `eventType`, `eventTypeDesc`, `eventTypeIcon` | `"SERVER_GAIN"`, `"单次叠加"`, `"icon_bond"` | `IN_BATTLE`, `SERVER_GAIN` 获得时, `SERVER_PREP_START` 进入休整期时, `SERVER_PREP_FIN` 休整期结束时, `SERVER_CHESS_SOLD`, `SERVER_PRICE`, `SERVER_REFRESH_SHOP` |
| `effectType` | `"SERVER_ADD_BOND_METHOD"` | |
| `effectKey` | `"SERVER_ADD_BOND_METHOD"` / `"act1autochess_gar_eff_attrByBond"` | handler key: `bbStr.key` (battle runes) else `effectType` |
| `battleRuneKey`, `charLevel` | `null`, `0` | |
| `bb`, `bbStr` | `{"multi":1}`, `{"add_method":"shoplv","bond":"yanShip"}` | |
| `owners[]` | `["chess_char_1_03_a"]` | chess carrying it (includes garrisons referenced by other garrisons, which may have no owner) |
## 5. `items.json` — `{ [itemChessId]: Item }` (115 = 56 EQUIP × 2 + 3 MAGIC)
| Field | Example (`chess_item_1_01_e_a`) | Meaning |
|---|---|---|
| `id`, `baseId`, `goldenId`, `isGolden` | `"chess_item_1_01_e_a"`, …, `"chess_item_1_01_e_b"`, `false` | |
| `trapId`, `iconId` | `"trap_1041_acarm041"` | icon id = trapId |
| `identifier`, `shopSortId` | `267`, `1` | |
| `name` | `"维式重锤"` | |
| `itemType` | `"EQUIP"` | `EQUIP` (equipped, max 2) / `MAGIC` (Arts, dropped on a tile) |
| `tier`, `price`, `hideInShop` | `1`, `1`, `false` | golden price 5 (never sold in shop) |
| `mergeable`, `upgradeNum`, `upgradeChessId` | `true`, `2`, `"chess_item_1_01_e_b"` | 2 copies → golden; `upgradeNum` 100 = never merges |
| `duration` | `-1` | trapDuration |
| `giveBondId`, `givePowerId`, `canGiveBond` | `"victoriaShip"`, `null`, `false` | 变形同构体 (`canGiveBond`) grants the `giveBondId` of the *other* equipped item |
| `requiresBondId` | `null` / `"yanShip"` | VI-tier bond signature items (research 04) |
| `effectId`, `effectName`, `desc`, `descRaw` | `"eff_acarm041"`, …, `"攻击力+15%"` | |
| `buffs[]`, `params` | `[{"key":"env_gbuff_new_with_verify","countType":"NONE","bb":{"atk":0.15},"bbStr":{"key":"attr_common_global_buff"}}]` | |
| `category`, `kind`, `family`, `implFormula` | `"STAT"`, `"passive"`, `null`, `"ATK% += atk …"` | research 04 (categories: STAT, ON_HIT, SURVIVAL, SP, ECONOMY, RECRUIT, BOND, BOND_GRANT, SET, BOND_SIGNATURE, MAGIC) |
| `rangeGrid` | `[[0,0]]` | Arts area (画卷 `[[0,0],[0,1]]`) |
| `flavor` | | |
## 6. `bands.json` — `{ [bandId]: Band }` (40)
| Field | Example | Meaning |
|---|---|---|
| `bandId`, `sortId`, `name`, `iconId` | `"band_bldsk"`, `1`, `"华法琳"`, `"icon_bldsk"` | |
| `modeTypeList` | `["LOCAL","SINGLE","MULTI"]` | |
| `totalHp` | `28` | starting LP (20–45) |
| `effectId`, `effectName`, `desc`, `descRaw` | `"aceffect_band_21"`, `"重点监护"`, `"【重点监护】开始作战时…"` | |
| `buffs[]`, `params` | `[{"key":"prep_finish_char_bond_add_layer","bb":{"layer":2},"bbStr":{}}]` | |
| `victorCount`, `rewardModulus`, `unlockDesc` | `3`, `1`, `null` | |
## 7. `effects.json` — `{ [effectId]: Effect }` (361: every effect of the season)
Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BUFF_GAIN` 43 (战术决策), `ENEMY` 6 (`aceffect_enemy_*` global modifiers), `CHAR_MAP` 4.
| Field | Example | Meaning |
|---|---|---|
| `effectId`, `effectType`, `name` | `"aceffect_band_21"`, `"BAND_INITIAL"`, `"重点监护"` | |
| `desc`, `descRaw` | | |
| `counterType`, `continuedRound`, `decoIconId`, `enemyPrice` | `"NONE"`, `-1`, `null`, `0` | `enemyPrice` = bounty coins |
| `buffs[]` | `{"key":"…","countType":"NONE","bb":{…},"bbStr":{…}}` | |
| `params` | `{"layer":2}` | merged convenience view |
## 8. `choices.json` — 机变 draft data
| Key | Example | Meaning |
|---|---|---|
| `events[id]` | `{"id":"enemy_initial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"…","color":"#35d8b4","family":"bounty","solo":false,"training":false}` | 109 official choice events; `_s` = solo copies; `usedBy` on events opened by items (`hunter_band_1` ← 教鞭 / “神秘顾客”) |
| `families` | `{"bounty":{"name":"悬赏决策",…},"supply":{…},"shop":{…},"tactic":{…}}` | 悬赏决策 / 道具补给 / 机密商店 / 战术决策 |
| `format` | `{"multi":{"cards":6,"pickOrder":"random","firstPickSec":30,"otherPickSec":16,"onTimeout":"autoPickRandom","eachPlayerPicks":1},"solo":{"cards":3,"timer":null},"opensAfterIncome":true}` | |
| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…]}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value) |
| `cards.tactic[]` | `{"effectId":"map_m01_1","name":"模拟战场演变·模式一","kind":"terrain","stageId":"act1autochess_m01","team":false}` | `kind` `ally` / `enemyDebuff` / `terrain` (only for the match stage); `team` = also given to teammates |
| `schedule[modeId]` | `{"spRounds":[3,6,9],"rounds":{"3":{"families":[{"family":"bounty","weight":50},…],"cards":6,"supplyTiers":[1,4],"bountyTiers":[1,2],"events":{"bounty":[…],"supply":[…],"shop":[…],"tactic":[…]},"assumed":true}}}` | research 01 A4 defaults: pick a family by weight; `supplyTiers` = item tier window for 道具补给; 机密商店 draws any tier; `events` = official event ids usable as the phase header |
| `pools[poolId]` | `{"kind":"equip","items":[…]}` / `{"kind":"chess","tier":2,"rule":"shopEligible"}` | server-side reward pools referenced by effects (`pool_equip_*`, `pool_chess_shop_N_reward`, `pool_char_later`, …), all [ASSUMED]; `rule:"shopEligible"` = visible/non-hidden entries, `maxTier:"shopLevel"` = ≤ current shop level, `weighted` = `[[id, weight]]` |
## 9. `enemies.json` — `{ [enemyKey]: Enemy }` (249 keys that can appear)
Collected from all wave templates (spawns, branches, enemyDbRefs), special-enemy entries and pools, bounty
effects, band effects, bosses, placeholders, mode bans, plus summons (`extraEnemyKeyList`) and enemy keys in enemy
blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for all); the season override level
`level_autochess_enemy_data` is applied (灼藤/元核孽生者 ATK 400, …).
| Field | Example (`enemy_1422_lrsldr`) | Meaning |
|---|---|---|
| `key`, `name`, `level`, `rank`, `handbookIndex` | …, `"萨卡兹枯朽前锋"`, `0`, `"NORMAL"`, `"NAC18"` | `rank` NORMAL / ELITE / BOSS |
| `desc`, `descRaw`, `applyWay` | …, `"MELEE"` | `applyWay` MELEE / RANGED / ALL / NONE |
| `stats` | `{"maxHp":4700,"atk":300,"def":100,"res":40,"moveSpeed":0.8,"bat":3,"aspd":100,"rangeRadius":0,"rawRangeRadius":-1,"blockCnt":1,"massLevel":1,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys","arts"],"motion":"WALK","immunities":{…},"otherImmunities":[],"tauntLevel":0}` | level-0 (unscaled). **`rangeRadius` ≥ 0 is the normal-attack radius under the DESIGN §5.5 contract: `> 0` ⇔ the enemy attacks units it is not blocked by; always 0 for `applyWay` MELEE** (melee enemies only hit their blocker; 粉碎攻坚手/宿主士兵/深池方阵步兵 carry an official 1.2–2.5 used by abilities) and negative sentinels become 0. `rawRangeRadius` = the official value. `blockCnt` = block slots it consumes; `lpr` = lifePointReduce; `dmgType` = first handbook damage type (`phys`/`arts`/`heal`/`none`/…), full list in `dmgTypes`. Undefined database fields (`m_defined:false`) holding the zero value fall back to the defaults `lpr` 1, `bat` 1, `aspd` 100, `moveSpeed` 1 (see §15) |
| `abilities[]` | `{"text":"位于源石污染区内时,攻击造成法术伤害","textRaw":"…","format":"NORMAL"}` | handbook ability list |
| `talents` | `{"bb":{"1.hp_ratio":0.2,"1.damage_scale":0.5},"bbStr":{}}` | talentBlackboard |
| `skills[]` | `{"prefabKey":"2","priority":0,"cooldown":50,"initCooldown":0,"spCost":0,"bb":{"hp_ratio":0.02},"bbStr":{"branch_id":"boss_summon_enemy","enemy_key":"enemy_1005_yokai"}}` | `branch_id` → `waves[t].branches` |
| `notCountInTotal`, `tags` | `false`, `["sarkaz"]` | |
| `be`, `beFactor`, `attrPower`, `isFlyEnemy` | `6620`, `1`, `6620`, `false` | `attrPower` = the official float32 attribute power `P = atk·5 + maxHp + def·3 + res·3` of the **enemy_database** record (season override NOT applied: 灼藤 / 元核孽生者 count with their database ATK) — the input of the replacement count (§10); `beFactor` = `randomEnemyAttributeDict.enemyBattleEffectivenessFactor`; `be` = round(P'/f) on the spawned stats (informative); `isFlyEnemy` = the official movement class |
| `tokenOnly` | `false` | only ever spawned by other enemies (summons / blackboards / level refs) — a token for 联防 routing (research 08 §5) |
| `acTypes`, `acType` | `[]`, `null` | 特训 type(s): FLY/TIMES/ELEMENT/DOT/INVISIBLE/REFLECTION/SPECIAL |
| `templateSlot` | `"N"` | placeholder slot when the key is a template key |
| `summons[]`, `inactiveIn[]`, `seasonOverride` | | spawned enemies; modes banning it; overridden fields |
| `iconId`, `spine` | `"enemy_1422_lrsldr"` | asset ids |
## 10. `factions.json` — special enemies (特训敌人)
| Key | Example | Meaning |
|---|---|---|
| `templateSlots` | `{"N":"enemy_1422_lrsldr","E":"enemy_1427_lrnazg","S":"enemy_1425_lrcmra","NF":"enemy_1005_yokai","EF":"enemy_1042_frostd","SF":"enemy_1040_bombd","T":"enemy_1000_gopro_2","TF":"enemy_1041_lazerd"}` | placeholder keys in wave templates |
| `types[type]` | `{"type":"FLY","name":"特训敌人·飞行","desc":"…","icon":"fly_icon","sortId":2,"typeIdentifier":2,"involveRandom":true,"count":3,"weight":3,"pool":[…],"entries":[…]}` | 7 types |
| `entries[key]` | `{"key":"enemy_1041_lazerd","type":"FLY","weight":10,"firstHalf":true,"fly":true,"N":[{"key":"enemy_10083_hlbird"}],"E":[{"key":"enemy_1005_yokai_2"}],"inactiveIn":[]}` | 67 entries (`specialEnemyInfoDict`); `fly` = the movement class shared by the special, normal and elite keys; `inactiveIn` = modes whose `inactiveEnemyKey` bans the SPECIAL key (attached keys are never filtered) |
| `generation` | `{"specialEnemyNum":3,"maxLevelCnt":15,"fillType":"SPECIAL","typeSlots":{"FLY":3,…},"firstHalfMaxRound":7,"minReplacedEnemyCount":1,"maxReplacedEnemyCount":5,"minActionIntervalRatio":0.05,"beFactors":{…},"placeholders":{"enemy_1422_lrsldr":{"slot":"N","cls":"normal","fly":false},…},"powerFormula":"…","countFormula":"…","timingFormula":"…","notes":[…]}` | parameters of the **official** generator (client `RandomEnemyGenerater`, research 08 §2 — normative) |
The official generator (implemented by `server/match/waves.js`):
1. **Match:** 3 random types (shuffle the 6 `involveRandom` types, keep 3); each owns `typeSlots[t]` (3) of the 15 round
slots, `fillType` (SPECIAL) the other 6; the slots are shuffled → one type per round. All 15 round picks are drawn
at match start (weighted entry of that type and half whose SPECIAL key the mode allows; random attached N / E).
2. **Round:** every template SPAWN action whose key is a `placeholders` key becomes the pick's normal / elite /
special key, or is **skipped** when that key's movement class differs from the placeholder's (ground rounds drop
NF/EF/SF, FLY rounds drop N/E/S). Its count `n' = clamp(roundHalfEven(f32(f32(f32(n·P(tpl))/f(tpl)) /
f32(P(new)/f(new)))), 1, 5)` (P = `enemies[k].attrPower`, f = `beFactor`), unit *i* at
`time + i·max(n·interval/n', 0.05·n·interval)`. Literal keys and the T / TF token keys are kept.
3. **Leader / hidden rounds** use the pick of their own slot (14 / 15; solo 标准 9): only E **or** EF escorts spawn.
## 11. `waves.json` — `{ [templateId]: Wave }` (38 templates)
Non-boss rounds use `act1autochess_01…07`, `h01…h06`; leaders `h07_0X` (+`_s` solo; boss_5 is `act2autochess_h07_05`);
hidden core `h08_0X`; 联防 `act1autochess_escaped_single|multi`; training `tr01/02/04`.
| Field | Example | Meaning |
|---|---|---|
| `id`, `kind`, `solo` | `"act1autochess_03"`, `"normal"`, `false` | `kind` normal / boss / hidden / escaped / training |
| `bossId` | `null` / `"boss_8"` | |
| `maxPlayTime` | `55` | **real** seconds of the forced-2× battle (DESIGN §4; spawn `time`s below are game seconds) |
| `dp`, `characterLimit`, `moveMultiplier`, `bgm` | `{"init":10,"perSec":1,"max":99}`, `8`, `0.5`, `"bat_kazimierz2_2"` | level options |
| `routes[]` | `{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,9],[12,9],…]}` | WALK routes have 0–1 checkpoints (usually the end) ⇒ pathfind between them on the stage grid; `motion:"E_NUM"` (start/end `[0,0]`) = dummy route of a non-SPAWN action (only `ACTIVATE_PREDEFINED` in boss_4 branches) |
| `routes[].steps` | `[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]` | present only when a route has non-MOVE checkpoints (`move`, `patrol`, `wait` s, `disappear`, `appear` p); authoritative when present |
| `routes[].spawnRandom` | `[0.3,0.3]` | random spawn offset (tiles), optional |
| `extraRoutes[]` | same shape | routes used by `branches` |
| `spawns[]` | `{"time":6,"key":"enemy_1422_lrsldr","count":10,"interval":6,"routeIndex":0,"slot":"N"}` | `time` = absolute game seconds of the first unit; unit i spawns at `time + i·interval`. Optional: `slot` (placeholder N/E/S/NF/EF/SF/T/TF → replace per `factions`), `tag` (`boss` / `part`), `hidden`/`hiddenGroup`, `group`/`pack`/`weight` (random spawn group: `group:"local"` = only on the local side), `unharmful` (counts as killed), `action` (non-SPAWN action, e.g. `ACTIVATE_PREDEFINED` with `key` = device alias) |
| `branches[name]` | `{"boss_summon_enemy":[[{"time":0,"key":"enemy_1005_yokai","count":1,"interval":1,"routeIndex":5}]]}` | script-triggered spawns (`skills[].bbStr.branch_id`, `dragon` = 炎佑, `boss_escaped_walk/fly`, `boss_battle_multi_player` = mirrored boss sharing HP); array of **phases**, times relative to the phase start, `routeIndex` → `extraRoutes`. [ASSUMED] phase *i* runs on the *i*-th trigger (clamped to the last) |
| `overrides[enemyKey]` | `{"enemy_1430_lrrook":{"stats":{"maxHp":60000}}}` | per-template enemy overrides (bosses): partial `stats`, `talents`, `skills` |
| `devices[]` | `{"key":"trap_039_dstnta","alias":"trap_039_dstnta#1","pos":[5,9],"dir":"…","hidden":true}` | template predefines (activated by branches) |
| `totalCount`, `slotCounts` | `22`, `{"N":10,"E":1,"NF":9,"EF":2}` | SPAWN counts before replacement (excl. `unharmful`) |
| `usedBy[]` | `{"modeId":"mode_multi_normal","round":3,"bossId":null}` | |
Timing: templates have one wave with one fragment, so `time = wave.preDelay + fragment.preDelay + action.preDelay`
exactly (the builder warns if a multi-fragment template ever appears).
## 12. `stages.json` — `{ [stageId]: Stage }` (11 terrains, 8 active)
| Field | Example | Meaning |
|---|---|---|
| `id`, `name` | `"act2autochess_m01"`, `"战场#05(下半) 源石流发生装置"` | player-facing name from research 05 (the official tables carry none; falls back to id). The build drops bracketed segments containing Latin letters (research notes such as `战场#01 (upper half #01)` → `战场#01`), logs a warning, and fails validation if Latin text remains |
| `weight`, `active`, `modes` | `50`, `true`, `["mode_single_normal",…]` | match-start pick weight (act1 m05–m07 weight 0) |
| `size` | `[19,21]` | |
| `rows[]` | `rows[9] = "##Errr#rrrSrrr#rrrS##"` | 19 strings, **index = row (0 = bottom)**, one glyph per col |
| `tiles[glyph]` | `{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}}` | actual tile properties of each glyph used |
| `devices[]` | `{"key":"trap_013_blower","name":"源石流发生装置","alias":"trap_013_blower#001","pos":[13,5],"dir":"DOWN","hidden":false,"role":"blower","active":true,"stats":{…},"rangeGrid":[[0,0],…],"rangeTiles":[[13,5],[12,5],[11,5],[10,5]],"skill":{"skillId":"sktok_blower","bb":{"blower_s_character[equal].atk":0.3,…}},"desc":null}` | predefined devices; `role` ∈ crate, platform, mound, blower, mireController, tideController, turret (双眼皮, activated by band 机械援助), coldWind, sandstorm, waterPlatform, sealedFloor, bush, bossSpawn; `active` = present at match start (= not `hidden` in the level file); `rangeGrid` is facing right, `rangeTiles` = absolute tiles after rotating by `dir` (RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)`), clipped to the grid (null without a range). Terrain cards / band effects toggle devices by `alias` |
| `mapChars[]` | `{"key":"char_605_cmedic","alias":"char_605_cmedic#1","pos":[10,2],…}` | band_amedic map characters (→ `tokens.json`) |
| `special` | `{"mire":{"intervalSec":3,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,…}}`, `{"infection":{"bb":{"damage":70,"atk":0.2,"attack_speed":20,"duration":300}}}`, `{"deepsea":{"skillId":"sktok_tidectrl_3","bb":{…}}}`, `{"smog":{"rule":"operatorsNotTargetableByEnemyRanged"}}`, `{"blower":{…}}` | special-terrain parameters |
| `runes[]`, `globalBuffs[]` | `{"key":"map_tile_blackb_assign","bb":{…},"bbStr":{…}}` | level runes (act2 m03 reed rune has no reed tiles — leftover) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW buildable ALL/MELEE (ranged may use them too), `rangedOnly` = high ground / active platforms; tiles under active crates/mounds excluded |
| `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — plus the remake's road-over-floor preference: among equal-length routes the fewest non-blockable floor / gate tiles, and smoothing never cuts across floor the grid route does not walk, e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane; DESIGN §14 corrections) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live |
| `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | |
Glyph legend (`rows`):
| Glyph | tileKey | Height | Build | Ground | Meaning |
|---|---|---|---|---|---|
| `#` | tile_forbidden | HIGH | NONE | no (fly yes) | void |
| `X` | tile_forbidden | HIGH | NONE | no (fly no) | hard separator (rows 6, 13) |
| `r` | tile_road | LOW | ALL | yes | deployable ground |
| `R` | tile_road | LOW | NONE | yes | non-deployable road |
| `f` | tile_floor | LOW | NONE | yes | lane col 9 / preview pen |
| `p` | tile_floor | LOW | NONE | yes | preview row (previewNotAlloed) |
| `h` | tile_wall | HIGH | RANGED | no | high ground |
| `b` | tile_fence_bound | LOW | ALL | no | fenced lowland (deployable, blocks ground) |
| `a` | tile_achand | HIGH | ALL | no | hand slot (row 7 cols 0–9; row 0 boss benches) |
| `A` | tile_achand | HIGH | ALL | no | temp hand slot (row 8 cols 4–8) |
| `S` / `E` | tile_start / tile_end | LOW | NONE | yes | red gate / blue objective |
| `I` / `O` | tile_telin / tile_telout | LOW | NONE | yes | teleport in / out (boss field) |
| `m` `g` `d` `i` | tile_mire / tile_smog / tile_deepsea / tile_infection | LOW | ALL | yes | special terrain (see `special`) |
## 13. `bosses.json` — `{ [bossId]: Boss }` (10; boss_8–10 hidden)
| Field | Example (`boss_5`) | Meaning |
|---|---|---|
| `bossId`, `enemyKey`, `handbookId`, `name`, `sortId` | `"boss_5"`, `"enemy_2016_csphtm"`, …, `"卢西恩,“猩红血钻”"`, `5` | |
| `weight`, `hidden` | `10`, `false` | pick weight within its round |
| `bloodPoint` | `{"FUNNY":200000,"NORMAL":390000,"HARD":780000,"ABYSS":3000000}` | shared leader HP pool per difficulty |
| `lpr` | `30` | LP cost if it leaks |
| `templates[modeId]` | `{"round":14,"template":"act2autochess_h07_05"}` | |
| `escortsByTemplate[templateId]` | `[{"key":"enemy_2009_csaudc","slot":null,"count":4},…]` | non-boss spawns (slot = still a placeholder) |
| `parts[]` | `["enemy_9014_acstma","enemy_9015_acstmb"]` | boss parts (random local groups / unharmful) |
| `abilities[]` | handbook texts | |
## 14. `tokens.json` — `{ [tokenId]: Token }` (22)
`kind`: `summon` (19 chess summons), `bondSummon` (`enemy_9012_acloon` 炎佑 for 炎 6/9), `mapChar` (`char_605_cmedic`
预备干员-医疗 / `char_613_acmedc` Touch placed by band `band_amedic`).
| Field | Example (`token_10028_vigil_wolf`) | Meaning |
|---|---|---|
| `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | |
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | |
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict`: DEFAULT = hand-placeable piece; HIDDEN = battle-only |
| `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | |
| `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults |
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
| `deployLimit`, `count` | `1`, `1` | `count` = copies sent to the hand / spawned (talent/skill `cnt`); `null` ⇒ use `deployLimit` |
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) + golden module `tokenAttributeBlackboard`; the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
| `variants[o].bySkill[i]` | `{"skill":{…},"count":1,"sources":["talent","display"]}` | owner loadout with the non-default skill index `i` (one entry per other selectable owner skill): the token skill of that slot (伺夜's wolves, 缪尔赛思's 流形, 凛御银灰's eagles…), the count and how the chess then produces it (`sources` may be `[]`: 风丸 S1 makes no 纸偶; 赫默 / 巫恋 S1 only `display` ⇒ no hand piece). The sim resolves them for an owner loadout: `simdata getToken(id, ownerChessId, loadout)` → `def.sources` / `def.count` |
| `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) |
| `assets` | `{"avatar":"token_10028_vigil_wolf","spine":"token_10028_vigil_wolf"}` | |
| mapChar extras | `phase`, `level`, `trait`, `talents`, `positions:[{"alias":"char_613_acmedc#2_multi_only","pos":[10,10],"dir":"RIGHT","multiOnly":true}]`, `source` | |
| bondSummon extras | `bondId`, `motion`, `skills`, `talents`, `assets.isEnemyModel` | 炎佑 base template; ATK/HP are **replaced** at battle start by 30 % of the 炎 operators' sums (bonds.yanShip) |
---
## 15. Anomalies found while joining (also in `.cache/build-data-report.json`)
1. **凛御银灰 container token** `token_10057_svash2_eagle` (talent tokenKey) is not in `character_table`: the talent's
`tokenKey` is remapped to the default skill's token (S2 → `…_eagle2`, `containerTokenKey` keeps the official id).
`…_eagle1/3` stay listed (`sources:["display"]`, belong to S1/S3). With another skill selected (DESIGN §16) the
talent follows that skill's eagle (`simdata loadoutRecord`; `variants[o].bySkill[i].sources`).
2. **DIY chess** (`chess_char_5_diy1/2`, `chess_char_6_diy1/2`, `_a` and `_b` = 8 records): no `charId`, no stats/skill; `visible:false`,
name placeholder `甄选干员`. Out of scope for v1.
3. **Module-less chess**: 蒂比 (`chess_char_2_13`) and 凛御银灰 (`chess_char_5_14`) have no module; their golden
record has `module:{id:null,active:false}` and no module stat bonus.
4. **Hidden chess (17)** are kept with `visible:false`; several operators exist in two tiers with one hidden
(锡人, 耶拉, 录武官, 白面鸮, 百炼嘉维尔, 魔王, 华法琳, 妮芙).
5. **Every non-DIY chess (258 records: 129 normal + 129 golden) has a resolvable default skill, stats and range** — no
skill anomalies.
6. **Skill triggers**: official rows with `skillIndex 0` apply to skill 1 only (research 03 C1): only 古米 and 灰毫 get
`TAKE_DAMAGE`; bearer/tactician/bard/librator/phalanx rows match no pool chess.
7. **Trait candidate `rangeId`** (送葬人, 松果 1-3; 风丸, 归溟幽灵鲨 x-4) is the trait-effect area, exposed as
`trait.rangeGrid`, not the attack range.
8. **Passive skills** use numeric `spType 8` in skill_table → normalized to `ON_DEPLOY`.
9. **Bond `effectDesc`** keeps positional placeholders by design (`effectDescParams`).
10. **Hidden module talents**: module data-only talent candidates have no name/text; kept as `hidden:true,fromModule:true`.
11. **act1 m02** crates and platforms are all `hidden` in the 下半 level file and absent from the PRTS 下半 screenshot
⇒ **no crates at start** (`active` = `!hidden` for every device; research 08 §3.3 — an earlier build kept
#001–#004 active, which blocked the top road). Its terrain cards can only act on hidden devices.
Platforms/mounds block ground movement [ASSUMED]; crates are obstacle-like (cost 1000), not walls.
12. **Boss per-template overrides** (e.g. 愧悔魂灵圣杯 HP 60 000, 昆图斯 ATK 480, 卢西恩 ATK 700/600) live in
`waves[t].overrides`, not in `enemies.json`.
13. `act1autochess_h08_01` `totalCount` 15 excludes the two unharmful hand parts (research counted 17).
14. Escaped (联防) templates have `maxPlayTime 1` in data; use the round's `combatTimeLimit`.
15. 炎佑 name in the enemy DB is `"炎佑"` with literal ASCII quotes.
16. Research-only fields (null without `docs/research`): stage `name`, item `category/kind/family/implFormula/
requiresBondId/rangeGrid/flavor`, bond `spec`, E2 art availability (falls back to "char has an E2 phase").
17. [ASSUMED] content (flagged in data): 机变 family schedule and server pools, the solo leader pool factor
(`bossHpScale.solo` 0.25), title criteria, income cap 12, per-turn band-draft timer 12 s. The special-enemy
generator, the co-op leader pool (`bloodPoint`, no alive-player scaling) and the 联防 timing are official.
18. **Module parts flagged `isToken`** (伺夜, 浊心斯卡蒂, 缪尔赛思, 耀骑士临光 golden) upgrade the summon only; they are
applied to `tokens.json` variants, never to the operator's talents/trait.
19. **Undefined enemy-database fields** (`m_defined:false`): a zero `m_value` means "never set" and falls back to the
default (`lifePointReduce` 1 — 萨卡兹王庭军战士, 深池逐火战士, 圣堂剑士…; `attackSpeed` 100 — 卢西恩, 萨卡兹枯朽战车…;
`baseAttackTime` 1 — 枯朽之种); a non-zero `m_value` is the prefab value and is kept (boss-part immunities).
Defined values are never changed: 假想敌:铳/管/弦 have an official `aspd` 0 (skill-driven; engines clamp).
20. **MELEE enemies with an official radius** (粉碎攻坚手 2.5, 宿主士兵 2.5, 迷路的巨像 2.5, 清明/堂皇 2, 雪怪小队破冰者
1.9, 冰爆源石虫 1.75, 深池方阵步兵 1.5, 深池伙友卫队 1.4, 山海众头目 1.2 …): `rangeRadius` 0, value in `rawRangeRadius`.
21. **Token counts**: a skill `cnt` is only used as a token count when that skill overrides the token (夕's S1 `cnt` 2
is a charge count ⇒ “小自在” `count` null).
22. **联防 spawn timing** (client `GenerateHelpBattleEnemyData` → `_GetSpEnemyActionData` →
`_CalculateActionPredelayConsiderUid`, decoded from GameAssembly.dll; `server/match/waves.js buildUniteWave`): every
leaked enemy is a 1-unit action on the route of the escaped template's first `lrsldr` (walker) / `yokai` (flyer) /
`gopro_2` / `lazerd` (token) action, else action 0. Per host action, with W = count·interval of that action and
M = the largest per-owner group, step = min(max(W/M, 0.05·W), 5 s); the k-th owner (first-leak order) starts
0.5·k s after the action's preDelay (no cap) and its i-th unit spawns i·step later.
23. **Preview zones** (`waves.js gateOf`, client `AutoChessEnemyPreviewManager`): target row = route start row + 6
(leader / hidden rounds + 13); the upper pen zone (anchor (18,7)) needs target row ≥ 18, everything else goes to the
lower zone (15,7). Normal fields: only the (12,10) gate is upper; boss fields: (5,10) upper, the (2,10) gate and the
leaders starting at (3,x) / (4,x) lower.
## 16. Counts (current build)
`chess 266 (112 visible; 283 selectable skills over the visible chess, 184 module choices over 129 goldens)`, `bonds 23`, `garrisons 249 (43 effect keys)`, `items 115`, `bands 40`, `effects 361`,
`enemies 249`, `factions 67 entries`, `waves 38`, `stages 11 (8 active)`, `bosses 10`, `tokens 22`, `choice events 109`,
`bounty cards 129`, `tactic cards 43`.
## 17. Integrity guarantees (checked by the builder and `test/data.test.js`)
Chess bonds/garrisons/tokens/base/golden ids resolve; talent tokens are in `chess.tokens`; every non-DIY chess has
`skills[]` with exactly one default equal to `skill` (and every skill token listed in `chess.tokens`); golden module
choices are consistent (one default iff `module.active`, base fields present; composing the default reproduces the
record); token variants carry `bySkill` / `byModule` for exactly the other owner choices; bond members exist and
carry the bond; items/bands/bonds reference existing effects; wave spawn keys and branch keys resolve in
`enemies.json`, route indices exist; every mode round has a template (or boss templates) present in `waves.json`;
stages are 19 × 21 with known glyphs and contiguous helper paths; bosses/factions/tokens/choice pools resolve; token
variants carry `sources`; enemies have `maxHp`/`bat` > 0, `aspd`/`moveSpeed`/`lpr` ≥ 0 and `rangeRadius` ≥ 0 (0 for
MELEE); no non-finite numbers; total size < 6 MB. `test/data.test.js` additionally re-derives every chess/enemy stat
from the raw official tables (incl. the float32 `attrPower`) and rebuilds offline to prove `data/` is not stale (both
skipped without the cache); faction entries carry no `k` copies and share one movement class; every device's
`active` = `!hidden`; the helper lanes of act1 m02 / m04 / act2 m02 are the official ones (research 08 §3.2).
`test/match/waves-official.test.js` checks the generator against 429 official entry × round compositions
(`test/fixtures/official-waves.json`); with the cache, `test/match/waves-crosscheck.test.js` re-implements the client
generator and preview zones on the raw level files for every mode × round × template (leader and hidden rounds
included) × allowed entry, and `test/sim/pathing-crosscheck.test.js` re-implements the client SPFA + smoothing over the
whole map for every walkable tile of the normal / 联防 / boss fields of the 8 stages (extended by the road-over-floor
preference; against the pure official algorithm: identical route lengths, never more non-blockable tiles crossed).
+240
View File
@@ -0,0 +1,240 @@
# 部署指南
目标:在一台家用 Windows 小主机上长期开服,让朋友通过局域网或公网来玩。macOS / Linux / Docker 放在后面。
所有命令都在项目根目录执行。遇到问题先运行 `node tools/doctor.mjs`(只读诊断)。
## 0. 资源需求
| 项目 | 说明 |
|---|---|
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
| 磁盘 | 素材约 250 MB(`public/assets`)+ 依赖约 125 MB(`node_modules`);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图。 |
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
## 1. Windows 小主机:一步步
### 1.1 安装与首次启动
1. 安装 Node.js 22 LTS 和 Git(在 PowerShell 或「终端」里):
```powershell
winget install OpenJS.NodeJS.LTS
winget install Git.Git
```
装完**关闭并重新打开**终端,`node -v` 应显示 v22 或更高(winget 的 LTS 目前是 v24.x,同样可用)。没有 winget 时从 <https://nodejs.org/zh-cn/download> 和 <https://git-scm.com/download/win> 下载安装。
2. 下载代码,建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如:
```powershell
git clone <本仓库地址> C:\Stronghold-Protocol
```
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`)→ 复制前端库 → 下载约 250 MB 素材(显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
4. 窗口里会打印朋友可用的地址,例如 `http://192.168.1.23:3000`。用另一台设备打开它确认能进入。关闭窗口即停止服务器。
等价的手动命令:`npm ci`、`node tools/setup.mjs`、`npm start`。
### 1.2 防火墙
- 第一次启动时 Windows 会弹出「Windows 安全中心警报」:勾选**专用网络**并点「允许访问」。
- 没弹窗或点错了,用**管理员** PowerShell 添加规则(下面的开机自启脚本也会自动添加):
```powershell
netsh advfirewall firewall add rule name="Stronghold Protocol" dir=in action=allow protocol=TCP localport=3000 profile=private,domain
```
- 家里的网络要是「公用网络」,Windows 会拦截入站连接。改成专用(管理员 PowerShell;网卡名用 `Get-NetConnectionProfile` 查看):
```powershell
Set-NetConnectionProfile -InterfaceAlias "以太网" -NetworkCategory Private
```
- `node tools/doctor.mjs` 会显示规则是否存在、每个网络的类型,以及朋友可用的地址。
### 1.3 固定局域网 IP(推荐)
主机 IP 变了,朋友收藏的地址就失效。推荐在**路由器**后台的「DHCP 静态分配 / 地址保留」里把小主机的 MAC 地址绑定到固定 IP(如 `192.168.1.50`)。也可以在 Windows「设置 → 网络和 Internet → 属性 → IP 分配 → 编辑」里手动设置(IP、子网掩码、网关、DNS 与路由器一致,且不要与别的设备冲突)。
### 1.4 开机自动在后台运行
先关闭 `start-windows.bat` 的窗口(否则端口冲突),然后在项目目录运行(会自动请求管理员权限):
```powershell
powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1
```
它会:运行一次 `tools/setup.mjs` → 把设置写入 `scripts\service.env.cmd`(node.exe 路径、端口等)→ 注册计划任务 **StrongholdProtocol**(开机 20 秒后以 SYSTEM 身份运行 `scripts\run-server.cmd`,无需登录;服务器退出后 5 秒自动重启)→ 添加防火墙规则 → 立即启动并显示状态。日志在 `logs\server.log`(超过 10 MB 自动轮换)。
| 需求 | 命令(都加在 `powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1` 之后) |
|---|---|
| 换端口 / 其他设置 | `-Port 8080`、`-Verify sample`、`-Combat server`、`-BindHost 127.0.0.1`(只给反向代理用) |
| 公用网络也放行 | `-AllowPublicNetwork`(一般不需要;Tailscale 网卡被识别为公用网络时可能需要) |
| 查看状态和最近日志 | `-Status` |
| 重启(更新代码后) | `-Restart` |
| 停止 | `-Stop`(下次开机仍会自动启动) |
| 卸载 | `-Uninstall`(删除计划任务、防火墙规则和 `service.env.cmd`) |
建议同时关闭睡眠,否则小主机会在无人操作时休眠:`powercfg /change standby-timeout-ac 0`。
<details>
<summary>替代方案:用 NSSM 注册成真正的 Windows 服务</summary>
```powershell
winget install NSSM.NSSM # 或从 https://nssm.cc 下载
nssm install StrongholdProtocol "C:\Program Files\nodejs\node.exe" server\index.js
nssm set StrongholdProtocol AppDirectory C:\Stronghold-Protocol
nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=0.0.0.0
nssm set StrongholdProtocol AppStdout C:\Stronghold-Protocol\logs\server.log
nssm set StrongholdProtocol AppStderr C:\Stronghold-Protocol\logs\server.log
nssm start StrongholdProtocol
```
防火墙规则仍需按 1.2 手动添加。两种方式只选一种。
</details>
### 1.5 更新
```powershell
cd C:\Stronghold-Protocol
powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Stop # 装了开机自启时
git checkout -- data/assets.json # 素材清单由 setup 重新生成,先还原以免 git pull 冲突
git pull
npm ci
node tools/setup.mjs # 补下载新增的素材(已有文件会跳过)
powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Restart
```
没装开机自启的话,最后一步改成重新双击 `start-windows.bat`。用 ZIP 下载的:解压新版本后,把旧目录里的 `public\assets`、`public\fonts`、`.cache` 和 `data\local-assets.json`(若有)复制过去,可避免重新下载。
## 2. 让不在同一网络的朋友加入
### 2.1 Tailscale / ZeroTier(推荐给家用小主机)
组一个虚拟局域网:不需要公网 IP、不需要改路由器、不暴露到互联网。
- **Tailscale**:主机和朋友都安装 <https://tailscale.com/download>(Windows:`winget install Tailscale.Tailscale`)并登录。朋友用自己的账号时,在 Tailscale 管理后台把这台主机「Share」给他们,或邀请他们加入你的 tailnet。朋友访问 `http://<主机的 100.x.y.z 地址>:3000`(`tailscale ip -4` 查看;开了 MagicDNS 也可以用 `http://<主机名>:3000`)。
- **ZeroTier**:在 <https://my.zerotier.com> 创建网络,主机和朋友安装客户端并加入同一个 Network ID,在后台勾选授权成员;访问 `http://<主机的 ZeroTier IP>:3000`。
- 连不上时运行 `node tools/doctor.mjs`:看 VPN 网卡是否被 Windows 识别为「公用网络」,是的话按 1.2 改为专用,或安装自启时加 `-AllowPublicNetwork`。
### 2.2 cloudflared 临时隧道(朋友什么都不用装)
```powershell
winget install --id Cloudflare.cloudflared # macOS: brew install cloudflared
cloudflared tunnel --url http://localhost:3000
```
把输出的 `https://xxxx.trycloudflare.com` 发给朋友。页面是 https 时客户端自动改用 `wss://`,不需要任何配置;服务器会通过隧道转发的 `CF-Connecting-IP` 识别真实来源(`TRUST_PROXY=auto`)。临时隧道每次启动地址都不同,且没有可用性保证;需要固定地址请使用 Cloudflare 账号 + 自己域名的「命名隧道」。
### 2.3 路由器端口转发
仅当你有**公网 IPv4**(很多宽带是运营商级 NAT,没有公网 IP,此时请用 2.1 / 2.2):
1. 先按 1.3 固定主机的局域网 IP。
2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。
3. 朋友访问 `http://<你的公网 IP>:外部端口`。
注意:游戏没有账号系统,知道地址的人都能进来。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),但仍建议不玩时关掉转发,或优先用 Tailscale。
### 2.4 反向代理与 HTTPS(有域名时)
必须部署在**域名根路径**(客户端使用 `/data/`、`/vendor/`、`/ws` 等绝对路径,不支持挂在子路径下)。代理需要转发 WebSocket 升级(路径 `/ws`)。建议让服务器只监听本机:`HOST=127.0.0.1`(Windows 自启:`-BindHost 127.0.0.1`)。
**Caddy**(自动申请 HTTPS 证书,WebSocket 无需额外配置):
```caddy
game.example.com {
reverse_proxy 127.0.0.1:3000
}
```
**Nginx**:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name game.example.com;
ssl_certificate /etc/letsencrypt/live/game.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/game.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h; # WebSocket 长连接
}
}
```
https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://同一域名/ws`;http 时用 `ws://`。服务器本身只提供 http,证书由代理 / 隧道负责。代理与服务器在同一台机器或内网时,`TRUST_PROXY=auto` 会信任它的 `X-Forwarded-For` / `X-Real-IP`;代理在公网另一台机器上时设 `TRUST_PROXY=1`(同时确保游戏端口只对代理开放)。
## 3. Docker
```bash
# A) 构建时下载素材(需要联网,约 250 MB)
docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
# B) 不把素材打进镜像:先在宿主机运行 node tools/setup.mjs,然后挂载
docker build -t stronghold-protocol .
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \
-v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol
```
镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等)。健康检查:`GET /healthz`。
docker compose 示例:
```yaml
services:
stronghold:
build:
context: .
args: { FETCH_ASSETS: "1" }
ports: ["3000:3000"]
restart: unless-stopped
environment:
SP_VERIFY: "off"
```
## 4. macOS / Linux 常驻
- 临时开服:`scripts/start.sh`(或 `npm start`),保持终端窗口打开。macOS 首次会询问是否允许 node 接受传入连接,选「允许」。
- Linux systemd(`/etc/systemd/system/stronghold.service`,路径与用户按实际修改):
```ini
[Unit]
Description=Stronghold Protocol game server
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory=/opt/Stronghold-Protocol
ExecStart=/usr/bin/node server/index.js
Environment=PORT=3000 HOST=0.0.0.0
Restart=always
RestartSec=5
User=stronghold
[Install]
WantedBy=multi-user.target
```
`sudo systemctl daemon-reload && sudo systemctl enable --now stronghold`;日志 `journalctl -u stronghold -f`;防火墙 `sudo ufw allow 3000/tcp`。
## 5. 排错
| 现象 | 处理 |
|---|---|
| 任何问题 | `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/fetch-assets.mjs --concurrency=4` 降低并发 |
| 本地提取失败 | 不影响游戏。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器 |
| 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 |
+563
View File
@@ -0,0 +1,563 @@
# 卫戍协议:盟约 — Web Remake · Architecture & Contracts (DESIGN.md)
This is the **single source of truth** for every implementer. Research lives in `docs/research/` (start with `00-INDEX.md`; where a research file's body and its "Addendum (critic)" disagree, the addendum wins). When this document and research disagree, **this document wins**; when this document is silent, follow research; when both are silent, choose the simplest faithful behaviour and write it down in the module's header comment.
Language: all player-facing text is **Simplified Chinese** (names/descriptions come from official data). Code, comments and identifiers are English.
---
## 0. Product scope (v1)
A faithful, polished, **online co-op** browser remake of Arknights' seasonal auto-chess TD mode 「卫戍协议:盟约」(下半, `act2autochess`).
In scope:
- Title → lobby (nickname, create room / join by 4-letter code or `?room=CODE` link, host picks difficulty, up to 4 players, **AI teammates** can fill seats, ready/start). Solo (独立模拟) and co-op (同盟模拟, 1–4 humans + optional AI).
- Difficulties: 标准 FUNNY, 险境 NORMAL, 绝境 HARD, 终极 ABYSS (solo and multi variants from `modeDataDict`). Training/tutorial mode is out of scope.
- Pre-game: INFO_CHECK briefing (disabled bonds, banned operators, enemy factions, stage) → BAND_CHECK strategy draft (40 bands, random order, 1 skip in co-op) → BATTLE_CHECK.
- Rounds 1–14 (+ hidden 15): prep (shop, hand, board, items, freeze, refresh, level-up, sell, merge + reward pick, ready), 机变 draft rounds, simultaneous auto-combat on each player's own field, 联防 unite phase, LP loss/elimination, Final Assault boss round with merged LP and shared boss HP pool, Hidden Core, settlement with titles.
- Full content: 112 visible chess (+ elites), their default skills and talents, 23 bonds with layers, all 43 特质 (garrison) effect keys, 56 equipment + Arts, 40 bands, 机变 cards, enemy factions/special enemies, 10 bosses, stage devices/terrain (crates, blowers, mire, smog, deep sea, infection).
- Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker.
- Reconnect, AI take-over of disconnected players, robust validation of every client intent.
Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the pool), trophies/progression persistence, reporting.
---
## 1. Tech stack
- **Node.js ≥ 18** (tested on 22), ESM (`"type": "module"`), single dependency `ws@8`. No bundler, no TypeScript. JSDoc types where helpful.
- **Server-authoritative simulation.** Clients send *intents*; the server validates, mutates state and pushes state/snapshots.
- **Client:** static files, native ES modules. Vendored libs in `public/vendor/`: `pixi.min.js` (PixiJS **7.4.2** UMD, global `PIXI`), `pixi-spine.js` (**4.0.6** UMD, `PIXI.spine`), `preact.module.js` + `hooks.module.js` + `htm.module.js` (Preact 10 + htm, no build step). No CDN at runtime (LAN play must work offline).
- **Shared code** in `shared/` is imported by both server and browser (pure ESM, no Node APIs).
- Tests: `node --test` (`test/**/*.test.js`). Browser E2E: `puppeteer-core` driving the system Chrome (dev dependency, optional).
Run: `npm install && npm run assets && npm start` → `http://localhost:3000`. Friends on LAN use `http://<host-ip>:3000`. Internet play: a tunnel (e.g. `cloudflared tunnel --url http://localhost:3000`) or any Node host (Dockerfile provided).
---
## 2. Repository layout & ownership
```
server/
index.js HTTP static server (gzip for .skel/.atlas/.json/.js/.css), WebSocket upgrade at /ws, boot
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
lobby.js rooms (4-letter codes), seats, host, AI seats, ready/start, reconnect tokens, room→Match wiring
data.js loads data/*.json once, builds indexes (getChess, getBond, …); frozen objects
match/
Match.js match state machine, timers, round loop, co-op orchestration, broadcasting views
PlayerState.js per-player economy/shop/hand/board/items/bonds/LP state + all prep-intent handlers
pool.js SharedPool (copies per base chess), banned/disabled bonds, odds & rolls
board.js placement legality, deploy cap, hand/temp management, merge detection & execution
bondsMeta.js bond member counting (BOARD / BOARD_AND_DECK / golden), activation tiers, persistent layers
effectsMeta.js registry + dispatcher of prep-phase ("SERVER_*") effects: garrisons, bands, items, bonds
choices.js 机变 draft: card generation per family, pick order, timeouts, application
waves.js match enemy factions, per-round spawn list generation, stat scaling, bounties
unite.js 联防 helper selection, leaked-enemy aggregation, LP attribution
finalAssault.js R14/R15 pairing, merged LP, shared boss HP pool, overtime drain
results.js settlement stats, titles (评语)
bot.js AI player (plays prep phases; also auto-play for disconnected humans)
sim/
Battle.js one field simulation (normal / unite / boss); public API §5
constants.js TICK, conversions, tuning knobs
rng.js seeded PRNG (mulberry32) + helpers
grid.js field grid, tile queries, passability, pathfinding (8-dir, no corner cutting)
units.js Unit / Operator / Enemy / Token classes, stat aggregation
buffs.js Buff model, status catalogue (stun, freeze, cold, sleep, slow, fragile, stealth, …)
damage.js damage & heal pipeline, element gauges
targeting.js target selection helpers, range tests, priorities
projectiles.js projectile flight/impact
skills.js skill runtime: SP, triggers, durations, ammo, charges; interprets SkillSpec
ai.js enemy movement/attack AI, operator attack loop
snapshot.js compact serialization for clients
content/
index.js installs all content into a Battle (kits, bonds, garrisons, items, bands, enemies, devices)
kits/tier1.js … tier6.js operator kits (skill + talents) keyed by base chessId
tokens.js summon/token definitions
bonds.js battle side of the 23 bonds
garrisons.js battle side (IN_BATTLE) garrison effect keys
items.js battle side of equipment + Arts
bands.js battle side of band (strategy) effects
enemies.js enemy ability specs + generic special-type behaviours
bosses.js boss scripts (boss_1 … boss_10)
devices.js stage devices & special terrain
shared/
constants.js enums, phases, areas, error codes, UI-relevant constants
protocol.js message catalogue + validators (§8)
format.js rich-text description → HTML/plain; number formatting
rules.js pure helpers shared by UI & server (e.g. price display, sell value, bond tier from count)
data/ generated by tools/build-data.mjs — committed; see docs/DATA.md
public/
index.html single page; loads /js/main.js as module
css/ theme.css (tokens, fonts), components.css, screens/*.css
js/
main.js boot, router between screens, global store
net.js WebSocket client, reconnect, request/response helpers
store.js tiny observable store (state from server + local UI state)
data.js fetches /data/*.json, same indexes as server/data.js
audio.js BGM/SFX manager (Web Audio), volume settings
assets.js asset URLs, image cache, Spine loader with LRU + fallback
screens/ title.js, lobby.js, room.js, briefing.js, bandDraft.js, game.js, result.js
ui/ hud.js, bondStrip.js, bondPopup.js, shopBar.js, teamPanel.js, detailPanel.js,
tooltip.js, choiceOverlay.js, rewardOverlay.js, ticker.js, emotes.js, settings.js, toasts.js
render/ app.js, projection.js, tiles.js, units.js, spine.js, fx.js, interp.js, drag.js (API §9)
vendor/ pixi, pixi-spine, preact, hooks, htm (copied from node_modules by tools/vendor.mjs)
assets/ downloaded art/audio (git-ignored), see docs/ASSETS.md
fonts/ self-hosted fonts
tools/
build-data.mjs official data + research JSON → data/*.json
fetch-assets.mjs downloads/optimizes assets → public/assets + data/assets.json
vendor.mjs copies vendor libs from node_modules → public/vendor
test/ node:test suites; test/e2e/ browser + bot tests
docs/ DESIGN.md (this), DATA.md, SIM.md, META.md, ASSETS.md, BALANCE.md, DEPLOY.md, PLAYING.md, research/
(the wire protocol is normative in shared/protocol.js itself)
```
Ownership rule for parallel agents: **only edit files assigned to you**; if you need a change in someone else's file, write it in your final report instead (the integrator applies it). Shared files (`shared/*`, `docs/DESIGN.md`) are owned by the architect.
---
## 3. Coordinates, fields and geometry
- Every stage is a **19-row × 21-col** grid (`data/stages.json`). **Row 0 is the bottom** row, col 0 is the left. Tile `(r,c)` has world centre `(x=c, y=r)`; 1 tile = 1 world unit. Unit positions are floats in this space.
- Areas (research 05 §2.2):
- **Normal field** (own board): rows 9–12, cols 2–10. Enemy gates `S` at (9,10) and (12,10) (R1–3 only the lower one); protection objective `E` at (9,2). Col 9 lane is not deployable (tile type decides).
- **Hand (整备区)**: row 7, cols 0–9 (10 regular slots, index = col). **Temp slots**: row 8, cols 4–8 (5 slots). Hand/temp tiles are never part of a battle.
- **Partner display** cols 11–18 of rows 9–12 (the unite field uses them).
- **Boss field**: rows 1–5; left half cols 2–10, right half cols 10–18.
- Enemy preview pen rows 14–18 × cols 7–13 (row 16 empty): during prep the next round's enemies idle there (upper-gate enemies rows 17–18, lower-gate rows 14–15, ≤ 50 models, spawn-time order) — research 08 §4 / 09 §2.
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). Melee chess may stand only on LOW tiles with buildable ALL/MELEE; ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`.
- **Facing (corrected, see research 09 §1.2):** like standard Arknights, every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT` chosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); default `RIGHT`; persists across rounds. Range grid entries are `[dRow, dCol]` relative to facing RIGHT and are rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)` (row 0 = bottom); `rangeExtend` applies along +dCol before rotating. The right-side Final Assault player is mirrored col `c→20−c` with RIGHT↔LEFT (UP/DOWN unchanged).
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles. Radius ranges (enemies, splash, auras) use Euclidean distance in tiles.
- **Fields at runtime:** a `Battle` receives a **field rect** (rows/cols subset) and the full stage grid. Normal battles use rows 9–12 × cols 0–10; unite uses rows 9–12 × cols 0–20; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect.
- **Speed:** `tilesPerSecond = moveSpeed × MOVE_SCALE` with `MOVE_SCALE = 0.5` (in `sim/constants.js`, tunable).
---
## 4. Time model
- Simulation time is **game seconds**. Fixed step `TICK = 1/30 s`.
- Combat runs at **2× real time** (forced, like the original): 60 ticks per real second — in the browser's runner (§14) or, for server-run fields, the server's accumulator (2 ticks every real 1/30 s, never more than 8 ticks per interval to avoid spirals).
- Combat time limits come from `data/config.json → modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`; also `modes[m].combatTimeLimit[r]`) and are **real seconds of the forced-2× battle** (verified by the balance pass: spawn schedules exceed the limit when read as game seconds). The server passes `2 × value` game seconds to the Battle (`server/match/gamedata.js → combatTimeLimit`; `config.combatTimeScale`, default 2); the countdown players see equals the data value. When time runs out, living non-boss enemies count as leaked. 联防 uses the round's limit.
- Final Assault / Hidden Core have **no hard stop** and their clocks are real seconds too: the boss level's `levelMaxPlayTime` (120 real s) is only the HUD countdown (`m.public.deadline`; the battle goes on past it), and the overtime drain (`bossOvertimeAfter` 150 / `bossOvertimeDrainPerSec` 1 = official `bossTurnHpReduceTime`) takes 1 team LP per whole **real** second from 150 real s (= 300 game s on the 2× field clock; first point at 151 s — `gamedata.js bossOvertimeDue`). `m.public.overtimeAt` = the ms epoch when the drain starts; both are placed on the field clock.
- **Solo pause** (§14): while `m.public.paused` the running battle's field clock, the HUD `deadline` / `overtimeAt` and every server deadline of the battle stand still; on resume they move on by the paused time. Co-op battles never pause.
- Prep/draft timers are **real seconds**, stored as absolute server deadlines (`Date.now()`-based) and sent to clients as `deadline` (ms epoch) + `serverNow` for clock-offset correction.
- Snapshots to clients: every **3 ticks** (= 20 Hz real) per watched field.
---
## 5. Simulation (server/sim) — engine contract
### 5.1 Battle API (implemented by the sim-core owner; consumed by match & content)
```js
import { Battle } from './sim/Battle.js'
const b = new Battle({
seed, // uint32
kind, // 'normal' | 'unite' | 'boss' | 'hidden'
modeId, round,
stage, // data/stages.json entry (full 19×21 grid)
rect: { r0, r1, c0, c1 }, // inclusive field bounds
timeLimit, // game seconds; boss/hidden: Infinity (overtime handled by match)
players: [ PlayerBattleInput ], // 1 (normal), 1–2 (unite, boss)
spawns: [ SpawnSpec ], // enemy schedule for this field
routes: [ RouteSpec ], // from the wave template, resolved to this field
sharedBoss: SharedBossPool | null,// { hp, maxHp, damage(playerId, amount) } for boss rounds
flags: { layerGainsEnabled: bool, dpInit: 10, dpPerSec: 1, dpMax: 99 },
})
b.step() // advance exactly one TICK
b.finished // true when: all enemies dead & no spawns pending, or time limit reached, or all players' LP pools say stop (boss: match decides via b.forceEnd())
b.forceEnd(reason)
b.time // game seconds elapsed
b.result() // BattleResult (below), valid once finished
b.snapshot() // compact full snapshot (§8.4) of this field
b.drainEvents() // array of client-facing events since last drain (§8.4)
b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4)
```
`PlayerBattleInput`:
```js
{ playerId, seat, side: 'L'|'R',
colOffset, // 0 for own field; +8 for the right half in unite/boss
units: [ { uid, kind: 'chess'|'token', chessId /* golden id if elite */, tokenId?, row, col,
items: [itemId...], // equipped items (golden ids when merged)
carryState?: { hpPct, sp, skillActive } } ], // unite: helper units keep end-of-combat state
bonds: { [bondId]: { count, active, tier, layers } }, // snapshot from bondsMeta at combat start
bandId, playerEffects: [ EffectRef ], // active band/choice/team effects with battle parts
lpForBoss?: number }
```
`SpawnSpec`: `{ time, enemyKey, routeIndex, count=1, interval=0, mods: { hpMul, atkMul, defMul, speedMul }, sourcePlayerId?, bounty?: { coins, ownerPlayerId } , tag?: 'escort'|'boss'|'bounty' }`
`BattleResult`:
```js
{ time, reason: 'cleared'|'timeout'|'forced',
perPlayer: { [playerId]: {
killed, total, // enemies attributed to this player's field/half
leaked: [ { enemyKey, mods, lpr, sourcePlayerId } ], // for unite aggregation & LP
perfect: bool, // no leaks
layerGains: { [bondId]: n }, // IN_BATTLE gains (0 if flags.layerGainsEnabled false)
coins: n, // bounty coins earned
damageDealt: n, bossDamage: n, healingDone: n, deaths: n,
unitsEnd: [ { uid, hpPct, sp, alive } ] } },
bossHpLeft? }
```
### 5.2 Units & stats
`Unit` fields (minimum): `id` (int, unique per battle), `side` ('ally'|'enemy'), `kind` ('op'|'token'|'enemy'|'device'), `defId`, `ownerId` (playerId; enemies: the field owner / source), `x, y`, `tileR, tileC` (ops), `dir` (allies: UP|RIGHT|DOWN|LEFT deploy direction, §3; `sim/dir.js`), `facing` (derived horizontal sign +1 / −1: LEFT ⇒ −1), `hp`, `alive`, `sp`, `buffs[]`, `statusFlags`, `blocking[]` (ops), `blockedBy` (enemies).
Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `bat` (base attack time, s), `blockCnt`, `rangeGrid` (ops) / `rangeRadius` (enemies, 0 = melee), `moveSpeed`, `massLevel`, `weight`, `lpr` (enemies), `dmgType` ('phys'|'arts'|'heal'|'true'), `motion` ('WALK'|'FLY'), `cost`, `respawnTime`, `spRecovery`.
**Aggregation** (`units.js`, recomputed lazily when buffs change):
- `ATK = (base + Σflat) × (1 + Σpct) × Πmul` — same shape for `maxHp`, `def`; `res = clamp((base + Σflat) × Πmul, 0, 100)`; `aspd = clamp(100 + Σaspd + base-100, 10, 600)`.
- `interval = bat × (1 + ΣbatPct) × 100 / aspd` (blackboard `base_attack_time` is treated as `batPct`, e.g. −0.3 → 70%).
- Changing `maxHp` keeps the HP **ratio**.
- Stats are rounded only for display; the sim keeps floats.
**Elite (精锐) chess** have their own stats in data (golden chess id). The module bonus is already folded into data stats by build-data.
### 5.3 Buffs & statuses
`Buff = { key, source, duration /*s, Infinity*/, stacks=1, maxStacks=1, refresh: 'replace'|'extend'|'stack', mods, flags, onTick?, onExpire?, tags[] }`
- `mods` keys (all optional, additive within a key): `atkFlat, atkPct, atkMul, defFlat, defPct, defMul, hpFlat, hpPct, hpMul, resFlat, resMul, aspd, batPct, blockCnt, rangeExtend (tiles), moveMul, dmgDealtMul (multiplicative, e.g. 1.2), dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, dodgePhys, dodgeArts, healingDealtMul, healingTakenMul, spRecoveryFlat, spRecoveryMul, maxTargets (+n), shield (absorbs damage; consumed)`.
- `flags`: `stun, freeze, sleep, silence (no skills), disarm (no attacks), stealth (untargetable by enemies/ops unless blocked or revealed), invulnerable, unblockable, levitate, fear (enemy: cannot attack), taunt (+aggro), cold`.
- **Status catalogue** (`buffs.js`, keys used by content): `stun`, `freeze` (stun + res −15), `cold` (aspd −30; a second cold while cold ⇒ freeze 3 s unless immune), `sleep` (stun; ends on damage? no: in AK sleep = untargetable & unable to act; keep AK: untargetable & inactive), `slow` (moveMul), `bind` (moveMul 0), `fragile` (dmgTakenMul), `artsFragile` (artsTakenMul), `silence`, `fear`, `burnGauge`/`necrosis`/`neural` element gauges (see damage), `stealth`, `reveal`.
- Immunities from enemy data (`stunImmune`, `silenceImmune`, `sleepImmune`, `frozenImmune`, `levitateImmune`) are honoured by `applyStatus`.
### 5.4 Hook bus (the only way content plugs in)
`battle.on(name, handler, { priority, owner })`; handlers run in descending priority, then registration order. Handlers receive a mutable `ctx`. Events:
| name | ctx | notes |
|---|---|---|
| `battleStart` | `{}` | after initial deployment |
| `deploy` | `{ unit, initial }` | ops/tokens/enemies spawn |
| `tick` | `{ dt }` | every tick after movement & attacks |
| `beforeAttack` | `{ attacker, targets }` | may modify `targets` |
| `attack` | `{ attacker, targets, isSkill }` | an attack was performed |
| `hit` | `{ source, target, dmg }` | before mitigation; mutate `dmg` (DamageInfo) |
| `damaged` | `{ source, target, amount, type, dmg }` | after application |
| `heal` | `{ source, target, amount }` | before application (mutable amount) |
| `kill` | `{ killer, victim }` | victim hp reached 0 |
| `death` | `{ unit }` | unit removed (ops may redeploy later) |
| `skillStart` / `skillEnd` | `{ unit, skill }` | |
| `ammoUsed` | `{ unit, left }` | per ammo consumed |
| `spGain` | `{ unit, amount }` | mutable |
| `statusApplied` | `{ source, target, status, duration }` | after immunity check |
| `blocked` | `{ blocker, enemy }` | enemy became blocked |
| `enemySpawn` / `enemyLeak` | `{ enemy }` | |
| `battleEnd` | `{ result }` | may add layer gains / coins |
DamageInfo: `{ amount (pre-mitigation), type: 'phys'|'arts'|'true'|'element', element?: 'burn'|'necrosis'|'neural'|'apoptosis', atkScale, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul (multiplier), canDodge, isSkill, isSplash, tags[] }`.
Engine helpers (content must use these, never touch internals): `battle.dealDamage(source, target, dmgInfo)`, `battle.heal(source, target, amount, {overheal=false})`, `battle.applyStatus(target, key, { duration, source, value })`, `battle.addBuff(unit, buff)`, `battle.removeBuff(unit, key)`, `battle.spawnToken(ownerUnit|playerId, tokenId, row, col, opts)`, `battle.spawnEnemy(enemyKey, { routeIndex, pos, mods, tag })`, `battle.addProjectile({ from, to|target, speed, onHit, visual })`, `battle.unitsInGrid(unit, grid)`, `battle.enemiesInRadius(x, y, r)`, `battle.alliesInRadius(x, y, r, ownerId?)`, `battle.addLayers(playerId, bondId, n, reason)` (no-op when gains disabled; records into result), `battle.addCoins(playerId, n)`, `battle.fx(kind, params)` (client VFX event), `battle.rng()` (never `Math.random`), `battle.getPlayer(playerId)` (bonds/band/effects view), `battle.redeploy(unit)`, `battle.after(seconds, fn)` (scheduled callbacks), `battle.every(seconds, fn)`.
### 5.5 Combat rules (engine)
- **Initial deployment** at t=0, free, order top→bottom then left→right (right-side boss player: right→left). Deploy effects fire (`deploy` with `initial: true`). Then `battleStart`.
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp).
- **Blocking**: a ground op with free capacity blocks a ground, non-unblockable enemy whose position enters its tile (|dx|,|dy| ≤ 0.5). Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). Blocker death frees its enemies.
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) units it blocks (melee), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) cannot hit FLY enemies. Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact.
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%; do nothing otherwise (SP still regenerates).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (`rangeRadius > 0`) attack ops within radius: priority blocker → highest taunt → **latest deployed**; they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore blocking and ground obstacles, follow their checkpoint route.
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul`; shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000 on enemies, 1000 on ops); at full: burn ⇒ 1200 arts dmg + res −20 for 10 s (enemies); neural ⇒ 1000 true dmg + stun 5 s (ops); necrosis/apoptosis per research; gauge resets after the burst effect ends.
- **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle.
- **End**: all spawns done and no enemies alive ⇒ `cleared`; `time ≥ timeLimit` ⇒ `timeout` (remaining enemies = leaked). Boss battles end when the shared boss pool reaches 0 (victory) or the match forces an end (team LP 0).
### 5.6 Skills (skills.js + kits)
SP: `spType` `INCREASE_WITH_TIME` (+`spRecovery`/s), `INCREASE_WHEN_ATTACK` (+1 per attack), `INCREASE_WHEN_TAKEN_DAMAGE` (+1 per hit taken); starts at `initSp`; no SP gain while a duration skill is active. `maxChargeTime > 1` ⇒ charges.
Auto-trigger rules (`skillTrigger.rule` in data): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range), `TAKE_DAMAGE` (on taking damage), `SP_FULL` (immediately), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed), special ids. PASSIVE skills are always on.
**SkillSpec** (authored per chess in `sim/content/kits/tierN.js`; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a *function of the blackboard*):
```js
// kits map: baseChessId -> (bb, chess) => Kit
export default {
chess_char_1_01: (bb, chess) => ({
skill: {
kind: 'duration' | 'ammo' | 'instant' | 'charges' | 'passive' | 'toggle',
duration: bb.duration, // duration kind
ammo: bb['attack@trigger_time'], // ammo kind
mods: { atkPct: bb.atk, batPct: bb.base_attack_time }, // applied while active
targeting: { maxTargets, rangeGrid, priority: 'fly'|'ranged'|'lowestHp'|'highestHp'|... },
attack: { dmgType, atkScale, splashRadius, hits, projectile: 'arrow'|'bolt'|'none'|'beam', onHit(ctx) },
onStart(ctx) {}, onEnd(ctx) {}, // bursts, summons, heals, statuses (use engine helpers only)
},
talents: [ { install(battle, unit) {} } ], // passive/conditional talents via hooks
trait: { ... } // optional overrides of profession trait behaviour
}),
}
```
The kit loader passes `bb` = flattened blackboard `{ key: value }` of the chess's skill (normal or elite), and `chess` = data record (for talents' blackboards). Missing kit ⇒ a **generic kit** derived from blackboard keys (atk/attack_speed/def/max_hp/base_attack_time/atk_scale/max_target/duration/ammo) so every chess always works.
Profession traits (default behaviours implemented once in the engine, keyed by `subProfessionId`): e.g. `fastshot` (fly priority), `aoesniper`/`splashcaster` (splash 1.1 tiles), `chain` caster (bounce ×3, −15%), `funnel`, `blastcaster`, `physician` (single heal), `ringhealer` (aoe heal), `bard` (aura heal, no attack), `slower` (slow on hit), `underminer`, `instructor`, `lord` (ranged attack ×0.8 when not melee), `centurion`/`fighter`/`musha`/`swordmaster`/`reaper` etc., `protector`/`guardian`/`fortress`/`duelist`, `pioneer`/`charger`/`tactician`/`bearer`/`agent`, `executor`, `merchant`, `hookmaster`, `pusher`, `stalker`, `geek`, `dollkeeper`, `traper`, `craftsman`, `summoner` … (build-data provides the list of subprofessions present in the pool; the sim-core owner implements all present ones with simplified faithful behaviour).
---
## 6. Match & meta (server/match)
### 6.1 State machine (Match.js)
```
LOBBY(room) → INFO_CHECK (co-op 25 s, solo untimed; all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, 12 s/turn, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ band_bldsk; solo: free pick, no timer) → BATTLE_CHECK (3 s)
→ loop r = 1..lastRound:
ROUND_START (income, upgrade price −1 (floor 0), temp hand wiped, unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects)
[SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards untimed)
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo: untimed, ends on ready)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; auto-resolve temp overflow: temp items destroyed/ops sold is NOT allowed → ready is blocked while temp non-empty; on timeout temp contents are sold/destroyed automatically)
COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause)
UNITE (co-op only, if ≥1 leaked & ≥1 perfect: ≤2 perfect helpers in `unite.js helperOrder` order (research 08 §5 / PRTS 帮助:
most units on the field > an active bond > most standing units > seat; the pair ordered by units > active bond >
Σ active layers > standing > seat, the first one on the right-hand field) fight the union of leaks; no layer gains)
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast)
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
real s after 150 real s (§4, m.public.overtimeAt); no layer gains)
R15 HIDDEN_CORE (if difficulty ≥ NORMAL and Σ activated layers > 350 solo / 1200 co-op and team LP > 1)
→ RESULT (per-player stats, titles, rounds passed, victory/defeat)
```
Solo FUNNY has 9 rounds (boss at R9); everything is read from `data/config.json → modes[modeId]`.
### 6.2 Player state (PlayerState.js) — authoritative per player
```js
{ playerId, seat, name, isBot, connected,
alive, lp, bandId,
funds, shop: { level, upgradePrice, slots: [ Slot ], frozen: bool, freeRefreshes: n, rewardOffer: null | { slots:[Slot], tier } },
hand: [ Piece|null ×10 ], temp: [ Piece|null ×5 ],
board: Map<'r,c', Piece>, // chess & token pieces on the normal field
bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match
effects: [ EffectRef ], // band/choice/team/garrison persistent effects & counters
counters: { refreshes, buys, sells, … },
stats: { … for results } }
Slot = { kind:'chess'|'item', id, price, sold:false, frozen }
Piece = { uid, kind:'chess'|'item'|'token', id /* chessId (golden id if elite) | itemId | tokenId */,
items:[itemPiece...] (chess only, max 2), count (token stacks), ownerUid (tokens), boughtRound, meta:{} }
```
Intent handlers (validate → mutate → recompute bonds → push private view): `buy(slot)`, `refresh()`, `freeze()`, `levelUp()`, `sell(uid)`, `move(uid, to)`, `equip(itemUid, targetUid, replaceUid?)`, `useArt(itemUid, row, col)`, `destroy(uid)`, `pickReward(idx)`, `pickChoice(idx)`, `setReady(bool)`. Every handler rejects when the phase forbids it (only PREP allows board/shop mutations; SP_DRAFT allows `pickChoice` only; reward pick allowed in PREP). Invalid intents return `error` with a code from `shared/constants.js → ERR` — the server never throws on bad input.
Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges or is sold (a sale is refused with `HAND_FULL` when there is no room for it).
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite to hand + reward offer of 3 free chess of tier `min(level+1, 6)`; purchase completing a merge allowed with full hand; full hand blocks buy; overflow to temp; temp blocks ready).
### 6.3 Bonds & layers (bondsMeta.js)
- `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count, etc. (research 02).
- `tier` from thresholds; `active = tier ≥ 1`.
- `layers[bondId]` persist all match; gains come from garrisons/bands/items/choices/bond effects; IN_BATTLE gains are collected from `BattleResult.layerGains` at SETTLE (disabled in unite/boss/hidden).
- Σ activated layers (hidden-core check) = Σ layers over bonds that are active at the moment of the check (end of R14 prep, per research).
### 6.4 Prep-phase effect dispatcher (effectsMeta.js)
A registry mapping **effect keys** → handler objects `{ onRoundStart, onPrepEnd, onGain(piece), onSold(piece), onRefresh, onPrice(slot), onBuy, onMerge, onLevelUp, onBattleResult }`. Sources: garrison `SERVER_*` keys (per chess on board or hand as the key requires), band effects, item meta effects, bond meta effects (助力, 远见, 奇迹, 投资人…), choice/team cards. Content owners register handlers; the dispatcher owns ordering and the 投资人 "获得时 ×2/×3" multiplier.
### 6.5 Waves (waves.js)
Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weight 50 each: 标准 FUNNY = 战场#01 `act1autochess_m01` only, 险境 NORMAL = 8 stages, 绝境 / 终极 = 7 without 战场#01 — `waves.js setupMatchWaves`, `test/match/stagepick.test.js`), 3 of 6 typed enemy factions, disabled bond set (3 core + 4 add-on for NORMAL+, per research), boss (weighted), hidden boss. Per round: wave template (by mode & round) → replace placeholders per research 05 §3.2 algorithm → apply stat multipliers (research 00-INDEX §5 `atk ×base×1.1^k`, `hp ×base×1.2^k`, ABYSS speed ×1.15 from R3) → `SpawnSpec[]` + routes resolved on the stage. *(Superseded by the official generator — §14 "Corrections from research 08/09" and the waves.js header.)* A player's normal battle (and the bot rehearsal) runs `withBounties(gd, round, wave, bounties, playerId)`: the bounty units inserted among their host action's own units, which are re-timed around them (client order); `previewOf` keeps one entry per action (`wave.spawns` + `bountySpawns`).
### 6.6 AI players (bot.js)
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy: pick a band; each prep: level up on schedule, buy chess that raise bond counts or complete merges, sell leftovers, place melee on road tiles nearest the enemy path, ranged on highland/others, equip items to highest-tier ops, ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time).
---
## 7. Content modules (server/sim/content + match/effectsMeta handlers)
Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live in `content/kits/tier1..6.js` (`export default { [baseChessId]: (bb, chess) => Kit }`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core).
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens.
- bonds: 23 (battle + meta), exact per-layer formulas from research 02.
- garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry).
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted; devices/terrain.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs).
---
## 8. Network protocol (shared/protocol.js is normative)
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
### 8.1 Session & lobby
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `ping {c}`
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks}, sp?:{family,cards,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }`
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total }` 20 Hz
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
- `m.toast {kind, text}` · `m.ticker {text}` · `m.emote {playerId, id}` · `m.result {...}`
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); both also on the prep-scouting `m.field` units (`Match.prepFieldMeta`), so a teammate's unit shows its owner's loadout in the detail card).
### 8.3 Exact view shapes (normative for match ⇄ UI)
`m.private`:
```js
{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */,
shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes,
frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ],
rewardOffer: null | { tier, slots: [ { kind: 'chess', id, price: 0, sold } ] } },
hand: [ Piece | null ], // length 10, index = hand slot
temp: [ Piece | null ], // length 5
board: [ Piece & { row, col, dir } ], // dir ∈ UP|RIGHT|DOWN|LEFT (§3; bench pieces carry none)
deployCap, deployCount,
bonds: [ { bondId, count, active, tier, layers, thresholds, countsHand } ], // sorted: active first, then layers desc
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter? } ],
nextEnemies: [ { enemyKey, count, tag } ], // preview of the upcoming round's wave (after waves.js generation)
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { uid, id } ] /* chess only */, count /* token stacks */, ownerUid /* token */ }
```
`m.public.players[].status` ∈ `'acting' | 'ready' | 'deciding' | 'combat' | 'done' | 'helping' | 'left' | 'dead'`.
`m.public.fields[]`: `{ fieldId, kind: 'normal'|'unite'|'boss'|'hidden', players: [playerId…], live: bool }` — `fieldId` is `'n:<playerId>'` for normal fields, `'u'` for the unite field, `'b1'`/`'b2'` for boss fields. Clients `g.watch` a fieldId; default = own field (or the unite/boss field they are in).
`flags` bitmask: 1 blocked, 2 stunned, 4 frozen, 8 stealth, 16 skillActive, 32 shielded, 64 invulnerable, 128 cold, 256 sleep, 512 flying.
`anim` small int: 0 idle, 1 move, 2 attack, 3 skill, 4 die, 5 stun, 6 deploy.
---
## 9. Client rendering contract (public/js/render)
`import { createFieldView } from './render/app.js'`
```js
const view = await createFieldView(canvasHost /* HTMLElement */, { data, assets, audio })
view.setStage(stage) // draw tiles (procedural), devices
view.setCamera(kind /* 'prep'|'normal'|'unite'|'boss' */, { rect, side }) // animates camera
view.setPrep(privateState, { editable }) // render hand/temp/board pieces as chibis (spine or fallback); editable enables drag
view.enterBattle(fieldMeta) // switch to battle rendering for m.field
view.pushSnapshot(snap) ; view.pushEvents(ev) // interpolated rendering (100 ms buffer)
view.highlightTiles(tiles, style) // legal/illegal placement, range preview
view.on('pieceDragStart'|'pieceDrop'|'pieceClick'|'pieceHover'|'tileHover', fn)
// pieceDrop: { uid, target: {area:'board',row,col}|{area:'hand',idx}|{area:'outside', clientX, clientY} }
// area 'outside' lets the DOM UI decide (e.g. dropped on the shop bar ⇒ sell)
view.pieceScreenRect(uid) // for tooltips/overlays
view.tileScreen(row, col) // { x, y, s, poly } client px of a tile top (direction wheel geometry)
view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile while its direction is chosen
view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
view.resize() ; view.destroy()
```
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons; damage numbers (phys orange-white, arts purple, true white, heal green); projectiles and hit sparks; skill activation flash; death fade; leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units.
---
## 10. Client UI (screens & components)
Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels.
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (stats, skill, talents, 特质, bonds, items); enemy preview drawer (next round enemies with counts, factions); 机变 overlay; merge reward overlay; phase banners; ticker; toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card and upgrade are two-tap (select → confirm); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag item onto unit ⇒ equip (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail; `R` refresh, `F` freeze, `D` level-up, `Space` ready (in a solo battle: pause / resume, §14 Solo pause). All actions optimistic-free: UI waits for server state (latency is small).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled), boss music in R14/15.
---
## 11. Quality bar & testing
- **No crash paths**: every server handler guarded; a thrown error inside a Battle tick for one field must not kill the match (log, force-end that battle as timeout, continue). Invariants asserted in tests: no NaN/Infinity in any unit field; hp ∈ [0, maxHp]; positions inside rect; battles terminate within `timeLimit + 1 s` (boss: terminate by pool/force); pool copy counts never negative and never exceed caps; funds never negative; hand/temp sizes respected.
- Unit tests per module; content tests per effect; **full-match bot tests**: 1/2/4-player matches of every difficulty with fixed seeds run to RESULT headlessly (fast-forward: no real-time pacing) with zero errors; protocol fuzz (random/invalid intents) never crashes and never corrupts state.
- **Browser E2E** (puppeteer-core + system Chrome): open 2 tabs, create/join room, add AI, play through prep/combat of several rounds, zero console errors, screenshots saved to `test/e2e/out/` for visual review.
- Performance: a normal battle tick with 60 enemies + 10 ops < 0.5 ms; 4 parallel fields at 2× real time < 15% CPU of one core.
---
## 12. Implementation phases
1. **Foundation**: build-data, fetch-assets/vendor, server platform (http/ws/lobby), client shell (screens up to room).
2. **Core**: sim engine + harness; match/meta; render engine; game UI.
3. **Content**: kits, bonds, garrisons, items, bands, enemies/bosses/devices, choices.
4. **Integration & QA**: wire everything, bots, E2E, balance pass, bug-hunt workflows until dry, visual polish.
---
## 13. Local-client art (optional, host-side)
`tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts art that no web source has from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art).
- `map/autochess/TX_autochessi_*` — the real autochess board atlas (`_D` diffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates; `_N/_M/_E` normal/mask/emission; `common_D` device icons; `BG` backdrop). `map/autochesssand/*` — the sand-theme variant.
- `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).
- `emoticon/{basic,basic_2}/pic_<id>_battle.png` — the 12 official battle emotes (`shared/constants.js → EMOTES`, `emoteArtPath`).
- `guide/autochess_{home,shop,handbook}_N.png` — the official tutorial pages; stored squashed to 1024² — **display at 16:9**.
- `projectiles/*` — battle projectile sprites.
- `module/<TYPE>` — the official module (uniequip) TYPE icons (white glyphs; keys are the client's mixed-case file names, e.g. `PRI-X`, `mar-x`, `isw-a`), matched **case-insensitively** against a ModuleRecord's `typeName` by `assetUrls.moduleTypeIconUrl(local, typeName)` (Greek type letters map to the file's Latin letter: `ISW-α` → `isw-a`) — shown on the 干员调配 module cards / module info, the detail panel's 模组 row and the shop card's module tag; lettered tiles / type text without them.
---
## 14. Client-side combat (v2 architecture — supersedes the combat-streaming parts of §4, §8.2, §9)
**Why:** like the official client, each player's browser simulates its own battle locally and uploads the result. The server no longer paces battles in real time nor streams `b.snap`/`b.ev` for combat, so a low-power host (e.g. a Windows mini PC) is enough, and combat rendering has no network jitter. Measured (4 human seats, HARD, real sim, in-process): server CPU per combat round ≈ 170–360 ms → ≈ 1 ms; server→client traffic ≈ 2–3.3 MB → ≈ 0.25 MB per round. Browser cost ≈ 0.05–0.1 ms per tick (≈ 3–6 ms per real second at 2×) on the main thread (no worker needed).
**Modes.** `Match` option `clientCombat` (default **on**; env `SP_COMBAT=server` → the legacy server-run + streaming mode, kept as a fallback). `m.public.combatMode` = `'client' | 'server'` tells the UI which one runs.
**Shared deterministic sim.** `server/sim/**` is pure ESM runnable in both Node and browsers. Node-only code (reading `data/*.json` from disk, research fallbacks) lives in `server/sim/nodeData.js`, imported dynamically by `simdata.js` under Node only; the browser injects its own frozen copies of `/data/{chess,enemies,tokens,stages,waves,bonds,items,garrisons,bands,effects}.json` via `setSimData(data)` (`getSimData()` serves them). The HTTP server serves `server/sim/**` read-only at `/sim/` (`.js` only, no listings, `nodeData.js` excluded under any spelling — the on-disk name decides, for case-insensitive hosts) and a generated `/data.js` stand-in of `server/data.js` (the content support module imports `../../../data.js`; in the browser it returns `getSimData()`). A battle is fully described by a JSON **BattleSpec** (`server/sim/spec.js buildBattleSpec`: `{ v, battleId, fieldId, kind, seed, modeId, round, stageId, rect, timeLimit|null, players:[PlayerBattleInput], spawns, routes, flags, enemyOverrides, waveId, bossId, content, boss?: { poolHp, poolMax } }` — the JSON round trip of the Battle options; ±Infinity → ±1e308, spawns at time Infinity dropped) and constructed with `createBattleFromSpec(spec, dataSource, { sharedBoss? })` identically on both sides; **the server builds its own battles from the spec too**, so both run exactly the same input. No wall-clock or `Math.random` inside the sim. Verified: spec-built battles give identical result digests (`resultDigest`) in any construction order; whole client-combat matches are outcome-identical to server-run matches (same seed).
**Authority per field.**
- Normal field: the owning human's client is **authoritative** (if connected).
- Unite field (联防) and boss/hidden pair fields: the lowest-seat **connected human** in that field is authoritative; every other human in (or watching) that field receives the same spec and simulates it locally **for display only**.
- Bots, departed/disconnected players, fields with no connected human: the **server** simulates. Normal and 联防 fields headlessly (`fields.js HeadlessJob`, with a `[gt, killed, total]` progress timeline) in wall-clock slices of `headlessSliceMs` (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (`HeadlessPacer`); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
- Takeover: an authority that disconnects or leaves, or misses its deadline (`timeLimit / speed` real s + 15 s grace, normal/联防) loses the field: normal/联防 → the server re-simulates the spec from t = 0 (deterministic: the same result); boss → the partner's replica is promoted (`b.start authoritative: true`), else the server runs it, fast-forwarded to the field clock through a `CreditPool` that only credits damage beyond what the client already reported (the fast-forward is spread over the pacing intervals, ≤ 240 extra ticks each). A boss authority silent for 12 s is handed over the same way. The former authority gets `b.end { reason: 'takeover' }` and keeps displaying.
**Protocol (additions; `shared/protocol.js` is normative).**
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle.
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans get the first field as a display replica — research 09 "keep-watching auto-observes the first available field"), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
- C→S `b.progress { battleId, gt, killed, total, leaks?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far. Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`).
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). A boss field whose client result is `'cleared'` while < 1 HP is left on the server (float dust of summing both fields) empties the pool.
- S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying.
- `m.public.fields[]` gains `progress: { killed, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
- `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ total ≤ 4·spawns + 100`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ 60 + 4·round`, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**Boss rounds.** The shared pool (`SharedBossPool`, co-op size = `bloodPoint[difficulty]`, see corrections) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`, BOSS_HIT tickers as before) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end`: any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw.
**Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause.
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`, unchanged). 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle`, sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; catch-up when visible again; the server deadline + takeover cover extreme cases). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
**Corrections from research 08/09 (normative):**
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled.
- Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2): route lengths stay the official ones, but among equal-length routes the one with the fewest non-blockable (floor / gate) tiles wins and smoothing never cuts across floor the grid route does not walk — e.g. 战场#01's lower-gate enemies take the col-8 road, not the col-9 floor lane (server/sim/grid.js header; test/sim/pathing-blockable.test.js).
- Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free.
- Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm); strategy draft forbids a strategy already taken by a teammate (队友已选).
- Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; `g.emote {id}` with the official emoji id.
---
## 15. Official 3D board scene (v2 visual target)
Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; the official hot-update list contains none). The client **builds the board at runtime** from the stage grid using the map theme `arts/maps/map_autochess` (materials `MT_autochess` (albedo `TX_autochessi_D` 2048², normal `_N`, metallic/gloss `_M`, emission `_E`), `MT_autochess_common` (`common_D/_E`: device/gate icons), `MT_autochess_Transparent` (`Transparent_D/N/M/E`), `MT_autochessi_BG` (`TX_autochessi_BG`)), the background meshes `arts/maps/map_autochess/bkg_mesh.ab` (`S_Background_common`, `S_Background_shadow`) and `arts/maps/common/meshes/s_background_common.ab`, device meshes (`s_common_box_01` crate, `s_wind_device` blower, …), plus the standard gate/objective effects. The level's `options.configBlackBoard` carries the official camera framing (`default_pos_x/y`, `default_size`, `enemy_pos_x/y`, …).
Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer:
- Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects.
- One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board.
- Must degrade gracefully: when local-client art or WebGL2 features are missing, fall back to the current 2D atlas board; keep 60 fps (static geometry merged into few draw calls; textures mipmapped; DPR-aware).
- Implemented (`public/js/render/board3d/*`, `render/app.js`): three.js is downloaded only when the local-art manifest lists the board atlas; the built 3D area, the drawn 2D rows and the lit rect follow `viewKind(kind, {rect})` (the field the camera actually shows: a 'prep' camera on rows ≤ 6 = `bossPrep`); the background plane is extended with mirrored copies and the clear colour matches the fog (no visible edge at any aspect); battle crates live in their own group (survive scene rebuilds) and follow the board mode (`switchableBox`: 3D mesh ⇄ 2D box on a mode switch or a lost WebGL context); quality 'low' drops shadows; `?board=2d|3d` overrides. The direction wheel's range stripes sit between the board canvas and the unit canvas.
- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests).
---
## 16. Operator loadout: skill & module selection (v2.1, user playtest #1)
Official rule (bwiki 盟约, 更新公告 5114): before a match the player cannot change an operator's tier, but can change the **equipped skill and module** of each chess; some non-default choices are stronger. Scope in the remake (no accounts ⇒ everything the chess's status unlocks is selectable):
- **Skills:** any skill unlocked at the chess's status (`evolvePhase`: E1 ⇒ S1–S2, E2 ⇒ S1–S3) at the chess's skill level (normal Lv4, elite Lv7). Default = `defaultSkillIndex`.
- **Modules:** only matter for the elite (golden) chess (`equipLevel` 1, or 3 at T6): any module of that character (or none); default = `defaultUniEquipId`. Normal chess have no module (equipLevel 0).
**Data (`data/chess.json`, built by tools/build-data.mjs):** every chess keeps `skill` (the default, unchanged shape) and gains `skills: [SkillRecord…]` (index, skillId, iconId, name, desc/descRaw, skillType, durationType, duration, spType, spCost, initSp, maxChargeTime, bb (at the chess's skill level), bbStr, rangeGrid, trigger {rule, customRangeGrid} resolved for THAT skill index, isDefault). Golden chess gain `modules: [ModuleRecord…]` = `{ uniEquipId, name, typeName, icon, isDefault, attr: {maxHp, atk, def, res, aspd, cost, blockCnt, respawnTime…} (flat adds at the chess's equipLevel), traitOverride: {desc, descRaw, bb} | null, talentChanges: [{talentIndex, name, desc, bb}] }` plus `statsBase` (stats WITHOUT any module); `stats` stays = stats with the default module (back-compat).
**Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
+505
View File
@@ -0,0 +1,505 @@
# META.md — match & meta engine (server/match)
Audience: **content authors** writing prep-side ("SERVER_*") effects in `server/sim/content/*.js → registerMeta(registry)`,
the **UI owner** consuming `m.public` / `m.private` / `m.result`, and anyone driving matches in tests or tools.
Normative contracts stay in DESIGN.md §6 and §8; this file documents the implementation and every assumption it makes.
```
server/match/
Match.js state machine, timers, round loop, co-op orchestration, views (the lobby⇄match interface is at the top)
PlayerState.js per-player economy / shop / hand / board / items / bonds / LP + every prep intent handler
gamedata.js typed, defaulted view of data/*.json (config tunables with research defaults) + the balance layer
(data/tuning.json, §3.1)
pool.js SharedPool (copies per base chess, across players), per-match bans, copy-weighted rolls
board.js placement legality from the stage legend, slot helpers, deployment order
bondsMeta.js bond counting modes, tiers, 调和 / 独行 / 助力 / 绝技, layers
effectsMeta.js MetaRegistry + EffectDispatcher + the handler ctx (this document, §2)
builtinMeta.js engine built-ins (consume-on-equip items, Arts, EffectRefs used by 机变 defaults)
choices.js 机变 draft cards + family defaults
waves.js stage / factions / bosses per match, per-round spawns, bounties, 联防 wave, preview
unite.js 联防 planning and LP attribution
finalAssault.js pairing, boss pool, hidden-core condition
fields.js FieldRunner: battle pacing (2×), snapshots, per-field error isolation
results.js m.result rows, titles, trophies, rewards
bot.js AI player (AI teammates, departed humans, "AI 托管")
scheduler.js RealScheduler / VirtualScheduler
StubMatch.js the old platform stub (platform tests only)
```
---
## 1. Match flow
```
LOBBY → INFO_CHECK (co-op 25 s, solo untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s)
→ for r = 1..lastRound (+ hidden):
ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp wiped (reward offers earned after
the last prep — a SETTLE merge — are kept for this prep),
shop reroll (frozen slots kept in place, then unfrozen), the round's wave generated (preview),
onRoundStart dispatch
[SP_DRAFT] r ∈ modes[m].spRounds (机变)
PREP onPrepStart; co-op timer rounds[r].prepTime, solo untimed; ends when every alive seat is ready
(prep end) onPrepEnd, temp auto-resolved, reward offers expire, unfrozen shop cleared, funds lost
(band_cannot keeps them), boss round: Σ activated layers recorded for the hidden-core check
COMBAT one Battle per alive player (FieldRunner, 2× game speed; limit = 2 × maxPlayTime game s, §3)
UNITE co-op, ≥ 1 leaker and ≥ 1 perfect player (§4)
SETTLE (3 s) LP, coins, layers, bounties, eliminations, onBattleResult
boss round → FINAL_ASSAULT (instead of COMBAT/UNITE/SETTLE) hidden round → HIDDEN_CORE
→ RESULT (m.result to every human, then onEnd once)
```
| Timer (real s, × `opts.timerScale`) | Value |
|---|---|
| INFO_CHECK | `config.timers.infoCheck` 25 |
| band draft turn / whole step | `bandTurn` 12 / `bandDraft` 50 (co-op; solo untimed) |
| BATTLE_CHECK | `battleCheck` 3 |
| 机变 first / other pickers | `spFirst` 30 / `spTurn` 16 (co-op; solo untimed) |
| PREP | `modes[m].rounds[r].prepTime` (co-op; solo untimed) |
| COMBAT / 联防 | `modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`) real seconds = 2× that in game seconds |
| 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain from `bossOvertimeAfter` 150 real s, 1 team LP per real s (§3) |
| ROUND_START / after combat / SETTLE | 2 / 1.5 / 3 (presentation delays, `Match.DELAYS`) |
| bot action delay | 0.9 s (+0.35 s per seat) |
`m.public.deadline` is the absolute end of the current timer (ms epoch, 0 = untimed; combat: estimated end at 2×;
最终攻势 / 隐秘核心: the boss level's `levelMaxPlayTime` countdown, 120 real s — the battle goes on past it — with
`m.public.overtimeAt` = when the overtime drain starts, 150 real s; both on the field clock).
### 1.1 Band draft
Co-op: random order (all seats, bots included), one pick per turn, 12 s per turn (timeout ⇒ `bandDraft.timeoutBandId`
华法琳), 50 s cap for the whole step (every unpicked seat gets 华法琳), one skip per player (`g.bandSkip`: the player moves to
the end of the order; refused when nobody is left to pass to), duplicates allowed, band must list the mode type in
`modeTypeList`. Solo: free pick, no timer, no skip. Starting LP = `bands[id].totalHp`.
### 1.2 机变 (SP draft)
Family = weighted pick from `choices.schedule[modeId].rounds[r].families`; cards: co-op 6 shared (each player takes 1,
random order, 30 s first / 16 s others, timeout ⇒ a random remaining card), solo 3 untimed. A 驰援 tactic card
(`single_special_choice_gain_bond_chess`) is only offered while its bond still has chess in this match's pool
(`Match.bondInPool`; a bond whose every member is banned would grant nothing); more generally a 驰援 or 盟誓
(`global_special_choice_bond_addlayer`) card is offered only while one of its bonds is live (`Match.bondLive`: not in
the mode's static `inactiveBondIds` — 标准 has no 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … — and with chess in the pool), so
玛恩纳的盟誓 / 莫斯提马的盟誓 / 卡西米尔驰援 never show up in 标准. Card generation and the
family defaults are documented in `choices.js` (bounty / supply / shop / tactic). 机密商店 cards are **free** (official
text "无需消耗资金"). A `choice:<effectId>` registry handler overrides the default application (§2.4).
### 1.3 Disconnects, AI takeover
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at
the deadline (temp sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
(normal / 联防: re-simulated from t = 0) or, on a boss field, to the partner's replica (DESIGN §14). The session stays
resumable for 10 min (net.js `reconnectWindowMs`); a **solo** run's for the official `constants.singleReconnectTime`
(86 400 s = 24 h, lobby.js `soloResumeWindowMs`) — nothing in a solo run is timed, so it simply waits. `onReconnect`
resends `m.public`, `m.private` and the `b.start` of the field the player is on / watching.
* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, ready) until turned off.
* `onLeave` (quit / reconnect window expired): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 /
§10.3): every copy the seat holds returns to the shared pool at once; the seat leaves the round loop and the Final
Assault pairing (re-planned when it quits before the boss fight; the boss pool has no alive-player factor, so it does
not change); its running normal battle is force-ended; a pending band pick
becomes the default band and a 机变 turn passes on. Status `left`, LP 0, rounds passed = the rounds it had survived.
When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are
left it ends as `'eliminated'`.
### 1.3a Solo pause
`g.pause { on }` (`Match.setPause`, DESIGN §14 Solo pause): solo matches only (co-op → `WRONG_PHASE`), `on: true` only
while a battle runs (COMBAT / 最终攻势 / 隐秘核心 with a live field, not while the Final Assault is ending); `on: false`
always. While paused (`m.public.paused`) the field clocks, the authority deadlines / release timers, the boss clock
(overtime drain, silence watchdog) and the server pacers stand still; `_resume()` shifts `deadline`, `overtimeAt`, the
fields' `startAt` / `lastProgressAt` and the boss start by the paused time and re-arms the timers. A disconnect or
`onLeave` resumes; the battle phase ending clears it (`pausedMs` totals the paused time).
### 1.4 Watching fields
Client-side combat (the default, DESIGN §14 Spectating; `Match._watchClient`): `g.watch` answers with the field's
`b.start` (a display replica fast-forwarded to the field clock); an alive player may not watch another normal field
while its own normal battle runs (`WRONG_PHASE 'own battle running'`) nor the other pair's boss field (`BAD_TARGET 'other
group hidden'`); eliminated players watch anything. The rest of this section is the legacy server-run mode
(`SP_COMBAT=server`):
`g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE)
`'n:<pid>'` returns a one-shot board view — during a battle phase an `'n:<pid>'` id must name a live field
(`BAD_TARGET 'no such field'` otherwise, and the viewer keeps its stream). In the
最终攻势 / 隐秘核心 a player fighting in a boss field may only watch its own field ("两名参与者会处于同一个战场,但无法查看
另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field.
### 1.5 AI player (bot.js)
Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), then levels on a curve
(L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free levels always), then
spends the rest on merge progress, bond thresholds around a focus core bond (most owned members, ties → most copies
left in the shared pool), role needs (2 blockers, anti-air when the wave flies, ≤ 2 healers), 特质 that keep adding
layers (every prep / refresh — the layer engines) and items for free carriers; refreshes while a purchase stays
affordable; sells bench chess that neither make the lineup nor build toward something (a live pair, the focus bond,
an elite). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`). Placement
uses the round's enemy preview: every route is traced over the own board (ground: the stage's device-aware ground
paths; flyers: through their checkpoints) and weighted by its enemies; an exposure model (time on each tile × DPS of
the units covering it, blocker hold time, anti-air only on flying routes) is maximized greedily (blockers, then
damage dealers, then healers). Boss rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the
own board (rows −7, the right player of a pair mirrored, only the routes that end on its half) and the leader counts
as 10 tough enemies with a 30 s dwell on its first tiles, so the damage dealers reach stationary leaders.
**Rehearsal:** with `opts.botRehearsal = N` (default 3) the bot simulates the N best
distinct layout variants once each with the real `Battle` (a rehearsal seed, no meta dispatch, board restored exactly)
and keeps the one with the fewest leaks; a candidate whose counted leaks already exceed the best finished one's stops
early. A candidate is a whole battle — ≈ 20–300 ms of CPU (late rounds, 4 bots: 0.2–1 s per bot prep, about twice the
CPU of the real battles), so it never runs in one go on a real server: `Match.scheduleBotPrep` runs `botPrepBegin`
(economy + the default layout on the board), then steps the rehearsal in slices of ≤ `opts.botSliceMs` (default 8 ms of
wall clock, checked every 4 ticks; one scheduler callback each, so other rooms' battles and every request keep
flowing), then `botPrepEnd` (the rehearsed layout when it won, temp, Ready). The prep ending first drops the job (the
default layout stays). Virtual time runs it in one go (same decisions). Tests default rehearsal to 0
(`test/match/harness.js`); `tools/matchrun.mjs --rehearsal N` sets it.
---
## 2. Effect registry (content API)
```js
// server/sim/content/bands.js
export function registerMeta(registry) {
registry.band('band_cannot', { // 坎诺特 利滚利: +1 fund at round start when ≥ 5 were carried over
onPrepEnd(ctx) { ctx.setCounter('cannot:left', ctx.funds()); },
onRoundStart(ctx) {
const b = ctx.data.bands.band_cannot.params; // numbers from data, never hard-coded
if (ctx.counter('cannot:left') >= (b.capital ?? 5)) ctx.addFunds(b.interest ?? 1, 'band');
},
});
}
```
### 2.1 Keys
One handler object per key; a later `register` of the same key **replaces** the earlier one (content registers after
the engine built-ins, so content always wins). Sugar: `registry.band(id, h)`, `.bond`, `.garrison`, `.item`,
`.choice`, `.effect`, `.global`. A bare function registers `{ run: fn }`.
| key | runs for | source (`ctx.source`) |
|---|---|---|
| `global:<name>` | every player, every hook | `{ kind:'global' }` |
| `band:<bandId>` | the band's owner | `{ kind:'band', bandId, band }` |
| `bond:<bondId>` | every player (active or not — check `ctx.bondActive`) | `{ kind:'bond', bondId, bond: {count,active,tier,layers} }` |
| `garrison:<effectKey>` | owners of chess whose garrison has that `effectKey`, on its eventType hook only (§2.3) | `{ kind:'garrison', piece, garrisonId, garrison, bb, bbStr, where }` |
| `item:<itemKey>` | items **equipped** on owned chess (every hook); the item itself for onEquip / onArt / onDestroy | `{ kind:'item', piece, holder, item }` |
| `effect:<id>` | EffectRefs in `ps.effects` whose `key` is this key | `{ kind:'effect', ref }` |
| `choice:<effectId>` | the picker (and teammates for team cards) when the card is applied | `{ kind:'choice', card }` |
`itemKey` = item id without `_a`/`_b` (`chess_item_1_03_e`); Arts have no suffix (`chess_item_6_02_m`).
Normal and golden share the key: read `ctx.gd.item(ctx.source.piece.id).params` (or `ev.item`) for the numbers.
### 2.2 Hooks and events
Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispatch and may be mutated where noted.
| hook | when | `ev` |
|---|---|---|
| `onIncome` | ROUND_START, before the income is credited | `{ round, income, pending }` — write `ev.income` / `ev.pending` (e.g. 老鲤 withholds R1–R2 income until R3) |
| `onRoundStart` | ROUND_START, after income / shop roll | `{ round }` |
| `onPrepStart` | PREP opens (after 机变) | `{ round }` |
| `onPrepEnd` | prep end, before funds are cleared | `{ round }` |
| `onGain` | a chess / item was acquired (buy, reward, grant, merge result) | `{ piece, kind:'chess'|'item', source }` |
| `onSold` | a chess was sold (after refund of copies) | `{ piece, gain }` — **write `ev.gain`** to change the funds paid |
| `onRefresh` | manual refresh | `{ slots, free, price }` (slots mutable, or use `ctx.setShopSlot`) |
| `onPrice` | every price query of a shop slot (views + buy) — must be **pure** | `{ slot, kind, id, price }` — write `ev.price` / `ctx.setPrice` / `ctx.modifyPrice` |
| `onBuy` | after a purchase | `{ piece, slot, price, kind }` (`piece` = owned result, elite after a merge) |
| `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid] }` |
| `onLevelUp` | shop level up | `{ level, price }` |
| `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) |
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden) | `{ input: PlayerBattleInput, kind, round }` — mutate/replace `ev.input` |
| `onBattleResult` | SETTLE (normal rounds) and after boss fields | `{ result, lpLoss, perfect, unite?|boss? }` |
| `onChoicePick` | a 机变 card was applied (the card's own `choice:` handler runs first, then every source observes) | `{ card, family, picker, forTeammate }` |
| `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 |
| `onArt` | `g.art` (item handler only) | `{ item, row, col, targets:[pieces], error, used }` |
| `onDestroy` | an item was destroyed (player / replaced) | `{ item, holder, reason }` |
| `onLayers` | bond layers were added (prep or battle gains) | `{ bondId, from, to, reason }` (milestones: 维多利亚 25, 远见 10, 奇迹 100 …) |
Dispatch order per player: `global` → `band` → `bond` (data order) → garrisons (board in deployment order, then hand)
→ equipped items → EffectRefs (insertion order). Every call is isolated with try/catch (the error is logged once and
counted in `match.dispatcher.errors`); nested dispatches are capped at depth 6.
### 2.3 Garrisons (特质)
The dispatcher calls `handler[hook] ?? handler.run` only on the hook of the garrison's `eventType` (a handler may widen
that per garrison with `garrisonHooks(garrison) → hook[]`, e.g. "<进入休整期时><休整期结束时>"):
| eventType | hook | whose garrisons |
|---|---|---|
| `SERVER_GAIN` 获得时 | `onGain` | the gained piece — run **×2 while 投资人 is active, ×3 at ≥ 100 投资人 layers** |
| `SERVER_PREP_START` 进入休整期时 | `onRoundStart` | owned chess: board, and hand unless `bbStr.conditionkey === 'character_target_inboard'` |
| `SERVER_PREP_FIN` 休整期结束时 | `onPrepEnd` | same |
| `SERVER_REFRESH_SHOP` 刷新时 | `onRefresh` | same |
| `SERVER_CHESS_SOLD` 售出时 | `onSold` | the sold piece |
| `SERVER_PRICE` 购买价格 | `onPrice` | the chess in the priced slot (`ctx.source.where === 'shop'`) |
| `IN_BATTLE` | — | battle side (server/sim/content/garrisons.js `install`) |
```js
// garrisons.js — "<获得时>获得等于当前调度中心等级的【炎】层数(无需激活盟约)" (SERVER_ADD_BOND_METHOD, add_method shoplv)
registry.garrison('SERVER_ADD_BOND_METHOD', {
run(ctx, ev) {
const { bb, bbStr } = ctx.source; // garrison blackboard
let n = 0;
if (bbStr.add_method === 'shoplv') n = ctx.shopLevel() * (bb.multi ?? 1);
else if (bbStr.add_method === 'hand_count') n = ctx.hand().filter((p) => p && p.kind === 'chess').length * (bb.multi ?? 1);
const requireActive = ctx.source.garrison.desc.includes('已激活');
ctx.addLayers(bbStr.bond, n, { requireActive });
},
});
```
### 2.4 The handler context (`ctx`)
Reads: `playerId seat name round phase modeId difficulty isSolo isCoop data (frozen raw data) gd (GameData) rng`
(`rng()`, `rng.int(n)`, `rng.pick(a)`, `rng.chance(p)`, `rng.shuffle(a)` — the match's meta stream, never
`Math.random`), `funds() lp() alive() bandId() shopLevel() bond(id) bonds() bondActive(id) bondCount(id) layers(id)
board() hand() temp() piece(uid) pieceAt(row, col) pieceBonds(uid) garrisonsOf(uid) chessRecord(id) stats() roundStats()
shopSlots() effect(id)` (`piece(uid)` adds `area`, `holderUid`, `idx`; "身前一格" of (r, c) is (r, c + 1)),
counters `counter(k) setCounter(k, v) incCounter(k, n)` (player scope, persistent; prefix keys with your module).
Writes (all validated, never throw on bad input, never make funds / pools negative):
| helper | effect |
|---|---|
| `addFunds(n, reason?)` / `addPendingFunds(n)` / `spendFunds(n)` | funds now / at the next round start / pay (false when short) |
| `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added; fires onLayers |
| `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain |
| `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item) |
| `rollChess({ maxTier, tier, bond, filter })` / `rollItem({ pool, tier, maxTier })` | copy-weighted chess id from the shared pool / item id (choices.json pools) |
| `grantFreeRefresh(n)` | free refreshes (stack) |
| `modifyPrice(delta)` / `setPrice(v)` | onPrice only: edit `ev.price` |
| `promote(uid)` / `transform(uid, chessId)` / `upgradeItem(uid)` | elite in place / replace a chess (keeps tile) / item → golden |
| `destroyPiece(uid)` / `equipDirect(itemUid, chessUid)` | remove a piece (chess copies return, items go back) / attach without equip effects |
| `offerChess(ids, { tier })` | queue a pick-one offer (shown as `shop.rewardOffer`, free) — 寻呼模块 / 信标 style |
| `offerItems(ids, { tier })` | the same for items (slots of kind `'item'`) — 凯瑟琳 定向投放 style |
| `triggerGarrisons(uid, eventType, { asUid })` | run another owned chess's 特质 of that eventType now (铃兰 "触发…的获得时效果"); 投资人 still multiplies SERVER_GAIN; SERVER_PRICE cannot be triggered; depth-capped |
| `setShopSlot(i, { kind, id, price?, frozen? } \| null)` | rewrite a shop slot (special refreshes) |
| `addDeployCap(n)` / `setDeployCapAtLeast(n)` | deploy cap (+effects; 人事部文档 = 9) |
| `setBondCountBonus(bondId, n)` | extra member count for a bond |
| `setDeviceActive(alias, on)` / `setTileOverride(r, c, 'melee'\|'ranged'\|'none')` | terrain changes of this player's board (legality + battle input `deviceOverrides`) |
| `addEffect(ref)` / `removeEffect(id)` / `setEffectCounter(id, v)` | EffectRefs `{ id, key?, name, desc, iconKind, iconId, counter?, battle=true, params?, data?, hidden? }` — shown in `m.private.effects`, passed to battles as `playerEffects` when `battle` |
| `addBounty(card)` | a bounty (choices.json cards.bounty shape) on the next battles |
| `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages |
| `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) |
### 2.5 Items: consume-on-equip and Arts
Items whose data `kind` starts with `consume_on_equip` resolve through their `item:` handler's `onEquip` and are
destroyed (they never take a slot); set `ev.keep = true` to keep the item equipped instead (博士投影 normal), or
`ev.error = 'BAD_TARGET'` (+ `ev.detail`) to refuse. Without a registered handler the equip is refused
(`BAD_TARGET 'effect not available'`). Arts (`MAGIC`): `g.art` needs a handler; at most `maxArtsPerRound` (2) per round;
`ev.targets` are the pieces under the Art's `rangeGrid` at (row, col); set `ev.error` to refuse, `ev.used = false` to keep it.
Other equipment: 2 slots; a third replaces the equipped item the player picks in the replace dialog — `g.equip
{ itemUid, targetUid, replaceUid }` (research 09 §1.2 `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a `replaceUid` not
equipped on the target ⇒ `BAD_TARGET`, nothing changes) — and the replaced item is destroyed. Equipped items are
otherwise locked (research 04 §2 / addendum: they leave the operator only on promotion, merge or sale): `g.destroy`
refuses them (`BAD_TARGET 'equipped items are locked'`). A second copy of an equipped normal item merges into the golden
item in the hand.
Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds),
紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶
(+funds next round), 人事部文档 (cap 9), 博士投影 (elite now / at the next round start), 寻呼模块 / 信标 (pick-one
offers; 信标 gifts the original chess to the teammate with the most members of its bonds next round), 商业包装方案 (every
N sells → same-bond chess), 突变细胞 (after battle → random tier+1 chess), 画卷 (copy the operator in range with its
items), 教鞭 / “神秘顾客” (a random bounty is added).
**教鞭 / “神秘顾客” stay a random bounty (deliberate).** The official Arts open a personal 悬赏 choice
("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`). The server applies a random bounty instead: content
(server/sim/content/items/meta.js) draws 3 of the band-bounty family `enemyeffect_b_*` and takes one at random (the
built-in fallback: a random tier ≤ II bounty of `cards.bounty`) and adds it with `ctx.addBounty`. A personal choice
overlay would need its own phase / protocol message outside SP_DRAFT (a second, simultaneous draft in co-op, with
timers and AI takeover) for a rarely used Art, while the random pick keeps the risk / reward the item is about. The
bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1
fund, the Art passes to the next alive player) is content too (`onDestroy`).
EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备),
`effect:builtin_next_buy_elite` (升华).
### 2.6 机变 card application
`choice:<effectId>` handler (content) → else the family default (choices.js): bounty → `ctx.addBounty`; supply/shop →
item to the hand; tactic by buff key (`global_special_choice_gain_equip`, `_bond_addlayer`, `_gain_coin`, `_refresh_free`,
`single_special_choice_gloden_equip_chess`, `_gloden_char_chess`, `_gain_bond_chess`, `auto_chess_change_map`); every
other buff becomes a battle EffectRef (`key: 'choice:<effectId>'`, `params` = effect params) for the sim content.
Team cards (`team: true`) apply to every alive teammate as well.
```js
// choices.js — override the 自愈 card: keep it as a battle effect but show a counter
registry.choice('allybuff_select_11', {
onChoicePick(ctx, ev) {
// the EffectRef below carries this same key, so this handler also runs (source kind 'effect') on every LATER
// onChoicePick dispatch — apply the card only when it is the card being applied
if (ctx.source.kind !== 'choice') return;
ctx.addEffect({ id: `heal:${ctx.round}`, key: 'choice:allybuff_select_11', name: ev.card.name, desc: ev.card.desc,
iconKind: 'team', params: ctx.data.effects.allybuff_select_11.params });
},
});
```
Any `choice:` handler whose EffectRef reuses its own key must guard like this (or give the EffectRef an `effect:` key).
---
## 3. Rules implemented (summary; details in each module header)
* **Economy**: income `config.economy.income[r]` (= min(3+r, 12)); bounty coins / pending funds credited at the next
round start; leftover funds lost at prep end except `leftoverFundsKeptByBands`. Chess price by tier (2/3/3/3/4/4),
items by `price`, refresh 1 (free refreshes first), sell +1 (elite too; items cannot be sold, only destroyed).
* **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned,
visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price =
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; `MAX_LEVEL` at 6. One freeze
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
selling, temp wipes and elimination return exactly what a piece holds (`left + held = cap` always).
* **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment);
a full hand refuses buys unless the purchase completes a merge, and withdrawals unless the withdrawn summoner's own
summon stack frees a slot (a deployed summon withdrawn with no stack of its own left to join is a new card: `HAND_FULL`,
never temp); temp blocks Ready; temp is resolved (chess sold back to the pool, items destroyed) at the
prep deadline and at the next round start.
* **Merge**: 3 normal copies (风丸 2) anywhere (board/hand/temp) → elite to the hand (the incoming copy, then temp, hand,
board copies are consumed); equipment returns to the hand; summons of consumed copies are removed; a reward offer of 3
free chess of tier min(level+1, 6) (pick 1, expires at prep end; queued when several merges happen). A merge
completed after the prep (SETTLE / Final Assault effects such as 突变细胞) keeps its offer for the next prep, and with
a full hand its elite takes a freed board tile of a consumed copy instead of temp (wiped at the round start).
* **Board**: rows 9–12 × cols 2–10, legality from `stages[id].tiles` + devices (board.js); deploy cap 8 (+effects);
summons don't use slots; board↔hand swaps always allowed. A terrain change (terrain 机变 cards such as 模拟战场演变·
模式二 "阻隔工事变为射击台", content `setDeviceActive` / `setTileOverride`) is checked at the next `recompute()` (at the
latest when the battle input is built): a piece left on a tile it may no longer occupy — a melee operator on a new
射击台 — is withdrawn to the hand (overflow temp: re-placed during the prep; its summons leave with it), a summon back
onto its stack, with a toast (`PlayerState._evictIllegal`).
* **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1, 独行 downward, 助力 upper tiers,
变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end.
* **Waves**: waves.js header (stage/factions/boss per match, faction replacement per round with `k` copies, scaling by
`enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Multi-round bounties ("之后的每场作战")
also spawn in the Final Assault / Hidden Core, on the owner's half of the boss field (a route ending at its goal); the
boss battle then uses up one of the bounty's battles and its kill coins go to pending funds.
* **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2×
battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`,
default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of
45 s, R3's at 62 s of 55 s); × 2 every limit ≈ last spawn + one flyer crossing. docs/BALANCE.md §2.1.
* **SETTLE**: LP −min(counted leaks, 10) (after 联防: survivors attributed to their source, same cap); IN_BATTLE layer
gains applied; kill-bounty coins (paid by the Battle to the killer — a 联防 helper included) and perfect-bounty coins
(own phase perfect) go to pending funds; bounty rounds decrement; LP ≤ 0 ⇒ eliminated (all copies back to the pool).
* **Final Assault / Hidden Core**: finalAssault.js header. Boards are passed in board coordinates; the sim maps board
rows 9–12 onto boss rows 2–5 (`BOSS_ROW_OFFSET` −7, matching every stage's boss rows) and mirrors the right side.
Pool (`finalAssault.js bossPoolHp`, research 08 §6 #7): co-op = `bloodPoint[difficulty]` as is — one pool shared by
every boss field, no alive-player factor ("敌方领袖的总生命值不变"); solo = × `bossHpScale.solo` (0.25 [ASSUMED]); leaders
are never scaled by `enemyScale`. The merged team LP loses leaks (`lpr`), the overtime drain
(`bossTurnHpReduceTime` 150 counts REAL seconds, like the boss level's 120 s maxPlayTime that runs out first — the
battle goes on — so 1 LP per real second from 150 real s = 300 game s on the 2× field clock; `gd.bossOvertimeDue`)
and leader "扣除目标生命" effects (the sim's `lpLoss` hook: boss_7 Doom, 斥退 …); after every change it is written back
to the alive players as shares of the LP each brought in (`lpAtFinal`, largest remainder), so `lp` in m.public /
m.private / m.result is what is left (Σ = team LP).
Client-side combat (DESIGN §14): a boss field's reports are plausibility-bounded on the SERVER's field clock — the
credited pool damage of one field ≤ the whole pool per `BOSS_MIN_CLEAR_GS` (5) game s (20 %/s; the balance model's
fastest mean kills run ≈ 5 %/s per field), its LP cost ≤ 10 + 1 per game s; reports are cumulative, so what exceeds
the budget is credited later (the boss clock re-applies the latest report), never lost. A boss field's `b.result` ends
it only when the pool is empty or after the match's `b.end`; any other (a 'forced' result at t = 0, 'cleared' while
the pool holds, a result failing validation) hands the field to the partner's replica or the server, and the sender is
never its authority again (`f.demoted`). Every boss field run by the server (takeover, or nobody connected at the
start) publishes its own damage as `b.pool.acked[fieldId]` (its CreditPool total), so the display replica of a
reconnecting / watching human stays in sync.
### 3.1 Balance layer (data/tuning.json)
`data/config.json` is generated and stays research-faithful. There is **no custom balance** any more (DESIGN §14
corrections, research 08 §6): enemy numbers are the official ones (the PRTS `enemyScale` table, leader pool =
`bloodPoint`). `data/tuning.json` (hand-maintained, loaded as `data.tuning`, layered on the config by gamedata.js only)
keeps just the result-title rules:
```jsonc
{ "titles": { "comment_3": { "stat": "lpLost", "rule": "min" } } } // merged over config.titles
```
The former `enemyHpMul` / `enemyAtkMul` / `enemySpeedMul` / `bossHpMul` / `flyPlaceholders` knobs were removed; a
tuning file that still carries them is ignored (`gd.bossHpMul()` always returns 1). `tools/balance.mjs --tuning off`
drops the file (titles only, so the numbers are the same). Titles: `rule: 'min'` ranks the players still alive by the
smallest stat (坚若磐石 "目标生命值损失最少" = least `lpLost`). docs/BALANCE.md has the balance measurements.
---
## 4. 联防 (Unite)
Planned after the normal combats, at the end of the COMBAT_END pause from the players still in (`Match._afterCombat`, so a
player who quit during the last field or the pause is neither a helper nor a leaker whose enemies re-enter; unite.js):
helpers = `unite.js helperOrder` (research 08 §5, PRTS 卫戍协议/帮助 §联防阶段): ≤ `unite.maxHelpers` (2) perfect players
chosen by most units on the field (downed included) > an active bond > most standing units > seat; the pair ordered by
units > active bond > Σ active layers > standing > seat (LP plays no part), the first one on the right-hand field
(colOffset +8, where escaped_multi enters), the other colOffset 0; the escaped template of that size routes the leaked
enemies by slot class; helpers' operators carry
`{ hpPct, sp, skillActive }` from `unitsEnd` ("阵地以其当前状态"); an operator dead at the end of the helper's own combat
stays out of the 联防 battle with its summons [ASSUMED: the sim has no "start undeployed with a running redeploy timer"
input]; `flags.layerGainsEnabled = false`; time limit = the round's combat limit.
Every enemy still alive at the end (leaked again, or never spawned before the limit) costs its **source** player 1 LP.
A client-run 联防 result may bill a survivor only to a leaker who sent that enemy in — a split / summon only to a leaker
who sent in its parent, ≤ the parents' data offspring count (磨砻 2, 烹泉 4 …; fields.js offspringPerParent).
---
## 5. Views (DESIGN §8.2 / §8.3) and deviations
`m.public` (throttled ≤ 10/s, only sent when it changed) carries the DESIGN fields plus: `drawnDisabledBonds` (the 3+4
drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`,
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
(solo pause, §1.3a),
`players[].autoplay`, and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, untimed }` (BAND_DRAFT),
`sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, coin?, payout?,
rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }`
(SP_DRAFT), `teamLp` / `bossHp {hp,max}` (Final Assault on), `overtimeAt` (最终攻势 / 隐秘核心: ms epoch when the
overtime drain starts; `deadline` = the level's 120 s countdown), `unite { helpers, leakers }` (UNITE).
`players[].status`: INFO_CHECK ready/deciding · drafts ready (picked) / deciding (their turn) / acting (waiting) ·
PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others done · `left` / `dead` override.
`m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave
(+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss`, + its bounties).
`m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:<pid>'` returns a one-shot board
view with `prep: true` (scouting a teammate).
**`b.snap` / `b.ev` game time**: every frame is `{ t: '<type>', … }`, so the snapshot's game time (DESIGN `b.snap.t`)
is sent as **`gt`** (game seconds); `b.ev` carries the same `gt`. The client reads `gt` (`render/interp.js frameTime`,
used by `normalizeSnapshot`). Legacy server-run mode only: client-side combat sends no `b.snap` / `b.ev`.
`m.result` (unicast to every human, `playerId` = the recipient) = `{ victory, roundsPassed, hiddenReached,
hiddenCleared, reason: 'victory'|'defeat'|'eliminated'|'abandoned'|'error', teamLp (merged Final Assault LP, null before
it; players[].lp are the alive players' shares), modeId, difficulty, stageId, bossId,
hiddenBossId, seed, durationMs, players:[{ playerId, seat, name, isBot, left, alive, victory (the team won AND this
player was still in at the end), roundsPassed,
eliminatedRound, lp, bandId, lineup:[{ id, golden, tier, row, col, items }], bonds, stats:{ dmgDealt, kills, leaks, gold,
refreshes, merges, itemsEquipped, bossDamage, activatedLayers, lpLost, perfectRounds }, title:{ id, name, picId, text }|null,
trophies, reward }] }`. Trophies and 卫戍认证 follow each row's OWN rounds passed (research 06 §6 / §10.4): the
Hidden-Core trophy row only for the players still in when it was cleared — a teammate eliminated or departed earlier
gets the table value of its own rounds. `onEnd(summary)` receives the same object (without `t`, plus `errors`).
`m.emote { playerId, id }`: `id` must be one of `shared/constants.js EMOTES` (`BAD_MSG` otherwise, also when `handle()`
is called without protocol validation); 1 per second per player (`RATE`).
Tickers (`m.ticker { text, id, type, priority, playerId }`) from `config.broadcasts`: SHOP_LEVEL, GOLDEN_CHAR,
CHAR_DAMAGE (per board unit per battle, highest threshold; summons created in battle once per unit type — a client
result may only name the unit types its lineup can field, fields.js validateClientResult), BOSS_HIT (20/50/80 % of the
pool per player), CHAR_GIFT (to the
receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
---
## 6. Testing & tools
* `test/match/fakeBattle.js` — scriptable DESIGN §5.1 Battle (`FakeBattle.script = (battle) => plan`); inject with
`new Match({ …, BattleClass: FakeBattle })`.
* `test/match/harness.js` — `makeMatch(opts)` (virtual scheduler, captured frames, `drive()` for human decisions,
`toPrep(r)`, `checkInvariants(m)`, `give()/giveItem()` scenario helpers).
* `VirtualScheduler({ instantCombat })`: `advance(ms)`, `runNext()`, `runUntil(pred)`, `runAll()`; with
`instantCombat` (default) battles run synchronously, so a full match takes milliseconds of wall time.
* Suites: pool, board, economy, merge, bonds, draft, combat (FakeBattle), finalAssault, connection, meta, waves,
results, fullmatch (fullmatch*.test.js, parallel files, runner fullmatchRun.js — REAL sim: solo × 4 difficulties, co-op
2/3/4 incl. AI, 20 seeds each; `MATCH_SEEDS=n` to change),
fuzz (random valid-shaped intents + connection churn), lobby-integration (real server + sockets), realtime (the real
server + lobby + Match with RealScheduler and the REAL sim: 2 scripted websocket humans + 2 AI from the room to the
prep of round 4 — drafts, 机变, buying/placing, combat snapshots `gt`, watching, 联防, throttled m.public), bot (field
model, layout planner, rehearsal side-effect freedom, economy/bench regression).
`node --test test/match/*.test.js`
* `node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20` — per-round balancing summary / aggregate
(`--check` audit, `--errors` per-source error table, `--lp N` / `--layers N` to reach late rounds, `--rehearsal N`).
* `node tools/balance.mjs --mode multi --difficulty NORMAL` — the competent-board difficulty model (docs/BALANCE.md):
per round leaks / LP after 联防 / clear time of representative boards against the real waves, boss damage by 150 s
and kill time; `--tuning off` (research numbers), `--legacy-time`, `--profile weak|strong`, `--bots N`, `--json`.
Tests: `test/match/balance.test.js` (tuning layer, model), `test/match/followups.test.js`.
## 7. Assumptions (all documented in module headers)
* Unite helper choice: `unite.js helperOrder` (§4; selection units > active bond > standing units > seat, the pair
units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with
their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22).
* A manual refresh while frozen keeps the new slots frozen until the next round start.
* Level-up does not add slots until the next roll (refresh / round start).
* Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped).
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it.
* Chess granted by effects need a free pool copy unless `requirePool: false` (then they hold 0 copies).
* Boss-round `local` pack spawns (boss parts) all spawn; content scripts (bosses.js) decide their behaviour.
* The Final Assault ends as a defeat when every field finished with the boss pool above 0 (boss escaped).
* Combat limits are real seconds (× 2 in game seconds) — see §3; research 00-INDEX §8 #10 assumed game seconds.
* Emotes faster than 1/s answer `RATE`.
* 中途退出 = elimination at once (research); a quitter's operators already fighting in a shared field (联防, boss
field) finish that battle, its LP already merged into the team LP stays there.
* After the LP merge (Final Assault) the per-player LP shown is a share of the team LP ∝ the LP each player brought in.
* A 联防 helper's operators dead at the end of its own combat do not take part in the 联防 battle.
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; with a full hand its elite
takes a freed board tile (a prep merge still overflows into temp).
+114
View File
@@ -0,0 +1,114 @@
# 玩法指南
「卫戍协议:盟约」是自走棋 + 塔防:休整期在调度中心招募干员、摆阵、配装备,作战期干员自动部署并迎击从红门涌来的敌人,漏过去的敌人扣目标生命值(LP)。同盟模拟中 1–4 人**合作**(没有 PvP),撑到第 14 回合打倒敌方领袖即胜利。
本文数值来自官方数据表(`data/config.json` 等)与 [research](research/00-INDEX.md);游戏内左下角 📖「玩法说明」有官方教程图(需本地提取)。操作方式见 [README · 操作](../README.md#操作)。
## 1. 一局的流程
```
确认本局信息(25 s)→ 选择策略(轮选)→ 协议启动
→ 每回合:[机变] → 休整期 → 作战 → [联防] → 结算
→ 第 14 回合:最终攻势 →(满足条件)第 15 回合:隐秘核心 → 结算称号
```
| 阶段 | 说明 |
|---|---|
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒,独立模拟不限时。 |
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,每人 12 秒,队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选华法琳。独立:自由选择、不限时。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒),独立 3 选 1。卡片包括资金、干员、盟约层数、战术特训(悬赏敌人)等。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加),独立不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
**断线与离开**:同盟模拟断线后 10 分钟内重新打开页面即可回到原座位;期间你的阵容照常自动作战,机变和休整期到时自动选择 / 准备,但不会替你购买(想让 AI 代打,请在「离开模拟」里选「暂离(AI 托管)」)。独立模拟的休整期与机变不计时,断线或关掉页面后 **24 小时内**用同一个浏览器打开即可继续;独立模拟的作战(含最终攻势 / 隐秘核心)还可以点顶栏的「暂停」(或按 Space)随时暂停,倒计时和领袖战的加时扣血一起停下(暂停中断线会自动继续,由服务器接着打完这场作战)。同盟模拟的作战不能暂停。「放弃模拟」:独立模拟立即结束本局;同盟模拟中视为你被淘汰(干员回到公共卡池),队友继续。服务器重启会结束所有对局。
**回合数与机变回合**
| 难度 | 同盟:回合 / 机变回合 | 独立:回合 / 机变回合 |
|---|---|---|
| 标准模拟 | 14 / 3、9 | 9(第 9 回合为领袖)/ 无 |
| 险境模拟 | 14 (+15) / 3、6、9 | 14 (+15) / 6、9 |
| 绝境模拟 · 终极模拟 | 14 (+15) / 3、9、11 | 14 (+15) / 3、9、11 |
难度越高敌人的攻击 / 生命倍率越高、出现的危险敌人越多,领袖血量也越高。
## 2. 经济
| 项目 | 数值 |
|---|---|
| 每回合资金 | 第 r 回合 `min(3 + r, 12)`:4、5、6、7 … 从第 9 回合起 12 |
| 剩余资金 | **休整期结束时清零**(策略「坎诺特」例外) |
| 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 |
| 刷新 | 1 资金 |
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
| 装备 / 法术 | 不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁),或在干员出售 / 合成精锐时退回整备区 |
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 |
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算 |
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
调度中心等级决定商店能出现的**最高阶级**和格子数(险境及以上:干员 3 / 4 / 4 / 5 / 5 / 5 格 + 装备 1 格)。
## 3. 招募、整备区与晋升
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位进入**临时整备区**(5 格),下一回合开始时自动销毁。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。原先配发的装备退回整备区。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)的随机干员中**免费**挑 1 名。它不能刷新、不能冻结、本回合结束失效。
- 每回合商店会自动刷新未冻结的格子。
## 4. 摆阵与朝向
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息(属性、技能、天赋、特质、盟约、装备)。
- 部署顺序是**从上到下、从左到右**,最后部署的干员嘲讽等级最高;部署费用不够时后面的干员会晚一点上场。
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
## 5. 联防(同盟)
某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(保持作战结束时的生命与技能状态,已倒下的干员不参加),迎击其他玩家漏掉的全部敌人。
- 参加者按官方规则挑选:场上单位最多 > 有已激活的盟约 > 未倒下的单位最多 > 座位靠前(与 LP 无关);两人参加时,排在前面的一位(再比较激活盟约的层数总和)在右半场「率先迎敌」。
- 只有完美作战的队友能参与联防;联防中**盟约不会叠加层数**。
- 联防中击杀悬赏敌人,资金归击杀者。
- 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。
- 每个普通回合每人**最多扣 10 点 LP**。
## 6. 盟约
干员属于 1–2 个盟约。场上**不同名**的成员达到人数后激活(大多只数棋盘上的,「远见」「奇迹」「投资人」连整备区也算)。
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。
## 7. 最终攻势与隐秘核心
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**,看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利,LP 归零即失败。此阶段盟约不叠层。
- **隐秘核心**(第 15 回合,仅险境及以上):打倒领袖后,若全队激活的盟约层数总和 **> 1200**(独立 > 350)且 LP > 1,进入隐秘核心,挑战更强的领袖。失败不影响本局的通关。
## 8. 表情与观战
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
- 被淘汰后可以自由观看任何战场。
## 9. 结算称号
结算时按表现评选称号(每人最多一个,每个称号只给一人):**卫戍之星**(对领袖伤害最高,仅胜利时)、**不朽盟约**(激活层数总和最高)、**坚若磐石**(LP 损失最少)、**精英云集**(晋升精锐最多)、**万事俱备**(装备配发最多)、**挥金如土**(消耗资金最多)。
## 10. 小技巧
- 前期优先凑出一个能激活的盟约,比零散买高阶干员更稳;同名 3 张会自动晋升并带来一次免费的高阶招募。
- 资金不能留到下回合:没用完就刷新或升级;想要的卡没钱买时先**冻结**。
- 升级价格每回合自动 −1,不急的话可以等一两回合再升。
- 休整期看看右上角的敌人预览,有飞行单位就留好对空(远程)干员。
- 同盟模拟里别和队友抢同一系干员(卡池共用),分工凑不同的盟约更容易。
- LP 只剩 10 左右时(一回合最多扣 10)要优先保完美作战。
- 联防只让完美作战的队友上场,所以稳定清怪的阵容对全队都有价值。
- 进入隐秘核心看的是全队的**激活层数总和**:后期可以针对性地堆层数。
+773
View File
@@ -0,0 +1,773 @@
# SIM.md — battle simulation engine reference (server/sim)
Audience: **content authors** (kits, bonds, garrisons, items, bands, enemies, bosses, devices, choices) and the
**match owner** who drives `Battle`. The normative contract is DESIGN.md §5; this file documents the concrete
implementation, every hook and helper, the SkillSpec schema with worked examples, the profession defaults and
the test harness. Everything here is deterministic: the only randomness is `battle.rng()`.
```
server/sim/
Battle.js one field (normal / unite / boss / hidden) — public API, hook bus, helpers
constants.js TICK, MOVE_SCALE, ATTACK_PAUSE, element numbers, tuning knobs
rng.js mulberry32 PRNG (+ int/range/chance/pick/shuffle/weighted)
grid.js stage grid, tile semantics, 8-dir A* (no corner cutting), obstacles
units.js Unit model + stat aggregation
buffs.js Buff model, mod keys, status catalogue
damage.js damage / heal pipeline, shields, dodge, element gauges
targeting.js range grids (rotated by the unit direction), target filters, priorities
dir.js the 4 deploy directions: vectors, rotation, mirror (shared with server/match and browsers)
projectiles.js projectile flight & impact
skills.js SkillRuntime (SP, charges, triggers, kinds, SkillSpec)
ai.js operator attack loop, enemy route following / blocking / attacks
professions.js default behaviour per subProfessionId
snapshot.js wire format (UnitInfo, snapshot tuples, flags, anim codes)
simdata.js data access + normalisation (data/*.json, research fallback)
content/index.js installContent / setupUnitKit / registerAllMeta
content/generic.js generic kit from skill blackboards
content/kits/tier1..6.js, content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js (content phase)
```
---
## 1. Driving a battle (match owner)
```js
import { Battle } from './sim/Battle.js';
const b = new Battle({
seed, kind: 'normal' | 'unite' | 'boss' | 'hidden', modeId, round,
stageId | stage, // data/stages.json id or entry
rect, // optional; defaults: normal rows 9–12 × cols 0–10, unite cols 0–20, boss rows 0–5 × cols 0–20
timeLimit, // game seconds (the match passes 2 × the round's combatTimeLimit, which data gives in
// real seconds of the forced-2× battle — DESIGN §4); boss/hidden default Infinity (match force-ends)
players: [PlayerBattleInput], // DESIGN §5.1
spawns: [SpawnSpec], routes: [RouteSpec],
sharedBoss, // { hp, maxHp, damage(playerId, amount) } or null
flags: { layerGainsEnabled, dpInit: 10, dpPerSec: 1, dpMax: 99 },
fieldId, // echoed in snapshots
enemyOverrides, // waves.json `overrides` ({ [enemyKey]: { stats: {…partial} } })
// optional: data (DataSource or raw maps), content ('full'|'generic'|'none'), kits ({baseId: kitFn}),
// extraContent ([{install(battle)}]), setup(battle) (runs after content install, before deployment),
// recordEvents (default true), autoFinish (default true), logger, quiet, verbose, devices (false = no stage crates)
});
while (!b.finished) b.step(); // 1 TICK = 1/30 game s; live battles run at the forced 2× = 60 ticks per real second
b.snapshot(); b.drainEvents(); // every 3 ticks for watchers (§8.2 wire format)
b.result(); // BattleResult (DESIGN §5.1) — see §1.3
b.forceEnd('forced' | 'timeout'); // 'timeout' converts remaining enemies to leaks
b.fieldMeta(); // { fieldId, kind, rect, stageId, units: UnitInfo[] } for m.field
```
Construction creates every ally unit (not yet deployed) and installs content (kits, then domain modules). The first
`step()` (or `b.start()`) spawns stage crates, deploys all board units for free — per player, **top→bottom then
left→right** (right boss side: right→left in field columns) — firing `deploy {initial:true}` for each, then
`battleStart`. Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`).
**Tick order:** scheduled callbacks → spawns → DP → buffs (+HP regen) → enemies (attack, move, block) →
enemy tile index → allies (skill tick, attack) → projectiles → auto-redeploys → boss pool sync → `tick` hook →
release hooks/timers of units removed this tick (§1.4) → `time += TICK` → end checks.
### 1.1 Coordinates (PlayerBattleInput units)
`units[].row/col` are **board coordinates** (rows 9–12, cols 2–10) unless `abs: true` (or player
`coords: 'field'`). Mapping:
| field | row | col | direction |
|---|---|---|---|
| normal | row | col + colOffset (0) | unit `dir` (default RIGHT) |
| unite | row | col + colOffset (0 or 8) | unit `dir` |
| boss/hidden, side L | row − 7 when row ≥ 7 (board 9–12 → boss 2–5) | col + colOffset | unit `dir` |
| boss/hidden, side R | same | **20 − col** (mirrored; colOffset ignored) | **mirrored**: RIGHT ↔ LEFT, UP / DOWN unchanged (`battle.mapDir`) |
`rowOffset` on the player overrides the boss row mapping. Unit input `dir` ∈ `'UP'|'RIGHT'|'DOWN'|'LEFT'` (the board
piece's facing, research 09 §1.2; absent/junk ⇒ RIGHT; with `abs` / `coords:'field'` it is a field direction as given).
Token pieces: `{ kind:'token', tokenId, ownerUid, row, col, dir? }`.
**Facing (DESIGN §3, `sim/dir.js`).** Every ally has `unit.dir`; `unit.fwd` = its forward vector `[dRow, dCol]` (UP
`[1,0]`, RIGHT `[0,1]`, DOWN `[−1,0]`, LEFT `[0,−1]`; row 0 is the bottom). Range grids and every relative offset
`[dRow, dCol]` are authored facing RIGHT and rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)`
(`rotateOffset`; the inverse `toLocal` turns an absolute delta into the unit's frame); `rangeExtend` adds tiles along
+dCol **before** rotating. `unit.facing` (±1) survives only as the derived horizontal sign for sprite flipping (a legacy
`facing = ±1` assignment sets RIGHT / LEFT). Content rules: "身前 k 格" = `frontOf(r, c, dir, k)` (`support.frontTile`);
"左右两格" = `offsetTile(r, c, ±1, 0, dir)` (`support.sideTiles`); kit pushes / pulls use `unit.fwd`; blowers compare
directions (same ⇒ `equal`, reverse ⇒ `opposite`, perpendicular ⇒ `vertical`); board-position rules ("更靠左", "最右边",
"同一行最右边") stay board positions in the player's own frame (mirrored FA side: counted from the field's other end),
independent of the units' directions. Enemies keep their own horizontal facing logic (content/enemies.js frontGuard).
"First tile" tie-breaks relative to a unit (tactical points `findTacticalPoint`, summon tiles `findSummonTile`, the 突袭
landing tile) compare offsets in the unit's facing-RIGHT frame (`localOrder` / `localBefore`; for a RIGHT unit exactly
the old tile-key order), so a rotated layout plays the same (test/sim/facing-invariance.test.js: every chess × 4
directions on an open field). 余 S3's fire wall runs through his tile perpendicular to his direction (his column facing
RIGHT / LEFT, his row facing UP / DOWN; fx `firewall.axis` = `'col'|'row'`).
`carryState: { hpPct, sp, skillActive }` restores unite helpers (HP ratio, SP; `skillActive` restarts a timed skill for a
fresh duration/ammo **without spending a charge** — `unitsEnd` reports `sp: 0` while a skill runs, so pass it through as is).
### 1.2 Enemies, routes, ownership
`SpawnSpec = { time, enemyKey, routeIndex, count=1, interval=0, mods:{hpMul,atkMul,defMul,resMul,speedMul}, sourcePlayerId?,
bounty?:{coins, ownerPlayerId}, tag?:'boss'|'part'|'escort'|'bounty', ownerPlayerId?, pos?:[r,c], route?:RouteSpec, countInTotal? }`.
`RouteSpec` accepts data/waves.json routes (`{motion, start, end, checkpoints:[[r,c]…], steps:[{t:'move',p},{t:'wait',s},{t:'disappear'},{t:'appear',p}]}`)
and research routes (`{m, s, e, cp:[['MOVE',r,c]…]}`). `spawnsFromTemplate(waveEntry, {mods})` (simdata.js) converts a
template into `{ routes, spawns, maxPlayTime, overrides, extraRoutes }` (non-spawn `action` entries are skipped; `unharmful`
and `tag:'part'` spawns don't count in `total`).
WALK legs pathfind on the stage grid inside the rect with the official flow field (grid.js: 4-direction SPFA from the
destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). Among equal-length routes the
one crossing the fewest non-blockable tiles (floor / gate lanes) wins, and a smoothed segment never cuts across floor
the grid route does not walk (user playtest #2 item 2; test/sim/pathing-blockable.test.js). Enemies re-path whenever
an obstacle changes (`grid.version`), after a displacement and when 诱导 ends; the smoothed chain is only line-of-sight
clear from a tile centre, so an enemy re-planning off-centre first steps back to its tile centre when the straight line
to its first waypoint would cut a tile it cannot walk or a crate (a pushed enemy never clips a fence corner or breaks
the decorative crate on it — act1 m03 (11,5); test/sim/pathing.test.js). If crates cut every path the enemy walks through them, gets blocked by the crate
and destroys it. FLY legs fly straight between checkpoints. `disappear` hides
the enemy (untargetable, not in snapshots), `wait` pauses, `appear` teleports.
**Enemy attacks:** blocked melee enemies hit their blocker; enemies with `rangeRadius > 0` **and `applyWay` ≠ `MELEE`**
attack allies within the radius (a MELEE enemy only ever hits its blocker — data/enemies.json already zeroes their
`rangeRadius`, the engine enforces it for any source; content may set `enemy.profile.melee = false`) (blocker → highest taunt → latest deployed; stealthed and `untargetable` allies are skipped for ranged shots)
and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Crates (stage devices with
role `crate`, 100 HP) are ground obstacles; an enemy forced through one is blocked by it and destroys it. A device is
present when data/stages.json says `active: true` (this wins over the level file's `hidden`: act1 m02's crates);
research stages without `active` use `!hidden`. Active platforms/mounds (射击台, act1 m03) [ASSUMED, DATA §15.11] are
ground obstacles, and an operator standing on one is elevated (`unit.ground = false`: never blocks).
**Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the
player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is
recorded for the player whose half contains the goal it reached, with `sourcePlayerId` preserved for unite LP.
### 1.3 BattleResult
```js
{ time, reason: 'cleared'|'timeout'|'forced', killed, total, errors, unspawned?: [{enemyKey,time,tag}], bossHpLeft?,
perPlayer: { [playerId]: { killed, total, perfect,
leaked: [{ enemyKey, mods, lpr, sourcePlayerId, tag, counted, boss?, spawned }],
layerGains: {bondId: n}, coins, damageDealt, bossDamage, healingDone, deaths,
unitsEnd: [{ uid, id, defId, hpPct, sp, skillActive, alive }],
unitStats: [{ id, uid, defId, name, kind, dmg, kills, heal, taken, attacks }] } } }
```
- `cleared`: no spawns pending and no enemy alive (or the shared boss pool reached 0). A boss/hidden battle **with a
`sharedBoss`** ends only when the pool reaches 0 or on `forceEnd` (DESIGN §5.5) — an emptied field keeps running
(the h07_04 pair leader has no wait step and can walk into the objective: the leak costs its `lpr`, then the field
idles while another field may still empty the pool or the match's overtime drain — team LP, from 150 real s — ends it); without a pool
(sandboxes, tools) it still ends `cleared` when empty. `timeout`: `time ≥ timeLimit` —
every living non-boss enemy becomes a leak; spawns that never happened are dropped from `total` and listed in `unspawned`.
- `leaked[].counted = false` for `notCountInTotal`/`unharmful`/boss parts (LP rules: normal rounds count only `counted`
leaks, cap 10; boss rounds use `lpr`). `perfect = no counted leak`.
- `battleEnd {result}` fires before the result is frozen: handlers may still call `addLayers`/`addCoins`.
### 1.4 Robustness
Every content callback (hooks, buffs, timers, kits, projectiles, skill callbacks) runs inside try/catch: the error is
logged once per (label, unit, message) into `battle.errors` and the battle continues. Engine-phase errors
(`internal:*`) are counted separately; after 200 of them the battle force-ends as a timeout. `step()` never throws.
Guarantees content can rely on (pinned by `test/sim/robustness.test.js`):
- **Re-entrancy.** `forceEnd()` called while a step runs (from any hook/callback) is deferred to the end of the current
phase: the remaining phases are skipped, `time` does not advance and the result is built once (never mutated later).
Enemies/units created by hooks during the enemy loop or the buff pass start acting next tick. A skill `onEnd` that runs
because its unit is dying cannot redeploy it (use the `death` hook). `gainSp` / `dealDamage` re-read cost and stats
after their `spGain` / `hit` hooks.
- **Recursion.** Hook emits and content callbacks share one nesting counter; deeper than `MAX_HOOK_DEPTH` (32) the
handler/callback is skipped and a `hookDepth:*` content error is logged (deterministic — no stack overflow). Its message
lists the open frames (`chain: hit(enemy_x→chess_y) > damaged(chess_y) > …`) so the looping pair is named; see the
re-entrancy rule in §5. A tripped guard is always a content bug, never a limit to raise.
- **Bad numbers.** Helpers sanitize their inputs: DP flags and non-boss `timeLimit` (0/NaN ⇒ 60 s) fall back to defaults;
unit rows/cols are coerced to integers (junk ⇒ unit skipped); `spawnEnemy` mods must be finite ≥ 0 (else ×1), the spawn
point is clamped into the rect; `spawnDevice`/`spawnToken`/`relocate` accept only integer in-rect free tiles
(`spawnToken` returns null and leaves nothing behind when the tile is busy, `relocate` only moves living deployed allies,
`redeploy {tile}` refuses a non-integer / out-of-rect / occupied tile without falling back to the home tile);
non-finite `hp`/`stats`/`duration`/`extend`/`addAmmo`/`addCharge`/`addLayers`/`addCoins`/`spCostMul` values are ignored;
aggregated stats that overflow (stacked `*Mul`) fall back to base values; a revive written by a `kill` handler is
clamped to max HP (NaN/≤ 0 ⇒ the unit dies with hp 0); projectiles with non-finite coordinates are re-aimed at their
origin; a NaN shared-boss pool shows the boss at full HP and counts 0 damage.
- **Runaway content.** At most `MAX_ALIVE_ENEMIES` (600) living enemies per field (`spawnEnemy` returns **null** beyond —
content must handle it); a SpawnSpec `count` is capped the same way and `time: Infinity` spawns are never scheduled.
- **Movement.** Route legs outside the rect are clamped onto it (h07_01's extra fly route runs along row 6, outside the
boss rect: without the clamp the flyer hovered at the border forever). An enemy that gains `unblockable`/`levitate`
through a plain buff is released by its blocker on its next update.
- **Listener hygiene.** When a unit is removed for good (enemy killed/leaked, token expired or dead, device destroyed,
permanent retreat) its hooks and periodic `every` timers registered with `{ owner: unit }` are dropped at the end of
that step — after its own `death`/`enemyLeak` handlers ran; one-shot `after` callbacks still run. Register global
handlers without an owner (or with a longer-lived owner) if they must outlive the unit.
---
## 2. Units
`Unit` fields: `id, side ('ally'|'enemy'), kind ('op'|'token'|'enemy'|'device'), defId, def (normalised), name, ownerId
(playerId), uid (board piece), ownerUnit (tokens), x, y (floats; x = col, y = row), tileR, tileC (allies), homeR, homeC
(board tile: auto-redeploys land there), dir ('UP'|'RIGHT'|'DOWN'|'LEFT'; getters `fwd` = forward vector, `facing` =
horizontal sign ±1 for sprites only), hp, alive, deployed, removed, hidden, base {…}, buffs[],
rangeKeys / rangeKeySet (current range, absolute tile keys `r × 21 + c`), baseRangeKeys (initial range, §7.1),
extraRangeKeys (content extra targets, `battle.setExtraRange`), blocking[] (allies), blockedBy (enemies), motion
('WALK'|'FLY' enemies), profile, skill (SkillRuntime), kit, items (itemIds), lpr/mods/tag/bounty/sourcePlayerId (enemies),
stats {dmg,kills,heal,taken,attacks}, mem {} and trait {} (free scratch space), persist {redeployMul, …}.`
Getters: `s` (aggregated stats), `maxHp`, `atk`, `hpRatio`, `sp`, `spMax`, `canAct`, `isFlying`, `statusFlags` (UF bits),
`dmgType`, `weight`.
`unit.s` (lazy, recomputed after any buff change): `maxHp, atk, def, res, aspd, bat, interval, blockCnt, moveSpeed,
rangeExtend, baseRangeExtend (its permanent part: persist + never-expiring buffs), massLevel (base + ΣmassFlat, ≥ 0 —
`unit.weight`), maxTargets (+n), taunt, dodgePhys, dodgeArts, defIgnoreFlat/Pct, resIgnoreFlat/Pct, dmgDealtMul,
physDealtMul, artsDealtMul, dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, elemTakenMul, healingDealtMul,
healingTakenMul, atkScaleMul, spRecovery, spCostFlat, redeployMul, hpRegen, shield, flags{…}`.
Aggregation: `ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul`; `res = clamp((base + ΣresFlat) × ΠresMul, 0, 100)`;
`aspd = clamp(base + Σaspd, 10, 600)`; `interval = bat × (1 + ΣbatPct) × 100 / aspd`; `moveSpeed = (base + ΣmoveFlat) × ΠmoveMul`;
tiles/s = `moveSpeed × MOVE_SCALE (0.5)`. A maxHp change keeps the HP ratio. Elite stats (module included) come from data.
---
## 3. Buffs, mods, statuses
`battle.addBuff(unit, { key, duration=Infinity, refresh='replace'|'extend'|'stack'|'independent'|'keep', stacks, maxStacks,
mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, shieldHits, persist, visible, data, allowDead })`
- `replace`: new instance replaces the old; `extend`: keep the longer remaining time, take the new mods; `stack`: +stacks
up to maxStacks, timer reset; `independent`: separate timers, at most `maxStacks` alive (oldest dropped); `keep`: ignore.
- Additive mods scale with stacks (`value × stacks`), `*Mul` mods multiply (`value ^ stacks`).
- `onTick({battle, unit, buff, dt})` every tick, or every `interval` s. `persist: true` survives death/redeploy.
- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated.
- `visible: true` emits `['status', id, key, 1/0]` client events. `battle.removeBuff(unit, key|buff)`.
**Mod keys** — additive: `atkFlat atkPct defFlat defPct hpFlat hpPct resFlat aspd batPct blockCnt rangeExtend
defIgnoreFlat defIgnorePct resIgnoreFlat resIgnorePct dodgePhys dodgeArts spRecoveryFlat maxTargets taunt hpRegen
hpRegenRatio spCostFlat moveFlat massFlat` (重量 levels: 失重 = `massFlat: −1`; never edit `base.massLevel`);
multiplicative: `atkMul defMul hpMul resMul moveMul dmgDealtMul dmgTakenMul physTakenMul artsTakenMul trueTakenMul
elemTakenMul healingDealtMul healingTakenMul spRecoveryMul redeployMul atkScaleMul physDealtMul artsDealtMul`.
A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also widens the initial range (§7.1).
**Flags:** `stun freeze sleep silence disarm stealth invulnerable unblockable levitate fear cold reveal bind noHeal
untargetable blockFly noMove noSp burstLock hidden attract`. `taunt: true` as a flag counts as +1 taunt level (DESIGN §5.3).
**Statuses** — `battle.applyStatus(target, key, { duration, source, value, force, refresh, point })` (returns true if
applied); honours enemy/op immunities (`stun`, `silence`, `sleep`, `frozen`, `levitate`, `feared`) unless `force`; fires
`beforeStatus` (cancellable; handlers may also change `ctx.duration` / `ctx.value`), then applies 抵抗 (`resist`, below)
and the 浮空 weight rule, then `statusApplied { source, target, status, duration (final), value, entered }` — `entered`
= the target carried no buff of that status before (a refresh / a weaker "取最高" application is not an entry: "进入…时"). Effects follow the official term table
(`gamedata_const.termDescriptionDict`, `ba.*`). Same-key statuses refresh to the longer duration, except the
"同名效果取最高" ones marked *strongest* below: the strongest value wins, a weaker application never overrides it and,
if it outlasts it, resumes when the strong one expires (pass `refresh` to opt out).
| key | effect | value |
|---|---|---|
| `stun` | cannot act / move; **a stunned operator blocks nothing** (its blocked enemies walk on) | – |
| `freeze` | stun; **enemies** also RES −15 | – |
| `cold` | ASPD −30; a 2nd cold while cold ⇒ `freeze` 3 s (unless frozen-immune) | – |
| `sleep` | 无敌且无法行动: inactive, untargetable, **takes no damage** (unless the attacker profile has `hitSleep` or the damage `ignoreSleep`), blocks nothing | – |
| `slow` | moveMul 1 − value (*strongest*) | default 0.5 |
| `sluggish` (停顿) | moveMul 0.2 | – |
| `bind` (束缚) | cannot move | – |
| `fragile` / `artsFragile` / `physFragile` / `elemFragile` | taken ×(1+value) (*strongest*) | 0.3 / 0.3 / 0.3 / 0.2 |
| `silence` | no skill activation | – |
| `fear` (恐惧) | 无法被阻挡并四散逃跑: enemy cannot attack, is unblockable (released) and does not advance | – |
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed) | stacks, default 1 |
| `disarm` | no normal attacks | – |
| `stealth` / `reveal` | untargetable unless blocked / cancels stealth | – |
| `invulnerable` | ignores damage | – |
| `levitate` (浮空) | stun + unblockable (unblocks enemies); **half duration on units with (current) massLevel > 3** | – |
| `attract` (诱导) | 无法被阻挡并向目标位置移动: unblockable (released); the engine walks it (own speed, grid path re-planned on obstacle changes; flyers straight) to `opts.point` (`[r, c]` or `{x, y}`, default the source's tile, clamped to the rect) and keeps it there; stun/bind/sleep stop it; its route re-plans from where it stands when the status ends. A new application moves the point | point |
| `resist` (抵抗) | the control statuses of `RESIST_STATUSES` (晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速) applied to the unit last ×(1 − value); a resisting unit loses one 麻痹 stack every 5 s. **同名效果不叠加** — `battle.resistOf(u)` = the strongest `status: 'resist'` buff (never a product): several sources (灵知, 流明, 寒檀, enemy talents) never compound. *strongest*; a permanent one that survives death is a `persist` buff with `status: 'resist', data: { value }` | 0.5 (≤ 0.95) |
| `taunt` | taunt level +value | 1 |
| `weaken` | atkMul 1 − value (*strongest*) | 0.3 |
| `aspdDown` | aspd + value (*strongest*) | −30 |
| `defDown` / `resDown` | defMul 1 − value / RES −value (*strongest*) | 0.3 / 20 |
Unknown keys become a flag buff `{ [key]: true }`. Flags `noBlock` (blocks nothing) and `tremble` exist for custom buffs;
a custom buff with `flags.sleep` also blocks nothing and is untargetable/invulnerable like the status.
**Element gauges** (`unit.elem = {burn, neural, apoptosis, erosion, necrosis}`; capacity `unit.gaugeMax` = 1000, enemy
leaders (rank BOSS / boss units) 2000): deal `{ type:'element', element, amount }` (fires `elementHit` first; the gauge
gain is × `elemTakenMul` × (1 − target `epDamageResistance`)). A full gauge bursts with the official effects, which
depend on the side hit (constants.js `ELEMENT`):
| element | operator hit by enemies (ba.dt.*) | enemy hit by operators ("·我方" ba.dt.*2) |
|---|---|---|
| `burn` 灼燃 | 1200 arts + RES −20, 10 s lock | 7000 元素伤害 + RES −20, 10 s lock |
| `neural` 神经 | 1000 true + stun 10 s (10 s lock) | 6000 元素伤害 + 3 `palsy`, 10 s lock |
| `apoptosis` 凋亡 | 15 s: no skill activation, −1 SP/s, 100 arts/s | 15 s: 50 % weaken recovering over the burst, 800 元素伤害/s |
| `erosion` 侵蚀 | permanent DEF −100 (stacking `erosionDown`) then 800 phys; no lock | permanent DEF −120 then 5000 元素伤害, 8 s lock |
| `necrosis` (legacy spare gauge) | 12 s: 100 true/s, ATK −20 % | same |
The gauge stays full during the lock (`<el>Burst` buff) and resets when it ends. A burst that is still resolving counts
as locked too (`unit.burstPending[el]`, `burstLocked(unit, el)` in damage.js): the `elementBurst` hook fires before the
lock buff exists (and operator erosion has no lock), so same-element fills of that unit are refused until the burst has
resolved — an `elementBurst` handler that spreads the element to neighbours (淤困 parasite) cannot bounce it back into
a second burst. `battle.reduceElement(unit, amount, el?)` skips locked gauges.
**元素伤害** (element HP damage, e.g. "每秒受到…元素伤害") is the DamageInfo type `'elemental'` (+ optional `element` for the
client colour): no DEF/RES/dodge, × source `dmgDealtMul` × target `dmgTakenMul` × `elemTakenMul`, shields absorb it.
---
## 4. Damage & heal pipeline
`battle.dealDamage(source, target, dmg)` → HP removed. `DamageInfo = { amount, type:'phys'|'arts'|'true'|'elemental'|'element',
element?, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul=1, canDodge (phys/arts), isSkill, isSplash,
isAttack, attackId, ignoreSleep, tags[], cancel }` (`battle.makeDamage(d)` normalises). `attackId` is the same for every
damage instance of one normal attack (all targets, splash, chain, projectile impacts; 0 for non-attack damage) — use it
for "本次攻击" procs that must roll once per attack. **Dodge** from several buffs rolls independently: the unit's
`s.dodgePhys` = 1 − Π(1 − pᵢ) (a single source keeps its exact value).
Order: invulnerable / asleep? → **`hit`** (mutate `dmg`, set `dmg.cancel`) → dodge (`rng()`) → mitigation (phys
`max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %A)`, arts `max(A×(1 − R′/100), 5 %A)`, true = A; source ignore
mods are added) → × source `dmgDealtMul` (× phys/artsDealtMul) × target `dmgTakenMul` × type-taken mul × `dmg.mul` →
shields → HP loss (boss units: routed to `sharedBoss.damage(playerId, amount)`) → if HP ≤ 0: **`fatal`**
(`ctx.prevented = true` keeps the unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`.
`battle.heal(source, target, amount, { overheal=false, self, silent })`: no-op on `noHeal` targets (unless self);
× source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; `overheal`
turns the excess into an `overheal` shield. `battle.loseHp(target, amount, {source})` = HP loss ignoring DEF/RES/shields/dodge (流失).
---
## 5. Hook bus
`battle.on(name, fn(ctx, battle), { priority=0, owner, once })` → handle; handlers run in descending priority, then
registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offOwner(owner)` (also cancels timers);
`battle.emit(name, ctx)` (custom events allowed); `ctx.stopPropagation = true` stops later handlers.
| name | ctx | notes |
|---|---|---|
| `battleStart` | `{}` | after the initial deployment |
| `deploy` | `{ unit, initial }` | ops/tokens (initial & redeploy), enemies (`initial:false`), devices |
| `tick` | `{ dt }` | end of every tick |
| `beforeAttack` | `{ attacker, targets, isSkill, profile }` | allies **and** enemies; replace/filter `ctx.targets` |
| `attack` | `{ attacker, targets, isSkill }` | an attack/heal was performed (projectiles may still be in flight) |
| `hit` | `{ source, target, dmg }` | before mitigation; mutate `dmg` (not fired for gauge fills — see `elementHit`). `source` may be null (terrain, bursts) |
| `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` |
| `damaged` | `{ source, target, amount, type, dmg }` | after application (`amount` may be 0 when shielded); element fills too |
| `heal` | `{ source, target, amount, opts }` | mutable `amount` |
| `fatal` | `{ unit, source, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items) |
| `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) |
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired', killer }` | unit removed |
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart |
| `ammoUsed` | `{ unit, left, skill }` | per ammo consumed |
| `spGain` | `{ unit, amount, reason:'time'|'attack'|'hurt'|'init'|…, skill }` | mutable `amount` (time gains fire every tick) |
| `beforeStatus` | `{ source, target, status, duration, value, cancel }` | set `cancel` (e.g. 浓缩嗅盐) |
| `statusApplied` | `{ source, target, status, duration, value, entered }` | after immunity check; `duration` = final (after 抵抗 / weight); `entered` = newly started (not a refresh) |
| `blocked` | `{ blocker, enemy }` | enemy became blocked |
| `enemySpawn` / `enemyLeak` | `{ enemy }` | |
| `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused |
| `dodge` | `{ source, target, dmg }` | an attack was dodged |
| `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …); `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) |
| `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` |
| `battleEnd` | `{ result }` | may still add layer gains / coins |
**Re-entrancy rule for content.** A handler that deals damage from `hit`/`damaged` (counters, reflection, sharing,
"bonus damage on hit") must not react to its own output or to other reactive damage, or two such effects ping-pong
until the nesting guard trips (the guard then skips *every* nested handler, including `kill`/`death` bookkeeping of
content). Respond only to `dmg.isAttack` (normal attacks), tag your damage (`tags: ['counter']`) and skip tagged damage,
or guard with a per-unit flag while dealing it. When the guard trips, the logged error names the open frames
(`chain: hit(enemy_x→chess_y) > damaged(chess_y) > …`).
---
## 6. Engine helpers (content must use these)
| helper | notes |
|---|---|
| `dealDamage(src, tgt, dmg)`, `heal(src, tgt, amount, opts)`, `loseHp(tgt, amount, {source})` | §4 |
| `applyStatus(tgt, key, {duration, source, value, force, point})`, `removeStatus(tgt, key)`, `resistOf(unit)` | §3 |
| `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 |
| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise |
| `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) |
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data |
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect or on a living unit |
| `isReservedTile(r, c)` | true when a living unit stands there or it is the home tile of an ally piece that has not deployed yet / waits to redeploy — summon tile pickers must skip these (`findTacticalPoint` does) |
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir` |
| `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | |
| `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss) |
| `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` |
| `mapTile(ps, row, col, abs?)` / `mapDir(ps, dir, abs?)` | board → field tile / direction of a player (the FA right-side mirror) |
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); `tile: [r, c]` lands on that tile once (home unchanged — later redeploys use the board tile; refused when off-rect or occupied, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 revive in place |
| `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 |
| `displace(enemy, {x, y}, tiles, {force})` | push/pull along passable tiles; heavier (massLevel) enemies move less; unblocks + re-paths |
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random |
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
| `findTacticalPoint(unit)`, `groundPathTiles()` | tactical point (战术点, tactician 援军 / talent tokens): a free (`isReservedTile`) walkable tile of the initial range **on an enemy ground path first** (`groundPathTiles`: grid paths of every non-FLY route, cached per grid version), then nearest (Chebyshev, rows break ties), then the lowest tile key |
| `effectiveProfile(unit)`, `reduceElement(unit, amount, el?)` | |
| `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})`, `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) |
| `battle.data` | DataSource: `getChess(id, loadout?)`, `getEnemy(key)`, `getToken(id, ownerChessId, ownerLoadout?)`, `getStage(id)`, `getWave(id)` (normalised defs; raw record in `def.raw`; the per-battle loadout view, §12) |
---
## 7. Skills
### 7.1 Runtime rules (skills.js)
- SP types: `time` (+`s.spRecovery`/s), `attack` (+1 per attack), `hurt` (+1 per hit taken); starts at `initSp`; **no SP
gain while a duration/ammo/toggle skill is active, while stunned, or with the `noSp` flag**. Attack-type SP: attacks made
by the skill (the pending "next attack" of an instant/charge skill, every shot of a timed skill including the one that
ends it) recover nothing, so a cost-N skill fires every **N+1** attacks (AK). Charges (`maxChargeTime > 1`):
SP fills to cost → +1 charge (SP restarts) until charges are full (then SP stays full).
- Triggers (`skill.trigger.rule` in data): `DEFAULT` — ready **and** about to attack/heal **and** an enemy (heal skills:
an injured ally) inside the **initial** range (`unit.baseRangeKeys`: its own grid + its permanent rangeExtend —
"攻击范围扩大" modules/talents as persist never-expiring `rangeExtend` buffs; no skill range, no temporary extend, no extra
keys) — or, checked **every tick**, an enemy (flyers included) inside a content trigger range
(`unit.skill.addTriggerRange(fn)`, `fn(battle, unit)` → list of ally units (their current range) or tile-key arrays;
returns an unregister fn; not for heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形); `TAKE_DAMAGE` — ready and just hit; `SP_FULL` (`ALWAYS`) — immediately;
`CUSTOM_RANGE` — an enemy inside `trigger.customRangeGrid` (rotated by the unit direction like every grid); `SEARCH` — an enemy inside the INITIAL range, checked every tick (no attack needed; BWIKI "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放", research 03 Addendum C1);
`NEVER` (alias `MANUAL`) — never auto-casts: the kit calls `unit.skill.activate(reason)` itself;
`GDGLOW_SKILL_2`, `MLYSS_WTRMAN` and any unknown rule behave like DEFAULT. Units that never attack (bard, phalanx,
librator) check DEFAULT every tick. Silence blocks activation. `gainSp` is ignored while a duration/ammo/toggle skill
runs (its bar shows the skill), whatever the reason.
- Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap),
`instant` (onStart + optional one-shot attack override applied to the next attack), `charges` (instant with charges),
`passive` (always on from deployment, no SP), `toggle` (stays on until death once activated).
- `end()`: `active` turns false, then **onEnd runs while the skill's mods / range are still applied** (finishers and
end bursts use the skill's stats and range), then they are removed (kept when onEnd re-activated the skill), then
`skillEnd` fires. `onAttack` ctx carries `noAmmo` (set it to true: this attack spends no bullet, no `ammoUsed` — 流明's
free heals).
- Runtime helpers on `unit.skill`: `activate(reason, {free})`, `end(reason)`, `stop()`, `addAmmo(n)`, `extend(s)`,
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn)`; fields `sp, spCost (= floor(base×spCostMul + spCostFlat)), spCostMul, charges,
maxCharges, active, timeLeft, ammoLeft, activations, kind, rule, bb`.
### 7.2 Kits
```js
// server/sim/content/kits/tierN.js
export default {
[baseChessId]: (bb, chess, def) => Kit, // bb = flattened blackboard of the SELECTED skill (normal Lv4 / elite Lv7)
}; // chess = the record as the unit's loadout makes it (simdata loadoutRecord),
// def = normalised def (def.skill = selected skill, def.talents[].bb, def.loadout)
Kit = {
skill: SkillSpec | null, // the chess's DEFAULT skill; null ⇒ unit never casts
skills: { [skillId]: SkillSpec }, // optional: specs of the other selectable skills (DESIGN §16 operator loadout)
talents: [{ install(battle, unit) {} }], // hooks with { owner: unit }; check unit.alive in handlers
trait: { ...profile overrides }, // e.g. { priority: 'lowDef', maxTargets: 2, splashRadius: 1.2 }
install(battle, unit) {}, // optional extra per-unit setup (runs under EVERY selected skill)
}
```
Keys: the data `baseId` (`chess_char_1_01_a`, also used for the elite `_b`), the exact chess id, or the suffix-less id of the
DESIGN §5.6 example (`chess_char_1_01`) — all three are looked up.
Missing kit ⇒ `content/generic.js` builds one from the blackboard (§7.4), so every chess always fights.
Operator loadouts (DESIGN §16): the unit's def is `getChess(chessId, { skillIndex, moduleId })` of its PlayerBattleInput
entry (no loadout fields = the default). The skill spec is `kit.skills[selectedSkillId]` when authored, else `kit.skill`
only when the selected skill is the default one, else the GENERIC spec of the selected skill (its generic install is
chained after the kit's; `kit.skillSource` = 'skills' | 'generic', absent for the default spec) — talents, trait and
`install` always come from the kit, so keep default-skill logic inside `skill` (or check `unit.skill.id`). Under a
non-default skill `spawnToken` refuses summons that skill does not make (琳琅诗怀雅 S1/S3: no 香槟炸弹), and the
tokens.js skill-summon fallback (a table of skill summons, e.g. 迷迭香 S3's two 战术装备) runs while the selected skill
has the generic spec. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnToken(unit, …)` resolve the owner's loadout
(token skill of that slot: `variants[owner].bySkill[i]`, module attributes: `.byModule[id]`). Coverage of the selectable
skills: `node tools/kit-coverage.mjs --missing [--tier N] [--strict]`.
### 7.3 SkillSpec schema
```js
{
kind: 'duration'|'ammo'|'instant'|'charges'|'passive'|'toggle',
duration, // s (duration kind; optional cap for ammo)
ammo, // attacks (ammo kind)
spCost, initSp, charges, spType: 'time'|'attack'|'hurt', // optional overrides of the data values
trigger: 'DEFAULT' | { rule, grid }, // optional override
heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer)
mods: { …mod keys }, // buff while active (instant: only during the pending attack)
flags: { …flags },
targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly },
attack: { dmgType, atkScale, healScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul,
chain: {count, falloff, radius, sluggish}, heal: {mode, count, falloff}, onHitStatus: {key, duration, value},
onHit(ctx), // once per attack (main target; target may be null for a splash landing)
onEachHit(ctx) }, // per victim: main, every splash / chain victim — ctx += { dealt, kind:'main'|'splash'|'chain', isSplash, isChain, main, attackId }
// (overrides the profession profile while active; profiles may define onEachHit(b, u, victim, hctx) too)
onStart(ctx), onEnd(ctx) /* mods & range still applied */, onHit(ctx), onAttack(ctx) /* ctx.noAmmo */, onTick(ctx),
}
ctx = { battle, unit, skill, bb, target?, dealt?, heal?, targets?, dt?, reason?, x?, y? }
```
### 7.4 Generic kit (content/generic.js)
Kind: PASSIVE (or a free skill without duration/ammo) ⇒ passive; AMMO ⇒ ammo; duration > 0 ⇒ duration; duration < 0 ⇒
ammo if an ammo key exists, **toggle only when the text says 持续时间无限** (史尔特尔) — the data also uses −1 for instant/charge
skills (塑心, 妮芙, 夕, 流星, 莱恩哈特 …); else instant (charges when maxChargeTime > 1).
Keys → spec (stat keys prefer the plain key, attack/targeting keys prefer `attack@`: 薄绿 hits for `attack@atk_scale` 1.1,
its `atk_scale` 2.1 is the end burst): `atk→atkPct, def→defPct, max_hp→hpPct` (values > 5 are flat: 史尔特尔 +5000 HP),
`attack_speed→aspd, base_attack_time→batPct, magic_resistance→resMul` (|v| < 1, "法术抗性+60%") or `resFlat`,
`damage_scale→dmgDealtMul, block_cnt→blockCnt, taunt_level→taunt, damage_resistance→dmgTakenMul (1−v)`, positive
`hp_recovery_per_sec(_by_max_hp_ratio)→hpRegen(Ratio)`, `sp_recovery_per_sec→spRecoveryFlat, magic_resist_penetrate_fixed→
resIgnoreFlat, def_penetrate_fixed→defIgnoreFlat, ability_range_forward_extend→targeting.rangeExtend, max_target→
targeting.maxTargets, atk_scale→attack.atkScale, heal_scale→attack.healScale, times→attack.hits, attack@range_radius→
attack.splashRadius, trigger_time/ammo→ammo`.
Text-aware rules: negative atk/def/RES described on enemies debuff the **targets** (aura over the range while a timed skill
runs — 初雪; on hit for bb.duration otherwise — 流星, 莱恩哈特); statuses (`stun cold sleep fear sluggish root unmovable`, ×
`prob`): `attack@` keys on hit, plain keys on hit for instant/charges but **once at start** on ≤ max_target enemies in range
for timed skills (小满), a plain `stun` described "…结束后…晕眩" = self-stun at the end; "立即…造成…" / "技能结束时…造成…" =
one AoE burst of the plain atk_scale at start / end (乌尔比安, 薄绿); "额外造成攻击力N%…" is bonus damage, never the attack
scale (烛煌); "停止攻击…" (duration skills) ⇒ `attack.noAttack` (泡泡, 蛇屠箱, 凯瑟琳, 菲莱, 铃兰); "受到攻击时…攻击力/防御力N%的X伤害"
⇒ counter-damage while the skill is active (星熊, 泡泡; 周围 ⇒ AoE, `aoe_cd` cooldown: 菲莱) via `kit.install`; a non-healer
whose skill "恢复…友方/友军…生命" heals the most injured ally in range on each hit (古米, 瑕光; 所有友军 ⇒ all: 塞雷娅);
`ep_damage_ratio` + "附带…凋亡/灼燃/神经损伤" ⇒ element damage on hit (凋亡 → `apoptosis`; × damage dealt when the text says
"伤害N%的…损伤", else × ATK); "屏障" + `shield_max_hp_ratio`/`hp_ratio` ⇒ decaying self shield at start (砾, 新约能天使);
"立即流失N%当前生命" ⇒ self HP loss at start (宴, 风丸); `hp_ratio` + "恢复/回复…生命" ⇒ self heal at start; `force` ⇒ on-hit
displacement (拖拽/hookmaster pull, else push). **Passive** skills only apply stat mods (timed when the text says "N秒内":
宴 +65 % ATK for 14 s) and the self/counter effects — their scales describe procs that need a kit. Instant skills with
mods/targeting but no attack override apply them to the next attack (the skill range is switched in for that attack).
### 7.5 Worked examples (real operators, numbers from blackboards)
**1. Ammo sniper — 隐现 `chess_char_1_01_a` “解决麻烦”** (bb `atk 0.8, base_attack_time −0.3, attack@trigger_time 14`;
text: prefers ranged enemies, less likely to be targeted; talent: +2 ammo after 20 s on field):
```js
chess_char_1_01_a: (bb, chess, def) => ({
skill: {
kind: 'ammo', ammo: bb['attack@trigger_time'],
mods: { atkPct: bb.atk, batPct: bb.base_attack_time, taunt: -1 },
targeting: { priority: 'ranged' },
},
talents: [{ install(battle, unit) {
const t = def.talents[0].bb; // { self_ammo: 2, duration: 20 }
battle.on('skillStart', ({ unit: u, skill }) => {
if (u === unit && battle.time - unit.deployedAt >= t.duration) skill.ammoLeft += t.self_ammo;
}, { owner: unit });
} }],
}),
```
**2. Duration ATK buff guard — 幽灵鲨 `chess_char_2_07_a` 肉斩骨断** (bb `atk 0.7, stun 10`: HP never below 1 during the
skill, self-stun 10 s afterwards):
```js
chess_char_2_07_a: (bb) => ({
skill: {
kind: 'duration', // duration comes from the data (11 s)
mods: { atkPct: bb.atk },
onStart({ battle, unit }) {
unit.mem.undying = battle.on('fatal', (c) => { if (c.unit === unit) c.prevented = true; }, { owner: unit });
},
onEnd({ battle, unit, reason }) {
battle.off(unit.mem.undying);
if (reason !== 'death') battle.applyStatus(unit, 'stun', { duration: bb.stun, source: unit });
},
},
}),
```
**3. Instant burst caster — 至简 `chess_char_3_13_a` 神工意匠** (bb `atk_scale 1.3, ct 2`; next attack 130 % arts, hits
twice; 2 charges; the funnel profile already ramps damage on the same target):
```js
chess_char_3_13_a: (bb) => ({
skill: { kind: 'charges', attack: { atkScale: bb.atk_scale, hits: 2, dmgType: 'arts' } },
}),
```
An AoE instant (德克萨斯 剑雨, bb `stun 2`: two hits of 120 % arts around her + stun) uses `onStart` instead:
```js
onStart({ battle, unit, bb }) {
for (const e of battle.enemiesInRadius(unit.x, unit.y, 1.5)) for (let i = 0; i < 2; i++) {
battle.dealDamage(unit, e, { amount: unit.s.atk * 1.2, type: 'arts', isSkill: true });
battle.applyStatus(e, 'stun', { duration: bb.stun, source: unit });
}
battle.addDp(unit.ownerId, 10);
}
```
**4. Healer — 调香师 `chess_char_2_14_a` 精调** (bb `atk 1.8, attack_speed −50`, 30 s; the ringhealer profile heals 3 allies):
```js
chess_char_2_14_a: (bb) => ({
skill: { kind: 'duration', heal: true, mods: { atkPct: bb.atk, aspd: bb.attack_speed } },
}),
```
and 华法琳 紧急包扎 (charges, bb `hp_ratio 0.15`: next heal +15 % of the target's max HP if it is below half):
```js
skill: { kind: 'charges', heal: true, attack: { onHit({ battle, unit, target, bb }) {
if (target && target.hpRatio < 0.5) battle.heal(unit, target, target.s.maxHp * bb.hp_ratio);
} } },
```
**5. Summoner — 赫默 `chess_char_2_02_a` 医疗无人机** (bb `cnt 1`: gain a medical drone that heals nearby allies for 10 s):
```js
chess_char_2_02_a: (bb) => ({
skill: {
kind: 'instant', trigger: 'SP_FULL',
onStart({ battle, unit }) {
for (const k of unit.rangeKeys) { // first free walkable tile in range, nearest first
const r = Math.floor(k / 21), c = k % 21;
if (!battle.unitAt(r, c) && battle.grid.canStand(r, c, { ranged: true })) {
battle.spawnToken(unit, 'token_10000_silent_healrb', r, c, { duration: 10 });
break;
}
}
},
},
}),
```
(the drone's stats come from `tokens.json variants[chess_char_2_02_a]`; it is untargetable and uses the heal profile.)
**6. Tank TAKE_DAMAGE — 古米 `chess_char_1_10_a` 备用军粮** (rule `TAKE_DAMAGE`, bb `heal_scale 1.15`: the next attack
heals a nearby ally for 115 % ATK):
```js
chess_char_1_10_a: (bb) => ({
skill: {
kind: 'instant', // trigger comes from data: TAKE_DAMAGE
attack: { onHit({ battle, unit }) {
const ally = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId).sort((a, b) => a.hpRatio - b.hpRatio)[0];
if (ally) battle.heal(unit, ally, unit.s.atk * bb.heal_scale);
} },
},
}),
```
**7. Bond hook example — 精准 3-tier** (`bonds.js`): members + ranged ops ignore 30 % DEF/RES:
```js
export function install(battle) {
battle.on('battleStart', () => {
for (const p of battle.players) {
const bond = p.bonds.preciShip;
if (!bond?.active || bond.tier < 2) continue;
for (const u of p.units) if (u.def.bonds?.includes('preciShip') || u.def.position === 'RANGED')
battle.addBuff(u, { key: 'bond:preci', persist: true, allowDead: true, mods: { defIgnorePct: 0.3, resIgnorePct: 0.3 } });
}
});
}
```
---
## 8. Professions (server/sim/professions.js)
Profile resolution: `PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait tunables (trait text + data
trait.bb, elites include module upgrades) → data fields (dmgType / attackKind / projectile / canHitFly / targetPriority)
→ kit.trait`. Defaults: SNIPER ranged phys arrow · CASTER ranged arts bolt · MEDIC ranged heal · SUPPORT ranged arts ·
TANK/WARRIOR/PIONEER/SPECIAL melee phys (melee cannot hit FLY). Ranged attacks fly as projectiles (arrow 14, bolt 11,
bomb/lob 8, drone 16 tiles/s); melee/`none` hits are instant. Kit-settable profile flags beyond the table: `hitSleep`
(targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, `afterHit`,
`afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal` (see the header of professions.js).
| sub | behaviour |
|---|---|
| fastshot | FLY first; module `atk_scale` vs FLY |
| closerange, underminer, primcaster, corecaster, ritualist, summoner, counsellor, pioneer, fearless, fighter, protector, guardian, primprotector, executor, duelist | plain profile (numbers from data; skills/talents via kits). underminer module: weaken 10 % ATK 2 s on hit |
| longrange | lowest DEF first |
| aoesniper / splashcaster / blastcaster | splash 1.1 tiles at full damage |
| bombarder | ground-only splash 1.0 + aftershock(s) at 50 % ATK (bb append_atk_scale / times) |
| hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty |
| loopshooter | next attack waits for the boomerang (2 × distance / 10 s) |
| reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction |
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.8-tile jumps, sluggish on each hit |
| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) |
| mystic | stores up to 3 (bb times) attacks while idle, fires them all at once |
| phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; attacks with 1.1 splash while on |
| physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) |
| ringhealer | heal 3 allies |
| chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles |
| healer (流明) | heal ×0.8 (bb heal_scale) beyond 2 tiles |
| wandermedic | heal + reduce element gauges by 50 % ATK (bb ep_heal_ratio); also targets uninjured allies with gauge |
| incantationmedic | arts attack; heals the lowest ally in range for 50 % (bb scale) of damage dealt |
| slower | sluggish 0.8 s on hit (bb sluggish) |
| bard | no attack; every second heals allies in range 10 % ATK (bb atk_to_hp_recovery_ratio) |
| craftsman | melee phys (support devices via kit) |
| shotprotector | ranged phys, can hit FLY, blocks 3 |
| fortress | melee single target while blocking, ranged 1.0 splash otherwise |
| unyield / musha / reaper | cannot be healed by others; musha heals itself 50 (bb value) per hit; reaper hits every enemy in range and heals 50 × min(hits, block) |
| centurion / crusher / pusher | hit every blocked enemy at once |
| hammer | 50 % splash (bb atk_scale_2) to others within 1 tile |
| instructor | ×1.2 (bb atk_scale) vs enemies it doesn't block |
| librator | no attack & block 0 while the skill is off; ATK +5 %/s up to +200 % (bb atk / max_stack_cnt), reset at skill end; elite module starts at +100 % (bb init_atk) |
| lord | can hit FLY at range; ×0.8 (bb atk_scale) unless the target is on its tile / the tile in front (along its direction) or blocked by it |
| sword / swordmaster | 2 hits per attack |
| artsfghter | melee arts |
| charger | +1 DP (bb cost) per kill |
| tactician | spawns a 援军 token (70 % HP, 50 % ATK, block 1) on the nearest walkable tile in range at each deployment; ×1.5 vs enemies it blocks |
| agent / hookmaster | can hit FLY (ranged reach); hook displacement comes from skills (generic: `force`) |
| bearer | block 0 while the skill is active |
| alchemist | ranged lob, can hit FLY |
| dollkeeper | fatal damage ⇒ substitute for 20 s (bb duration): block 0, doll HP (its own substitute token's stats, else 50 % max HP: 归溟幽灵鲨); swaps back at full HP; dies if the doll dies |
| geek | loses 1–3 % max HP per second (bb hp_ratio), never lethal on its own |
| merchant | −3 DP every 3 s (bb cost/interval); retreats when DP runs out |
| skywalker | can block FLY enemies |
| stalker | hits every enemy in range; 50 % dodge (bb prob), taunt −1 |
| traper | ranged, ground only |
Unknown subprofessions fall back to the profession default (test `professions.test.js` checks every pool subprofession).
---
## 9. Wire format (snapshot.js, DESIGN §8.2)
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss? }`.
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy), `['atk', src, tgt, projKind]`
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|chain|chainHeal`), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8, skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
anim: ANIM codes (idle 0, move 1, attack 2, skill 3, die 4, stun 5, deploy 6).
---
## 10. Testing content (test/helpers/battleHarness.js)
```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
test('隐现 S2: 14 shots then the skill ends', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, // synthetic target
units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }], // board coords
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
hooks: ['ammoUsed', 'skillEnd'],
});
const u = h.unit('chess_char_1_01_a');
assert.ok(h.runUntil(() => u.skill.activations === 1, 60));
h.runUntil(() => !u.skill.active, 60);
assert.equal(h.hooksOf('ammoUsed').length, 14);
checkInvariants(h.b);
});
```
`makeBattle(opts)` options: `stageId` (default `'flat'`: synthetic open lanes, gates (9,10)/(12,10), goal (9,2); `flat:
{ rows, crates }` to customise) or `stage`; `kind`; `seed`; `units` (board coords, player `p1`; each may carry
`dir: 'UP'|'RIGHT'|'DOWN'|'LEFT'`, default RIGHT — facing tests: test/sim/facing.test.js, test/sim/facing-invariance.test.js,
test/content/facing.test.js)
or `players`; `enemies:
[{ key, time, route (index | RouteSpec), pos, count, interval, mods, tag, bounty, sourcePlayerId }]` or `waveTemplate`
(id or object ⇒ routes, spawns, time limit); `routes` (flat defaults: 0 walk low gate, 1 walk high gate, 2/3 fly);
`timeLimit`; `content` (`'full'|'generic'|'none'`); `kits` (inject `{ baseId: kitFn }`); `defs: { chess, enemies, tokens }`
(extra/override records; build them with `chessRec({...})` / `enemyRec({...})`); `bonds`, `bandId`, `playerEffects`,
`flags`, `sharedBoss`, `setup(battle)`, `hooks` (names to capture; `captureNoisy` to keep tick/hit/damaged/heal/spGain/
attack contexts), `autoFinish` (default: true when enemies are scheduled).
Harness API: `battle`/`b`, `step(n)`, `run(seconds)`, `runUntil(pred | seconds, maxSeconds)`, `runToEnd(max)`,
`unit(uid | chessId | baseId | id)`, `allies()`, `enemies()`, `enemy(key)`, `spawn(key, opts)`, `events` /
`eventsOf(kind)` (client tuples), `hooks` / `hooksOf(name)` (captured ctx copies with `t`), `result()`, `snapshot()`,
`invariants()`. `checkInvariants(b)` (no NaN, hp ∈ [0, maxHp], positions inside the rect, SP/charge bounds, finite skill
timers, occupancy map in sync with deployed allies, block links, DP, finite projectiles and per-player result counters,
no open hook emit between steps), `hashOf(v)`, `flatStage()`, `flatRoutes(kind)` are exported too.
Soaks: `SIM_FUZZ_N=3000 SIM_FUZZ_CHECK_EVERY=3 node --test test/sim/fuzz.test.js` (random real battles) and
`SIM_CHAOS_N=500 SIM_CHAOS_SEED=<n> node --test --test-name-pattern="chaos fuzz" test/sim/robustness.test.js` (content that
abuses every helper from every hook with junk numbers; asserts invariants and zero engine errors).
Normalised defs from `battle.data` are **deep-frozen** (shared by every battle in the process): never mutate `bb`, `def`,
`def.stats` or range grids in a kit — copy them (`{ ...bb }`, `grid.map(…)`).
Rules for content tests: seed everything, prefer synthetic `enemyRec` targets for exact numbers, assert on ids (not unit
objects), and call `checkInvariants` at the end. Run: `node --test test/sim/*.test.js test/content/*.test.js`.
## 11. Tools
`node tools/simrun.mjs --mode mode_multi_normal --round 5 --stage act2autochess_m01 --lineup "角峰@9,7 隐现@10,4 艾丝黛尔*@12,5" --ascii 10`
prints a per-unit damage/DPS/kills/heal table (+ ASCII frames: letters = operators, digits = ground enemies per tile,
`^` = flyers, `■` = crates). `--wave <template>`, `--seed`, `--content generic`, `--hpMul/--atkMul/--speedMul`,
`--time`, `--bossHp`, `--json`, `--events`, or a scenario JSON file (see the file header).
## 12. Data notes (simdata.js)
Defs are normalised from data/*.json (DATA.md) with research JSON as a fallback. Chess: `stats {maxHp, atk, def, res, aspd,
bat, blockCnt, cost, respawnTime, spRecovery, tauntLevel, massLevel, hpRecoveryPerSec}`, `rangeGrid`, `dmgType`,
`attackKind`, `projectile`, `canHitFly`, `targetPriority`, `trait` (text), `traitBb`, `immune` (Set), `skill {id, name,
skillType, durationType, duration, spType, spCost, initSp, maxCharges, rangeGrid, trigger {rule, grid}, bb, description}`,
`talents [{name, description, bb, bbStr, rangeGrid, tokenKey}]`, `raw`. Enemy: `maxHp, atk, def, res, aspd, bat,
rangeRadius, moveSpeed, massLevel, lpr, motion, applyWay, dmgType, immune, tauntLevel, blockCnt, epResistance,
epDamageResistance, hpRecoveryPerSec, notCountInTotal, tags, abilities, skills, talent, raw`. Tokens use
`variants[ownerChessId]` (owner-level stats). `skcom_withdraw` token skills are ignored.
Operator loadouts (DESIGN §16, DATA.md §2.2 / §14): `getChess(id, { skillIndex, moduleId })` = the def with the selected
skill (`def.skill`, its bb / SP / trigger) and, for elites, the module's stats / trait / talents; `def.loadout` = the
resolved loadout `{ skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault }` (illegal choices fall back to the
default; cached per loadout; the default loadout is the same object as `getChess(id)`). `getToken(id, ownerChessId,
ownerLoadout)` = the summon for that owner loadout (`bySkill` / `byModule` merged; `def.sources` ⊆ ['talent', 'skill',
'display'] and `def.count` of that loadout — `[]` / `['display']`: the owner does not make it). Every Battle gets a
per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owner)` use the loadout the inputs give that
chess id — the first player's when two players of one field differ, `view.loadoutConflicts`); Battle itself always passes
the unit's own loadout (`_createAllyFromInput`, `tokenDef` / `spawnToken`, the dollkeeper substitute), so it is exact.
Assumptions taken here (documented choices): element burst numbers for necrosis/apoptosis; unspawned enemies at timeout
are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement distance 0.5 + 0.5·force;
platforms/mounds are ground obstacles that elevate operators; the generic kit maps 凋亡 to `apoptosis` (侵蚀 has no element key
yet — enemy content must pick one); 抵抗 covers the control statuses of `RESIST_STATUSES` (the official term lists 晕眩/寒冷/
冻结/恐惧/诱导…, the rest follow the operator kits that grant it) and caps at 0.95; tactical points prefer enemy path tiles.
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

+156
View File
@@ -0,0 +1,156 @@
# 00 · Research index and key numbers: 卫戍协议:盟约 (act2autochess, 盟约·下半)
Written 2026-09-27 by the completeness critic, after reading every file in this folder.
**Precedence rule:** where a file's body and its `## Addendum (critic)` disagree, **the addendum wins**. In JSON, `_criticAddendum` wins over the older fields.
Tags: **[DATA]** client game data · **[VERIFIED]** official or wiki rule text, or a screenshot · **[ASSUMED]** a proposal, kept in config.
## 1. Files
| File | What it holds | Key machine paths |
|---|---|---|
| `01-core-rules.md` / `01-core-data.json` | Modes, round schedule and timers, economy, shop, merging, LP, Final Assault and Hidden Core, 40 strategies (bands), 机变 choice events, scoring and tips. **Addendum A1–A5** has the verified rules, the ban rule and the enemy-multiplier table. | `modes`, `rounds`, `shopLevels`, `bands`, `choiceEvents`, `choiceAndEnemyEffects`, **`_criticAddendum`** (income, freeze, hand, bandDraft, bannedOperators, enemyStatMultipliers, specialPhase…) |
| `02-bonds.md` / `02-bonds.json` | All 23 bonds (8 core, 15 add-on): activation, layer formulas, tier effects, layer sources and readers, band/choice/item interactions. Addendum: 华法琳 cap 12/24, ban interplay. | `bonds[*]`, `layerGarrisons`, `layerScalingGarrisons`, `bondGrantingItems` |
| `03-operators.md` / `03-operators.json` | 133 chess ×2 (normal/elite): stats at the tier's status, range grids, the default skill with blackboard, talents, module, 特质 (garrison) blackboards, tokens, distribution. Addendum: **skill-trigger correction**, summons and hand, deploy rules, DIY. | `chess[*].stats/rangeGrid/skill/talents/garrisons/module`, `tokensUsedByPool`, `skillTriggerDataList` |
| `04-items.md` / `04-items.json` | 56 equipment ×2 + 3 Arts: effects, formulas, merge, sources, 变形同构体 table, VI-tier combos. Addendum: **items can't be sold**, 机变 item card formats. | `items[*]`, `itemSources`, `bondGrantTable_for_变形同构体` |
| `05-enemies-levels.md` / `05-enemies.json` / `05-maps.json` | Stage terrains (8 active), 38 wave templates with routes, 326 enemies, special-enemy pools, generation algorithm, bosses, bounties, LP rules. Addendum: DP, hidden-core thresholds, multi FUNNY rounds, stat scaling. | `05-maps.json: stages, roundLevels`; `05-enemies.json: enemies, specialEntries, bosses, bountyEffects, generation, _criticAddendum` |
| `06-multiplayer-ux.md` | Co-op flow and state machine, 联防, shared vs individual, shared pool, cross-player effects, emotes and broadcasts, settlement, per-screen UI layout, style guide, server notes. Addendum: TL;DR fixes, banned-operator UI. | – |
| `07-assets.md` / `07-assets.json` | Verified URL patterns and resolved URLs for avatars, portraits, skills, bond/item/band icons, UI, Spine (ops + enemies), BGM/SFX, fonts; build plan. Addendum: spot check 40/40 OK. | `operators[*]`, `enemies[*]`, `bonds`, `items`, `bands`, `autochessUi`, `audio` |
## 2. Match structure
| Item | Value | Tag |
|---|---|---|
| Players | Co-op 1–4 (PvE, no PvP). Solo 1. Tutorial = 1 human + 3 NPCs. | DATA/VERIFIED |
| Modes | FUNNY 标准, NORMAL 险境, HARD 绝境, ABYSS 终极 (×solo/multi), plus training. FUNNY disables 10 bonds (laterano, egir, kazimierz, skillful, arcane, mira, invest, raid, solo, sunt) and 18 enemy keys; NORMAL disables the same 18 enemy keys. | DATA |
| Rounds | Multi: FUNNY 14; NORMAL/HARD/ABYSS 14 + hidden R15. Solo: FUNNY **9** (boss R9); others 14 + R15. | DATA |
| Pre-game | INFO_CHECK 25 s → BAND_CHECK 50 s (co-op: random-order sequential picks, one skip each, duplicates allowed, timeout → 华法琳) → BATTLE_CHECK 3 s | DATA/VERIFIED |
| SP (机变) rounds (`isSpPrepare`) | multi FUNNY 3, 9 · multi NORMAL 3, 6, 9 · HARD/ABYSS 3, 9, 11 · solo NORMAL 6, 9 · solo FUNNY none | DATA |
| 机变 format | Multi: 6 shared cards of one family; random pick order; 30 s first, 16 s others; timeout auto-assign. Solo: 3 cards, no timer. Opens at prep start, after income. | DATA/VERIFIED |
| Round flow | round start (income, −1 upgrade price, wipe temp hand, refresh unfrozen slots, <进入休整期时> effects) → [机变] → prep → <休整期结束时> → combat (all boards in parallel, 2× speed) → 联防 if needed → LP loss → next | VERIFIED |
**Timers, co-op (s)** [DATA]. Solo in 下半 has **no** prep or 机变 timer.
| Round | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Prep | 65 | 65 | 65 | 95 | 95 | 100 | 105 | 105 | 125 | 125 | 125 | 125 | 150 | 195 | 195 (HARD/ABYSS 215) |
| Combat limit (`maxPlayTime`, game s [ASSUMED unit]) | 45 | 45 | 55 | 55 | 55 | 70 | 85 | 85 | 95 | 95 | 115 | 115 | 115 | boss | boss |
| Wave level | 01 | 02 | 03 | 04 | 05 | 06 | 07 | h01 | h02 | h03 | h04 | h05 | h06 | h07_0X | h08_0X |
Boss rounds: the battle continues past the timer. Merged team LP drains **1/s after 150 s** (`bossTurnHpReduceTime`).
## 3. Economy
| Item | Value | Tag |
|---|---|---|
| Income per round | **4, 5, 6, 7, 8, 9, 10, 11, 12, 12…** = `min(3+r, 12)`. The same in every mode (no FUNNY bonus). Paid at round start. | R1–R3 VERIFIED; +1/round and cap 12 ASSUMED (alt cap 10) |
| Leftover funds | Lost at prep end, except band 坎诺特 (carry over; +1 if ≥5 left) | VERIFIED |
| Buy operator | T1 **2** · T2 **3** · T3 **3** · T4 **3** · T5 **4** · T6 **4** | DATA |
| Buy item | 1–5 by item (`purchasePrice` of the `_a` entry). Golden items are never sold in the shop. | DATA |
| Refresh | **1** (rerolls all slots, including frozen ones) | DATA/VERIFIED |
| Freeze | Free. **One toggle freezes all unsold slots** until the next round start. | VERIFIED |
| Sell operator | **+1** (normal or elite, from board or hand) | DATA/VERIFIED |
| Sell item | **Not possible.** Destroy only, for 0. | VERIFIED |
| Upgrade price (Lk→k+1) | NORMAL/HARD/ABYSS: **5, 8, 11, 12, 13** · multi FUNNY: 5, 8, 10, 11, 11 · solo FUNNY: 1, 1, 5, 8, 10 · training: 4, 4, 6, 9, 12. **−1 per round start (floor 0); resets to the next base after upgrading.** | DATA/VERIFIED |
| Shop slots (ops + item) | NORMAL+: 3/4/4/5/5/5 + 1 · FUNNY: 3/4/4/4/4/5 + 1 · training: 3 ×6, item from L5 | DATA |
| Shop cleared | Unfrozen slots are emptied at combat start and refilled at round start. | VERIFIED |
| Bounty coins | 1–6 per bounty enemy, to its killer (a 联防 helper counts). 战术特训: paid only on a perfect clear in your own phase. | DATA |
**Shared pool (co-op), copies per operator:** T1 **12**, T2 **14**, T3 **18**, T4 **16**, T5 **8**, T6 **5** (缪尔赛思 4). An elite counts as 3. Every owned copy counts; shop displays don't. Eliminated or quitting players return their copies. [COMM, two independent sources]
**Shop odds [ASSUMED; the official odds are unpublished].** Each slot draws 1 copy uniformly from all remaining pool copies of unbanned, non-hidden chess with tier ≤ shop level. Duplicates in one roll are allowed; a screenshot shows 2× 蛇屠箱 in one roll. Resulting tier shares with full pools and no bans:
| Shop Lv | T1 | T2 | T3 | T4 | T5 | T6 |
|---|---|---|---|---|---|---|
| 1 | 100 % | – | – | – | – | – |
| 2 | 44.7 % | 55.3 % | – | – | – | – |
| 3 | 24.9 % | 30.8 % | 44.3 % | – | – | – |
| 4 | 17.1 % | 21.2 % | 30.4 % | 31.3 % | – | – |
| 5 | 15.0 % | 18.7 % | 26.8 % | 27.6 % | 11.9 % | – |
| 6 | 14.0 % | 17.4 % | 25.0 % | 25.7 % | 11.1 % | 6.9 % |
The visible chess per tier are 16/17/19/22/19/19. The item slot uses the same tier shares, then picks uniformly within the tier [ASSUMED].
**Merge:** 3 copies of the same chess (board or hand) merge into 1 elite (`_b`), sent to the **hand**. 风丸 needs 2. Elites never merge. The equipment of the merged copies returns to the hand. Reward: the shop temporarily shows 3 operators of tier min(shopLv+1, 6) at price **0**; take 1; no refresh or freeze; gone at round end. A purchase that completes a merge is allowed with a full hand. [VERIFIED]
## 4. Board, hand, combat
| Item | Value | Tag |
|---|---|---|
| Deploy cap | **8** (`characterLimit`); **9** with 人事部文档. Summons don't use a slot [ASSUMED]. | DATA/VERIFIED |
| Hand (整备区) | **10 regular + 5 temporary**, shared by operators, items and summon stacks. Full ⇒ no buy or withdraw. Passive overflow goes to temp; **temp blocks Ready**; temp is wiped at the next phase. Board↔hand swap is allowed when full. | VERIFIED |
| Equipment | 2 per operator (board **or** hand). Locked; 3rd = replace, and the old one is destroyed. 2 identical normal items (equipped or not) merge into golden. | VERIFIED |
| Deployment | Free, at t=0, order **top→bottom then left→right** (right-side boss player mirrored). Ranged units may use melee tiles. | VERIFIED |
| DP | Start **10**, **+1/s**, cap **99**. Used only for auto-redeploy (original tile, respawn timer done, DP ≥ `cost`). | DATA/VERIFIED |
| Skill auto-cast | Default: SP ready + about to attack/heal + an enemy in the **initial** range. `skillTriggerDataList` rows with `skillIndex` 0 = **skill 1 only** (TANK S1 → on damage: only 古米 and 灰毫 this season). charId rows per 03. | VERIFIED |
| Speed | 2× forced, no manual control | VERIFIED |
| Board geometry | 19×21 stage. Player board rows 9–12, cols 2–10. Gates (9,10) and (12,10); R1–3 use only the lower one. Goal (9,2). Col 9 is a non-deployable lane. Hand row 7 cols 0–9. Temp hand row 8 cols 4–8. Boss field rows 1–5. | DATA |
| Bond counting | Distinct operators on board. 远见/奇迹/投资人 also count the hand. 绝技 counts elites. Layers persist all match; **no IN_BATTLE layer gains in 联防 or boss rounds**. | DATA/VERIFIED |
## 5. LP, 联防, Final Assault, Hidden Core
| Item | Value | Tag |
|---|---|---|
| Starting LP | Band `totalHp`: 20 (铃兰) … 45 (歌利亚); default 华法琳 28 | DATA |
| Normal-round loss | 1 per enemy not killed (reached the goal or alive at timeout), **max 10 per round per player** | DATA/VERIFIED |
| 联防 | Runs if ≥1 player leaked and ≥1 was perfect. **≤2 perfect helpers** keep HP%, SP and positions, and fight the union of leaked enemies (`escaped_single` / `escaped_multi`). No layer gains. Survivors cost their **source** player LP (cap 10). | VERIFIED |
| Elimination | LP ≤ 0: out; copies return to the pool | VERIFIED/COMM |
| Final Assault (R14) | LP of all alive players **merged**, no cap. Players in pairs, and an **odd player alone**. Movable bosses spawn one per alive player's side and share one HP pool. Overtime −1 LP/s after 150 s. Wave leaks cost `lifePointReduce`. | VERIFIED |
| Boss HP pool (`bloodPoint`, F/N/H/A) | boss_1 247.5k/675k/1.8M/3.6M · boss_2 225k/400k/800k/3M · boss_3 285k/708.75k/2M/4M · boss_4 307.5k/708.75k/2.1M/4.2M · boss_5 200k/390k/780k/3M · boss_6 285k/705k/1.99M/3.98M · boss_7 277.5k/787.5k/2M/4M. Pick weights 6/6/6/5/10/5/5. | DATA |
| Hidden Core (R15, 险境+) | Solo: activated layers **> 350** and LP > 1. Co-op: team sum **> 1200** and merged LP > 1. Bosses boss_8/9/10 (weights 50/40/40; HP N 937.5k/900k/1.0125M, H 3.6M/2.8M/3.8M, A 7.2M/3.95M/7.6M). Failure doesn't affect the clear. | VERIFIED/DATA |
**Enemy scaling (non-boss)**, per mode and round [VERIFIED via PRTS, user-sourced]:
- `atk × base × 1.1^k`, `hp × base × 1.2^k`.
- Base values: solo FUNNY/NORMAL 0.7 (HP 0.75 per data), solo HARD 0.8, solo ABYSS 1.0, multi FUNNY/NORMAL 0.8, multi HARD/ABYSS 1.0.
- k climbs to 5 (solo NORMAL), 4 (solo HARD), 7 (solo ABYSS), 1 (multi FUNNY), 7 (multi NORMAL) and 8 (multi HARD/ABYSS) by R14.
- ABYSS also gets move speed ×1.15 from R3.
- Full round-by-round arrays: `01-core-data.json → _criticAddendum.enemyStatMultipliers`.
## 6. Per-match bans (VERIFIED rule)
The server draws a disabled bond set **D**: **3 core + 4 add-on** in NORMAL/HARD/ABYSS (observed in ABYSS; [ASSUMED] for NORMAL/HARD), and **the static list + 0 core + 1 add-on** in FUNNY. Weight-0 bonds are never drawn.
**An operator is banned iff all its bonds ∈ D.** At 3 + 4 that averages ≈22 banned operators. The briefing shows lit and greyed bonds plus the banned list; the in-match dropdown shows banned counts per bond. The rule reproduces a real screenshot's counts (7/6/5/5/5/4/3) exactly (01 A2).
## 7. Contradictions found and resolved
| Topic | Conflict | Resolution |
|---|---|---|
| Hidden-core thresholds | 05: 300/1000 vs 01/02/06: 350/1200 | **350/1200** (下半); 300/1000 was 上半 |
| DP | 05: initialCost 0 vs 01/06: 10 | **10**. 05 read the terrain file, not the round level. |
| FUNNY round count | 05: 9 | Solo 9, **multi 14** |
| Enemy multipliers | 06: flat 0.7/0.8; 01: per-mode table; 05: invented mapping | Per-round table (§5) |
| Freeze | 01: per slot | **Whole shop, one toggle** |
| Item sell | 04: 1 fund | **Not sellable** (destroy for 0) |
| 机密商店 | 04: 3 items of tier ≥ IV | **6 cards (multi), any tier I–VI, duplicates allowed** |
| Band choice | 01: 3 random + reroll; 06: full grid draft | **Full grid, random order, one skip, duplicates allowed** |
| Banned bonds | 01: 1 core + 1 add-on | **3 + 4 (NORMAL+), subset rule** |
| TANK skill rule | 03: any skill index | **Skill 1 only** |
| 联防 partner rule | 01: fewest leaks | Meaningless; **highest LP, then seat** [ASSUMED] |
| FUNNY income bonus | 01: +2 | **None** (official intro shows 4 at R1 in 标准) |
| 华法琳 granted cap | data 7/14 vs patch 12/24 | **12/24** (official 3/27 notice) |
## 8. Remaining open questions, with recommended defaults
| # | Question | Default (config) |
|---|---|---|
| 1 | Income after R3 and its cap | +1/round, **cap 12** (alt 10) |
| 2 | Shop tier odds | Copy-weighted pool draw (table in §3); a per-level odds table as an optional override |
| 3 | Item slot odds and server pools `pool_equip_*` | Same tier shares; pool contents as in 04 §8 |
| 4 | Disabled-bond counts for NORMAL/HARD | 3 core + 4 add-on |
| 5 | 机变 family per SP round, card pools, 道具补给 tiers | 01 A4 table: HARD+ R3/R9 悬赏, R11 道具补给/机密商店. 道具补给 tier windows R3 I–IV, R6 II–V, R9 III–VI, R11 IV–VI. |
| 6 | Boss HP pool vs alive players | `bloodPoint × alive/4`; solo ×0.25 |
| 7 | Pair order in the Final Assault | Alive players by seat: (1,2), (3,4); an odd player gets an `_s` map |
| 8 | 联防 helper choice when >2 are perfect; leaked-enemy HP | Highest LP, then seat; leaked enemies re-enter at full HP |
| 9 | Per-turn band-draft timer | 12 s, with the 50 s step cap |
| 10 | Unit of `maxPlayTime` | Game seconds (the sim runs 2× real-time) |
| 11 | Do summons use deploy slots? | No |
| 12 | Special-enemy replacement counts and faction pick | 05 §3.2 algorithm (`k = clamp(round(be_t/be_new), 1, 5)`) |
| 13 | Do A3 multipliers apply to bounty and special enemies? | Yes, the current round's k |
| 14 | Title (评语) criteria | 06 §10.5 mapping; one title per player, each title used once |
| 15 | 炎佑 spawn, AI and model; token Spines | 02 §3.1 assumptions; 07 §5.6 fallbacks |
| 16 | DIY (甄选) slots | Disabled in v1; curated 6★ list in v2 |
| 17 | Co-op disconnect | Keep the seat; auto-ready at the deadline; auto-pick drafts; optional AI takeover |
| 18 | Hidden boss selection | Weighted 50/40/40, independent of the R14 boss |
| 19 | Tile rendering | Procedural (no 2D map art exists); see 07 §7 |
File diff suppressed because it is too large. Load diff
+926
View File
@@ -0,0 +1,926 @@
# 01 · Core Rules: loop, economy, shop, rounds, modes, strategies (bands)
Scope: 卫戍协议:盟约. The season data is `act2autochess` (盟约 下半, CN start 2026‑03‑14). The data key `act1autochess` is 盟约 上半, kept here for reference only.
Machine-readable companion: `01-core-data.json`. It holds every table below plus the raw data. That includes the per-mode round schedule, shop tables, all 40 bands with buff blackboards, choice effects, tips, broadcasts, bosses and reward factors.
Confidence tags used throughout:
- **[DATA]**: read directly from the client game data (`activity_table.json` and the level JSON files).
- **[WIKI]**: taken from official help text as quoted by PRTS or BWIKI, or from the official patch notes.
- **[COMM]**: community-measured (NGA, Bahamut, bilibili, note.com).
- **[INFER]**: derived from the data by arithmetic.
- **[ASSUMED]**: not documented anywhere I could find. These are proposed values that we can tune.
---
## 0. TL;DR constants for the server
| Constant | Value | Tag |
|---|---|---|
| Funds per round | R1=4, R2=5, R3=6, then +1/round, capped at **10** | R1–R3 [INFER from band_lmlee: R1+R2+R3 = 15]; growth and cap [ASSUMED] |
| Standard (FUNNY) mode | +2 funds/round on top of the above | [ASSUMED] ("初始资源更加丰富") |
| Leftover funds | Lost when the prep phase ends. Only band 坎诺特 keeps them. | [WIKI] |
| Refresh | 1 fund (`shopRefreshPrice`=1) | [DATA][WIKI] |
| Sell any operator (normal or elite) | +1 fund (`chessSoldPrice`=1) | [DATA][WIKI] |
| Buy operator, by tier I..VI | 2 / 3 / 3 / 3 / 4 / 4 | [DATA] |
| Shop upgrade price (NORMAL/HARD/ABYSS, both solo and multi), level 1→2…5→6 | 5, 8, 11, 12, 13 | [DATA] |
| Upgrade price change | −1 per round (floor 0). Resets to the next level's base after an upgrade. | [WIKI] |
| Shop slots (operators + items) | 3+1 / 4+1 / 4+1 / 5+1 / 5+1 / 5+1 (NORMAL+). `storeCntMax`=6 | [DATA] |
| Board cap / bench cap | 8 (`maxBattleChessCnt`) / 10 (`maxDeckChessCnt`) | [DATA] |
| Max LP lost per normal round | 10 (`costPlayerHpLimit`) | [DATA][WIKI] |
| Starting LP | the band's `totalHp` (20–45) | [DATA] |
| Merge | 3 identical → elite (精锐). 风丸 needs only 2. Elites never merge further. | [DATA] |
| Promotion reward | Free pick of 1 from 3 random operators of tier min(shopLevel+1, 6) | [COMM] |
| Shared pool copies per operator, tier I..VI | 12 / 14 / 18 / 16 / 8 / 5. An elite occupies 3 copies. | [COMM] |
| Rounds: multi FUNNY / other multi / solo FUNNY / other solo | 14 / 14 (+15 hidden) / 9 / 14 (+15 hidden) | [DATA] |
| Players (同盟模拟) | up to 4, co-op PvE | [WIKI] |
---
## 1. Modes (`modeDataDict`) [DATA]
`modeType`: LOCAL = tutorial, SINGLE = 独立模拟 (solo), MULTI = 同盟模拟 (co-op, up to 4 players; also called 联合模拟 in tips).
| modeId | Name / code | Type | Difficulty | specialPhaseTime (s) | Active bonds | Unlock (`preposedMode`) | effectDescList |
|---|---|---|---|---|---|---|---|
| mode_training_1 | 入门协议 AC‑TR‑1 | LOCAL | TRAINING | 150 | all 23 | – | ·仅可使用预置的调配干员 ·无奖励 ·无法用于解锁策略 |
| mode_single_funny | 标准模拟 AC‑1 | SINGLE | FUNNY | 150 | 13 (10 inactive) | training | ·可以快速完成作战 ·常规奖励 |
| mode_single_normal | 险境模拟 AC‑2 | SINGLE | NORMAL | 150 | 23 | funny (solo or multi) | ·可使用盟约数增加 ·大幅增加奖励 |
| mode_single_hard | 绝境模拟 AC‑3 | SINGLE | HARD | 150 | 23 | normal | ·作战环境困难 ·出现更加危险的敌人 |
| mode_single_abyss | 终极模拟 AC‑4 | SINGLE | ABYSS | 150 | 23 | hard ×2 ("通关2次【绝境模拟】") | ·作战环境无比困难 ·出现极度危险的敌人 (startTime 2026‑03‑27) |
| mode_multi_funny | 标准模拟 | MULTI | FUNNY | 16 | 13 | training | ·作战环境较为温和 ·常规奖励 |
| mode_multi_normal | 险境模拟 | MULTI | NORMAL | 16 | 23 | funny | as solo |
| mode_multi_hard | 绝境模拟 | MULTI | HARD | 16 | 23 | normal | as solo |
| mode_multi_abyss | 终极模拟 | MULTI | ABYSS | 16 | 23 | hard ×2 | as solo |
- **FUNNY inactive bonds:** lateranoShip, egirShip, kazimierzShip, skillfulShip, arcaneShip, miraShip, investShip, raidShip, soloShip, suntShip.
- **FUNNY and NORMAL also disable 18 dangerous enemies** (`inactiveEnemyKey`, e.g. `enemy_1112_emppnt`, `enemy_1207_sfji`, `enemy_1072_dlancer`…). HARD and ABYSS have none disabled.
- **Mode description (PRTS 下半):** 标准模拟 = "轻松的协议模式,作战环境较为温和,**初始资源更加丰富**,升级条件更加宽松。无法使用全部盟约。可获得卫戍认证并解锁策略。"
Enemy HP and ATK multipliers by mode [WIKI, PRTS 下半]:
| | FUNNY | NORMAL | HARD | ABYSS |
|---|---|---|---|---|
| 独立 (solo) | ×0.7 | ×0.7 | ×0.8 | ×1.0 |
| 同盟 (multi) | ×0.8 | ×0.8 | ×1.0 | ×1.0 |
Solo is shorter and uses lower enemy numbers. Solo has no time limit on prep or choice phases in 下半. You may leave and return within 24 h (`singleReconnectTime`=86400). Trophies (奖杯) are **only** awarded in multi [WIKI + tip].
Reward currency: 卫戍认证 (`act2autochess_token_chess`).
- **Formula:** `baseRewardDataList[roundsCleared].count × difficultyFactor × modeFactor` [INFER; verified against the 250/400/425 per full clear reported on note.com].
- **baseRewardDataList (round → count):** 10, 20, … 100 (R10), 120, 140, 160, 200 (R14).
- **difficultyFactor:** FUNNY 1.0, NORMAL 1.6, HARD 1.7, ABYSS 1.7.
- **modeFactor:** 1.25 for both SINGLE and MULTI.
- **Daily bonus:** earn 200 → +300 ("每日防卫目标", resets 04:00).
### Training (入门协议)
- **Format:** 4 rounds on stage `act1autochess_m01`.
- **Players:** band 歌利亚 (`band_sarkazb`, 45 LP) plus 3 NPC teammates (`trainingNpcList`: 华法琳/阿米娅/杜遥夜 bands). Preset operators only.
- **Boss:** `boss_7` (“萨米的意志”) in R4. R3 has a choice event (`equip_free_1_tr`).
- **Enemy types:** FLY, TIMES, ELEMENT.
- **Bonds:** `trBondIds` = 14 bonds, of which 5 are banned (`trBannedBondIds`: kjerag, stead, indom, swift, visi).
- **Shop:** L1–L6 upgrade 4/4/6/9/12/99. 3 operator slots at every level; the item slot appears only at L5+.
- **Prep time:** 999 s.
---
## 2. Match lobby → start (`enterStepList`) [DATA]
| Step | Time (s) | Hint at (s) | Title |
|---|---|---|---|
| INFO_CHECK | 25 | 5 | 确认本局信息 |
| BAND_CHECK | 50 | 15 | 选择策略 |
| BATTLE_CHECK | 3 | 0 | 协议启动 |
**INFO_CHECK** shows the match configuration. The screen shows:
- the stage map (`stageDatasDict`, weight 50 each): FUNNY = act1 m01 only; NORMAL = act1 m01–m04 + act2 m01–m04; HARD/ABYSS = act1 m02–m04 + act2 m01–m04. act1 m05–m07 have weight 0 (unused);
- the R14 boss (weighted: boss_1 胄 6, boss_2 铳 6, boss_3 管 6, boss_4 昆图斯 5, boss_5 卢西恩 10, boss_6 阿利斯泰尔 5, boss_7 萨米的意志 5);
- the special-enemy types (3 of FLY/TIMES/ELEMENT/DOT/INVISIBLE/REFLECTION, `specialEnemyNum`=3);
- the **banned bonds ("B位")**. Bonds with `weight`=10 are bannable. 投资人, 调和, 协防, 绝技 have weight 0 and are never banned.
- Number banned per match: [ASSUMED] 1 core + 1 add-on. The exact count was not found.
- Tip: "归属于特定盟约的干员只有部分会出现在调度中心内". Proposal: an operator is removed from the pool only if *all* its bonds are inactive or banned [ASSUMED].
**BAND_CHECK:** each player picks one strategy ("策略/分队" = band) from the unlocked bands.
- Tip: "联合模拟在选择策略时可以进行一次跳过". [ASSUMED] Each player is offered 3 random unlocked bands and may reroll once.
- Bands are not exclusive between teammates [ASSUMED].
- Clearing 3 times with a band (`victorCount`=3) grants a corner badge (角标).
**Before the match (out-of-match loadout):**
- Players fill 2 DIY slots at tier V and 2 at tier VI (`chess_char_5_diy1/2`, `chess_char_6_diy1/2`) with their own operators. Community: "自己五阶放一个白铁".
- They can borrow up to **20 support operators** (`borrowCount`=20; "助战8变20人"). These cover NORMAL-type chess they don't own. Otherwise the chess falls back to `backupCharId`.
---
## 3. Round schedule [DATA]
Columns: `turnInfoDataDict` (prep time, boss flags), `battleDataDict` (level, SP flag) and each level's `options.maxPlayTime` (combat time limit).
Definitions:
- `normalPhaseTime` = **prep (休整期) time limit in seconds**. Solo = 300. PRTS 上半: "各轮次均有300秒的决策时间"; in 下半 solo prep became unlimited.
- `isSpPrepare` = this round's prep is preceded by a **机变阶段** (choice event). Community counts it as "decision after round N−1".
- `specialPhaseTime` = the choice-phase timer: multi 16 s per picker (first picker 30 s, ~70 s total); solo 150 s, unlimited in 下半.
- Combat (`maxPlayTime`) is the round level's time limit. Enemies still alive at the limit count as leaked [ASSUMED; community "超时" complaints agree].
- All round levels have DP `initialCost`=10, +1 DP/s, max 99, 8 deploy slots.
### Multi (同盟) — NORMAL / HARD / ABYSS (FUNNY = same but ends at R14 and SP rounds differ)
| R | Prep s | Combat s | Level | SP before prep (FUNNY / NORMAL / HARD+ABYSS) | Notes |
|---|---|---|---|---|---|
| 1 | 65 | 45 | act1_01 | – | first 3 rounds: enemies only use the lower red gate [COMM] |
| 2 | 65 | 45 | act1_02 | – | |
| 3 | 65 | 55 | act1_03 | ✔ / ✔ / ✔ | decision after R2 |
| 4 | 95 | 55 | act1_04 | | upper route enemies from R4 [COMM] |
| 5 | 95 | 55 | act1_05 | | |
| 6 | 100 | 70 | act1_06 | – / ✔ / – | decision after R5 (NORMAL) |
| 7 | 105 | 85 | act1_07 | | |
| 8 | 105 | 85 | act1_h01 | | |
| 9 | 125 | 95 | act1_h02 | ✔ / ✔ / ✔ | decision after R8 |
| 10 | 125 | 95 | act1_h03 | | |
| 11 | 125 | 115 | act1_h04 | – / – / ✔ | decision after R10 (HARD+) |
| 12 | 125 | 115 | act1_h05 | | |
| 13 | 150 | 115 | act1_h06 | | |
| 14 | 195 | boss (120 s level; −1 LP/s after `bossTurnHpReduceTime`=150 s) | h07_0X (boss_1..7) | | **最终攻势** (Final Assault). Last round for FUNNY. |
| 15 | 195 (NORMAL) / 215 (HARD, ABYSS) | boss | h08_0X (boss_8..10) | | **隐秘核心** (Hidden Core); only if its conditions are met (§10) |
### Solo (独立)
| Mode | Rounds | SP rounds (`isSpPrepare`) | Prep | Notes |
|---|---|---|---|---|
| single_funny | **9** | none | 300 | R9 = boss (uses `_S` boss levels). No decisions. |
| single_normal | 14 (+15) | 6, 9 (decisions after R5 = 道具补给, R8 = 战术决策) | 300 | |
| single_hard | 14 (+15) | 3, 9, 11 | 300 | |
| single_abyss | 14 (+15) | 3, 9, 11 | 300 | |
Solo boss levels are the `…_S` variants (`level_act1autochess_h07_0X_S`).
Bosses (`bossInfoDict`, HP pool by difficulty FUNNY / NORMAL / HARD / ABYSS) [DATA]:
| bossId | Enemy | Pick weight | HP F / N / H / A | Hidden |
|---|---|---|---|---|
| boss_1 | 假想敌:胄 | 6 | 247 500 / 675 000 / 1 800 000 / 3 600 000 | |
| boss_2 | 假想敌:铳 | 6 | 225 000 / 400 000 / 800 000 / 3 000 000 | |
| boss_3 | 假想敌:管 | 6 | 285 000 / 708 750 / 2 000 000 / 4 000 000 | |
| boss_4 | 盐风主教昆图斯 | 5 | 307 500 / 708 750 / 2 100 000 / 4 200 000 | |
| boss_5 | 卢西恩,“猩红血钻” (new in act2) | 10 | 200 000 / 390 000 / 780 000 / 3 000 000 | |
| boss_6 | 阿利斯泰尔,帝国余晖 | 5 | 285 000 / 705 000 / 1 990 000 / 3 980 000 | |
| boss_7 | “萨米的意志” | 5 | 277 500 / 787 500 / 2 000 000 / 4 000 000 | |
| boss_8 | 假想敌:胄 (hidden) | 50 | 750 000 / 937 500 / 3 600 000 / 7 200 000 | ✔ |
| boss_9 | 假想敌:铳 (hidden) | 40 | 675 000 / 900 000 / 2 800 000 / 3 950 000 | ✔ |
| boss_10 | 假想敌:管 (hidden) | 40 | 825 000 / 1 012 500 / 3 800 000 / 7 600 000 | ✔ |
Boss HP scaling:
- The boss HP is one shared pool for the whole team.
- It shrinks when teammates are eliminated or leave [COMM: "联机队友变少,最后boss血条也会变少"].
- Proposal: `hp = bloodPoint × alivePlayers / 4` [ASSUMED]. Solo uses `bloodPoint × 0.25` [ASSUMED].
---
## 4. Phase flow of one round
```
[round start] ──► (机变阶段 if isSpPrepare) ──► 休整期 (prep) ──► 各自行动阶段 (own combat) ──► 联防 (unite, multi only, if needed) ──► 结算 (settlement) ──► next round
```
### 4.1 Round start ("进入休整期时") — proposed server order [WIKI + ASSUMED order]
1. `round += 1`. If round > 1: `upgradePrice = max(0, upgradePrice − 1)` [WIKI: "升级调度中心所需的资金每回合-1直至0,升级后重置"].
2. `funds = income(round)` + pending coins earned since the last prep (bounties, 暴行-type "下回合加钱", band 玛恩纳, etc.).
- Funds do **not** carry over [WIKI: "没有用完的资金无法留到下一回合"].
- Exception, band 坎诺特 (`coin_carry_over {capital:5, interest:1, max:1}`): keep leftovers, +1 if leftover ≥ 5.
3. Destroy everything in the 临时整备区 (temporary overflow bench) [tip: "处于临时整备区的调度资源,在进入下一回合后会自动销毁"].
4. If `isSpPrepare`: run the 机变阶段 choice (§12) before the shop opens.
5. Free automatic shop refresh of all **non-frozen** slots [ASSUMED, auto-chess convention]. Promotion-reward slots from last round are gone.
6. Fire "<进入休整期时>" effects: bands (e.g. 小贾斯汀 R1/4/7/10, 杰西卡 R4/7/10/13, 松桐 on even rounds…), operator traits (garrison), equipment.
7. Start the prep timer (`normalPhaseTime`; solo unlimited in 下半).
### 4.2 Prep (休整期): what a player can do [WIKI/tips/COMM]
- **Buy** an operator (tier price) or an item from the shop. Bought units go to the bench (待部署区/整备区, max 10). If the bench is full, they go to the temp area and are destroyed at the next round.
- **Sell** any operator or item for **1 fund** by dragging it to the shop area. Tip: "拖拽干员至整备区可以直接撤退干员" = drag a board unit to the bench to un-deploy it.
- **Refresh** the shop: 1 fund. Several effects make refreshes free: 奇迹 bond proc, the "补给" choice.
- **Freeze** shop slots. Tip: "调度中心即使冻结,依然可以主动刷新". [ASSUMED] Freezing is per slot and free. Frozen slots survive refreshes and the next round start; toggle to unfreeze.
- **Upgrade** the 调度中心 (shop level 1→6) at the current price. Broadcast: "{0}博士将调度中心等级提升为{1}级".
- **Arrange** the board: max 8 deployed operators. Grid position matters for traits (前方/周围4格/8格, 同一行, 最右边…).
- **Equip** items to operators on the board or the bench (下半 allows the bench). Use 法术 (MAGIC) items on map tiles.
- **Look** at teammates' boards and at the enemies for this round (right-top magnifier: route / enemy preview).
- **Emotes** (multi). **Ready** (准备): cancellable until all players are ready. Once everyone is ready, combat starts ("全体参与者就绪之前可随时取消准备").
- At prep end: fire "<休整期结束时>" effects (e.g. 助力 +2 layers to all active bonds), then funds are cleared.
### 4.3 Combat (各自行动阶段)
Each player fights their own copy of the round level independently. There is no PvP.
- **Deployment:** automatic, paying DP (start 10, +1/s). **Redeploy also costs DP** [COMM].
- **Deploy order:**
- 上半: left→right, bottom→top.
- 下半: top→bottom, then left→right. The last-placed unit has the highest taunt.
- **Skills:** fire automatically. Units with summons deploy the summon on first deployment regardless of SP [COMM].
- **Perfect clear (完美作战):** every enemy killed before the time limit, none reached the objective.
- **Bond stacking:**
- "<战斗中>" triggers work only here, never in 联防 or boss rounds [COMM].
- Layers persist all match. Tip: "叠加的盟约会在单次模拟中始终保留,但只有在盟约被激活时才会发挥作用".
- Only active bonds gain layers in most cases.
### 4.4 联防 (Unite / joint defense), multi only [WIKI]
- **Trigger:** some but not all players failed to clear perfectly.
- **Setup:** 1–2 players who *did* clear perfectly field their board as-is. They face the **union of all enemies the other players leaked**, using the escaped-battle template `level_act1autochess_escaped_multi` (char limit 20).
- **Rules:**
- Bond stacking is disabled ("该阶段不能叠加层数").
- Bounty kills here pay the killer.
- Only perfect-clear teammates can defend (tip).
- Community: in 下半 the result shown is the *defended* player's view.
- **LP loss:** enemies that also survive 联防 cost their original owner LP (cap 10 per round).
- **No perfect player:** leaks cost LP directly.
- **Partner choice:** [ASSUMED] the defenders are the 2 perfect players with the fewest leaks this round, tie broken by seat index. The official selection rule is unknown.
### 4.5 Settlement
- Each player loses `min(10, number of their enemies not killed)` LP [WIKI: "每回合最多-10血 (就算大漏50个也-10)"]. `enemy.lifePointReduce` is 1 for almost all regular enemies. Use 1 per enemy (see 05-enemies-levels.md).
- **Bounty coins:**
- Kill-type bounty: whoever kills the enemy (owner or 联防 helper) gets the coins.
- "战术特训" type: coins if the chooser cleared perfectly in their own phase.
- Coins are credited to the next prep.
- **Elimination:** LP ≤ 0 → the player is out.
- Multi: their operators return to the shared pool immediately [COMM]. Boss HP drops.
- Community calls ≤10 LP "斩杀线" (one bad round kills you).
- Score/rewards use rounds cleared (`roundScoreDataList`, `baseRewardDataList`).
---
## 5. Economy
### 5.1 Income per round
- **Official text:** "每回合开始时,将会进入休整期并且提供一定量的资金…没有用完的资金无法留到下一回合。" The amounts are not published.
- **Evidence:**
- Band 老鲤 `give_coin_in_round`: R1 = 0, R2 = 0, **R3 = 15**. It reads "第1、2回合的资金暂存至第3回合发放", so **R1+R2+R3 = 15**.
- Linear +1/round then gives **4, 5, 6**. Start 3 gives 12 and start 5 gives 18, so only 4 fits.
- NGA: "2回合升本…亏一块". The R2 upgrade costs 4, and 5 − 4 = 1 is left over, which only fits R2 = 5.
- NGA: "3回合都做不到连买三张". With 6 funds at shop L2, three purchases are impossible.
- **Proposal:** `income(r) = min(3 + r, 10)` → 4,5,6,7,8,9,10,10,10…
- Cap 10 [ASSUMED]. The 2024 season capped at 10–12, and cap 12 is a fine alternative.
- FUNNY: +2 per round [ASSUMED].
- **Other income sources [DATA]:**
- Bounties (1–6 coins).
- Choice "财富" (+1 to every teammate).
- Bonds: 远见 "<每叠加10层>获得一次2资金"; 奇迹 "<每叠加100层>获得20资金" and chance of a free next refresh.
- Items: 骑士储蓄罐 (1–6), 炎 VI items (+2 per acquisition, max 3/round).
- Operator traits: 红豆 0-cost, 暴行 "下回合加钱"…
- Bands: 玛恩纳 (+1 per Kazimierz bought, max 3), 坎诺特 interest, 鸭爵 (+1 per duck-type enemy killed).
### 5.2 Spending [DATA unless noted]
| Action | Cost |
|---|---|
| Buy operator (`shopCharChessInfoData`) | tier I **2**, II **3**, III **3**, IV **3**, V **4**, VI **4**. The same price is charged if the shop offers an elite copy (e.g. "升华" choice). |
| Buy item (`trapChessDataDict.purchasePrice`) | 1–5 by item (tier I items 1, most tier II–IV 2, tier VI 3–4, advanced/金 items 5). See 04-items.md. |
| Refresh | 1 (`shopRefreshPrice`) |
| Sell (operator of any tier, normal *or* elite) | refund **1** (`chessSoldPrice`=1 for all rows). Community special case: selling 煌 gives 1 fund + transforms. |
| Upgrade shop level N→N+1 | `initialUpgradePrice[N]` − rounds elapsed since reaching N (floor 0) |
Shop upgrade base prices (`shopLevelDataDict`, price shown at level N = cost to go N→N+1) [DATA]:
| Mode | L1→2 | L2→3 | L3→4 | L4→5 | L5→6 | Operator slots at L1..L6 | Item slots |
|---|---|---|---|---|---|---|---|
| NORMAL / HARD / ABYSS (solo & multi) | 5 | 8 | 11 | 12 | 13 | 3,4,4,5,5,5 | 1 at every level |
| multi_funny | 5 | 8 | 10 | 11 | 11 | 3,4,4,4,4,5 | 1 |
| single_funny | 1 | 1 | 5 | 8 | 10 | 3,4,4,4,4,5 | 1 |
| training | 4 | 4 | 6 | 9 | 12 | 3 ×6 | 0 (L1–4), 1 (L5–6) |
Level 6 has price 99 (no further upgrade). Level tag colours: #434343, #626654, #445760, #615B74, #6C5E41, #5d341e.
The 20-fund 绮良 / 55-fund 帕格尼尼 band thresholds count **funds spent** (buy, refresh and upgrade) [ASSUMED: all spending].
---
## 6. Shop (调度中心)
- **Slots:** `charChessCount` operator slots + `itemCount` item slots, total ≤ `storeCntMax` = 6.
- **Tier availability:** shop level L offers operators of tier ≤ L [WIKI/COMM: "商店等级决定会卖的干员最高阶级"]. Items also ≤ L [ASSUMED].
- **Chess counts per tier (`charShopChessDatas`, 133 chess):**
| Tier | Visible (PRESET / NORMAL / DIY slots) | Hidden (not in shop; only granted by effects) |
|---|---|---|
| I | 16 PRESET | 4 (incl. Pith's special 盟约·辅助干员) |
| II | 17 PRESET | 2 |
| III | 13 PRESET + 6 NORMAL | 2 |
| IV | 9 PRESET + 13 NORMAL | 4 |
| V | 2 PRESET + 17 NORMAL + 2 DIY | 3 PRESET + 1 NORMAL (百炼嘉维尔) |
| VI | 2 PRESET + 17 NORMAL + 2 DIY | 1 (妮芙) |
Chess types:
- PRESET = fixed system operator.
- NORMAL = uses the player's own operator (or a borrowed support). If neither is available it uses `backupCharId` (预备干员).
- DIY = a player-chosen own operator in a tier V/VI slot.
- **Shared pool (multi) [COMM, bilibili BV1JWy3BkEpt + note.com]:**
- Each operator has a fixed number of copies shared by all 4 players: **I 12, II 14, III 18, IV 16, V 8, VI 5** (缪尔赛思 4).
- An elite occupies 3 copies. Copies in a shop display do **not** occupy the pool.
- All owned copies (board + bench, however obtained) do.
- If owned copies ≥ cap, that operator can no longer roll.
- Eliminated or quitting players' copies return immediately.
- DIY slots: [ASSUMED] private per player, with the same copy caps as their tier.
- **Roll odds [ASSUMED; the official odds are unpublished]:**
- Each empty operator slot draws one copy uniformly at random from **all remaining pool copies** of non-hidden chess with tier ≤ shop level (Hearthstone-Battlegrounds model).
- This matches community observations: "一本卡池最小所以好凑三连", "三本大大稀释浓度", "卡池变大了三连概率低".
- Resulting share of tiers at L6 ≈ I 14%, II 17%, III 25%, IV 26%, V 11%, VI 7%.
- Optional: never show the same operator twice in one roll, except via 梓兰's special refresh.
- **Special refreshes:** band effects replace the normal roll.
- 杜遥夜: first 2 active refreshes each round guarantee 1 Yan.
- 梓兰: every refresh contains 2 copies of one operator and freezes one.
- 佩佩: after upgrading to L2/4/6, one Sargon-priority refresh.
- 寻呼模块 / 信标: special 3- or 2-choice refresh.
- **Promotion reward slot:** see §7. Tip: "晋级的特殊奖励无法被刷新、冻结,也不会保留至下一回合".
---
## 7. Merging (精锐 / elite promotion) [DATA + WIKI]
- **Merge rule:** 3 copies with the same `chessId` merge automatically into `upgradeChessId`, the `_b` elite. Copies on the board or bench all count.
- WIKI: "三位同名干员(无论是否部署)会自动合并且晋级为精锐干员".
- `upgradeNum`=3 for 132 chess; **风丸 `chess_char_2_11_a` needs 2**.
- Elites have `upgradeNum`=0: **no further merging**.
- **Equipment:** anything the merged units carried returns to the bench. Tip: "干员晋级后已配发装备会回收至整备区".
- **Bond traits** of "<获得时>" type: a triple stacks 1+1+2 (the elite value is double) [COMM].
- **Stats (`shopCharChessInfoData`):**
| Tier | Normal copy | Elite copy |
|---|---|---|
| I | E1 Lv55, skill 4 | E2 Lv50, skill 7, module lv1 |
| II | E1 Lv60 | E2 Lv55 |
| III–V | E2 Lv1, skill 4 | E2 Lv60, skill 7, module lv1 |
| VI | E2 Lv1, skill 4 | E2 Lv60, skill 7, module lv3 |
Trust 0. Owned-operator training adds `cultivateEffectList`: E1 +5% ATK/DEF; E2 +10% ATK, +5% DEF/HP; E2 Lv60 +10% all.
- **Promotion reward [COMM]:** after a merge the player gets a special shop offer. They pick **1 of 3 random operators of tier min(shopLevel+1, 6)**, free.
- Sources: "碰出來可以選一個階級+1的幹員"; "资金0でランダムに選ばれた3体の中から1体"; NGA "卡一本…三连给的二本".
- Reward pools are named `pool_chess_shop_{N}_reward` in the data (老鲤 draws from `pool_chess_shop_2_reward` / `_4_reward`).
- It cannot be refreshed or frozen and expires at round end.
- **Broadcast:** "{0}博士的{1}干员晋升为精锐".
---
## 8. Board, bench and bonds (limits only; details in 02-bonds.md)
- **Board:** 8 deployed operators (`maxBattleChessCnt`, level `characterLimit`=8).
- **Bench:** 10 (`maxDeckChessCnt`), plus a temporary overflow area that is wiped each round.
- **Board and bench swaps:** in 下半 operators on the bench and the board can be swapped even with a full bench.
- **Bond activation:** most bonds count only board operators (`activeCondition` BOARD).
- 远见 / 奇迹 / 投资人 are BOARD_AND_DECK: bench units count too. Tip: "一些特殊的盟约不需要部署干员也能激活生效".
- `fallbackBondId`=emptyShip (协防干员): the bond for operators with no faction bond.
---
## 9. Life points (目标生命值)
- **Start:** the band's `totalHp`: 20 (铃兰) … 45 (歌利亚). See §11.
- **Normal rounds:** lose 1 per enemy not killed (leaked or alive at timeout, after 联防), **max 10 per round** (`costPlayerHpLimit`=10) [DATA+WIKI].
- **Loss:** LP ≤ 0 → eliminated.
- Multi: the team wins if anyone survives to kill the R14 boss [ASSUMED: the team result is shared, and the clear counts for everyone still in].
- Quitting counts as elimination.
- **Final assault (R14/R15):**
- All remaining LP of all players merge into one pool; there is no 10 cap.
- After the boss round has run `bossTurnHpReduceTime` = 150 s, the team loses 1 LP per second.
- If regular wave enemies reach the goal and bring LP to 0, the team fails immediately [WIKI].
## 10. Final Assault and Hidden Core [WIKI, PRTS 下半]
- **最终攻势 (R14):**
- Players are paired. Each pair shares one boss battlefield: one plays the left half and one the right half ("两名参与者会处于同一个战场,但无法查看另一组队友的战场情况").
- For the right-side player the board is mirrored ("你的最左边是你的最右边").
- Everyone damages one shared boss HP bar ("所有人将一起对敌方领袖造成伤害"). Some bosses appear on both sides and share HP.
- Broadcasts at 20/50/80% damage share per player: "{0}博士对敌方领袖造成的伤害超过20%!".
- **隐秘核心 (R15, NORMAL+ only):**
- Entry condition after passing R14:
- solo: activated bond layers total **> 350** and LP > 1;
- multi: sum over all players **> 1200** and LP sum > 1.
- (盟约 上半 values were 300 / 1000.)
- The boss is weighted among 胄/铳/管 hidden versions (50/40/40).
- Failing it does not affect the clear or the reward. Success shows a special banner and awards more trophies.
---
## 11. Strategies / bands (`bandDataListDict`, 40 entries) [DATA]
A band is the player's commander/"分队" chosen at BAND_CHECK. It sets **starting LP** (`totalHp`) and grants one permanent rule-changing effect (`effectType` BAND_INITIAL). Common fields: `victorCount`=3 (3 clears → badge) and `bandRewardModulus`=1.0.
`band_zumama` (森蚺, 上半 only) is defined in `autoChessData.bandDataDict` but absent from act2.
Modes column: L=LOCAL(training), S=SINGLE, M=MULTI. Raw rich-text is kept in the JSON as `descRaw`.
| # | bandId | Name | LP | Modes | Effect (plain text) | Key buff params | Unlock |
|---|---|---|---|---|---|---|---|
| 1 | `band_bldsk` | 华法琳 | 28 | L,S,M | 【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2 | `prep_finish_char_bond_add_layer` {"layer": 2.0} | - |
| 2 | `band_amiya` | 阿米娅 | 25 | L,S,M | 【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40% | `env_gbuff_new_with_verify` {"key": "act1autochess_band2_buff", "atk_1": 0.2, "atk_2": 0.3, "atk_3": 0.4, "max_hp_1": 0.2, "max_hp_2": 0.3, "max_hp_3": 0.4, "value_1": 3.0, "value_2": 4.0, "value_3": 5.0} | - |
| 3 | `band_duyaoy` | 杜遥夜 | 29 | L,S,M | 【广交豪杰】每回合前2次主动刷新为特殊刷新:优先刷新出1名<炎>干员 / 在<炎>部分干员缺席时体验可能不完整 | `band_first_self_refresh_present_char` {"bond": "yanShip", "count": 1.0} | - |
| 4 | `band_sarkazb` | 歌利亚 | 45 | L,S,M | 【坚不可摧】初始目标生命值为45点 | | - |
| 5 | `band_orchid` | 梓兰 | 30 | S,M | 【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块> / <寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级) | `band_shop_refresh_copy_max_lv_char` {"count": 1.0}; `preparation_start_gain_chess_from_round` {"round": 10.0, "count": 1.0, "type": "equip", "chess": "chess_item_4_01_e_a"} | 在【标准模拟】中通过第9回合后解锁 |
| 6 | `band_justin` | 小贾斯汀 | 23 | S,M | 【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐> / <骑士储蓄罐>:装备时随机获得1~6资金并销毁 | `preparation_start_gain_chess_from_round` {"round": 1.0, "count": 1.0, "type": "equip", "chess": "chess_item_3_12_e_a"}; `preparation_start_gain_chess_from_round` {"round": 4.0, "count": 1.0, "type": "equip", "chess": "chess_item_3_12_e_a"}; `preparation_start_gain_chess_from_round` {"round": 7.0, "count": 1.0, "type": "equip", "chess": "chess_item_3_12_e_a"}; `preparation_start_gain_chess_from_round` {"round": 10.0, "count": 1.0, "type": "equip", "chess": "chess_item_3_12_e_a"} | 通关【标准模拟】后解锁 |
| 7 | `band_ermengard` | 埃芒加德 | 27 | S,M | 【命结之秘】战斗中前3个被击倒的干员立刻复活! | `env_gbuff_new_with_verify` {"key": "act1autochess_band28_buff", "max_respawn_cnt": 3.0} | 在【险境模拟】中通过第9回合后解锁 |
| 8 | `band_lmlee` | 老鲤 | 27 | S,M | 【得闲饮茶】第1、2回合的资金暂存至第3回合发放;第3回合进入休整期时,获得2、4阶随机干员各1名 | `give_coin_in_round` {"round": 1.0, "coin": 0.0}; `give_coin_in_round` {"round": 2.0, "coin": 0.0}; `give_coin_in_round` {"round": 3.0, "coin": 15.0}; `prep_start_gain_chess_from_pool_in_round` {"round": 3.0, "type": "char", "pool": "pool_chess_shop_2_reward", "count": 1.0}; `prep_start_gain_chess_from_pool_in_round` {"round": 3.0, "type": "char", "pool": "pool_chess_shop_4_reward", "count": 1.0} | 通关任意模拟2次解锁 |
| 9 | `band_kirara` | 绮良 | 26 | S,M | 【通关奖励】每使用20资金,获得一名不高于当前调度中心等级的随机干员 | `band_coin_cost_gain_random_char_by_shop_level` {"coin_cnt": 20.0, "count": 1.0} | 在【险境模拟】中通过第13回合后解锁 |
| 10 | `band_pepe` | 佩佩 | 23 | S,M | 【博学多通】升级调度中心至2、4和6级后,获得1次特殊刷新:此次刷新出现的干员优先为<萨尔贡>干员 / 在<萨尔贡>部分干员缺席时体验可能不完整 | `up_shop_next_refresh_must_present_bond_char` {"bond": "sargonShip", "cnt": 0.0, "price": 0.0, "lvlist": "2,4,6"} | 通关时的结算阵容中,包含6名【萨尔贡】干员解锁 |
| 11 | `band_harold` | 哈洛德 | 23 | S,M | 【人才盲盒】从第4回合开始每2回合获得1名<维多利亚>干员 / 在<维多利亚>部分干员缺席时体验可能不完整 | `gain_bond_char_per_round` {"bond": "victoriaShip", "round": 4.0, "count": 1.0, "preround": 2.0} | 通关时的结算阵容中,包含6名【维多利亚】干员解锁 |
| 12 | `band_sciurus` | 休露丝 | 24 | S,M | 【雪域礼赠】每回合购买的首名<谢拉格>干员消耗资金为1 / 在<谢拉格>部分干员缺席时体验可能不完整 | `first_buy_in_round_char_price_change` {"bond": "kjeragShip", "price": 1.0} | 通关时的结算阵容中,包含6名【谢拉格】干员解锁 |
| 13 | `band_paganini` | 潘格尼尼 | 24 | S,M | 【定制铳械】累计花费55资金后,获得1名4阶及以上随机的精锐<拉特兰>干员 / 在<拉特兰>部分干员缺席时体验可能不完整 | `band_cost_coin_reach_cnt_gain_chess_from_pool` {"coin_cnt": 55.0, "count": 1.0, "type": "char", "pool": "pool_char_later"} | 多场模拟中【拉特兰】盟约累计200层解锁 |
| 14 | `band_clementia` | 克莱门莎 | 26 | S,M | 【崇高牺牲】<阿戈尔>干员被击倒时,提供等同于该干员等阶的<阿戈尔>盟约层数 / 在<阿戈尔>部分干员缺席时体验可能不完整 | `env_gbuff_new_with_verify` {"key": "act1autochess_band13_buff", "bond_type": "bond_by_id", "bond_id": "egirShip", "bond_add_type": "by_charlevel"} | 多场模拟中【阿戈尔】盟约累计200层解锁 |
| 15 | `band_emperor` | 大帝 | 26 | S,M | 【加急调派】我方干员每次部署后再部署时间减少50% | `env_gbuff_new_with_verify` {"key": "act1autochess_band16_buff", "respawn_time": -0.5, "value": 0.0} | 通关时本局内,累计获取过6名特种干员解锁 |
| 16 | `band_mberry` | 桑葚 | 28 | S,M | 【药枚实验】战斗开始时,使最右边的所有我方单位获得效果:每次攻击时有25%的概率获得1层护盾(最多1层) | `env_gbuff_new_with_verify` {"key": "act1autochess_band17_buff", "prob": 0.25} | 通关任意模拟4次解锁 |
| 17 | `band_humus` | 休谟斯 | 24 | L,S,M | 【回收利用】地面干员技能结束时使周围四格随机1名干员获得3点技力 | `env_gbuff_new_with_verify` {"key": "act1autochess_band18_buff", "sp": 3.0} | 通关任意模拟3次解锁 |
| 18 | `band_quintus` | 昆图斯 | 31 | S,M | 【不稳定要素】第3回合获得1件特殊装备<突变细胞> / <突变细胞>:战斗结束后使装备者变为高一阶的随机干员(最高6阶) | `preparation_start_gain_chess_from_round` {"round": 3.0, "count": 1.0, "type": "equip", "chess": "chess_item_5_08_e_a"} | 通关任意模拟3次解锁 |
| 19 | `band_yu` | 余 | 27 | S,M | 【文火慢炖】第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层 | `round_start_bond_check_gain_layer` {"round": 8.0, "factioncount": 1.0, "count1": 36.0, "count2": 12.0} | 在【标准模拟】中通过第9回合后解锁 |
| 20 | `band_doberm` | 杜宾 | 30 | S,M | 【加练!】每2个回合获得1个特殊法术<教鞭> / <教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金 | `preparation_start_gain_chess_every_n_round` {"round": 2.0, "count": 1.0, "type": "trap", "chess": "chess_item_6_03_m"} | 通关任意模拟1次解锁 |
| 21 | `band_cathy` | 凯瑟琳 | 26 | S,M | 【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件 | `up_shop_add_special_goods` {"count": 3.0, "choice": 1.0, "pool": "pool_equip_kathe"} | - |
| 22 | `band_malkie` | 马克维茨 | 28 | S,M | 【商业包装】初始获得1个<商业包装方案> / <商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员 | `preparation_start_gain_chess_from_round` {"round": 1.0, "count": 1.0, "type": "equip", "chess": "chess_item_5_07_e_a"} | 通关任意模拟1次解锁 |
| 23 | `band_qalaisa` | 卡莱莎 | 26 | S,M | 【食腐之蝶】我方干员被击倒时,场上剩余我方单位攻击力+20%(最大200%,持续至该干员被击倒或战斗结束) | `env_gbuff_new_with_verify` {"key": "act1autochess_band15_buff", "atk": 0.2, "max_stack_cnt": 10.0} | 通关任意模拟1次解锁 |
| 24 | `band_chen` | 陈 | 22 | S,M | 【以己之长】所有干员造成的物理和法术伤害变为弱点伤害(根据敌人的防御力和法术抗性变更伤害类型) | `env_gbuff_new_with_verify` {"key": "act1autochess_band19_buff"} | - |
| 25 | `band_damaztic` | “变形者集群” | 29 | S,M | 【变形同构】每5回合获得1件特殊装备<变形同构体> / <变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员 | `preparation_start_gain_chess_from_round` {"round": 5.0, "count": 1.0, "type": "equip", "chess": "chess_item_6_09_e_a"}; `preparation_start_gain_chess_from_round` {"round": 10.0, "count": 1.0, "type": "equip", "chess": "chess_item_6_09_e_a"}; `preparation_start_gain_chess_from_round` {"round": 15.0, "count": 1.0, "type": "equip", "chess": "chess_item_6_09_e_a"} | 通关任意模拟1次解锁 |
| 26 | `band_pith` | Pith | 24 | S,M | 【优等生】第1回合获得1名可以造成神经、灼燃、凋亡损伤的特殊干员,其属于调和盟约:可以使部分盟约人数计数+1 | `preparation_start_gain_chess_from_round` {"round": 1.0, "count": 1.0, "type": "char", "chess": "chess_char_1_15_a"} | - |
| 27 | `band_dusk` | 夕 | 28 | S,M | 【墨色真颜】若你的场上存在同名干员,这些干员攻击力+30% / 【幻画为真】第1回合获得1个特殊法术<画卷> / <画卷>:使用后可以复制该法术范围内的1名干员(包括精锐状态和携带的装备) | `preparation_start_gain_chess_from_round` {"round": 1.0, "count": 1.0, "type": "trap", "chess": "chess_item_6_02_m"}; `env_gbuff_new_with_verify` {"key": "act2autochess_band12_buff", "atk": 0.3} | 通关任意模拟1次解锁 |
| 28 | `band_cannot` | 坎诺特 | 27 | S,M | 【利滚利】每回合剩余的资金可以继承,剩余至少5资金时,每回合额外获得1资金 | `coin_carry_over` {"capital": 5.0, "interest": 1.0, "max": 1.0} | - |
| 29 | `band_ducklord` | 鸭爵 | 29 | S,M | 【“神秘顾客”】从第5回合起,你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>,击倒这些敌人者获得1资金奖励(包括联防阶段) | `round_start_all_player_change_enemy_2` {"min": 0.0, "max": 2.0, "enemylist": "enemy_2002_bearmi_2,enemy_2034_sythef_2,enemy_2085_skzjxd_2,enemy_2001_duckmi_2", "round": 5.0, "count": 1.0, "minweight": 0.6, "maxweight": 0.99} | 通关任意模拟1次解锁 |
| 30 | `band_lisa` | 铃兰 | 20 | S,M | 【御守之力】每个回合开始时,触发战场上一名干员的获得时效果一次(优先选择最靠右和最靠下) | `round_start_activate_char_chess_effect_in_board` {"event_type": "SERVER_GAIN", "count": 1.0} | - |
| 31 | `band_vodfox` | 巫恋 | 22 | S,M | 【替身娃娃】每回合首次出售初始干员时,改为与调度中心中随机一名干员交换 | `first_sell_char_chess_exchange_char_chess_in_shop` {"count": 1.0} | - |
| 32 | `band_ioleta` | 伊奥莱塔·罗素 | 29 | S,M | 【统御号令】战斗开始时,场上每有一个精锐干员,精锐干员的攻击力,生命值+10% | `env_gbuff_new_with_verify` {"key": "act2autochess_band3_buff", "atk_per_cnt": 0.1, "max_hp_per_cnt": 0.1} | - |
| 33 | `band_jesica` | 杰西卡 | 22 | S,M | 【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块> / <寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级) | `preparation_start_gain_chess_from_round` {"round": 4.0, "count": 1.0, "type": "equip", "chess": "chess_item_4_01_e_a"}; `preparation_start_gain_chess_from_round` {"round": 7.0, "count": 1.0, "type": "equip", "chess": "chess_item_4_01_e_a"}; `preparation_start_gain_chess_from_round` {"round": 10.0, "count": 1.0, "type": "equip", "chess": "chess_item_4_01_e_a"}; `preparation_start_gain_chess_from_round` {"round": 13.0, "count": 1.0, "type": "equip", "chess": "chess_item_4_01_e_a"} | 通关任意模拟1次解锁 |
| 34 | `band_mlyss` | 缪尔赛思 | 28 | S,M | 【至纯凝结】在第1回合开始时获得一个进阶<博士投影> / 进阶<博士投影>:任意干员可装备,立即使该干员晋升为精锐干员 | `preparation_start_gain_chess_from_round` {"round": 1.0, "count": 1.0, "type": "equip", "chess": "chess_item_5_06_e_b"} | 通关任意模拟1次解锁 |
| 35 | `band_makiri` | 松桐 | 24 | S,M | 【九流之缘】偶数回合的休整期开始时,随机获得调度中心的一名干员 | `round_start_gain_char_chess_in_shop_every_n_round` {"round": 2.0, "count": 1.0} | - |
| 36 | `band_fang` | 芬 | 24 | S,M | 【协力共进】第8、10、12、14回合时,获得1个<信标> / <信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友 | `preparation_start_gain_chess_from_round` {"round": 8.0, "count": 1.0, "type": "equip", "chess": "chess_item_5_04_e_a"}; `preparation_start_gain_chess_from_round` {"round": 10.0, "count": 1.0, "type": "equip", "chess": "chess_item_5_04_e_a"}; `preparation_start_gain_chess_from_round` {"round": 12.0, "count": 1.0, "type": "equip", "chess": "chess_item_5_04_e_a"}; `preparation_start_gain_chess_from_round` {"round": 14.0, "count": 1.0, "type": "equip", "chess": "chess_item_5_04_e_a"} | 通关任意模拟3次解锁 |
| 37 | `band_mlynar` | 玛恩纳 | 24 | S,M | 【业务指标】每购买一名<卡西米尔>干员,下回合开始时资金+1(每回合至多3资金) / 在<卡西米尔>部分干员缺席时体验可能不完整 | `round_start_gain_coin_by_bond_char_chess_buy` {"bond": "kazimierzShip", "count": 1.0, "max_count": 3.0} | 通关时的结算阵容中,包含6名【卡西米尔】干员解锁 |
| 38 | `band_chiave` | 贾维 | 23 | S,M | 【团伙行动】主动刷新6次调度中心后获得一名不高于当前调度中心等级的<叙拉古>干员(每回合至多获得2名) / 在<叙拉古>部分干员缺席时体验可能不完整 | `refresh_shop_count_gain_coin_bond_char_chess` {"refresh_count": 6.0, "bond": "siracusaShip", "max_count": 2.0} | 通关时的结算阵容中,包含6名【叙拉古】干员解锁 |
| 39 | `band_narant` | 娜仁图亚 | 26 | S,M | 【见者有份】偶数回合进入休整期时,调度中心特殊刷新两件装备,免费获取其中一件;萨尔贡盟约<在场6名不同【萨尔贡】干员>效果替换为【萨尔贡】干员技能开启后,周围八格干员同时享受该干员携带的不高于V阶的装备效果,持续60秒 / 在<萨尔贡>部分干员缺席时体验可能不完整 | `preparation_start_add_special_goods_every_n_round` {"round": 2.0, "count": 1.0, "type": "equip", "pool": "pool_equip_narant", "refresh_cnt": 2.0, "choice_cnt": 1.0} | 通关时的结算阵容中,包含6名【萨尔贡】干员解锁 |
| 40 | `band_amedic` | Touch | 28 | S,M | 【外勤医疗】所有玩家场地上出现一名<预备干员-医疗>;战斗开始时,若场上至少有两名精锐干员,则替换为<Touch> | `auto_chess_change_map` {"common_condition": "condition_golden_chess_le", "cnt": 1.0, "char_605_cmedic#1": 1.0, "char_605_cmedic#2_multi_only": 1.0, "char_605_cmedic#3": 1.0, "char_605_cmedic#4_multi_only": 1.0, "char_613_acmedc#1": 0.0, "char_613_acmedc#2_multi_only": 0.0, "char_613_acmedc#3": 0.0, "char_613_acmedc#4_multi_only": 0.0}; `auto_chess_change_map` {"common_condition": "condition_golden_chess_ge", "cnt": 2.0, "char_613_acmedc#1": 1.0, "char_613_acmedc#2_multi_only": 1.0, "char_613_acmedc#3": 1.0, "char_613_acmedc#4_multi_only": 1.0, "char_605_cmedic#1": 0.0, "char_605_cmedic#2_multi_only": 0.0, "char_605_cmedic#3": 0.0, "char_605_cmedic#4_multi_only": 0.0} | 通关任意模拟3次解锁 |
Band notes for implementation:
- **Economy bands:**
- 坎诺特 (carry-over + interest). `constData.noMoneyTipsBand`=[band_cannot]: the "leftover funds will be lost" warning is not shown to this band.
- 老鲤: R1/R2 income 0, R3 15, plus 1 random tier II and 1 random tier IV at R3.
- 小贾斯汀 (piggy bank 1–6).
- 绮良: 1 random operator of tier ≤ shop level per 20 funds spent.
- 帕格尼尼: an elite Laterano of tier ≥ IV after 55 funds spent (`pool_char_later`).
- 玛恩纳: +1 next round per Kazimierz bought, max 3.
- 贾维: 6 active refreshes → 1 Siracusa operator, max 2/round.
- **Shop-manipulation bands:** 杜遥夜, 梓兰, 佩佩, 凯瑟琳 (on every shop upgrade, choose 1 of 3 items), 巫恋 (the first sale of a normal operator each round swaps it with a random shop operator), 娜仁图亚 (even rounds: 1 of 2 items free).
- **Free-unit bands:** 哈洛德 (a Victoria operator every 2 rounds from R4), 松桐 (even rounds: a random shop operator), Pith (R1 special 调和 operator `chess_char_1_15_a`), 缪尔赛思 (R1 advanced 博士投影 = instant elite), 昆图斯 (R3 突变细胞), 变形者集群 (变形同构体 at R5/10/15), 杰西卡 / 芬 (寻呼模块 / 信标 at fixed rounds), 杜宾 (教鞭 every 2 rounds: add enemies, earn coins on perfect).
- **Combat bands:** 阿米娅, 埃芒加德, 大帝, 桑葚, 休谟斯, 卡莱莎, 陈, 夕, 伊奥莱塔, Touch (swaps a map token 预备干员-医疗 → Touch if ≥2 elites), 余 (R8 bond burst), 华法琳, 克莱门莎, 铃兰 (at each round start re-trigger 1 operator's "获得时" effect).
- **Multi-only effects:**
- 鸭爵 affects teammates too. From R5, 0–2 enemies per round are replaced by 鸭爵/高普尼克/流泪小子/圆仔, each worth 1 coin to the killer.
- 芬's 信标 passes the operator to a teammate.
---
## 12. 机变阶段: choice events (`effectChoiceInfoDict`, 109 entries incl. `_s` solo variants) [DATA + WIKI]
| choiceType | effectType | Name | Ids | What it does |
|---|---|---|---|---|
| EQUIP_FREE | EQUIP | 道具补给 | equip_free_1..4 (+_tr) | Pick 1 free item |
| EQUIP_FREE | EQUIP | 机密商店 | artifact_paid_1..5 | Pick 1 free (special) item |
| BOUNTY_HUNT | ENEMY_GAIN | 悬赏决策 | enemy_initial_1..10, bounty_hunter_1..15, bossInitial_1..6 | Pick a bounty: extra enemies in your next 1 / 2 / all battles; coins on kill or on perfect |
| BUFF_SELECT | BUFF_GAIN | 战术决策 | buff_select_1..4, hardbuff_select_1..3, terrain_m01_1…m07_1 | Team buff, enemy debuff or map change |
| PERSONAL_CHOOSE | ENEMY_GAIN | 悬赏决策 | hunter_band_1 | Personal bounty pick (pool server-side) |
The `_s` ids are the solo copies with identical text.
When they happen:
- Rounds with `isSpPrepare`. PRTS (multi): FUNNY after R2/R8 (no bounties); NORMAL after R2/R5/R8; HARD+ after R2/R8 (bounties fixed) and R10 (no 战术决策).
- Solo NORMAL: R5 道具补给, R8 战术决策.
- Proposed mapping [ASSUMED]:
- HARD/ABYSS: R3 = bounty (enemy_initial / bounty_hunter tier I–II); R9 = bounty (tier II–III); R11 = 道具补给 or 机密商店.
- NORMAL: R3 bounty, R6 道具补给, R9 战术决策.
- FUNNY: R3 道具补给, R9 战术决策.
Choice format:
- **Multi:** 6 options shared by the team. Players pick 1 each in turn (first picker 30 s, others 16 s, `specialPhaseTime`=16, ~70 s total).
- **Solo:** 3 options (3选1), time unlimited in 下半.
- Effects worded "若存在其他队友则他们也获得" apply to everyone.
Bounty semantics (buff keys) [DATA]:
- **Kill bounty** `add_enemy_kill_gain_coin {count, enemy_id, coin, round}`:
- Adds `count` copies of `enemy_id` to the chooser's next `round` battles (99 = all remaining).
- Whoever kills it (the chooser or a 联防 teammate) gets `coin`.
- `enemyPrice` = coin value (1–6).
- **Perfect bounty** `add_enemy_selfbattle_win_gain_coin` / `next_battle_add_enemy_win_gain_coin` ("战术特训"): adds enemies; coins only if the chooser clears perfectly in their own phase.
- **多轮悬赏 · 假想敌 (enemyInitial_1..6):** adds 1 imaginary enemy to every subsequent battle, 1 coin each kill.
Team buffs (BUFF_GAIN, `allybuff_select_*`):
- 列装: 2 random tier‑I items.
- 盟誓 x8: bond layers +8…12 for specific bonds.
- 财富: +1 fund.
- 补给: next 2 refreshes free, stackable.
- 整备: next item bought is advanced.
- 升华: next operator bought is elite.
- 驰援 x8: 1 random operator of a nation bond.
- 自愈, 火力, 征召, 无瑕, 锐利.
Enemy debuffs (`enemydebuff_select_1..9`): all/elite/leader enemies get −ATK%, −DEF or −RES.
Map changes (`map_m0X_*`): remove barricades, turn them into shooting platforms, stop a sandstorm, remove bushes.
The full list of 182 choice, enemy and char-map effects is in Appendix A.
Enemy-side global modifiers (`aceffect_enemy_*`, type ENEMY):
- 急行军: move speed +15%.
- 补给线: HP +8%; 补给线II: HP +20%.
- 攻坚装备: ATK +10%, HP +20%.
- 攻坚装备II: ×0.8 / ×0.8.
- 攻坚装备III: ATK ×0.7, HP ×0.75.
The last two look like the solo/easy stat reductions. [ASSUMED] Map them per mode as in §1.
---
## 13. Scoring, trophies, titles, tips, broadcasts
- **`roundScoreDataList`** (score by rounds cleared) [DATA]: 0→0, 1→10, 2→20, 3→30, 4→50, 5→70, 6→90, 7→110, 8→130, 9→150, 10→170, 11→200, 12→230, 13→260, 14→300. Used for the result screen and history [ASSUMED purpose].
- **Trophies (成就奖杯), multi only** [WIKI PRTS 下半]:
| Rounds passed | 标准 | 险境 | 绝境 | 终极 |
|---|---|---|---|---|
| ≤4 | 0 | 0 | 0 | 0 |
| ≤8 | 1 | 1 | 1 | 1 |
| ≤11 | 2 | 2 | 2 | 2 |
| ≤13 | 2 | 3 | 3 | 3 |
| 14 (clear) | 3 | 4 | 5 | 6 |
| ?? (hidden core) | – | 5 | 7 | 8 |
- **Trophy tiers (`medalDataList`):** 0, 10, 30, 60, 100 → trophy_level1..5 icons. The tier is shown in rooms and at band selection and is used for matchmaking ("精确搜寻").
- **End-of-match titles (`playerTitleDataDict`):** 卫戍之星, 不朽盟约, 坚若磐石, 精英云集, 万事俱备, 挥金如土. [ASSUMED meanings]: top boss damage, most bond layers, least LP lost, most elites, most items, most funds spent.
- **Broadcasts (`broadcastList`)**, in priority order (`{0}` = player, `{1}` = operator/level, `{2}` = amount):
- BOSS_HIT (30): "{0}博士对敌方领袖造成的伤害超过20% / 50% / 80%!"
- CHAR_DAMAGE (20): "{0}博士的{1}干员造成的伤害量达到{2}", at 200 000 / 500 000 / 1 000 000.
- SHOP_LEVEL (11): "{0}博士将调度中心等级提升为{1}级", for levels 2–6.
- BOND_EFFECT (10): "叠加层数".
- GOLDEN_CHAR (2): "{0}博士的{1}干员晋升为精锐".
- CHAR_GIFT (1): "{0}博士给你赠送了{1}".
- **Report reasons (`reportPlayerDataList`):** 消极游戏, 故意捣乱, 中途退出, 不当言论, 骚扰, 作弊, 脚本, 其他.
- **Emote themes:** emoticon_autochess_basic, emoticon_originium_slug, emoticon_autochess_basic_2, emoticon_foolsday_doctor / _amiya / _wisdel. Chat CD 1 s, bubble 3 s (`chatCD`, `chatTime`).
- **Other constData:**
- ping colours <60 / <200 / ≥200 ms;
- `invitationSendCd` 60 s; `matchTimeMax` 120 s;
- `bossTrailerStartRound` 3 (boss preview from R3);
- hint timers 10 s;
- `singleClosureStayTime` 15 s.
- Price colours: discount #59f4ca, premium #ff5454, normal #ffc600.
### Game tips (`gameTipsList`, all weight 50), CN → EN
1. 只有在联合模拟中才能获得奖杯 → Trophies are only earned in co-op (joint) simulation.
2. 联合模拟在选择策略时可以进行一次跳过 → In co-op you may skip once while choosing a strategy.
3. 归属于特定盟约的干员只有部分会出现在调度中心内 → Only some operators of certain alliances appear in the Dispatch Center.
4. 调度中心即使冻结,依然可以主动刷新 → Even when frozen, the Dispatch Center can still be manually refreshed.
5. 晋级的特殊奖励无法被刷新、冻结,也不会保留至下一回合 → Special promotion rewards can't be refreshed or frozen and don't carry to the next round.
6. 处于临时整备区的调度资源,在进入下一回合后会自动销毁 → Resources in the temporary bench are destroyed when the next round begins.
7. 一些特殊的盟约不需要部署干员也能激活生效 → Some special alliances activate without deploying operators.
8. 干员晋级后已配发装备会回收至整备区,需要重新配置 → After promotion, equipped items return to the bench and must be re-equipped.
9. 两件同名装备可以合成一件更强力的装备 → Two identical items merge into a stronger one.
10. 特定的装备组合可以为干员赋予额外的盟约效果 → Certain item combos grant an operator an extra alliance.
11. 叠加的盟约会在单次模拟中始终保留,但只有在盟约被激活时才会发挥作用 → Alliance stacks persist for the whole run but only work while the alliance is active.
12. 多数情况下只有已经激活的盟约才可进行叠加 → Usually only active alliances can gain stacks.
13. 休整期可以查看队友的战场情况 → During the rest phase you can view teammates' battlefields.
14. 休整期可以查看当前回合即将迎击的敌方单位 → During the rest phase you can view this round's incoming enemies.
15. 拖拽干员至整备区可以直接撤退干员 → Drag an operator to the bench to withdraw it.
16. 全体参与者就绪之前可随时取消准备,准备战斗后将无法进行操作 → You can cancel Ready until everyone is ready; after the battle starts no actions are possible.
17. 只有达成完美作战的队友可以进行联防 → Only teammates with a perfect clear can join the joint defense.
18. 进行联防时,盟约不会继续叠加 → Alliances don't gain stacks during joint defense.
19. 最终攻势中,两名参与者会处于同一个战场,但无法查看另一组队友的战场情况 → In the Final Assault, two participants share one battlefield but can't see the other pair's.
20. 最终攻势中,所有人将一起对敌方领袖造成伤害 → In the Final Assault, everyone damages the enemy leader together.
21. 达成特定的条件,可在最终攻势后解锁隐秘核心 → Meeting certain conditions unlocks the Hidden Core after the Final Assault.
---
## 14. Implementation notes for an authoritative server
Proposed server state per player:
```
{ lp, funds, pendingFunds, shopLevel, upgradePrice, roundsAtLevel, shopSlots[6]{chessId|itemId, frozen, special},
board[8]{chessId,pos,items[]}, bench[10], tempBench[], bondLayers{bondId:int}, spentTotal, refreshCountRound, perfectThisRound, eliminated }
```
- Shared state: `pool{chessId:copiesLeft}` (multi), `round`, `phase`, `choiceQueue`, `bannedBonds`, `bossId`, `stageId`, `teamLp` (final).
- Phase timers come from `turnInfoDataDict[mode][round].normalPhaseTime`. When a timer expires, auto-ready the player; they keep whatever they did. The combat limit comes from the round level's `maxPlayTime`.
- Round-start order and settlement: §4.
- Pool bookkeeping must be atomic, since two players can buy the last copy at once. Shop-displayed copies are not reserved, so the purchase fails if the pool is empty [COMM behaviour].
- Merge check runs after every acquisition, including gifts, band grants and item effects. Board and bench copies both count.
- Suggested defaults for undocumented values:
- income `min(3+r, 10)`, FUNNY +2;
- copy-weighted shop roll;
- per-slot free freeze;
- banned bonds per match: 1 core + 1 add-on;
- boss HP `× alive/4` (solo ×0.25).
---
## 15. Open questions
1. **Exact income after R3, and the cap.** The formula is inferred: `3+r` is solid for R1–R3 only; the cap of 10 is a guess (could be 12). FUNNY's "更加丰富" bonus is unknown.
2. **Official shop tier odds.** Unpublished. The copy-weighted model is consistent with community remarks but unverified.
3. **Number of bonds banned per match** and exactly which operators disappear ("只有部分会出现").
4. **Band offer count and skip semantics** at BAND_CHECK (3 offered + 1 reroll assumed).
5. **Which choice-event ids occur at which SP round** (server-side pools). The same applies to the `pool_*` contents.
6. **Boss HP scaling** with player count, and solo boss HP.
7. **联防 defender selection** when more than 2 players are perfect. Whether 1 or 2 defenders fight (PRTS: "1~2名").
8. **Round-15 hidden boss selection.** Is it weighted random, or tied to the R14 boss? Thresholds: 下半 350/1200 vs 上半 300/1000. The sibling doc 05 uses 300/1000; act2 should use **350/1200**.
9. **Whether the team result counts for eliminated players** (trophies are by "自身的通过回合数", i.e. per player rounds survived).
## 16. Sources
- Game data: Kengxxiao/ArknightsGameData zh_CN (`activity_table.json` → `autoChessData`, `activity.AUTOCHESS_SEASON.act2autochess`; level JSONs).
- PRTS: https://prts.wiki/w/卫戍协议 (raw); https://prts.wiki/w/卫戍协议:盟约 (上半); https://prts.wiki/w/卫戍协议:盟约_下半 (modes, hidden core, trophies).
- BWIKI 盟约 玩法介绍: https://wiki.biligame.com/arknights/盟约 (refresh 1, sell 1, merge, funds not kept, upgrade −1/round).
- Official patch notes: https://ak.hypergryph.com/news/5114.
- Bahamut guides: https://forum.gamer.com.tw/C.php?bsn=33651&snA=12294 and snA=12316 (10 LP cap, B位, decision rounds, deploy order, pool rules).
- NGA: tid 45713703 (economy / leveling), 45597280, 45645958, 46479287, 46592301 (mechanics and bugs), 46551276 (R3 bounty numbers).
- bilibili BV1JWy3BkEpt (shared pool 12/14/18); note.com yu_channel_jp (pool I–VI = 12/14/18/16/8/5); note.com aik0aaac (250/400/425 rewards).
- arknights.wiki.gg/wiki/Stronghold_Protocol (upgrade costs, 10 LP cap).
---
## Appendix A: all choice / enemy / char-map effects (`effectInfoDataDict` minus EQUIP/BOND/BAND/GARRISON)
| effectId | Type | Name | Bounty funds | Effect |
|---|---|---|---|---|
| `aceffect_char_1` | CHAR_MAP | 未精英化 | 0 | 无相关加成 |
| `aceffect_char_2` | CHAR_MAP | 精英阶段1 | 0 | 攻击力和防御力+5% |
| `aceffect_char_3` | CHAR_MAP | 精英阶段2 | 0 | 攻击力+10%,防御力和最大生命值+5% |
| `aceffect_char_4` | CHAR_MAP | 精英阶段2-60级 | 0 | 攻击力、防御力和最大生命值+10% |
| `enemyeffect_1` | ENEMY_GAIN | 无人机护障·P·战术特训 | 2 | 为自身下场作战添加3只无人机护障·P,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_2` | ENEMY_GAIN | 法术大师A2·多轮战术特训 | 2 | 之后的每场作战添加2只法术大师A2,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_3_2` | ENEMY_GAIN | 法术大师A2·悬赏 | 2 | 为自身下场作战添加1只法术大师A2,将其击倒者获得2资金 |
| `enemyeffect_3_3` | ENEMY_GAIN | 元核孽生者·悬赏 | 3 | 为自身下场作战添加1只元核孽生者,将其击倒者获得3资金 |
| `enemyeffect_3_4` | ENEMY_GAIN | 山海众头目·悬赏 | 2 | 为自身下场作战添加1只山海众头目,将其击倒者获得2资金 |
| `enemyeffect_3_2_e` | ENEMY_GAIN | “萨科塔之眼”·悬赏 | 2 | 为自身下场作战添加1只“萨科塔之眼”,将其击倒者获得2资金 |
| `enemyeffect_3_3_e` | ENEMY_GAIN | 幽灵组长·悬赏 | 1 | 为自身下场作战添加1只幽灵组长,将其击倒者获得1资金 |
| `enemyeffect_3_4_e` | ENEMY_GAIN | 重装防御者·悬赏 | 1 | 为自身下场作战添加1只重装防御者,将其击倒者获得1资金 |
| `enemyeffect_3_5_e` | ENEMY_GAIN | 重弩突袭者·悬赏 | 2 | 为自身下场作战添加1只重弩突袭者,将其击倒者获得2资金 |
| `enemyeffect_4` | ENEMY_GAIN | 山海众头目·多轮悬赏 | 1 | 之后的每场作战添加1只山海众头目,将其击倒者获得1资金 |
| `enemyeffect_5_1` | ENEMY_GAIN | 源石虫·特训 | 0 | 为自身下场作战添加1只源石虫,但不获得资金 |
| `enemyeffect_5` | ENEMY_GAIN | 鸭爵·悬赏 | 3 | 为自身下场作战添加1只鸭爵,将其击倒者获得3资金 |
| `enemyeffect_6` | ENEMY_GAIN | 高普尼克·悬赏 | 3 | 为自身下场作战添加1只高普尼克,将其击倒者获得3资金 |
| `enemyeffect_7` | ENEMY_GAIN | 流泪小子·悬赏 | 3 | 为自身下场作战添加1只流泪小子,将其击倒者获得3资金 |
| `enemyeffect_8` | ENEMY_GAIN | 圆仔·悬赏 | 3 | 为自身下场作战添加1只圆仔,将其击倒者获得3资金 |
| `enemyeffect_b_1` | ENEMY_GAIN | 碎骨·悬赏 | 1 | 为自身下场作战添加1个碎骨,将其击倒者获得1资金 |
| `enemyeffect_b_2` | ENEMY_GAIN | 萨卡兹百夫长·悬赏 | 2 | 为自身下场作战添加1个萨卡兹百夫长,将其击倒者获得2资金 |
| `enemyeffect_b_3` | ENEMY_GAIN | 弑君者·悬赏 | 1 | 为自身下场作战添加1个弑君者,将其击倒者获得1资金 |
| `enemyeffect_b_4` | ENEMY_GAIN | 泥岩·悬赏 | 4 | 为自身下场作战添加1个泥岩,将其击倒者获得4资金 |
| `enemyeffect_b_5` | ENEMY_GAIN | 腐败骑士·悬赏 | 3 | 为自身下场作战添加1个腐败骑士,将其击倒者获得3资金 |
| `enemyeffect_b_6` | ENEMY_GAIN | 凋零骑士·悬赏 | 3 | 为自身下场作战添加1个凋零骑士,将其击倒者获得3资金 |
| `enemyeffect_b_7` | ENEMY_GAIN | “遗弃者”·悬赏 | 4 | 为自身下场作战添加1个“遗弃者”,将其击倒者获得4资金 |
| `enemyeffect_b_8` | ENEMY_GAIN | 喷气人·悬赏 | 3 | 为自身下场作战添加1个喷气人,将其击倒者获得3资金 |
| `enemyeffect_b_9` | ENEMY_GAIN | 杰斯顿·威廉姆斯·悬赏 | 4 | 为自身下场作战添加1个杰斯顿·威廉姆斯,将其击倒者获得4资金 |
| `enemyeffect_b_10` | ENEMY_GAIN | W·悬赏 | 1 | 为自身下场作战添加1个W,将其击倒者获得1资金 |
| `enemyeffect_b_11` | ENEMY_GAIN | “鼠王”·悬赏 | 2 | 为自身下场作战添加1个“鼠王”,将其击倒者获得2资金 |
| `enemyeffect_b_12` | ENEMY_GAIN | “庞贝”·悬赏 | 2 | 为自身下场作战添加1个“庞贝”,将其击倒者获得2资金 |
| `enemyeffect_b_13` | ENEMY_GAIN | 大鲍勃·悬赏 | 2 | 为自身下场作战添加1个大鲍勃,将其击倒者获得2资金 |
| `enemyeffect_b_14` | ENEMY_GAIN | “自在”·悬赏 | 4 | 为自身下场作战添加1个“自在”,将其击倒者获得4资金 |
| `enemyeffect_b_15` | ENEMY_GAIN | 锏·悬赏 | 5 | 为自身下场作战添加1个锏,将其击倒者获得5资金 |
| `enemyeffect_b_16` | ENEMY_GAIN | 扎罗,“狼之主”·悬赏 | 5 | 为自身下场作战添加1个扎罗,“狼之主”,将其击倒者获得5资金 |
| `enemyeffect_b_17` | ENEMY_GAIN | “墓碑”·悬赏 | 4 | 为自身下场作战添加1个“墓碑”,将其击倒者获得4资金 |
| `enemyeffect_b_18` | ENEMY_GAIN | 迷路的巨像·悬赏 | 5 | 为自身下场作战添加1个迷路的巨像,将其击倒者获得5资金 |
| `enemyeffect_b_19` | ENEMY_GAIN | 澪·悬赏 | 3 | 为自身下场作战添加1个澪,将其击倒者获得3资金 |
| `enemyeffect_b_20` | ENEMY_GAIN | “巨大的丑东西”·悬赏 | 5 | 为自身下场作战添加1个“巨大的丑东西”,将其击倒者获得5资金 |
| `enemyeffect_b_21` | ENEMY_GAIN | “复仇者”·悬赏 | 6 | 为自身下场作战添加1个“复仇者”,将其击倒者获得6资金 |
| `enemyeffect_b_22` | ENEMY_GAIN | “邪魔的利刃”·悬赏 | 3 | 为自身下场作战添加1个“邪魔的利刃”,将其击倒者获得3资金 |
| `enemyeffect_b_23` | ENEMY_GAIN | 陷落雪祀·悬赏 | 3 | 为自身下场作战添加1个陷落雪祀,将其击倒者获得3资金 |
| `enemyeffect_b_24` | ENEMY_GAIN | 纠缠藤蔓·悬赏 | 3 | 为自身下场作战添加1个纠缠藤蔓,将其击倒者获得3资金 |
| `allybuff_select_1` | BUFF_GAIN | 列装 | 0 | 你获得2件随机1阶装备,若存在其他队友则他们也获得 |
| `allybuff_select_2_1` | BUFF_GAIN | 斯卡蒂的盟誓 | 0 | 你的阿戈尔、突袭和坚守盟约层数+8,若存在其他队友则他们也获得 |
| `allybuff_select_2_2` | BUFF_GAIN | 诗怀雅的盟誓 | 0 | 你的炎和投资人盟约层数+10,若存在其他队友则他们也获得 |
| `allybuff_select_2_3` | BUFF_GAIN | 风笛的盟誓 | 0 | 你的维多利亚、远见和不屈盟约层数+8,若存在其他队友则他们也获得 |
| `allybuff_select_2_4` | BUFF_GAIN | 银灰的盟誓 | 0 | 你的谢拉格、投资人、迅捷盟约层数+8,若存在其他队友则他们也获得 |
| `allybuff_select_2_5` | BUFF_GAIN | 莫斯提马的盟誓 | 0 | 你的拉特兰、奥术盟约层数+10,若存在其他队友则他们也获得 |
| `allybuff_select_2_6` | BUFF_GAIN | 缇缇的盟誓 | 0 | 你的萨尔贡、精准盟约层数+10,若存在其他队友则他们也获得 |
| `allybuff_select_2_7` | BUFF_GAIN | 玛恩纳的盟誓 | 0 | 你的卡西米尔盟约层数+12,若存在其他队友则他们也获得 |
| `allybuff_select_2_8` | BUFF_GAIN | 德克萨斯的盟誓 | 0 | 你的叙拉古、突袭盟约层数+10,若存在其他队友则他们也获得 |
| `allybuff_select_3` | BUFF_GAIN | 财富 | 0 | 你获得1资金,若存在其他队友则他们也获得 |
| `allybuff_select_4` | BUFF_GAIN | 补给 | 0 | 你接下来的2次刷新变为免费(可叠加),若存在其他队友则他们也获得 |
| `allybuff_select_5` | BUFF_GAIN | 整备 | 0 | 你接下来购买的1件装备变为进阶品质 |
| `allybuff_select_6` | BUFF_GAIN | 升华 | 0 | 你接下来购买的1名干员变为精锐状态 |
| `allybuff_select_7_1` | BUFF_GAIN | 炎盟约驰援 | 0 | 你获得1名炎盟约的随机干员棋子 |
| `allybuff_select_7_2` | BUFF_GAIN | 谢拉格驰援 | 0 | 你获得1名谢拉格盟约的随机干员棋子 |
| `allybuff_select_7_3` | BUFF_GAIN | 萨尔贡驰援 | 0 | 你获得1名萨尔贡盟约的随机干员棋子 |
| `allybuff_select_7_4` | BUFF_GAIN | 叙拉古驰援 | 0 | 你获得1名叙拉古盟约的随机干员棋子 |
| `allybuff_select_7_5` | BUFF_GAIN | 卡西米尔驰援 | 0 | 你获得1名卡西米尔盟约的随机干员棋子 |
| `allybuff_select_7_6` | BUFF_GAIN | 拉特兰驰援 | 0 | 你获得1名拉特兰盟约的随机干员棋子 |
| `allybuff_select_7_7` | BUFF_GAIN | 维多利亚驰援 | 0 | 你获得1名维多利亚盟约的随机干员棋子 |
| `allybuff_select_7_8` | BUFF_GAIN | 阿戈尔驰援 | 0 | 你获得1名阿戈尔盟约的随机干员棋子 |
| `allybuff_select_11` | BUFF_GAIN | 自愈 | 0 | 你获得:我方单位每次受到伤害时回复50点生命值;若存在其他队友则他们也获得此效果 |
| `allybuff_select_13` | BUFF_GAIN | 火力 | 0 | 你获得:休整期结束时若手牌区至少有3个单位,我方所有干员无视敌人20法术抗性;若存在其他队友则他们也获得此效果 |
| `allybuff_select_14` | BUFF_GAIN | 征召 | 0 | 你获得:开始作战时若场上至少有一行存在3名干员,所有干员的再部署时间-50%;若存在其他队友则他们也获得此效果 |
| `allybuff_select_18` | BUFF_GAIN | 无瑕 | 0 | 你获得:干员在生命值为满时,攻击力、防御力+30%;若存在其他队友则他们也获得此效果 |
| `allybuff_select_19` | BUFF_GAIN | 锐利 | 0 | 你获得:休整期结束时若手牌区为空,我方所有干员无视敌人30%的防御力;若存在其他队友则他们也获得此效果 |
| `enemydebuff_select_1` | BUFF_GAIN | 排斥:乏力 | 0 | 全部敌人获得减益效果:攻击力-5% |
| `enemydebuff_select_2` | BUFF_GAIN | 排斥:脆弱 | 0 | 全部敌人获得减益效果:防御力-100 |
| `enemydebuff_select_3` | BUFF_GAIN | 排斥:致幻 | 0 | 全部敌人获得减益效果:法术抗性-3 |
| `enemydebuff_select_4` | BUFF_GAIN | 责罚:乏力 | 0 | 精英敌人获得减益效果:攻击力-6% |
| `enemydebuff_select_5` | BUFF_GAIN | 责罚:脆弱 | 0 | 精英敌人获得减益效果:防御力-150 |
| `enemydebuff_select_6` | BUFF_GAIN | 责罚:致幻 | 0 | 精英敌人获得减益效果:法术抗性-5 |
| `enemydebuff_select_7` | BUFF_GAIN | 裁决:乏力 | 0 | 领袖敌人获得减益效果:攻击力-7% |
| `enemydebuff_select_8` | BUFF_GAIN | 裁决:脆弱 | 0 | 领袖敌人获得减益效果:防御力-200 |
| `enemydebuff_select_9` | BUFF_GAIN | 裁决:致幻 | 0 | 领袖敌人获得减益效果:法术抗性-7 |
| `aceffect_enemy_1` | ENEMY | 急行军 | 0 | 所有敌人移动速度+15% |
| `aceffect_enemy_2` | ENEMY | 补给线 | 0 | 所有敌人最大生命值+8% |
| `aceffect_enemy_2_2` | ENEMY | 补给线II | 0 | 所有敌人最大生命值+20% |
| `aceffect_enemy_3` | ENEMY | 攻坚装备 | 0 | 所有敌人攻击力+10%,最大生命值+20% |
| `aceffect_enemy_4` | ENEMY | 攻坚装备II | 0 | 所有敌人攻击力-20%,最大生命值-20% |
| `aceffect_enemy_5` | ENEMY | 攻坚装备III | 0 | 所有敌人攻击力-30%,最大生命值-25% |
| `enemyInitial_1` | ENEMY_GAIN | 多轮悬赏 · 假想敌:蚀裂 | 1 | 后续每场作战添加1只假想敌:蚀裂(被击倒时造成持续伤害),击倒它的你或队友获得1资金 |
| `enemyInitial_2` | ENEMY_GAIN | 多轮悬赏 · 假想敌:淤困 | 1 | 后续每场作战添加1只假想敌:淤困(可造成元素损伤),击倒它的你或队友获得1资金 |
| `enemyInitial_3` | ENEMY_GAIN | 多轮悬赏 · 假想敌:骨刺 | 1 | 后续每场作战添加1只假想敌:骨刺(拥有隐匿),击倒它的你或队友获得1资金 |
| `enemyInitial_4` | ENEMY_GAIN | 多轮悬赏 · 假想敌:黑云 | 1 | 后续每场作战添加1只假想敌:黑云(空中敌人),击倒它的你或队友获得1资金 |
| `enemyInitial_5` | ENEMY_GAIN | 多轮悬赏 · 假想敌:再生 | 1 | 后续每场作战添加1只假想敌:再生(需要一定攻击次数击破),击倒它的你或队友获得1资金 |
| `enemyInitial_6` | ENEMY_GAIN | 多轮悬赏 · 假想敌:镜膜 | 1 | 后续每场作战添加1只假想敌:镜膜(拥有折射),击倒它的你或队友获得1资金 |
| `map_m01_1` | BUFF_GAIN | 模拟战场演变·模式一 | 0 | 移除场地上全部阻隔工事 |
| `map_m02_1` | BUFF_GAIN | 模拟战场演变·模式二 | 0 | 场地上全部阻隔工事变为射击台 |
| `map_m02_2` | BUFF_GAIN | 模拟战场演变·模式三 | 0 | 移除场地上全部阻隔工事 |
| `map_m03_1` | BUFF_GAIN | 模拟战场演变·模式四 | 0 | 移除场地左侧的阻隔工事 |
| `map_m03_2` | BUFF_GAIN | 模拟战场演变·模式五 | 0 | 场地右侧的射击台变为可以部署高台干员和地面干员的地面 |
| `map_m04_1` | BUFF_GAIN | 模拟战场演变·模式六 | 0 | 移除场地上全部阻隔工事 |
| `map_m06_1` | BUFF_GAIN | 模拟战场演变·模式八 | 0 | 场地中的沙尘暴停止 |
| `map_m07_1` | BUFF_GAIN | 模拟战场演变·模式九 | 0 | 场上的树丛全部消失 |
| `enemyeffect_10_1` | ENEMY_GAIN | 战术特训·飞行I | 1 | 为自身下场战斗添加1只妖怪,若各自行动阶段就达成完美作战,获得1资金 |
| `enemyeffect_10_2` | ENEMY_GAIN | 战术特训·飞行II | 2 | 为自身下场战斗添加1只暴鸰,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_10_3` | ENEMY_GAIN | 战术特训·飞行III | 3 | 为自身下场战斗添加1只帝国炮火中枢先兆者,若各自行动阶段就达成完美作战,获得3资金 |
| `enemyeffect_10_4` | ENEMY_GAIN | 悬赏·飞行I | 1 | 为自身接下来两场作战添加1只妖怪,将其击倒者获得1资金 |
| `enemyeffect_10_5` | ENEMY_GAIN | 悬赏·飞行II | 2 | 为自身接下来两场作战添加1只暴鸰,将其击倒者获得2资金 |
| `enemyeffect_10_6` | ENEMY_GAIN | 悬赏·飞行III | 3 | 为自身接下来两场作战添加1只法术大师A1,将其击倒者获得3资金 |
| `enemyeffect_11_1` | ENEMY_GAIN | 战术特训·频次I | 1 | 为自身下场战斗添加1只磨砻,若各自行动阶段就达成完美作战,获得1资金 |
| `enemyeffect_11_2` | ENEMY_GAIN | 战术特训·频次II | 2 | 为自身下场战斗添加1只烹泉,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_11_3` | ENEMY_GAIN | 战术特训·频次III | 3 | 为自身下场战斗添加1只新硎,若各自行动阶段就达成完美作战,获得3资金 |
| `enemyeffect_11_4` | ENEMY_GAIN | 悬赏·频次I | 1 | 为自身接下来两场作战添加1只磨砻,将其击倒者获得1资金 |
| `enemyeffect_11_5` | ENEMY_GAIN | 悬赏·频次II | 2 | 为自身接下来两场作战添加1只烹泉,将其击倒者获得2资金 |
| `enemyeffect_11_6` | ENEMY_GAIN | 悬赏·频次III | 3 | 为自身接下来两场作战添加1只清明,将其击倒者获得3资金 |
| `enemyeffect_12_1` | ENEMY_GAIN | 战术特训·损伤I | 1 | 为自身下场战斗添加1只底海滑动者,若各自行动阶段就达成完美作战,获得1资金 |
| `enemyeffect_12_2` | ENEMY_GAIN | 战术特训·损伤II | 2 | 为自身下场战斗添加1只炽焰源石虫,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_12_3` | ENEMY_GAIN | 战术特训·损伤III | 3 | 为自身下场战斗添加1只灼藤,若各自行动阶段就达成完美作战,获得3资金 |
| `enemyeffect_12_4` | ENEMY_GAIN | 悬赏·损伤I | 1 | 为自身接下来两场作战添加1只底海滑动者,将其击倒者获得1资金 |
| `enemyeffect_12_5` | ENEMY_GAIN | 悬赏·损伤II | 2 | 为自身接下来两场作战添加1只控潮术师,将其击倒者获得2资金 |
| `enemyeffect_12_6` | ENEMY_GAIN | 悬赏·损伤III | 3 | 为自身接下来两场作战添加1只术师快艇,将其击倒者获得3资金 |
| `enemyeffect_13_1` | ENEMY_GAIN | 战术特训·持续I | 1 | 为自身下场战斗添加1只逐腐兽,若各自行动阶段就达成完美作战,获得1资金 |
| `enemyeffect_13_2` | ENEMY_GAIN | 战术特训·持续II | 2 | 为自身下场战斗添加1只萨卡兹枯朽战士组长,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_13_3` | ENEMY_GAIN | 战术特训·持续III | 3 | 为自身下场战斗添加1只尖端萨卡兹枯朽战车,若各自行动阶段就达成完美作战,获得3资金 |
| `enemyeffect_13_4` | ENEMY_GAIN | 悬赏·持续I | 1 | 为自身接下来两场作战添加1只萨卡兹枯朽战士组长,将其击倒者获得1资金 |
| `enemyeffect_13_5` | ENEMY_GAIN | 悬赏·持续II | 2 | 为自身接下来两场作战添加1只逐腐兽,将其击倒者获得2资金 |
| `enemyeffect_13_6` | ENEMY_GAIN | 悬赏·持续III | 3 | 为自身接下来两场作战添加1只萨卡兹枯朽战车,将其击倒者获得3资金 |
| `enemyeffect_14_1` | ENEMY_GAIN | 战术特训·隐匿I | 1 | 为自身下场战斗添加1只隐形弩手,若各自行动阶段就达成完美作战,获得1资金 |
| `enemyeffect_14_2` | ENEMY_GAIN | 战术特训·隐匿II | 2 | 为自身下场战斗添加1只节日爵士乐手,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_14_3` | ENEMY_GAIN | 战术特训·隐匿III | 3 | 为自身下场战斗添加1只家族灭迹人,若各自行动阶段就达成完美作战,获得3资金 |
| `enemyeffect_14_4` | ENEMY_GAIN | 悬赏·隐匿I | 1 | 为自身接下来两场作战添加1只隐形弩手,将其击倒者获得1资金 |
| `enemyeffect_14_5` | ENEMY_GAIN | 悬赏·隐匿II | 2 | 为自身接下来两场作战添加1只隐形术师,将其击倒者获得2资金 |
| `enemyeffect_14_6` | ENEMY_GAIN | 悬赏·隐匿III | 3 | 为自身接下来两场作战添加1只山海众头目,将其击倒者获得3资金 |
| `enemyeffect_15_1` | ENEMY_GAIN | 战术特训·折射I | 1 | 为自身下场战斗添加1只深池方阵战士,若各自行动阶段就达成完美作战,获得1资金 |
| `enemyeffect_15_2` | ENEMY_GAIN | 战术特训·折射II | 2 | 为自身下场战斗添加1只深池暗影术师队长,若各自行动阶段就达成完美作战,获得2资金 |
| `enemyeffect_15_3` | ENEMY_GAIN | 战术特训·折射III | 3 | 为自身下场战斗添加1只深池伙友卫队精英,若各自行动阶段就达成完美作战,获得3资金 |
| `enemyeffect_15_4` | ENEMY_GAIN | 悬赏·折射I | 1 | 为自身接下来两场作战添加1只深池方阵战士,将其击倒者获得1资金 |
| `enemyeffect_15_5` | ENEMY_GAIN | 悬赏·折射II | 2 | 为自身接下来两场作战添加1只深池暗影术师,将其击倒者获得2资金 |
| `enemyeffect_15_6` | ENEMY_GAIN | 悬赏·折射III | 3 | 为自身接下来两场作战添加1只深池伙友卫队,将其击倒者获得3资金 |
| `enemyeffect_16_1` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只泥岩巨像,将其击倒者获得3资金 |
| `enemyeffect_16_2` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只“越长尘”,将其击倒者获得3资金 |
| `enemyeffect_16_3` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只狂暴宿主组长,将其击倒者获得3资金 |
| `enemyeffect_16_4` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只温顺的武装大驮兽,将其击倒者获得3资金 |
| `enemyeffect_16_5` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只萨卡兹骸骨拷打者,将其击倒者获得3资金 |
| `enemyeffect_16_6` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只高准度伦蒂尼姆城防自行炮,将其击倒者获得3资金 |
| `enemyeffect_16_7` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只乌顶巨角卢鲁,将其击倒者获得3资金 |
| `enemyeffect_16_8` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身下场战斗添加1只伊利昂的木驮兽,将其击倒者获得3资金 |
| `enemyeffect_16_9` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身下场战斗添加1只拥霜羽兽,将其击倒者获得2资金 |
| `enemyeffect_16_10` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身下场战斗添加1只异质裂兽·α,将其击倒者获得2资金 |
| `enemyeffect_16_11` | ENEMY_GAIN | 悬赏·损伤I | 1 | 为自身下场战斗添加1只心虚设计师,将其击倒者获得1资金 |
| `enemyeffect_16_12` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身下场战斗添加1只转译基底·α,将其击倒者获得1资金 |
| `enemyeffect_10_7` | ENEMY_GAIN | 悬赏·飞行I | 1 | 为自身下场战斗添加1只法术大师A2,将其击倒者获得1资金 |
| `enemyeffect_10_8` | ENEMY_GAIN | 悬赏·飞行II | 2 | 为自身下场战斗添加1只枯朽萃聚使徒,将其击倒者获得2资金 |
| `enemyeffect_11_7` | ENEMY_GAIN | 悬赏·频次I | 1 | 为自身下场战斗添加1只沏虹,将其击倒者获得1资金 |
| `enemyeffect_11_8` | ENEMY_GAIN | 悬赏·频次II | 2 | 为自身下场战斗添加1只新硎,将其击倒者获得2资金 |
| `enemyeffect_12_7` | ENEMY_GAIN | 悬赏·损伤II | 2 | 为自身下场战斗添加1只灼藤,将其击倒者获得2资金 |
| `enemyeffect_12_8` | ENEMY_GAIN | 悬赏·损伤II | 2 | 为自身下场战斗添加1只异光体孽生者,将其击倒者获得2资金 |
| `enemyeffect_13_7` | ENEMY_GAIN | 悬赏·持续I | 1 | 为自身下场战斗添加1只假想敌:蚀裂,将其击倒者获得1资金 |
| `enemyeffect_13_8` | ENEMY_GAIN | 悬赏·持续II | 2 | 为自身下场战斗添加1只尖端萨卡兹枯朽战车,将其击倒者获得2资金 |
| `enemyeffect_14_7` | ENEMY_GAIN | 悬赏·隐匿I | 1 | 为自身下场战斗添加1只重弩突袭者,将其击倒者获得1资金 |
| `enemyeffect_14_8` | ENEMY_GAIN | 悬赏·隐匿II | 2 | 为自身下场战斗添加1只家族暗影灭迹人,将其击倒者获得2资金 |
| `enemyeffect_15_7` | ENEMY_GAIN | 悬赏·折射II | 2 | 为自身下场战斗添加1只深池重甲卫士队长,将其击倒者获得2资金 |
| `enemyeffect_15_8` | ENEMY_GAIN | 悬赏·折射II | 2 | 为自身下场战斗添加1只愤怒的守墓石像,将其击倒者获得2资金 |
| `enemyeffect_17_1` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只雪孩子,将其击倒者获得1资金 |
| `enemyeffect_17_2` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只新手祭司学徒,将其击倒者获得1资金 |
| `enemyeffect_17_3` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只冒失的小弟,将其击倒者获得1资金 |
| `enemyeffect_17_4` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身接下来两场作战添加1只骇笑看客,将其击倒者获得2资金 |
| `enemyeffect_17_5` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身接下来两场作战添加1只水遁忍者,将其击倒者获得2资金 |
| `enemyeffect_17_6` | ENEMY_GAIN | 悬赏·特异III | 3 | 为自身接下来两场作战添加1只自制投石机,将其击倒者获得3资金 |
| `enemyeffect_18_1` | ENEMY_GAIN | 悬赏·损伤I | 1 | 为自身接下来两场作战添加1只临时收音师,将其击倒者获得1资金 |
| `enemyeffect_18_2` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只迷茫的修理小助手,将其击倒者获得1资金 |
| `enemyeffect_18_3` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只高能源石虫,将其击倒者获得1资金 |
| `enemyeffect_18_4` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身接下来两场作战添加1只圣堂剑士,将其击倒者获得2资金 |
| `enemyeffect_18_5` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身接下来两场作战添加1只寻仇者,将其击倒者获得2资金 |
| `enemyeffect_18_6` | ENEMY_GAIN | 悬赏·损伤III | 3 | 为自身接下来两场作战添加1只主角阵营角色,将其击倒者获得3资金 |
| `enemyeffect_19_1` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只节日气球,将其击倒者获得1资金 |
| `enemyeffect_19_2` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只简饲源石虫,将其击倒者获得1资金 |
| `enemyeffect_19_3` | ENEMY_GAIN | 悬赏·折射I | 1 | 为自身接下来两场作战添加1只深池侦察犬pro,将其击倒者获得1资金 |
| `enemyeffect_19_4` | ENEMY_GAIN | 悬赏·飞行II | 2 | 为自身接下来两场作战添加1只吉兆飞鳞,将其击倒者获得2资金 |
| `enemyeffect_19_5` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身接下来两场作战添加1只咸鳞汁推荐者,将其击倒者获得2资金 |
| `enemyeffect_19_6` | ENEMY_GAIN | 悬赏·损伤III | 3 | 为自身接下来两场作战添加1只反派阵营角色,将其击倒者获得3资金 |
| `enemyeffect_20_1` | ENEMY_GAIN | 悬赏·持续I | 1 | 为自身接下来两场作战添加1只萨卡兹枯朽战士,将其击倒者获得1资金 |
| `enemyeffect_20_2` | ENEMY_GAIN | 悬赏·频次I | 1 | 为自身接下来两场作战添加1只俗心,将其击倒者获得1资金 |
| `enemyeffect_20_3` | ENEMY_GAIN | 悬赏·特异I | 1 | 为自身接下来两场作战添加1只潜伏者,将其击倒者获得1资金 |
| `enemyeffect_20_4` | ENEMY_GAIN | 悬赏·特异II | 2 | 为自身接下来两场作战添加1只“独轮车玩具”,将其击倒者获得2资金 |
| `enemyeffect_20_5` | ENEMY_GAIN | 悬赏·损伤II | 2 | 为自身接下来两场作战添加1只无胄盟清扫小队,将其击倒者获得2资金 |
| `enemyeffect_20_6` | ENEMY_GAIN | 悬赏·损伤III | 3 | 为自身接下来两场作战添加1只鼎沸,将其击倒者获得3资金 |
---
## Addendum (critic)
Written by the completeness critic on 2026-09-27, after cross-reading 01–07. New sources:
- PRTS `卫戍协议/帮助` and the raw wikitext of `卫戍协议:盟约_下半` §开始模拟.
- The raw wikitext of the BWIKI page `盟约` and of `盟约/S.W.E.E.P.报告`.
- The official gameplay-intro long image (`img.71acg.net/sykb~bbs/pc/1763033996225879`, 800×13685, in 10 slices).
- The TW/CN screenshots already listed in 06 §15.
- The official 3/27 notice (`ak.hypergryph.com/news/8584`).
Local copies are in `scratchpad/gd/extra/critic/` (`bw_盟约.txt`, `bw_sweep.txt`, `bw_卫戍协议_1.txt`, `intro_00..09.jpg`, screenshots). Tags: **[VERIFIED]** = from an official or wiki rule text or a screenshot. **[ASSUMED]** = still a proposal.
### A1. Corrections and confirmations to this file
| § | Was | Now | Tag |
|---|---|---|---|
| 0, 5.1 | income R1–R3 = 4/5/6 inferred. Cap 10. FUNNY +2 | **R1 = 4, R2 = 5, R3 = 6 confirmed by screenshots.** R1 = 4: official intro (标准, round 1, funds 4, board empty) and TW client R1. R2 = 5: TW R2, upgrade price 4. R3 = 6: official intro 标准 R3 and the co-op 道具补给 screenshot at R3. **FUNNY gets no bonus** (the official intro shows 4 at R1 in 标准). Cap still unknown: **recommend `income(r) = min(3 + r, 12)`**, because the predecessor 卫戍协议 (2024) used "从第2回合开始每回合获得资金数+1,上限12" and 盟约's upgrade prices (5/8/11/12/13) are one step above 2024's (5/6/8/11/12). Keep the cap in config (10 is the alternative). | VERIFIED R1–R3; cap ASSUMED |
| 4.1 | round-start auto refresh [ASSUMED] | Confirmed: "进入此阶段时,触发<进入休整期时>的效果,分配一定资金,刷新调度中心未被冻结的位置". Income is granted **before** the 机变 phase: funds are visible behind every 机变 overlay screenshot. | VERIFIED |
| 4.2 / 14 | freeze per slot [ASSUMED] | **One 冻结 button freezes ALL unsold slots**, operators and item: "【冻结】会将当前整备区的全部未售出栏位冻结,将其保留至下一回合。点击【解冻】可以解除该操作". The freeze is consumed at the next round start. In the official intro, frozen cards reappear in R3 and the button reads 冻结 again. A manual refresh is allowed while frozen (tip 4) and rerolls every slot, frozen ones included (2024 rule "重新调配包括冻结中的所有干员和道具"). Keep a per-slot `frozen` flag internally, because 梓兰 freezes one card. | VERIFIED |
| new | – | At **combat start, all unfrozen shop contents are cleared** ("进入作战期后,栏上若未处于冻结状态则清空全部物资"). | VERIFIED |
| 4.2 / 8 | bench 10 + temp area | **Hand (手牌区/整备区) = 10 regular + 5 temporary slots**, shared by operators, **items** and hand-deployable **summons**. Several summons of one owner stack into 1 slot; summons of different owners don't stack.<br>When the regular hand is full, every action that would add a card is refused: buy, and withdraw from the board. Exception: a purchase that completes a 3-merge nets −1 card, so allow it.<br>Passive gains (merge result, band/trait/item grants, items returned by promotion) overflow into the temp slots. **While temp is non-empty the player cannot start combat.** Temp is wiped when the next phase starts.<br>A board↔hand swap is allowed even with a full hand. Cards fill hand slots **right→left**. | VERIFIED |
| 5.2 | – | **Items cannot be sold** ("多余的装备无法进行出售,可以销毁"). They can only be destroyed for 0 funds. Operators sell for 1 from board or hand. | VERIFIED |
| 7 | promotion reward [COMM] | Confirmed (PRTS 帮助, BWIKI, official intro). On acquiring the 3rd copy:<br>1. The 3rd copy is not created, and the 2 owned copies are destroyed wherever they are.<br>2. **1 elite goes to the hand, not to a board tile.** Their equipment returns to the hand.<br>3. The shop then temporarily shows **3 operators of tier min(shopLevel+1, 6) at price 0**. The official intro shows "II 0 ×3" after a tier-I merge at L1 and "III 0 ×3" at L2.<br>4. The player takes **1**. The offer cannot be refreshed or frozen and expires at round end. | VERIFIED |
| 2 | BAND_CHECK "3 random + reroll" [ASSUMED] | **All unlocked bands are shown.** In co-op, players pick **one at a time in a random order** shown on the left ("以随机顺序进行策略轮选"). Each co-op player may **skip once**. The step lasts 50 s; on timeout the band is 华法琳. **Duplicates are allowed:** the official illustration shows 2 teammates' picks still un-greyed in the grid. [ASSUMED] 12 s per turn inside the 50 s step. | VERIFIED except the per-turn timer |
| 2 | banned bonds "1 core + 1 add-on" [ASSUMED] | Replaced by the verified rule in **A2**. | VERIFIED |
| 1, 12 | one ×0.7/×0.8 multiplier per mode; `aceffect_enemy_*` mapping [ASSUMED] | Replaced by the **per-round table in A3**. | VERIFIED (PRTS, user-sourced) |
| 4.3 | "automatic, paying DP" | At battle start **all board units deploy for free**, one by one in order: 下半 top→bottom, then left→right; right-side players in the boss round are mirrored.<br>**DP starts at 10, +1/s, cap 99.** These are the round-level `options`. `initialCost` 0 in `m0X` is the terrain file, not the round level.<br>DP is only spent on **automatic redeploy**: the unit returns to its original tile when its respawn timer is done, the tile is free, and DP ≥ `cost`.<br>Deploy effects do not fire during prep placement. Battle speed is forced to **2×**. | VERIFIED |
| 4.3 | leaked/timeout | When `maxPlayTime` ends, the battle stops. Every enemy not killed (still alive, or reached the goal) is "未击倒" and feeds 联防/LP. [ASSUMED] `maxPlayTime` is **game-time** seconds, so the client at 2× shows it ticking twice as fast. Last spawns land at 15/38/73 s against limits of 45/55/115 s, which leaves the time needed to kill walkers. | VERIFIED + ASSUMED unit |
| 3 | solo prep 300 s | 下半 solo has **no timer** on prep or 机变 ("休整期及机变阶段没有时间限制…24小时内随时返回"). Treat `normalPhaseTime` 300 and `specialPhaseTime` 150 as an optional "timed solo" setting. | VERIFIED |
| 4.4 | 联防 partner rule "fewest leaks" | Meaningless, because helpers are perfect by definition. There are **at most 2 helpers**: "所有防卫成功的玩家(最多2名)". Default choice: highest LP, then seat index [ASSUMED]. 1 helper uses `escaped_single` (8 units); 2 helpers use `escaped_multi` (20). | VERIFIED cap |
| 10 | Final Assault | Players are paired. **With an odd count the leftover player forms their own group** ("如有落单的玩家将单独成组") and uses the `_s` map.<br>**Movable bosses spawn one per alive player's side** and share the HP pool ("对于可移动的领袖敌人,将在每个存活玩家的一侧生成并行动").<br>**The boss HP pool is not affected by the A3 multipliers** ("领袖单位于服务器的生命值加成不受上述加成影响").<br>Overtime −1 LP/s starts at `bossTurnHpReduceTime` = 150 s. The level's 120 s `maxPlayTime` is not a hard stop in boss rounds: "计时结束后战斗仍然会继续".<br>Wave enemies that bring the merged LP to 0 end the match at once. | VERIFIED |
| 3 (table) | multi FUNNY = 14 rounds | Confirmed from data: **multi FUNNY has 14 rounds, no R15.** **Solo FUNNY has 9 rounds** (boss `h07_01_S` at R9). The 05 doc's "FUNNY has 9 rounds" applies to solo only. | VERIFIED |
### A2. Per-match disabled bonds and banned operators (VERIFIED)
**Rule.** At INFO_CHECK the server picks a disabled bond set `D`. **An operator is removed from the pool iff every one of its bonds is in `D`** (or in the mode's static `inactiveBondIdList`).
**Proof 1.** Screenshot `i.meee.com.tw/9H7Kugy.png` (终极, 下半, in-match dropdown "本局禁用干员情况") shows these banned counts per bond: 维多利亚 **7**, 阿戈尔 **6**, 谢拉格 **5**, 精准 **5**, 助力 **5**, 奥术 **4**, 奇迹 **3**. Take D = those 7 bonds and count the visible, non-DIY act2 chess whose `bondIds ⊆ D`. The result is exactly 7/6/5/5/5/4/3, over 25 operators. An exact match on 7 numbers cannot be coincidence.
**Proof 2.** The official intro (上半 标准) greys only 坚守. Its 本局禁用干员 list shows exactly the 2 chess whose only bond is 坚守 (tier I and tier V).
```
core = bonds with autoChessData.bondInfoDict[b].isPower (8)
addon = other bonds with weight > 0 (11; 投资人/调和/协防干员/绝技 have weight 0 → never disabled)
D = weightedSample(core, Nc) ∪ weightedSample(addon, Na) // all weights are 10 → uniform
banned = { visible non-DIY chess c : c.bondIds ≠ [] ∧ c.bondIds ⊆ (D ∪ mode.inactiveBondIdList) } (+ their _b elites)
```
- **Nc/Na:**
- Observed: 终极 3 core greyed in 2 separate matches, with 3 + 4 in the dropdown. 上半 标准: 0 + 1.
- **Recommend NORMAL/HARD/ABYSS = 3 core + 4 add-on.** A simulation gives a mean of 22 banned operators (range 12–29).
- **Recommend FUNNY = static list + 0 core + 1 add-on.** The static FUNNY list alone removes 27 operators.
- Nc/Na for NORMAL/HARD are [ASSUMED].
- **Other bonds:** a disabled bond can still be activated through members that also carry a non-disabled bond, and through 变形同构体 items. The briefing marks such bonds with "部分盟约所含干员阵容不完整".
- **UI:** the briefing shows a core row and an add-on row (lit = complete, grey + badge = incomplete) plus a "本局禁用干员" portrait list. The in-match info dropdown shows per-bond banned counts.
- **DIY/support picks** may be affected ("禁用盟约有可能影响自选的支援干员"). In the remake, a DIY pick whose derived bond is in D is still allowed [ASSUMED].
### A3. Enemy HP/ATK multipliers by mode and round (replaces §1 table and the §12 mapping)
Source: PRTS `卫戍协议:盟约_下半` §开始模拟. PRTS flags it as user-provided, but it is the only per-round data available.
Formula: `atkMul = base × 1.1^k`, `hpMul = base × 1.2^k × extra`, using the same `k` for ATK and HP except where noted. DEF and RES are unchanged.
Each `k` is one stack of the data effect 攻坚装备 (ATK +10 %, HP +20 %, multiplicative). `base` 0.8 = 攻坚装备II. `base` 0.7 = 攻坚装备III: data says ATK ×0.7 / HP ×0.75, PRTS shows 0.7 for both. Use the data values. `extra` 1.08 = 补给线. **终极 (ABYSS): enemy move speed ×1.15 from round 3** (急行军). **Leader HP pools are excluded.**
| Mode | base | k for rounds 1…14 | hidden (R15) |
|---|---|---|---|
| solo 标准 FUNNY (9 rounds) | 0.7 | 0 ×9 | – |
| solo 险境 NORMAL | 0.7 | 0,0,0,1,1,1,1,2,2,2,3,4,5,5 | 5 [ASSUMED = R14] |
| solo 绝境 HARD | 0.8 | 0,0,0,0,1,1,1,1,2,2,3,3,4,4 | 4 |
| solo 终极 ABYSS | 1.0 | 1,2,2,2,2,3,3,3,3,4,5,5,6,7 | 7 |
| multi 标准 FUNNY | 0.8 | 0 ×11, then 1,1,1 | – |
| multi 险境 NORMAL | 0.8 | 0,1,1,2,2,2,2,3,4,4,5,6,7,7 | 7 [ASSUMED = R14] |
| multi 绝境 HARD | 1.0 | 1,2,2,3,3,3,3,3,4,5,6,6,7,8 | 8 |
| multi 终极 ABYSS, **ATK** | 1.0 | 1,2,2,3,3,4,5,5,5,5,6,6,7,8 | 8 |
| multi 终极 ABYSS, **HP** | 1.0 | 1,2,2,3,4,4\*,7,8,8,8,9,10,10\*,10\* (\* = ×1.08) | 10\* |
Machine-readable copy: `01-core-data.json → _criticAddendum.enemyStatMultipliers`.
### A4. 机变 (special draft) details
- **Timing:** it opens at the start of that round's prep, after income.
- **Multi:**
- One **family** per SP round and 6 shared cards.
- Pick order is random, not seat order (screenshots show out-of-seat order). Everyone takes exactly 1 card.
- Timers: 30 s for the first picker, 16 s for the others. On timeout a random remaining card is auto-assigned.
- **Solo:** 3 cards, pick 1, no timer.
- **Family per SP round.** What is documented:
- FUNNY never has bounties.
- HARD+ R3 and R9 are bounties; R11 has no 战术决策 (PRTS/Bahamut).
- Solo NORMAL: R6 = 道具补给, R9 = 战术决策 (PRTS note "5道具补给8战术决策").
- Bahamut: 悬赏 is common, 战术决策 rare.
Recommended defaults [ASSUMED where not listed above]:
| Mode | SP rounds | Family |
|---|---|---|
| multi/solo HARD, ABYSS | 3, 9, 11 | R3 悬赏 (`enemy_initial_*`), R9 悬赏 (`bounty_hunter_*`/`bossInitial_*`), R11 道具补给 50 % / 机密商店 50 % |
| multi NORMAL | 3, 6, 9 | weighted: 悬赏 50, 道具补给 25, 机密商店 10, 战术决策 15 |
| solo NORMAL | 6, 9 | R6 道具补给, R9 战术决策 |
| multi FUNNY | 3, 9 | 道具补给 45, 战术决策 35, 机密商店 20 |
- **Card contents, observed:**
- 机密商店: 6 random equipment of **any tier I–VI**, duplicates allowed (screenshot: VI, III, V, I, V, I).
- 道具补给: 6 random equipment. At R3 the observed tiers were II–IV, with a duplicate.
- 悬赏: 6 bounty effects (coins 6/2/1/2/3/1).
- 战术决策: a mix of `allybuff_select_*` (盟誓/列装/财富/补给…), `enemydebuff_select_*`, and the current stage's `terrain_*`.
- [ASSUMED] 道具补给 tier windows: R3 I–IV, R6 II–V, R9 III–VI, R11 IV–VI. Solo draws 3 cards from the same pools.
### A5. Other resolved items
- The **华法琳** granted-trait cap should be **12 / 24 per battle**. The official 3/27 notice changed it and PRTS lists 7/14 → 12/24. The client data still shows 7/14, even though the other 3/27 changes (奇迹 18 %, 远见 80/150, 商业包装方案 8/7) are already in it. See 02.
- The skill auto-cast rows with `skillIndex` 0 mean **skill 1 only** (BWIKI "重装职业干员 技能1,受到伤害后自动释放"). See 03.
- Asset URLs: 40 random URLs from `07-assets.json` were re-checked on 2026-09-27; 40/40 returned `200`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+481
View File
@@ -0,0 +1,481 @@
# 04 - Items (装备 EQUIP / 法术 MAGIC) - 卫戍协议:盟约 (act2autochess)
Source of truth: `activity_table.json -> activity.AUTOCHESS_SEASON.act2autochess` (`trapChessDataDict`, `trapShopChessDatas`, `effectInfoDataDict`, `effectBuffInfoDataDict`) + `character_table.json` (trap_*) + `skill_table.json` (sktok_*). Data corresponds to 盟约 **下半** (contains 下半 items 简易通讯机/紧急调度券/天马之枪 etc. and the 下半 changes to 奥术法阵 and 商业包装方案). Machine-readable version: `04-items.json` (every entry, raw buffs/blackboards, flattened `params`, `implFormula`).
Tags: **[DATA]** = read directly from game data, **[WEB]** = community/official web source, **[ASSUMED]** = our proposal where sources are silent.
## 1. Key facts (TL;DR)
- 59 shop-table entries: **56 EQUIP** items (each has normal `_a` + golden/进阶 `_b` chess entry = 112) + **3 MAGIC** Arts (no golden) = 115 chess entries in `trapChessDataDict`. [DATA]
- Tier (`itemLevel`) I-VI. Distinct buyable equipment by tier: I:5, II:7, III:12, IV:12, V:9, VI:11. Normal prices 1-5 funds; every golden entry has `purchasePrice` 5. [DATA]
- Shop (调度中心) has **1 item slot at every shop level** in all real modes (`shopLevelDataDict[*][L].itemCount = 1`; training mode 0 until L5). Season 1 only had items from shop level 5. [DATA]
- **Max 2 equipment per operator**; equipped items are locked; a 3rd equip = replace (the replaced item is destroyed). Items return to hand when the operator is promoted (3->elite) or sold. [WEB: PRTS 卫戍协议/帮助, arknights.wiki.gg, in-game tip]
- **2 identical normal items auto-merge into 1 golden** (`upgradeNum=2`). `upgradeNum=100` = never merges (寻呼模块, 信标, 拟态物质, 突变细胞, 人事部文档, 变形同构体; talent note "本装备不会合并为进阶品质"). [DATA]
- Many items are **consumed on equip** ("装备时销毁"): economy/recruit effects. They need an operator target but never take a slot. [DATA]
- `trapDuration`: -1 on all normal EQUIP (permanent), 0 on golden & MAGIC (unused by us). `canGiveBond=true` only on 变形同构体; `giveBondId` is set on 18 items (14 bonds) and is the lookup table used by 变形同构体. `givePowerId` always null. [DATA]
- **MAGIC (法术)**: 画卷, 教鞭, “神秘顾客”; all `hideInShop=true`; only granted by strategies (bands). Drag onto a map tile during prep; instant. Limit: max **2** current-round Arts on the map; all Arts removed at round end. [DATA + WEB wiki.gg]
## 2. Rules for implementation
| Rule | Value / behaviour | Source |
|---|---|---|
| Equip slots per operator | 2 | [WEB] PRTS 帮助 "每名干员最多可佩戴2件装备" |
| Who can be equipped | Operators on the battlefield **or in hand/整备区** (下半) | [WEB] ak.hypergryph.com/news/5114 "装备可直接配置在处于整备区的干员身上" |
| Unequip | Not allowed. Items leave an operator only on: operator promoted to elite, item merges to golden, operator sold -> items go to hand (overflow to 5 temporary hand slots, which are wiped at next phase) | [WEB] PRTS/bwiki, in-game tip "干员晋级后已配发装备会回收至整备区,需要重新配置" |
| Replacement | Drop an item on an operator with 2 items -> choose 1 equipped item to **destroy**; cancel allowed | [WEB] PRTS 帮助 "达到上限强行佩戴会改为替换装备"; wiki.gg |
| Merge | 2 x same normal (`_a`, upgradeNum 2) -> 1 golden (`_b`), automatic, immediate | [DATA]+[WEB] tip "两件同名装备可以合成一件更强力的装备" |
| Merge counts equipped copies? | Yes: copies in hand and on operators both count; resulting golden goes to hand (slot freed) | [ASSUMED] (consistent with "道具晋级" being a removal condition) |
| Non-mergeable | upgradeNum 100 items (list above); golden entries have upgradeNum 0 | [DATA] |
| Consume-on-equip | Effects tagged `kind=consume_on_equip` resolve once on drop, item destroyed, no slot used | [DATA] |
| Hand capacity | Items share the 10 regular hand slots with operators (+5 temporary) | [DATA] constData.maxDeckChessCnt=10; [WEB] PRTS 帮助 |
| Shop item slot | 1 slot per refresh at all shop levels (L1-L6) | [DATA] shopLevelDataDict.itemCount |
| Shop item pool | Non-hidden normal EQUIP with tier <= shop level (mirrors operator rule "调度中心等级≥干员所在等阶") | [ASSUMED] |
| Shop item tier odds | Reuse operator tier-odds of that shop level, then uniform within tier; if tier has no item fall back to lower tier | [ASSUMED] |
| Item price | `purchasePrice` of `_a` entry (1-5) | [DATA] |
| Golden in shop | Never; golden only via merge or effects (整备 buff: "你接下来购买的1件装备变为进阶品质"; band 至纯凝结 gives golden 博士投影) | [DATA]/[ASSUMED] |
| Refresh / freeze | Item slot refreshes (1 fund, constData.shopRefreshPrice) and freezes together with operator slots | [ASSUMED] |
| Sell item | Unequipped item in hand sells for 1 fund (normal or golden) | [ASSUMED] (official text only states operators sell for 1) |
| Stacking | Different items on one operator stack: %ATK/%DEF/%HP additive with other % buffs (`final = base*(1+Σ%)+flat`), ASPD flat additive, `damage_scale` multipliers multiply, procs roll independently. Normal+golden copy of same item may coexist and both apply | [ASSUMED] |
| Bond-gated effects | "若携带者为【X】盟约干员" checks the operator's bonds **including** a bond granted by 变形同构体 | [ASSUMED] |
| Arts usage | Drag onto a map tile during 休整期; instant resolve; 画卷 range `1-1` = placed tile + tile in front; others `0-1` | [DATA] range_table |
| Arts limit | Max 2 current-round Arts on map at once; all Arts destroyed at round end | [WEB] wiki.gg Stronghold_Protocol |
| Free item events | 道具补给 (`equip_free_*`, choiceType EQUIP_FREE) in 机变阶段: after rounds 2/8 (标准), 2/5/8 (险境), 2/8/10 (绝境+). Offer 3 items (tier <= shopLevel+1, cap 6), pick 1 free. 机密商店 (`artifact_paid_*`, also EQUIP_FREE, "无需消耗资金"): offer 3 items tier >= IV, pick 1 | [WEB] Bahamut snA=12294 (rounds); offer size/tier [ASSUMED] |
### Shop item slots by mode (itemCount per shop level) [DATA]
| Mode | L1 | L2 | L3 | L4 | L5 | L6 |
|---|---|---|---|---|---|---|
| mode_training_1 | 0 (ops 3) | 0 (ops 3) | 0 (ops 3) | 0 (ops 3) | 1 (ops 3) | 1 (ops 3) |
| mode_single_funny | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) |
| mode_single_normal | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) | 1 (ops 5) | 1 (ops 5) |
| mode_single_hard | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) | 1 (ops 5) | 1 (ops 5) |
| mode_single_abyss | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) | 1 (ops 5) | 1 (ops 5) |
| mode_multi_funny | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) |
| mode_multi_normal | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) | 1 (ops 5) | 1 (ops 5) |
| mode_multi_hard | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) | 1 (ops 5) | 1 (ops 5) |
| mode_multi_abyss | 1 (ops 3) | 1 (ops 4) | 1 (ops 4) | 1 (ops 5) | 1 (ops 5) | 1 (ops 5) |
## 3. Full item list (EQUIP)
Columns: normal id (golden = same id with `_b`), name / EN name [EN names for 下半-new items are our translation], tier, price (normal; golden always 5), category, normal effect -> **golden** effect, giveBond (for 变形同构体), merge.
### Tier I
| id | name | EN | price | cat | normal effect | golden effect | giveBond | merge |
|---|---|---|---|---|---|---|---|---|
| `chess_item_1_01_e_a` | 维式重锤 | Victorian Hammer | 1 | STAT | 攻击力+15% | 攻击力+25% | 维多利亚(victoriaShip) | x2 |
| `chess_item_1_02_e_a` | 坚守盾牌 | Durable Shield | 1 | STAT | 防御力+20% | 防御力+35% | 坚守(steadShip) | x2 |
| `chess_item_1_03_e_a` | 盟约之币 | Alliance Coin | 1 | ECONOMY | 装备时销毁,并获得1资金 | 装备时销毁,并获得2资金 | | x2 |
| `chess_item_1_04_e_a` | 随身身份牌 | Portable ID Tag | 1 | BOND | 装备时销毁,并使携带者所属盟约层数+3(无需激活盟约) | 装备时销毁,并使携带者所属盟约层数+6(无需激活盟约) | | x2 |
| `chess_item_1_05_e_a` | 源石溶剂 | Originium Solvent | 1 | STAT | 携带者每秒流失60点生命值,攻击力+40% | 携带者每秒流失60点生命值,攻击力+60% | | x2 |
### Tier II
| id | name | EN | price | cat | normal effect | golden effect | giveBond | merge |
|---|---|---|---|---|---|---|---|---|
| `chess_item_2_01_e_a` | 不屈弹射器 | Tough Launcher | 1 | STAT | 再部署时间-30%,生命值-30% | 再部署时间-50%,生命值-30% | 不屈(indomShip) | x2 |
| `chess_item_2_02_e_a` | 紧急调度券 | Emergency Dispatch Voucher | 2 | RECRUIT | 装备时销毁,随机获取当前调度中心内的1名干员 (调度中心中对应干员消失) | 装备时销毁,随机获取当前调度中心内的2名干员 (调度中心中对应干员消失) | | x2 |
| `chess_item_2_03_e_a` | 战栗维式重锤 | Frightening Victorian Hammer | 2 | ON_HIT | 攻击力+20%;若携带者是地面干员则攻击时有10%概率使目标战栗2秒 | 攻击力+35%;若携带者是地面干员则攻击时有10%概率使目标战栗2秒 | 维多利亚(victoriaShip) | x2 |
| `chess_item_2_04_e_a` | 萨尔贡浓茶 | Sargonian Teaspresso | 2 | STAT | 技力自然回复速度+0.15/秒 | 技力自然回复速度+0.25/秒 | 萨尔贡(sargonShip) | x2 |
| `chess_item_2_05_e_a` | 精打细算玩偶 | Thrifty Doll | 3 | ECONOMY | 装备时销毁,之后每回合开始时额外获得1资金 | 装备时销毁,之后每回合开始时额外获得2资金 | | x2 |
| `chess_item_2_06_e_a` | 简易通讯机 | Simple Communicator | 2 | RECRUIT | 装备时销毁,随机获得1名与该干员有相同盟约的干员 | 装备时销毁,随机获得2名与该干员有相同盟约的干员 | | x2 |
| `chess_item_2_07_e_a` | 见钱眼开玩偶 | Money-Eyed Doll | 1 | ECONOMY | 装备时销毁,下回合额外获得2资金 | 装备时销毁,下回合额外获得4资金 | | x2 |
### Tier III
| id | name | EN | price | cat | normal effect | golden effect | giveBond | merge |
|---|---|---|---|---|---|---|---|---|
| `chess_item_3_01_e_a` | 叙拉古正装 | Siracusan Formalwear | 2 | STAT | 攻击速度+15,携带者部署方向左右两侧的我方干员攻击速度+10 | 攻击速度+25,携带者部署方向左右两侧的我方干员攻击速度+15 | 叙拉古(siracusaShip) | x2 |
| `chess_item_3_02_e_a` | 精准狙击镜 | Precision Scope | 2 | ON_HIT | 攻击距离自身3格及以上的目标时,伤害提高30% | 攻击距离自身3格及以上的目标时,伤害提高50% | 精准(preciShip) | x2 |
| `chess_item_3_03_e_a` | 激光发射器 | Laser Sighter | 2 | STAT | 攻击无视目标25%法术抗性 | 攻击无视目标45%法术抗性 | | x2 |
| `chess_item_3_04_e_a` | 炎国短刀 | Yanese Dagger | 2 | ON_HIT | 每开启一次技能,获得5%的攻击力加成(最多叠加10次) | 每开启一次技能,获得8%的攻击力加成(最多叠加10次) | 炎(yanShip) | x2 |
| `chess_item_3_05_e_a` | 迅捷作战粮 | Quick Combat Rations | 2 | SP | 部署时,获得3点技力;每有一个同盟约的其他干员,额外获得3点技力 | 部署时,获得6点技力;每有一个同盟约的其他干员,额外获得6点技力 | 迅捷(swiftShip) | x2 |
| `chess_item_3_06_e_a` | 歌利亚头盔 | Goliath Helmet | 2 | STAT | 生命值+25%;部署时若身前一格没有其他干员,生命值额外+15% | 生命值+45%;部署时若身前一格没有其他干员,生命值额外+25% | | x2 |
| `chess_item_3_07_e_a` | 阿戈尔重刃 | Aegirian Blade | 2 | STAT | 攻击力+40%,攻击速度-10 | 攻击力+60%,攻击速度-10 | 阿戈尔(egirShip) | x2 |
| `chess_item_3_08_e_a` | 奥术法阵 | Pocket Arts Circle | 1 | STAT | 法术抗性+20,攻击使目标失去特殊能力5秒 | 法术抗性+30,攻击使目标失去特殊能力5秒 | 奥术(arcaneShip) | x2 |
| `chess_item_3_09_e_a` | 坚固维式重锤 | Durable Victorian Hammer | 2 | SURVIVAL | 攻击力+25%;首次受到致命伤害时生命值不低于1,持续8秒 | 攻击力+45%;首次受到致命伤害时生命值不低于1,持续8秒 | 维多利亚(victoriaShip) | x2 |
| `chess_item_3_10_e_a` | 加速维式重锤 | Speedy Victorian Hammer | 2 | STAT | 攻击力+25%;攻击速度+30 | 攻击力+45%;攻击速度+30 | 维多利亚(victoriaShip) | x2 |
| `chess_item_3_11_e_a` | 突袭手雷 | Raid Grenade | 1 | ON_HIT | 每次部署后的10秒内,攻击时使目标晕眩2秒 | 每次部署后的15秒内,攻击时使目标晕眩2秒 | 突袭(raidShip) | x2 |
| `chess_item_3_12_e_a` | 骑士储蓄罐 | Knight Piggy Bank | 3 | ECONOMY | 装备时销毁,并随机获得1~6资金 | 装备时销毁,并随机获得2~12资金 | | x2 |
### Tier IV
| id | name | EN | price | cat | normal effect | golden effect | giveBond | merge |
|---|---|---|---|---|---|---|---|---|
| `chess_item_4_01_e_a` | 寻呼模块 | Dispatch Module | 4 | RECRUIT | 装备时销毁,立即特殊刷新一次调度中心,出现三名与该干员有相同盟约的干员,免费获取其中一名!(不高于当前调度中心等级) | 装备时销毁,立即特殊刷新一次调度中心,出现三名与该干员有相同盟约的干员,免费获取其中一名!(不高于当前调度中心等级) | | no |
| `chess_item_4_02_e_a` | 蜂鸣器 | Buzzer | 1 | SURVIVAL | 更容易受到攻击,生命值+40% | 更容易受到攻击,生命值+60% | | x2 |
| `chess_item_4_03_e_a` | 有限加速器 | Miniature Accelerator | 2 | ON_HIT | 每次攻击或治疗后,本场战斗中攻击速度+1,最高60层 | 每次攻击或治疗后,本场战斗中攻击速度+2,最高60层 | | x2 |
| `chess_item_4_04_e_a` | 伪装服 | Disguise Outfit | 1 | SURVIVAL | 首次受到伤害后获得隐匿,持续15秒 | 首次受到伤害后获得隐匿,持续25秒 | | x2 |
| `chess_item_4_05_e_a` | 防暴盾 | Riot Shield | 2 | SURVIVAL | 阻挡敌人时,受到来自非自身阻挡单位的伤害降低40% | 阻挡敌人时,受到来自非自身阻挡单位的伤害降低70% | | x2 |
| `chess_item_4_06_e_a` | 休眠子裔 | Dormant Heir | 2 | SURVIVAL | 每攻击1个目标,回复自身2%最大生命值 | 每攻击1个目标,回复自身4%最大生命值 | | x2 |
| `chess_item_4_07_e_a` | 卡西米尔竞技旗 | Kazimierz Tournament Banner | 2 | ON_HIT | 部署后15秒内造成的伤害提升至135%,随后逐渐衰减 | 部署后15秒内造成的伤害提升至160%,随后逐渐衰减 | 卡西米尔(kazimierzShip) | x2 |
| `chess_item_4_08_e_a` | 拉特兰桥夹 | Lateran Clip | 1 | ON_HIT | 子弹类技能剩余一发子弹时有50%概率恢复40%的子弹(每次部署最多触发3次) | 子弹类技能剩余一发子弹时有60%概率恢复60%的子弹(每次部署最多触发3次) | 拉特兰(lateranoShip) | x2 |
| `chess_item_4_09_e_a` | 灼燃维式重锤 | Burning Victorian Hammer | 2 | ON_HIT | 攻击力+30%;造成法术伤害附带相当于10%伤害的灼燃损伤 | 攻击力+50%;造成法术伤害附带相当于10%伤害的灼燃损伤 | 维多利亚(victoriaShip) | x2 |
| `chess_item_4_10_e_a` | 浓缩嗅盐 | Concentrated Smelling Salts | 2 | SURVIVAL | 生命值大于70%时,免疫晕眩、冻结等特殊状态 | 生命值大于40%时,免疫晕眩、冻结等特殊状态 | | x2 |
| `chess_item_4_11_e_a` | 护盾无人机 | Protection Drone | 2 | SURVIVAL | 治疗时有10%概率使目标获得1层护盾(最多1层) | 治疗时有15%概率使目标获得1层护盾(最多1层) | | x2 |
| `chess_item_4_12_e_a` | M3茧甲 | M3 Cocoon Shell | 3 | SURVIVAL | 战斗阶段被击倒时立刻复活(一场战斗仅1次) | 战斗阶段被击倒时立刻复活(一场战斗仅2次) | | x2 |
### Tier V
| id | name | EN | price | cat | normal effect | golden effect | giveBond | merge |
|---|---|---|---|---|---|---|---|---|
| `chess_item_5_01_e_a` | 催泪瓦斯 | Teargas Can | 2 | ON_HIT | 攻击时有3%概率使目标获得一层麻痹 | 攻击时有5%概率使目标获得一层麻痹 | | x2 |
| `chess_item_5_02_e_a` | 谢拉格不融冰 | Kjeragi Nevermeltice | 2 | ON_HIT | 攻击时有12%概率对目标施加1.5秒的寒冷 | 攻击时有20%概率对目标施加1.5秒的寒冷 | 谢拉格(kjeragShip) | x2 |
| `chess_item_5_03_e_a` | 双模机械臂 | Bimodule Robotic Arm | 3 | ON_HIT | 攻击力+30%,自身造成物理和法术伤害时,伤害类型转化为弱点伤害 | 攻击力+50%,自身造成物理和法术伤害时,伤害类型转化为弱点伤害 | | x2 |
| `chess_item_5_04_e_a` | 信标 | Transmitter (Beacon) | 3 | RECRUIT | 装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的干员,免费获取其中一名 / 若在同盟模拟中且存在其他队友,下个休整期向相应盟约人数最多的队友发送1个原干员 / 相应盟约人数相同则随机发送 / 无法装备给自编干员 | 装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的干员,免费获取其中一名 / 若在同盟模拟中且存在其他队友,下个休整期向相应盟约人数最多的队友发送1个原干员 / 相应盟约人数相同则随机发送 / 无法装备给自编干员 | | no |
| `chess_item_5_05_e_a` | 拟态物质 | Mimic Matter | 4 | RECRUIT | 装备时销毁,若已拥有至少2名该初始干员,则再获得1名该初始干员;否则随机获得1名同盟约初始干员 | 装备时销毁,若已拥有至少2名该初始干员,则再获得1名该初始干员;否则随机获得1名同盟约初始干员 | | no |
| `chess_item_5_06_e_a` | 博士投影 | Doctor Hologram | 5 | RECRUIT | 任意干员可装备,下个回合开始时销毁,并使该干员晋升为精锐干员 | 任意干员可装备,立即使该干员晋升为精锐干员 | | x2 |
| `chess_item_5_07_e_a` | 商业包装方案 | Commercial Packaging Plan | 3 | RECRUIT | 每出售8名干员,额外获得一个与此装备携带者的同盟约初始干员(不高于当前调度中心等级) | 每出售7名干员,额外获得一个与此装备携带者的同盟约初始干员(不高于当前调度中心等级) | | x2 |
| `chess_item_5_08_e_a` | 突变细胞 | Mutated Cells | 2 | RECRUIT | 战斗结束后,装备者替换为高一阶的随机干员 | 战斗结束后,装备者替换为高一阶的随机干员 | | no |
| `chess_item_5_09_e_a` | 天马之盔 | Pegasus Helm | 3 | SET | 生命值+50%,若与“天马之枪”一起装备,每秒回复8%最大生命值。 | 生命值+75%,若与“天马之枪”一起装备,每秒回复8%最大生命值。 | | x2 |
### Tier VI
| id | name | EN | price | cat | normal effect | golden effect | giveBond | merge |
|---|---|---|---|---|---|---|---|---|
| `chess_item_6_01_e_a` | 天马之枪 | Pegasus Lance | 3 | SET | 攻击力+40%,若与“天马之盔”一起装备,造成伤害时额外造成30%的真实伤害 | 攻击力+60%,若与“天马之盔”一起装备,造成伤害时额外造成30%的真实伤害 | | x2 |
| `chess_item_6_02_e_a` | 铳骑之威 | Gun-Knight's Might | 4 | BOND_SIGNATURE | 攻击力+40%;若携带者为【拉特兰】盟约干员,该干员每次攻击时,有35%概率额外对攻击范围内一名敌人发射子弹造成150%攻击力的物理伤害 / 若该【拉特兰】盟约干员同时装备“拉特兰桥夹”,子弹伤害改为300%攻击力 | 攻击力+60%;若携带者为【拉特兰】盟约干员,该干员每次攻击时,有35%概率额外对攻击范围内一名敌人发射子弹造成150%攻击力的物理伤害 / 若该【拉特兰】盟约干员同时装备“拉特兰桥夹”,子弹伤害改为300%攻击力 | | x2 |
| `chess_item_6_03_e_a` | 天师古鼎 | Tianshi's Cauldron | 4 | BOND_SIGNATURE | 生命值+45%;若携带者为【炎】盟约干员,本回合每获得过1名干员战斗开始后攻击速度+25(最多3层) / 若该【炎】盟约干员同时装备“炎国短刀”,每次获得干员时获得2资金(每回合最多3次) | 生命值+70%;若携带者为【炎】盟约干员,本回合每获得过1名干员战斗开始后攻击速度+25(最多3层) / 若该【炎】盟约干员同时装备“炎国短刀”,每次获得干员时获得2资金(每回合最多3次) | | x2 |
| `chess_item_6_04_e_a` | 海沟实验体 | Trench Test Subject | 4 | BOND_SIGNATURE | 获得180点伤害减免;若携带者为【阿戈尔】盟约干员,自身受到伤害时对伤害来源造成自身攻击力50%的法术伤害(每0.5秒最多触发一次) / 若该【阿戈尔】盟约干员同时装备“阿戈尔重刃”,则自身受到伤害时额外造成一次50%攻击力的法术伤害(每0.5秒最多触发一次) | 获得300点伤害减免;若携带者为【阿戈尔】盟约干员,自身受到伤害时对伤害来源造成自身攻击力50%的法术伤害(每0.5秒最多触发一次) / 若该【阿戈尔】盟约干员同时装备“阿戈尔重刃”,则自身受到伤害时额外造成一次50%的攻击力的法术伤害(每0.5秒最多触发一次) | | x2 |
| `chess_item_6_05_e_a` | 蒸汽之心 | Steam Heart | 4 | BOND_SIGNATURE | 攻击速度+35;若携带者为【维多利亚】盟约干员,该干员获得当前场上所有维式重锤特殊效果 / 若该【维多利亚】盟约干员同时装备“维式重锤”系列装备,该“维式重锤”装备特殊效果获得强化(效果变为2倍) | 攻击速度+55;若携带者为【维多利亚】盟约干员,该干员获得当前场上所有维式重锤特殊效果 / 若该【维多利亚】盟约干员同时装备“维式重锤”系列装备,该“维式重锤”装备特殊效果获得强化(效果变为2倍) | | x2 |
| `chess_item_6_06_e_a` | 耶拉冈德之泪 | Kjeragandr's Tears | 4 | BOND_SIGNATURE | 法术抗性+30;若携带者为【谢拉格】盟约干员,该干员攻击范围内被寒冷或冻结的敌人每秒受到一次自身攻击力30%的法术伤害 / 若该【谢拉格】盟约干员同时装备“谢拉格不融冰”,则使自身攻击范围内寒冷或冻结的敌人每秒受到的法术伤害提升至100%攻击力 | 法术抗性+50;若携带者为【谢拉格】盟约干员,该干员攻击范围内被寒冷或冻结的敌人每秒受到一次自身攻击力30%的法术伤害 / 若该【谢拉格】盟约干员同时装备“谢拉格不融冰”,则使自身攻击范围内寒冷或冻结的敌人每秒受到的法术伤害提升至100%攻击力 | | x2 |
| `chess_item_6_07_e_a` | 黄沙罗盘 | Desert Compass | 4 | BOND_SIGNATURE | 初始技力+30;若携带者为【萨尔贡】盟约干员,该干员首次技能结束时立刻回复30点技力 / 若该【萨尔贡】盟约干员同时装备“萨尔贡浓茶”,每次开启技能时为全部【萨尔贡】干员回复3点技力 | 初始技力+50;若携带者为【萨尔贡】盟约干员,该干员首次技能结束时立刻回复30点技力 / 若该【萨尔贡】盟约干员同时装备“萨尔贡浓茶”,每次开启技能时为全部【萨尔贡】干员回复3点技力 | | x2 |
| `chess_item_6_08_e_a` | 人事部文档 | HR File | 4 | RECRUIT | 装备时销毁,最大可部署人数变为9 | 装备时销毁,最大可部署人数变为9 | | no |
| `chess_item_6_09_e_a` | 变形同构体 | Damazti Isomorph | 3 | BOND_GRANT | 携带者获得额外盟约(由另一件携带装备而定) / 具体对应关系可在模拟中查看本装备天赋栏 | 携带者获得额外盟约(由另一件携带装备而定) / 具体对应关系可在模拟中查看本装备天赋栏 | | no |
| `chess_item_6_10_e_a` | 骑士戒律 | Knightly Code | 4 | BOND_SIGNATURE | 再部署时间-40%;若携带者为【卡西米尔】盟约干员,开启技能后20s内,使攻击范围内敌人攻击速度-35%,移动速度-35% / 若该【卡西米尔】盟约干员同时装备“卡西米尔竞技旗”,该卡西米尔干员技能持续期间,攻击力+100%,且受到致命伤害时不撤退,技能结束后退场 | 再部署时间-60%;若携带者为【卡西米尔】盟约干员,开启技能后20s内,使攻击范围内敌人攻击速度-35%,移动速度-35% / 若该【卡西米尔】盟约干员同时装备“卡西米尔竞技旗”,该卡西米尔干员技能持续期间,攻击力+100%,且受到致命伤害时不撤退,技能结束后退场 | | x2 |
| `chess_item_6_11_e_a` | 家族徽章 | Family Crest | 4 | BOND_SIGNATURE | 技力自然回复速度+0.2/秒;若携带者为【叙拉古】盟约干员,该干员处于隐匿状态时,攻击力逐渐提升至200%,持续至隐匿结束后首次造成伤害或干员离场 / 若该【叙拉古】盟约干员同时装备“叙拉古正装”,该叙拉古干员失去隐匿后首次造成伤害时额外造成800%攻击力的真实伤害 | 技力自然回复速度+0.35/秒;若携带者为【叙拉古】盟约干员,该干员处于隐匿状态时,攻击力逐渐提升至200%,持续至隐匿结束后首次造成伤害或干员离场 / 若该【叙拉古】盟约干员同时装备“叙拉古正装”,该叙拉古干员失去隐匿后首次造成伤害时额外造成800%攻击力的真实伤害 | | x2 |
## 4. Implementable categories and formulas
Param names below are the keys of `items[].normal.params` / `items[].golden.params` in the JSON (flattened blackboards; duplicate keys get suffix `_1`). Values shown as normal / golden.
### STAT - 属性型 Flat stat modifiers (always on while equipped)
- **维式重锤** (`chess_item_1_01_e_a`, TI, 1) - ATK% += atk (additive with other ATK% buffs)
params: `atk=0.15/0.25`; buff keys: `env_gbuff_new_with_verify`
- **坚守盾牌** (`chess_item_1_02_e_a`, TI, 1) - DEF% += def
params: `def=0.2/0.35`; buff keys: `env_gbuff_new_with_verify`
- **源石溶剂** (`chess_item_1_05_e_a`, TI, 1) - ATK% += atk; in battle carrier loses `damage` HP per second (periodic self-damage, true/unmitigated [ASSUMED], can kill the carrier [ASSUMED]).
params: `atk=0.4/0.6, damage=60.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **不屈弹射器** (`chess_item_2_01_e_a`, TII, 1) - respawnTime *= (1 + respawn_time) (i.e. -30%/-50%); maxHP% += max_hp (-30%).
params: `respawn_time=-0.3/-0.5, max_hp=-0.3`; buff keys: `env_gbuff_new_with_verify`
- **萨尔贡浓茶** (`chess_item_2_04_e_a`, TII, 2) - spRecoveryPerSec += sp_recovery_per_sec (flat, on top of base 1 SP/s for auto-recovery skills).
params: `sp_recovery_per_sec=0.15/0.25`; buff keys: `env_gbuff_new_with_verify`
- **叙拉古正装** (`chess_item_3_01_e_a`, TIII, 2) - Carrier ASPD += attack_speed. Allies deployed on the 2 tiles left/right of the carrier (perpendicular to its facing direction) get ASPD += attack_speed_1 while the carrier is on the field.
params: `attack_speed=15.0/25.0, at_root=1.0, attack_speed_1=10.0/15.0`; buff keys: `env_gbuff_new_with_verify, char_dynamic_ability_new`
- **激光发射器** (`chess_item_3_03_e_a`, TIII, 2) - Carrier's attacks ignore magic_resist_penetrate (percent) of target RES: effRES = RES * (1 - p).
params: `magic_resist_penetrate=0.25/0.45`; buff keys: `env_gbuff_new_with_verify`
- **歌利亚头盔** (`chess_item_3_06_e_a`, TIII, 2) - maxHP% += init_max_hp; additionally, if at deploy time the tile directly in front of the carrier (facing direction) has no allied operator, maxHP% += ex_max_hp.
params: `init_max_hp=0.25/0.45, ex_max_hp=0.15/0.25`; buff keys: `env_gbuff_new_with_verify`
- **阿戈尔重刃** (`chess_item_3_07_e_a`, TIII, 2) - ATK% += atk; ASPD += attack_speed (-10).
params: `atk=0.4/0.6, attack_speed=-10.0`; buff keys: `env_gbuff_new_with_verify`
- **奥术法阵** (`chess_item_3_08_e_a`, TIII, 1) - RES += magic_resistance (flat); each attack applies 失去特殊能力 (silence: enemy loses its special abilities/talents) to target for `silence` s.
params: `magic_resistance=20.0/30.0, silence=5.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **加速维式重锤** (`chess_item_3_10_e_a`, TIII, 2) - ATK% += atk; ASPD += attack_speed (the hammer 'special effect' = the ASPD part).
params: `atk=0.25/0.45, attack_speed=30.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
### ON_HIT - 战斗触发 On-attack / on-skill / time-window combat effects
- **战栗维式重锤** (`chess_item_2_03_e_a`, TII, 2) - ATK% += atk. If carrier is a MELEE(ground) operator: on each attack, prob chance to apply 战栗(Tremble: target cannot normal-attack while blocked) for disarmed_duration s. Special effect doubled (prob x2 [ASSUMED]) when carrier also has 蒸汽之心 and is Victoria.
params: `atk=0.2/0.35, prob=0.1, disarmed_duration=2.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **精准狙击镜** (`chess_item_3_02_e_a`, TIII, 2) - If distance(carrier tile, target) >= radius (3 tiles, Chebyshev/Euclid on tile grid [ASSUMED Euclidean]), outgoing damage *= damage_scale.
params: `radius=3.0, damage_scale=1.3/1.5`; buff keys: `env_gbuff_new_with_verify`
- **炎国短刀** (`chess_item_3_04_e_a`, TIII, 2) - Each time the carrier activates a skill: stacks = min(stacks+1, atk_buff_cnt); ATK% += atk * stacks. Stacks reset each battle.
params: `prob=1.0, atk=0.05/0.08, atk_buff_cnt=10.0`; buff keys: `env_gbuff_new_with_verify`
- **突袭手雷** (`chess_item_3_11_e_a`, TIII, 1) - For `duration` s after EACH deploy, every attack stuns the target for `stun` s.
params: `stun=2.0, duration=10.0/15.0`; buff keys: `env_gbuff_new_with_verify`
- **有限加速器** (`chess_item_4_03_e_a`, TIV, 2) - After each attack or heal: stacks = min(stacks+1, max_buff_cnt); ASPD += attack_speed * stacks. Resets each battle.
params: `attack_speed=1.0/2.0, max_buff_cnt=60.0, atk=0.0`; buff keys: `env_gbuff_new_with_verify`
- **卡西米尔竞技旗** (`chess_item_4_07_e_a`, TIV, 2) - After each deploy: outgoing damage *= damage_scale for `interval` (15) s; afterwards every ex_interval (0.5) s the multiplier changes by damage_scale_minus until it reaches 1.0.
params: `damage_scale=1.35/1.6, interval=15.0, ex_interval=0.5, damage_scale_minus=-0.04/-0.07`; buff keys: `env_gbuff_new_with_verify`
- **拉特兰桥夹** (`chess_item_4_08_e_a`, TIV, 1) - Ammo-type skills: when ammo remaining becomes 1, prob chance to restore ceil(ammo_percent * maxAmmo) [ASSUMED rounding] bullets; max max_trigger_cnt triggers per deploy.
params: `prob=0.5/0.6, ammo_percent=0.4/0.6, max_trigger_cnt=3.0`; buff keys: `env_gbuff_new_with_verify`
- **灼燃维式重锤** (`chess_item_4_09_e_a`, TIV, 2) - ATK% += atk. When dealing ARTS damage: also inflict 灼燃损伤(Burn elemental damage) = damage_scale * that damage.
params: `atk=0.3/0.5, damage_scale=0.1`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **催泪瓦斯** (`chess_item_5_01_e_a`, TV, 2) - On attack: prob chance to add 1 layer of 麻痹(Paralysis: each layer interrupts one enemy normal attack, max 3 layers).
params: `prob=0.03/0.05`; buff keys: `env_gbuff_new_with_verify`
- **谢拉格不融冰** (`chess_item_5_02_e_a`, TV, 2) - On attack: prob chance to apply 寒冷(Cold: ASPD -30; cold again while cold -> Frozen) for `cold` s.
params: `prob=0.12/0.2, cold=1.5`; buff keys: `env_gbuff_new_with_verify`
- **双模机械臂** (`chess_item_5_03_e_a`, TV, 3) - ATK% += atk; carrier's physical and arts damage becomes 弱点伤害(weakness damage): each hit is dealt as whichever of physical/arts yields MORE damage against that target's DEF/RES.
params: `atk=0.3/0.5`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
### SURVIVAL - 生存 Survival (taunt, shields, undying, revive, DR, CC-immunity)
- **坚固维式重锤** (`chess_item_3_09_e_a`, TIII, 2) - ATK% += atk. First time per battle carrier would take lethal damage: HP cannot drop below 1 for undeadable_duration s.
params: `atk=0.25/0.45, undeadable_duration=8.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **蜂鸣器** (`chess_item_4_02_e_a`, TIV, 1) - tauntLevel = 1 (enemies prefer this unit as target); maxHP% += max_hp.
params: `taunt_level=1.0, max_hp=0.4/0.6`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **伪装服** (`chess_item_4_04_e_a`, TIV, 1) - First time the carrier takes damage in a battle: gain 隐匿(Camouflage: not targeted by ranged enemies unless blocking) for `duration` s.
params: `duration=15.0/25.0`; buff keys: `env_gbuff_new_with_verify`
- **防暴盾** (`chess_item_4_05_e_a`, TIV, 2) - While the carrier is blocking >=1 enemy: damage taken from sources NOT blocked by the carrier *= damage_scale.
params: `damage_scale=0.6/0.3`; buff keys: `env_gbuff_new_with_verify`
- **休眠子裔** (`chess_item_4_06_e_a`, TIV, 2) - For each target hit by an attack: heal self hp_ratio * maxHP.
params: `hp_ratio=0.02/0.04`; buff keys: `env_gbuff_new_with_verify`
- **浓缩嗅盐** (`chess_item_4_10_e_a`, TIV, 2) - While HP/maxHP > hp_ratio: immune to stun, freeze, sleep, and other CC 'special states' [ASSUMED list: stun/freeze/sleep/levitate/palsy].
params: `hp_ratio=0.7/0.4`; buff keys: `env_gbuff_new_with_verify`
- **护盾无人机** (`chess_item_4_11_e_a`, TIV, 2) - Each heal the carrier performs: prob chance to give the healed target 1 shield layer (max max_stack_cnt layers; 1 layer blocks one instance of damage).
params: `prob=0.1/0.15, max_stack_cnt=1.0`; buff keys: `env_gbuff_new_with_verify`
- **M3茧甲** (`chess_item_4_12_e_a`, TIV, 3) - When the carrier is defeated during the battle phase: revive immediately in place at full HP [ASSUMED full HP] up to max_respawn_cnt times per battle.
params: `max_respawn_cnt=1.0/2.0`; buff keys: `env_gbuff_new_with_verify`
### SP - 技力 SP gain
- **迅捷作战粮** (`chess_item_3_05_e_a`, TIII, 2) - On deploy: SP += sp_each_person * (1 + N), N = number of OTHER deployed operators sharing at least one bond with the carrier [ASSUMED interpretation].
params: `sp_each_person=3.0/6.0`; buff keys: `env_gbuff_new_with_verify`
### ECONOMY - 经济 Consumed on equip, gives funds
- **盟约之币** (`chess_item_1_03_e_a`, TI, 1) - On equip: destroy item, player.funds += randInt(min,max) (normal 1, golden 2). Does not use an equip slot.
params: `min=1.0/2.0, max=1.0/2.0`; buff keys: `equip_destory_gain_random_coin`
- **精打细算玩偶** (`chess_item_2_05_e_a`, TII, 3) - On equip: destroy item; register a permanent player effect: at the start of every subsequent round player.funds += count (stackable).
params: `count=1.0/2.0`; buff keys: `gain_coin_when_round_start`
- **见钱眼开玩偶** (`chess_item_2_07_e_a`, TII, 1) - On equip: destroy item; at the start of NEXT round only: player.funds += count.
params: `count=2.0/4.0`; buff keys: `use_equip_gain_coin_when_next_round_start`
- **骑士储蓄罐** (`chess_item_3_12_e_a`, TIII, 3) - On equip: destroy item, player.funds += randInt(min,max) inclusive (normal 1-6, golden 2-12).
params: `min=1.0/2.0, max=6.0/12.0`; buff keys: `equip_destory_gain_random_coin`
### RECRUIT - 调度 Roster manipulation (get / transform / promote operators, deploy cap)
- **紧急调度券** (`chess_item_2_02_e_a`, TII, 2) - On equip: destroy item; pick `count` random operator(s) currently displayed in the player's shop (调度中心) slots, move them to hand for free; those shop slots become empty.
params: `count=1.0/2.0`; buff keys: `use_equip_reward_random_char_chess_in_shop`
- **简易通讯机** (`chess_item_2_06_e_a`, TII, 2) - On equip: destroy item; give player `count` random NORMAL-quality operator(s) that share at least one bond with the target operator (tier <= current shop level [ASSUMED]).
params: `count=1.0/2.0`; buff keys: `use_equip_reward_char_chess_with_same_bond`
- **寻呼模块** (`chess_item_4_01_e_a`, TIV, 4) - On equip: destroy item; open a special shop refresh with refresh_cnt(3) operators sharing a bond with the target (tier <= current shop level); player takes choice_cnt(1) of them for free. Never merges (upgradeNum=100).
params: `refresh_cnt=3.0, choice_cnt=1.0`; buff keys: `use_equip_reward_special_goods_char_chess`
- **信标** (`chess_item_5_04_e_a`, TV, 3) - On equip: destroy BOTH the item and the target operator; special refresh of refresh_cnt(2) operators of the SAME tier as the target, take choice_cnt(1) free. Multiplayer: at next prep phase send 1 copy of the original operator to the teammate who has the most operators of that operator's bond(s) (tie -> random). Cannot target DIY(自编) operators. Never merges.
params: `refresh_cnt=2.0, choice_cnt=1.0`; buff keys: `use_equip_recruit_new_char_and_give_char_to_player_most_bond`
- **拟态物质** (`chess_item_5_05_e_a`, TV, 4) - On equip: destroy item; if player owns >= 2 NORMAL copies of the target operator (field+hand), give 1 more normal copy (usually triggers 3->elite merge); else give 1 random normal operator sharing a bond with the target. Never merges.
params: `-`; buff keys: `use_equip_reward_char_chess`
- **博士投影** (`chess_item_5_06_e_a`, TV, 5) - Normal: equip onto any normal operator (occupies a slot until next round start), at the start of the next round destroy item and promote the operator to elite(精锐/golden). Golden: on equip, destroy item and promote immediately. On promotion, equipped items return to hand.
params: `-`; buff keys: `equip_round_start_upgrade_char`
- **商业包装方案** (`chess_item_5_07_e_a`, TV, 3) - Stays equipped. Counter of operators SOLD by the player (any): every `count` sales, give 1 random NORMAL operator sharing a bond with the carrier, tier <= current shop level.
params: `count=8.0/7.0`; buff keys: `sell_char_count_gain_equip_owner_bond`
- **突变细胞** (`chess_item_5_08_e_a`, TV, 2) - After the battle ends: replace the carrier with a random operator one tier higher (max tier 6); item consumed [ASSUMED]. Keeps elite status? [ASSUMED: result is NORMAL quality; other equipped item returns to hand]. Never merges.
params: `-`; buff keys: `char_chess_transformation_equip`
- **人事部文档** (`chess_item_6_08_e_a`, TVI, 4) - On equip: destroy item; player's max deployable operator count becomes `count` (9) (base maxBattleChessCnt = 8). Never merges; a second copy has no further effect [ASSUMED].
params: `count=9.0`; buff keys: `equip_destory_deployment_cnt_change`
### BOND - 盟约层数 Bond-layer consumables
- **随身身份牌** (`chess_item_1_04_e_a`, TI, 1) - On equip: destroy item; for EACH bond of the target operator: bondLayer[bond] += layer (works even if that bond is not activated).
params: `layer=3.0/6.0`; buff keys: `use_equip_reward_char_chess_bond_layer`
### BOND_GRANT - 盟约赋予 Grants an extra bond
- **变形同构体** (`chess_item_6_09_e_a`, TVI, 3) - Carrier additionally counts as a member of bond B where B = giveBondId of the OTHER item equipped on the same operator (see bondGrantTable). No effect if the other item has giveBondId=null. Never merges.
params: `-`; buff keys: ``
### SET - 套装 Two-piece set (天马)
- **天马之盔** (`chess_item_5_09_e_a`, TV, 3) - maxHP% += max_hp. If carrier also holds 天马之枪 (either quality): regen hp_recovery_per_sec_by_max_hp_ratio * maxHP per second.
params: `max_hp=0.5/0.75, hp_recovery_per_sec_by_max_hp_ratio=0.08`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **天马之枪** (`chess_item_6_01_e_a`, TVI, 3) - ATK% += atk. If carrier also holds 天马之盔: every damage instance additionally deals atk_scale * ATK as TRUE damage [ASSUMED: % of ATK, per blackboard key atk_scale; description says 30% true damage].
params: `atk=0.4/0.6, atk_scale=0.3`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
### BOND_SIGNATURE - VI阶盟约专属 Bond-gated signature items (bonus if carrier has bond X, extra bonus if paired with bond's base item)
- **铳骑之威** (`chess_item_6_02_e_a`, TVI, 4) - ATK% += atk. If carrier has bond Laterano: on each attack, prob chance to fire an extra bullet at 1 enemy in range dealing atk_scale_1 * ATK physical damage; if carrier also holds 拉特兰桥夹 -> atk_scale_2 * ATK.
params: `prob=0.35, atk_scale_1=1.5, atk_scale_2=3.0, atk=0.4/0.6`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **天师古鼎** (`chess_item_6_03_e_a`, TVI, 4) - maxHP% += max_hp. If carrier has bond Yan: at battle start ASPD += attack_speed * min(k, max_cnt), k = operators the player acquired during THIS round's prep. If also holding 炎国短刀: each time the player acquires an operator, funds += count (max `max` times per round).
params: `attack_speed=25.0, max_cnt=3.0, max_hp=0.45/0.7, count=2.0, max=3.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify, equip_with_another_gain_coin_when_gain_char`
- **海沟实验体** (`chess_item_6_04_e_a`, TVI, 4) - Flat damage reduction: each incoming hit reduced by `value` (180/300, min 0 [ASSUMED]). If carrier has bond Aegir: when taking damage, deal atk_scale * ATK arts damage to the source (internal CD lock_duration 0.5 s); if also holding 阿戈尔重刃, one additional hit of atk_scale * ATK arts (same CD).
params: `lock_duration=0.5, atk_scale=0.5, value=180.0/300.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **蒸汽之心** (`chess_item_6_05_e_a`, TVI, 4) - ASPD += attack_speed_1 (35/55). If carrier has bond Victoria: gains the special effects of every hammer (灼燃/坚固/加速/战栗) currently equipped by ANY of the player's on-field operators (burn 10% of arts dmg, undying 8 s, ASPD +30, 10% tremble 2 s). If it also holds a hammer-series item, that hammer's special effect is doubled (burn 20%, undying 16 s, ASPD +60, tremble prob 20% [ASSUMED doubling targets]).
params: `damage_scale=0.1, undeadable_duration=8.0, attack_speed=30.0, prob=0.1, disarmed_duration=2.0, attack_speed_1=35.0/55.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
- **耶拉冈德之泪** (`chess_item_6_06_e_a`, TVI, 4) - RES += magic_resistance. If carrier has bond Kjerag: every `interval` (1 s) each Cold or Frozen enemy inside the carrier's attack range takes atk_scale * ATK arts damage; atk_scale_ex (100%) if carrier also holds 谢拉格不融冰.
params: `atk_scale=0.3, interval=1.0, atk_scale_ex=1.0, magic_resistance=30.0/50.0`; buff keys: `char_dynamic_ability_new, env_gbuff_new_with_verify`
- **黄沙罗盘** (`chess_item_6_07_e_a`, TVI, 4) - Initial SP += init_sp (30/50). If carrier has bond Sargon: when its FIRST skill ends, SP += sp (30). If also holding 萨尔贡浓茶: each time the carrier activates a skill, all Sargon operators on field gain addition_sp (3) SP.
params: `sp=30.0, addition_sp=3.0, init_sp=30.0/50.0`; buff keys: `env_gbuff_new_with_verify`
- **骑士戒律** (`chess_item_6_10_e_a`, TVI, 4) - respawnTime *= (1 + respawn_time) (-40%/-60%). If carrier has bond Kazimierz: for `duration` (20) s after activating a skill, enemies inside its attack range get ASPD x attack_speed(0.65) and move speed x move_speed(0.65) (i.e. -35%). If also holding 卡西米尔竞技旗: during skill duration ATK% += atk (+100%), and lethal damage does not retreat it; it retreats when the skill ends.
params: `respawn_time=-0.4/-0.6, max_hp=0.0, duration=20.0, atk=1.0, move_speed=0.65, attack_speed=0.65`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify, char_dynamic_ability_new`
- **家族徽章** (`chess_item_6_11_e_a`, TVI, 4) - spRecoveryPerSec += sp_recovery_per_sec (0.2/0.35). If carrier has bond Siracusa: while Camouflaged, ATK% += atk_per_sec per second (2%/s) up to max_atk (+100%, i.e. 200% ATK); the bonus lasts until the first damage dealt after camouflage ends, or until the carrier leaves the field. If also holding 叙拉古正装: that first hit after losing camouflage additionally deals atk_scale (800%) * ATK true damage.
params: `sp_recovery_per_sec=0.2/0.35, atk_per_sec=0.02, max_atk=1.0, atk_scale=8.0`; buff keys: `env_gbuff_new_with_verify, env_gbuff_new_with_verify`
### MAGIC - 法术 Arts: placed on a map tile, not equipped
- **“神秘顾客”** (`chess_item_6_01_m`, TVI, 2) - Drag onto the field: choose a special bounty (hunter_band_1). When actively destroyed: +1 fund and the item passes to the next player. UNUSED in act2 (no band/garrison grants it) - implement last or skip.
params: `count=1.0`; buff keys: `trap_create_self_choice, trap_disney_special`
- **画卷** (`chess_item_6_02_m`, TVI, 2) - Drag onto a map tile during prep. Range 1-1 = the tile itself + the tile in front (facing). Gives the player a copy of 1 operator inside that range, INCLUDING elite status and equipped items (copies go to hand). Consumed.
params: `-`; buff keys: `trap_copy_front_char`
- **教鞭** (`chess_item_6_03_m`, TVI, 2) - Drag onto the field during prep: opens a personal bounty choice (choice event hunter_band_1). The chosen bounty adds extra enemies to your next battle; killing them / perfect defence grants funds (see ENEMY_GAIN '...悬赏' effects). Consumed.
params: `-`; buff keys: `trap_create_self_choice`
## 5. 变形同构体 (Damazti Isomorph) bond-grant table [DATA]
Carrier gains the bond of its OTHER equipped item. From `trapChessDataDict[*].giveBondId` (also listed in the trap talent text).
| bond | items that grant it |
|---|---|
| 维多利亚 (`victoriaShip`) | 维式重锤(chess_item_1_01_e), 战栗维式重锤(chess_item_2_03_e), 坚固维式重锤(chess_item_3_09_e), 加速维式重锤(chess_item_3_10_e), 灼燃维式重锤(chess_item_4_09_e) |
| 坚守 (`steadShip`) | 坚守盾牌(chess_item_1_02_e) |
| 不屈 (`indomShip`) | 不屈弹射器(chess_item_2_01_e) |
| 萨尔贡 (`sargonShip`) | 萨尔贡浓茶(chess_item_2_04_e) |
| 叙拉古 (`siracusaShip`) | 叙拉古正装(chess_item_3_01_e) |
| 精准 (`preciShip`) | 精准狙击镜(chess_item_3_02_e) |
| 炎 (`yanShip`) | 炎国短刀(chess_item_3_04_e) |
| 迅捷 (`swiftShip`) | 迅捷作战粮(chess_item_3_05_e) |
| 阿戈尔 (`egirShip`) | 阿戈尔重刃(chess_item_3_07_e) |
| 奥术 (`arcaneShip`) | 奥术法阵(chess_item_3_08_e) |
| 突袭 (`raidShip`) | 突袭手雷(chess_item_3_11_e) |
| 卡西米尔 (`kazimierzShip`) | 卡西米尔竞技旗(chess_item_4_07_e) |
| 拉特兰 (`lateranoShip`) | 拉特兰桥夹(chess_item_4_08_e) |
| 谢拉格 (`kjeragShip`) | 谢拉格不融冰(chess_item_5_02_e) |
Note: 9 bonds cannot be obtained this way (灵巧, 助力, 远见, 奇迹, 投资人, 调和, 协防, 独行, 绝技 have no item).
## 6. VI-tier bond signature items and combos [DATA]
| item | base stat (normal/golden) | needs bond | bond effect | combo partner | combo effect |
|---|---|---|---|---|---|
| 铳骑之威 | ATK +40%/+60% | 拉特兰 | 35% on attack: extra bullet 150% ATK phys to 1 enemy in range | 拉特兰桥夹 | bullet 300% ATK |
| 天师古鼎 | HP +45%/+70% | 炎 | battle start ASPD +25 per operator acquired this round (max 3) | 炎国短刀 | +2 funds per operator acquired (max 3/round) |
| 海沟实验体 | flat DR 180/300 | 阿戈尔 | on hit taken: 50% ATK arts to source (0.5 s CD) | 阿戈尔重刃 | extra 50% ATK arts hit (0.5 s CD) |
| 蒸汽之心 | ASPD +35/+55 | 维多利亚 | gains special effects of all hammers equipped on your field | 维式重锤系列 | that hammer's special effect x2 |
| 耶拉冈德之泪 | RES +30/+50 | 谢拉格 | cold/frozen enemies in range take 30% ATK arts per s | 谢拉格不融冰 | 100% ATK per s |
| 黄沙罗盘 | initial SP +30/+50 | 萨尔贡 | first skill end: +30 SP | 萨尔贡浓茶 | each skill cast: all Sargon +3 SP |
| 骑士戒律 | redeploy -40%/-60% | 卡西米尔 | 20 s after skill: enemies in range ASPD/move x0.65 | 卡西米尔竞技旗 | during skill ATK +100%, no retreat on lethal, retreats when skill ends |
| 家族徽章 | SP regen +0.2/+0.35 /s | 叙拉古 | while camouflaged ATK +2%/s up to 200%, until first damage after camo | 叙拉古正装 | first hit after camo: +800% ATK true damage |
| 天马之枪 (no bond) | ATK +40%/+60% | - | - | 天马之盔 | +30% true damage on each damage instance |
| 天马之盔 (T5, no bond) | HP +50%/+75% | - | - | 天马之枪 | regen 8% maxHP per s |
Combo check: `equip_chess_id` in blackboard is the partner's id WITHOUT the `_a/_b` suffix -> either quality satisfies it.
## 7. MAGIC (法术 / Arts) [DATA]
| id | name | EN | tier | price | range | effect | obtained from |
|---|---|---|---|---|---|---|---|
| `chess_item_6_01_m` | “神秘顾客” | "Mystery Customer" | 6 | 2 (hidden) | `0-1` [{'row': 0, 'col': 0}] | 拖拽至场上使用后,选择一项特殊悬赏任务进行挑战;主动销毁时获得1份资金,并将“神秘顾客”传递给下一名玩家 | nothing in act2 (legacy/unused) |
| `chess_item_6_02_m` | 画卷 | Dusk's Scroll | 6 | 2 (hidden) | `1-1` [{'row': 0, 'col': 0}, {'row': 0, 'col': 1}] | 拖拽至场上使用后,获得范围内的干员(包括精锐状态和装备!) | band_dusk (【幻画为真】 round 1) |
| `chess_item_6_03_m` | 教鞭 | Pointing Stick | 6 | 2 (hidden) | `0-1` [{'row': 0, 'col': 0}] | 拖拽至场上使用后,选择一项悬赏任务进行挑战 | band_doberm (【加练!】 every 2 rounds) |
- Buff keys: 画卷 `trap_copy_front_char{}`; 教鞭 `trap_create_self_choice{choice_event=hunter_band_1}`; 神秘顾客 additionally `trap_disney_special{count=1}`.
- `hunter_band_1` (choiceType PERSONAL_CHOOSE, "悬赏决策") pool is server-side. [ASSUMED] offer 3 random `enemyeffect_b_*` bounties (e.g. 碎骨·悬赏 +1, 萨卡兹百夫长·悬赏 +2, 泥岩·悬赏 +4, 锏·悬赏 +5, “复仇者”·悬赏 +6 funds; buff `add_enemy_kill_gain_coin{count,enemy_id,coin,round=1}`): adds 1 enemy to your next battle, killer gets `coin` funds.
- 画卷 copy: duplicates the operator (same chess id incl. elite `_b`) AND clones its equipped items into the copy [DATA desc "包括精锐状态和装备"]; copy goes to hand [ASSUMED]; if hand full -> temporary hand.
## 8. Item sources other than the shop [DATA]
| source | id | trigger / what |
|---|---|---|
| BAND_INITIAL band_orchid | `aceffect_band_23` | 【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块> / <寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级) -> `chess_item_4_01_e_a` {'round': 10.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_doberm | `aceffect_band_25` | 【加练!】每2个回合获得1个特殊法术<教鞭> / <教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金 -> `chess_item_6_03_m` {'round': 2.0, 'count': 1.0, 'type': 'trap'} |
| BAND_INITIAL band_cathy | `aceffect_band_26` | 【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件 -> `pool_equip_kathe` {'count': 3.0, 'choice': 1.0} |
| BAND_INITIAL band_malkie | `aceffect_band_27` | 【商业包装】初始获得1个<商业包装方案> / <商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员 -> `chess_item_5_07_e_a` {'round': 1.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_damaztic | `aceffect_band_40` | 【变形同构】每5回合获得1件特殊装备<变形同构体> / <变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员 -> `chess_item_6_09_e_a` {'round': 5.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_damaztic | `aceffect_band_40` | 【变形同构】每5回合获得1件特殊装备<变形同构体> / <变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员 -> `chess_item_6_09_e_a` {'round': 10.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_damaztic | `aceffect_band_40` | 【变形同构】每5回合获得1件特殊装备<变形同构体> / <变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员 -> `chess_item_6_09_e_a` {'round': 15.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_quintus | `aceffect_band_41` | 【不稳定要素】第3回合获得1件特殊装备<突变细胞> / <突变细胞>:战斗结束后使装备者变为高一阶的随机干员(最高6阶) -> `chess_item_5_08_e_a` {'round': 3.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_dusk | `aceffect_band_45` | 【墨色真颜】若你的场上存在同名干员,这些干员攻击力+30% / 【幻画为真】第1回合获得1个特殊法术<画卷> / <画卷>:使用后可以复制该法术范围内的1名干员(包括精锐状态和携带的装备) -> `chess_item_6_02_m` {'round': 1.0, 'count': 1.0, 'type': 'trap'} |
| BAND_INITIAL band_justin | `aceffect_band_47` | 【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐> / <骑士储蓄罐>:装备时随机获得1~6资金并销毁 -> `chess_item_3_12_e_a` {'round': 1.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_justin | `aceffect_band_47` | 【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐> / <骑士储蓄罐>:装备时随机获得1~6资金并销毁 -> `chess_item_3_12_e_a` {'round': 4.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_justin | `aceffect_band_47` | 【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐> / <骑士储蓄罐>:装备时随机获得1~6资金并销毁 -> `chess_item_3_12_e_a` {'round': 7.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_justin | `aceffect_band_47` | 【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐> / <骑士储蓄罐>:装备时随机获得1~6资金并销毁 -> `chess_item_3_12_e_a` {'round': 10.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_jesica | `aceffect_band_54` | 【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块> / <寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级) -> `chess_item_4_01_e_a` {'round': 4.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_jesica | `aceffect_band_54` | 【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块> / <寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级) -> `chess_item_4_01_e_a` {'round': 7.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_jesica | `aceffect_band_54` | 【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块> / <寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级) -> `chess_item_4_01_e_a` {'round': 10.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_jesica | `aceffect_band_54` | 【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块> / <寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级) -> `chess_item_4_01_e_a` {'round': 13.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_mlyss | `aceffect_band_55` | 【至纯凝结】在第1回合开始时获得一个进阶<博士投影> / 进阶<博士投影>:任意干员可装备,立即使该干员晋升为精锐干员 -> `chess_item_5_06_e_b` {'round': 1.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_fang | `aceffect_band_57` | 【协力共进】第8、10、12、14回合时,获得1个<信标> / <信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友 -> `chess_item_5_04_e_a` {'round': 8.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_fang | `aceffect_band_57` | 【协力共进】第8、10、12、14回合时,获得1个<信标> / <信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友 -> `chess_item_5_04_e_a` {'round': 10.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_fang | `aceffect_band_57` | 【协力共进】第8、10、12、14回合时,获得1个<信标> / <信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友 -> `chess_item_5_04_e_a` {'round': 12.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_fang | `aceffect_band_57` | 【协力共进】第8、10、12、14回合时,获得1个<信标> / <信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友 -> `chess_item_5_04_e_a` {'round': 14.0, 'count': 1.0, 'type': 'equip'} |
| BAND_INITIAL band_narant | `aceffect_band_60` | 【见者有份】偶数回合进入休整期时,调度中心特殊刷新两件装备,免费获取其中一件;萨尔贡盟约<在场6名不同【萨尔贡】干员>效果替换为【萨尔贡】干员技能开启后,周围八格干员同时享受该干员携带的不高于V阶的装备效果,持续60秒 / 在<萨尔贡>部分干员缺席时体验可能不完整 -> `pool_equip_narant` {'round': 2.0, 'count': 1.0, 'type': 'equip', 'refresh_cnt': 2.0, 'choice_cnt': 1.0} |
| BOND | `bondeffect_victoria` | 携带装备的【维多利亚】干员伤害提升至{0:0%}(受层数影响) / <每叠加25层>获得一件带有随机特殊效果的维式重锤 / <在场6名不同【维多利亚】干员>【维多利亚】干员每携带1件装备攻击力+50%,携带进阶装备改为+80% -> `pool_equip_vict` {'layer': 25.0, 'count': 1.0} |
| BUFF_GAIN | `allybuff_select_1` | 你获得2件随机1阶装备,若存在其他队友则他们也获得 -> `pool_equip_shop_1` {'count': 2.0} |
| GARRISON | `garrison_45_a` | <进入休整期时>获得1件随机装备 -> `pool_equip_normal` {'count': 1.0, 'conditionkey': 'character_target_inboard'} |
| GARRISON 卡涅利安(char_426_billro), 蜜蜡(char_344_beewax) | `garrison_49_a` | <获得时>获得1件“迅捷作战粮” -> `chess_item_3_05_e_a` {'count': 1.0} |
| GARRISON 缪尔赛思(char_249_mlyss) | `garrison_76_a` | <获得时>获得1个“变形同构体” -> `chess_item_6_09_e_a` {'count': 1.0} |
| GARRISON 洛洛(char_4040_rockr) | `garrison_91_a` | <获得时>随机制造1件洛洛的定制品 -> `pool_equip_rockr` {'count': 1.0} |
| GARRISON 耶拉(char_4013_kjera) | `garrison_92_a` | <获得时>获得1件“谢拉格不融冰” -> `chess_item_5_02_e_a` {'count': 1.0} |
| GARRISON 佩佩(char_4058_pepe) | `garrison_94_a` | <进入休整期时>获得1件“盟约之币”或“萨尔贡浓茶”,有小概率发现“黄沙罗盘” -> `pool_equip_pepe` {'count': 1.0, 'conditionkey': 'character_target_inboard'} |
| GARRISON 凯瑟琳(char_4162_cathy) | `garrison_127_a` | <进入休整期时>若当前为奇数回合,获得1件随机装备 -> `pool_equip_normal` {'round_list': '1,3,5,7,9,11,13,15', 'count': 1.0, 'conditionkey': 'character_target_inboard'} |
| GARRISON 诗怀雅(char_308_swire) | `garrison_133_a` | <获得时>获得1个“盟约之币” -> `chess_item_1_03_e_a` {'count': 1.0} |
| GARRISON | `garrison_134_a` | 本干员三合一为精锐状态时获得1件高阶装备:“骑士戒律” -> `None` {} |
| GARRISON | `garrison_135_a` | <获得时>获得1件“卡西米尔竞技旗” -> `chess_item_4_07_e_a` {'count': 1.0} |
| GARRISON | `garrison_45_b` | <进入休整期时>获得2件随机装备 -> `pool_equip_normal` {'count': 2.0, 'conditionkey': 'character_target_inboard'} |
| GARRISON 卡涅利安(char_426_billro), 蜜蜡(char_344_beewax) | `garrison_49_b` | <获得时>获得2件“迅捷作战粮” -> `chess_item_3_05_e_a` {'count': 2.0} |
| GARRISON 缪尔赛思(char_249_mlyss) | `garrison_76_b` | <获得时>获得2个“变形同构体” -> `chess_item_6_09_e_a` {'count': 2.0} |
| GARRISON 洛洛(char_4040_rockr) | `garrison_91_b` | <获得时>随机制造2件洛洛的定制品 -> `pool_equip_rockr` {'count': 2.0} |
| GARRISON 耶拉(char_4013_kjera) | `garrison_92_b` | <获得时>获得2件“谢拉格不融冰” -> `chess_item_5_02_e_a` {'count': 2.0} |
| GARRISON 佩佩(char_4058_pepe) | `garrison_94_b` | <进入休整期时>获得2件“盟约之币”或“萨尔贡浓茶”,有概率发现“黄沙罗盘” -> `pool_equip_pepe` {'count': 2.0, 'conditionkey': 'character_target_inboard'} |
| GARRISON 凯瑟琳(char_4162_cathy) | `garrison_127_b` | <进入休整期时>若当前为奇数回合,获得2件随机装备 -> `pool_equip_normal` {'round_list': '1,3,5,7,9,11,13,15', 'count': 2.0, 'conditionkey': 'character_target_inboard'} |
| GARRISON 诗怀雅(char_308_swire) | `garrison_133_b` | <获得时>获得2个“盟约之币” -> `chess_item_1_03_e_a` {'count': 2.0} |
| GARRISON | `garrison_134_b` | <获得时>获得1件【骑士戒律】 -> `chess_item_6_10_e_a` {'count': 1.0} |
| GARRISON | `garrison_135_b` | <获得时>获得2件【卡西米尔竞技旗】 -> `chess_item_4_07_e_a` {'count': 2.0} |
Pools referenced but NOT defined in client data (server-side) - proposed contents [ASSUMED]:
- `pool_equip_normal` (凯瑟琳/garrison_45/127): any non-hidden normal EQUIP with tier <= shop level.
- `pool_equip_shop_1` (列装): tier I normal EQUIP.
- `pool_equip_vict` (维多利亚 bond, every 25 layers): {灼燃, 坚固, 加速, 战栗}维式重锤, uniform.
- `pool_equip_pepe` (佩佩): 盟约之币 45%, 萨尔贡浓茶 45%, 黄沙罗盘 10% (golden 佩佩: 40/40/20).
- `pool_equip_rockr` (洛洛 "定制品"): {有限加速器, 激光发射器, 护盾无人机, 双模机械臂, 蜂鸣器} uniform.
- `pool_equip_kathe` (band_cathy 定向投放, on each shop upgrade 3 choose 1) and `pool_equip_narant` (band_narant, every even round 2 choose 1): tier <= new shop level normal EQUIP.
- garrison_45 / 134 / 135 exist but are not referenced by any operator in `charChessDataDict` (unused this season).
## 9. Status / damage terms used by items
- **晕眩 Stun**: cannot move, block, attack or use skills
- **寒冷 Cold**: ASPD -30; if Cold is applied again while Cold -> Frozen
- **冻结 Frozen**: cannot move, attack or use skills; (AK standard) RES -15 while frozen
- **战栗 Tremble**: while blocked, cannot perform normal attacks (implemented as disarm-when-blocked)
- **麻痹 Paralysis**: each layer cancels one enemy normal attack; max 3 layers
- **失去特殊能力 Silence(enemy)**: enemy loses its special abilities/talents for the duration
- **隐匿 Camouflage**: not targeted by enemy attacks unless blocking
- **护盾 Shield layer**: each layer absorbs one instance of damage completely
- **灼燃损伤 Burn (elemental)**: accumulates on target; (AK standard) at 1000 burst for 7000 arts damage and RES -20 for 10 s; simplify if needed
- **弱点伤害 Weakness damage**: deals physical or arts, whichever the target resists less
- **伤害减免 Flat DR**: each hit reduced by a fixed value
- **嘲讽 tauntLevel**: higher tauntLevel is targeted first by enemies
## 10. Changes vs season 1 (act1autochess) [DATA]
- 13 new items: 天马之枪, 源石溶剂, 家族徽章, 天马之盔, 精打细算玩偶, 骑士储蓄罐, 见钱眼开玩偶, 卡西米尔竞技旗, 随身身份牌, 骑士戒律, 紧急调度券, 简易通讯机, 叙拉古正装. None removed.
- Items now sold from shop level 1 (1 slot) instead of level 5.
- Tier/price changes (act1 tier,price -> act2): 盟约之币 [4, 3]->[1, 1]; 不屈弹射器 [1, 1]->[2, 1]; 萨尔贡浓茶 [3, 2]->[2, 2]; 激光发射器 [2, 2]->[3, 2]; 炎国短刀 [2, 1]->[3, 2]; 迅捷作战粮 [2, 2]->[3, 2]; 歌利亚头盔 [2, 2]->[3, 2]; 阿戈尔重刃 [1, 1]->[3, 2]; 奥术法阵 [1, 2]->[3, 1]; 坚固维式重锤 [2, 2]->[3, 2]; 加速维式重锤 [2, 2]->[3, 2]; 寻呼模块 [5, 4]->[4, 4]; 蜂鸣器 [2, 1]->[4, 1]; 有限加速器 [2, 2]->[4, 2]; 伪装服 [3, 1]->[4, 1]; 防暴盾 [3, 2]->[4, 2]; 休眠子裔 [4, 1]->[4, 2]; 拉特兰桥夹 [3, 2]->[4, 1]; 灼燃维式重锤 [2, 2]->[4, 2]; 护盾无人机 [4, 3]->[4, 2]; 催泪瓦斯 [4, 2]->[5, 2]; 谢拉格不融冰 [4, 2]->[5, 2]; 双模机械臂 [4, 4]->[5, 3]; 信标 [5, 1]->[5, 3]; 商业包装方案 [6, 3]->[5, 3]; 突变细胞 [6, 2]->[5, 2]; 人事部文档 [6, 5]->[6, 4]; 变形同构体 [6, 4]->[6, 3].
- arknights.wiki.gg "Stronghold_Protocol_Alliance/Items" reflects older global data (e.g. 维式重锤 +10%); do NOT use its numbers, use this file.
## 11. Open questions / assumptions to confirm
1. Shop item tier odds and whether item slot shares the operator odds table (server-side). [ASSUMED above]
2. Item sell value (1 fund assumed) - no official statement found.
3. Whether equipped copies count toward the 2-copy merge (assumed yes).
4. Exact contents of server pools (`pool_equip_*`) and of `hunter_band_1` bounty choices.
5. 道具补给 / 机密商店 offer size and tier (3-pick-1 assumed); round schedule taken from a community post.
6. M3茧甲 revive HP (full assumed); 突变细胞 result quality (normal assumed) and whether the other equipped item returns to hand.
7. 天马之枪 "30% true damage": `atk_scale=0.3` suggests 30% of ATK per damage instance; alternative reading 30% of damage dealt. We use 30% ATK.
8. 蒸汽之心 doubling for 战栗锤: prob or duration? (we double prob 10%->20%).
## 12. Sources
- Game data: Kengxxiao/ArknightsGameData zh_CN (activity_table act2autochess, character_table, skill_table, range_table).
- PRTS 卫戍协议/帮助: https://prts.wiki/w/%E5%8D%AB%E6%88%8D%E5%8D%8F%E8%AE%AE/%E5%B8%AE%E5%8A%A9
- PRTS 卫戍协议:盟约 下半 (change log 奥术法阵, 商业包装方案): https://prts.wiki/w/%E5%8D%AB%E6%88%8D%E5%8D%8F%E8%AE%AE%EF%BC%9A%E7%9B%9F%E7%BA%A6_%E4%B8%8B%E5%8D%8A
- Official 更新公告 (new items; equip on 整备区): https://ak.hypergryph.com/news/5114
- arknights.wiki.gg Stronghold Protocol (equip rules, Arts limit): https://arknights.wiki.gg/wiki/Stronghold_Protocol ; items (EN names): https://arknights.wiki.gg/wiki/Stronghold_Protocol_Alliance/Items
- Bahamut 衛戍協議:盟約入門介紹 (道具補給 rounds): https://forum.gamer.com.tw/C.php?bsn=33651&snA=12294
- In-game tips (autoChessData.gameTipsList): "两件同名装备可以合成一件更强力的装备", "特定的装备组合可以为干员赋予额外的盟约效果", "干员晋级后已配发装备会回收至整备区,需要重新配置".
---
## Addendum (critic)
Written 2026-09-27 by the completeness critic. Sources: the official gameplay-intro long image (§装备, §休整期), BWIKI `盟约` §道具, PRTS `卫戍协议/帮助` §装备/手牌区, and screenshots `i.meee.com.tw/Mb2mtd9.png` (机密商店) and `SEbySVl.png` (战术决策).
Corrections to §2 and §11:
| Rule | Was | Now | Tag |
|---|---|---|---|
| Sell item | 1 fund [ASSUMED] | **Items cannot be sold.** Unwanted items can only be **destroyed for 0 funds**: "多余的装备无法进行出售,可以销毁" (official intro) and "多余的道具只能被销毁,无法出售获得资金" (BWIKI). UI: drag to a destroy zone and confirm. | VERIFIED |
| Merge counts equipped copies | yes [ASSUMED] | Confirmed: "两个同名道具(无论是否被装备)会自动合并且晋级…并自动返回整备区". The golden goes to the hand, and the slot it came from is freed. | VERIFIED |
| Unequip | – | Confirmed. An item is locked on its operator and leaves only when the operator is promoted, when the item itself merges, or when the operator is sold. In every case it returns to the hand. Replacing an item destroys the replaced one. | VERIFIED |
| Who can be equipped | board or hand (下半) | Confirmed (BWIKI: "可直接配置在处于整备区的干员身上"). | VERIFIED |
| Hand capacity | 10 shared + 5 temp | Confirmed: "手牌区域分为10个常规手牌区和5个临时手牌区". Items, operators and summon stacks all use it. With a full regular hand you **cannot buy**, items included. Passive gains overflow into temp, and temp blocks readying. | VERIFIED |
| Refresh/freeze covers the item slot | [ASSUMED] | Confirmed: the single 冻结 button freezes **all** unsold slots, the item slot included. A refresh rerolls the item slot too. | VERIFIED |
| Shop item pool / tier odds | tier ≤ shop level, operator odds | The tier cap is confirmed by "※仅在调度中心等级 ≥ 干员所在等阶时,该干员才有可能出现在栏位中" for operators, and mirrored for items. The odds are still [ASSUMED]. **Recommended:** pick the tier with the same copy-weighted tier shares as operators (see 00-INDEX), then draw uniformly within the tier. | ASSUMED |
| 道具补给 / 机密商店 format | 3-pick-1, 机密商店 tier ≥ IV | **Multi: 6 cards shared by the team, each player takes 1.** Solo: 3 cards. **机密商店 draws from all tiers I–VI with duplicates.** A screenshot shows 变形同构体 (VI), 骑士储蓄罐 (III), 双模机械臂 (V) ×2 and 盟约之币 (I) ×2. **道具补给** at R3 (上半 data) showed tiers II–IV, including 有限加速器 ×2. [ASSUMED] tier windows for 道具补给: R3 I–IV, R6 II–V, R9 III–VI, R11 IV–VI. All cards are `_a` (normal) items. | VERIFIED format; tiers partly ASSUMED |
| 列装 (`allybuff_select_1`) | 2 random tier-I items | Confirmed: "你获得2件随机1阶装备,若存在其他队友则他们也获得". | VERIFIED |
| Arts (MAGIC) | max 2 on the map | Unchanged. Only bands grant them (画卷, 教鞭). | – |
Other notes:
- **人事部文档** (`chess_item_6_08_e`) sets the deploy cap to **9**: "最大可部署人数变为9". The UI counter "剩余可放置角色" must read the cap per player, not the constant 8.
- The item card in the shop shows the tier chip and a price badge. There is always exactly 1 item slot at every level in all real modes (`itemCount` = 1).
+446
View File
@@ -0,0 +1,446 @@
# 05 · Enemies, Waves, Bosses, Maps — 卫戍协议:盟约 (act2autochess)
Research deliverable for the architect. It comes with two data files:
- `05-maps.json` (~200 KB): 11 stage terrains (8 active), 38 round/wave templates with routes, and helper ground paths.
- `05-enemies.json` (~340 KB): 326 enemies (stats, abilities, skills, autochess roles), special-enemy pools, the per-mode round schedule, bosses, bounty effects, the recommended generation algorithm, and LP rules.
Sources:
- Official: Kengxxiao/ArknightsGameData zh_CN. The files used are `activity_table.autoChessData` and `activity_table.activity.AUTOCHESS_SEASON.act2autochess`, plus `enemy_database`, `enemy_handbook_table`, `character_table` (devices), and `levels/act1autochess|act2autochess`.
- Community: PRTS (卫戍协议/帮助, 卫戍协议:盟约 + 战场一览, 下半/战场一览), arknights.wiki.gg/Stronghold_Protocol, forum.gamer.com.tw (#12294, #12316), and the official notice ak.hypergryph.com/news/5114.
- Anything the data does not state is marked **[ASSUMED]**.
---
## 1. How a match is put together (data flow)
```
modeId ──► stageDatasDict (weighted) ──► ONE stage map (terrain) for the whole match (05-maps.json stages)
└─► battleDataDict[mode][round] ──► wave TEMPLATE level (routes+spawns) (05-maps.json roundLevels)
└─ template enemy keys = placeholders ─► replaced by special-enemy pools (05-enemies.json)
turnInfoDataDict[mode][round] ─► rest-phase time, isBossTurn, boss overtime (150 s)
```
- The round levels (`level_act1autochess_01…h06`, `h07_xx`, `h08_xx`, `tr01/02/04`) have their own `mapData`, but it is only a generic all-road grid. **Ignore that grid.** Take the terrain from the stage chosen at match start, and use only the round level's `routes`, `waves` and `branches`.
- WALK routes in the templates have no checkpoints (start (9,10) or (12,10) → end (9,2)). The server must pathfind on the real stage grid.
- FLY routes carry explicit N-shaped checkpoints. PRTS confirms: "空中敌人路线为N形或反N形".
## 2. Maps
### 2.1 Coordinate convention (verified)
- Position `[row, col]`: row 0 is the **bottom** row and col 0 is the left column.
- The tile at (row, col) is `tiles[ mapData.map[H-1-row][col] ]`. `map[0]` in the raw file is the **top** row. This was verified because route start/end points fall on `tile_start` / `tile_end` only under this convention.
- In `05-maps.json`, `stage.rows[i]` is row i (bottom first). `rowsTopDown` holds the same grid printed top-first for human reading.
- 1 tile = 1 world unit. Every level uses `moveMultiplier` 0.5, so effective speed is about `moveSpeed × 0.5` tiles/s **[ASSUMED engine semantics; community rule of thumb]**.
### 2.2 Layout of every stage (19 rows × 21 cols; several areas on one grid)
| rows | area |
|---|---|
| 14–18 | Enemy preview pen: the next-round enemies are displayed here during the rest phase. `enemy_place_rect ((14,7),(18,13))`; row 16 is `previewNotAlloed`. |
| 13, 6 | Hard separators (`X`). The act2 m01 blowers sit on them. |
| **9–12** | **Normal-round battlefield.** Cols 2–10 are the LOCAL player's board. Cols 11–18 are the slot used to display the partner's board (`player_map_lr_offset` 8, `player_map_lr_boundary_col` 10). |
| 8 | Cols 4–8: 5 hand tiles *without* `isValidHand`. Not bench slots **[ASSUMED: staging/equipment display]**. |
| **7** | **Bench**: cols 0–9, 10 slots (`isValidHand`=1; constData `maxDeckChessCnt` = 10). |
| **1–5** | **Boss / final-offensive battlefield** shared by 2 players: left half cols 2–10, right half cols 10–18. |
| 0 | Boss-area benches: cols 0–9 (left player), cols 11–20 (right player). |
Normal board, per player:
- Enemy gates: `S` at (9,10) ("lower red gate"; rounds 1–3 use only this one) and at (12,10).
- Protection objective: `E` at (9,2).
- Col 9 is a floor lane you cannot deploy on.
- Deploy cap is 8 (`characterLimit`).
- DP: `initialCost` 0 / `maxCost` 99. DP is irrelevant because units auto-deploy.
- Ranged units may use melee tiles.
Boss board:
- Teleport-outs / spawns `O` at (2,10) and (5,10).
- Ends `E` at (2,2) and (2,18).
- Teleport-ins `I` at (1,3) and (1,17).
- Multi-player boss routes cross **both** halves: walk to (1,3), DISAPPEAR, wait 3 s, APPEAR at (5,10), then cross the other half. The two paired players therefore defend in series.
- Single-player `*_s` templates use only the left half.
### 2.3 Legend (glyphs in `rows`)
| glyph | tile | height | build | ground-passable | notes |
|---|---|---|---|---|---|
| `#` | forbidden | HIGH | – | no (fly only) | void |
| `X` | forbidden | HIGH | – | no (nothing) | separator |
| `r` | road | LOW | ALL | yes | main deploy tile (melee **or** ranged) |
| `R` | road | LOW | NONE | yes | (act1 m06 only) |
| `f` / `p` | floor | LOW | NONE | yes | lane col 9 / preview pen |
| `h` | wall | HIGH | RANGED | no | high ground |
| `b` | fence_bound | LOW | ALL | no (fly only) | deployable, but ground enemies path around it |
| `a` / `A` | achand | HIGH | – | – | bench slot / non-bench hand tile |
| `S` `E` `I` `O` | start / end / telin / telout | LOW | NONE | yes | |
| `m` | mire | LOW | ALL | yes | +1 stack every 3 s: atk-speed −5, move −5 %; max 10; cleared on leaving |
| `g` | smog | LOW | ALL | yes | an operator here cannot be targeted by enemy **ranged** attacks |
| `d` | deepsea | LOW | ALL | yes | enemies take 40 dmg/s, atk-speed −60, move ×0.6 |
| `i` | infection | LOW | ALL | yes | 70 true dmg/s to allies on it and enemies crossing it; +20 % ATK, +20 ASPD; 300 s |
### 2.4 Stage pool (`stageDatasDict`)
| stage | PRTS name | weight | modes | deploy tiles on normal board (ranged-only) | devices (`predefines.tokenInsts`) |
|---|---|---|---|---|---|
| act1autochess_m01 | 战场#01 | 50 | training, FUNNY, NORMAL | 22 (0) | 阻隔工事 ×12 (crate, HP 100; reroutes enemies; an enemy it blocks destroys it) |
| act1autochess_m02 | 战场#02 | 50 | NORMAL/HARD/ABYSS | 21 (0) | 阻隔工事 ×16, 射击台 ×16 (hidden; lets high-ground ops sit on it) |
| act1autochess_m03 | 战场#03 | 50 | NORMAL/HARD/ABYSS | 19 (0) | 射击台 ×16 (visible), 阻隔工事 ×8 |
| act1autochess_m04 | 战场#04 活性源石 | 50 | NORMAL/HARD/ABYSS | 19 (0) | 阻隔工事 ×12; 8 `i` tiles |
| act2autochess_m01 | 战场#05 源石流发生装置 | 50 | NORMAL/HARD/ABYSS | 16 (3) | 8 blowers (`trap_013_blower`) in rows 13/6, cols 5/9/13/17 (boss area 5/9/11/15), facing DOWN, 3 tiles. Operators facing the airflow direction get ATK +30 %, facing against it −30 %. Enemies moving with the flow get move +80 %, against it −50 %. |
| act2autochess_m02 | 战场#06 沼泽控制 | 50 | NORMAL/HARD/ABYSS | 19 (3) | `trap_098_mire` controller; 阻隔工事 ×4 on the col-9 lane (forces the upper gate around) |
| act2autochess_m03 | 战场#07 排气格栅 | 50 | NORMAL/HARD/ABYSS | 23 (0) | `g` smog tiles; 阻隔工事 ×10. The rune references `tile_reed` (ignite 15 s / 40 dmg) with no reed tiles present, so it is probably a leftover. |
| act2autochess_m04 | 战场#08 涨潮控制 | 50 | NORMAL/HARD/ABYSS | 25 (0) | `trap_042_tidectrl`: deep water `d` on the lanes |
| act1 m05 / m06 / m07 | season-1 #05–#07 | **0** | – | – | Disabled this season: deepsea+canoe, sandstorm+mounds, bushes. Kept in the JSON for reference. |
Always-present hidden controllers:
- `trap_1112_acblzd` 盟约寒风 at (18,0): Kjerag bond, periodic cold on all enemies.
- `trap_1104_aclasert` “双眼皮” turrets (HP 3000, ATK 900) on m01. They scale with the top bond's layers and are bond-activated. See the bond research.
`groundPathsHelper` in each stage holds the shortest 8-direction path (no corner cutting) for every gate→end pair. It also has a variant with crates treated as blocked. Use it for tests; the server should re-path live, because crates break.
Example (act2autochess_m01, top row first):
```
12 ####hrrr#fS#hrrr#fS## S(12,10)/(9,10) → E(9,2); snake path via (9,7)-(12,7)-(12,5)-(9,5)
11 ####hr#r#f##hr#r#f###
10 ####hr#r#f##hr#r#f###
9 ##Errr#rrrSrrr#rrrS##
8 ####AAAAA##I#########
7 aaaaaaaaaa########### bench
```
## 3. Waves
### 3.1 Round schedule (from `battleDataDict` + `turnInfoDataDict` + template `maxPlayTime`)
Template slot codes:
- N = `enemy_1422_lrsldr`, E = `1427_lrnazg`, S = `1425_lrcmra` (ground)
- NF = `1005_yokai`, EF = `1042_frostd`, SF = `1040_bombd` (fly)
- T = `1000_gopro_2`, TF = `1041_lazerd` (token; used only in the escaped templates)
| R | template | battle s | rest s (multi) | 机变 (multi NORMAL) | composition | total |
|---|---|---|---|---|---|---|
| 1 | act1autochess_01 | 45 | 65 | | N2 NF2 + literal 源石虫 ×2 | 6 |
| 2 | 02 | 45 | 65 | | N6 NF9 | 15 |
| 3 | 03 | 55 | 65 | Y | N10 E1 NF9 EF2 | 22 |
| 4 | 04 | 55 | 95 | | N4 E1 S1 NF8 EF3 SF2 | 19 |
| 5 | 05 | 55 | 95 | | N11 E2 S1 NF13 EF3 SF2 | 32 |
| 6 | 06 | 70 | 100 | Y | N13 E2 S2 NF16 EF3 SF3 | 39 |
| 7 | 07 | 85 | 105 | | N14 E4 S3 NF15 EF5 SF2 | 43 |
| 8 | h01 | 85 | 105 | | N11 E3 S4 NF8 EF5 SF4 | 35 |
| 9 | h02 | 95 | 125 | Y | N15 E3 S2 NF8 EF5 SF4 | 37 |
| 10 | h03 | 95 | 125 | | N15 E4 S4 NF9 EF5 SF4 | 41 |
| 11 | h04 | 115 | 125 | | N13 E4 S7 NF11 EF6 SF5 | 46 |
| 12 | h05 | 115 | 125 | | N15 E9 S10 NF17 EF10 SF9 | 70 |
| 13 | h06 | 115 | 150 | | N13 E12 S13 NF11 EF10 SF10 | 69 |
| 14 | boss (h07_xx) | until the boss dies; −1 LP/s after 150 s | 195 | | boss + escorts | |
| 15 | hidden core (h08_xx) | same | 195 (215 HARD/ABYSS) | | | |
Mode differences:
- **FUNNY (标准)** has 9 rounds: 01–07, h01, then the boss in round 9.
- **Single modes** have rest time 300. The 下半 update made it unlimited, with a 24 h reconnect window.
- `specialPhaseTime` (机变阶段) is 150 s in single and 16 s in multi.
- 机变 rounds (`isSpPrepare`) by mode:
| mode | 机变 rounds |
|---|---|
| multi NORMAL | 3, 6, 9 |
| multi FUNNY | 3, 9 |
| multi HARD/ABYSS | 3, 9, 11 |
| single NORMAL | 6, 9 |
| single HARD/ABYSS | 3, 9, 11 |
| single FUNNY | none |
| training | 3 |
- Training has 4 rounds (slime / ghost, then boss_7). Its rest time is 999 s.
Timing: every action in a template has `preDelay` (seconds from battle start) and `interval` between its `n` spawns. Enemy AI ends the battle when all enemies are dead, or when the battle time runs out (any enemies still alive then count as leaked).
### 3.2 Enemy factions & special enemies (特训敌人)
Official ingredients:
- `constData`: `templateEnemy*` (8 placeholders), `specialEnemyNum` 3, `minReplacedEnemyCount` 1, `maxReplacedEnemyCount` 5, `enemyTypeIdentifierToFillRandom` 1 (= SPECIAL), `enemyMaxHpFactor` 1, `enemyAtkFactor` 5, `enemyDefFactor` 3, `enemyMagicResistanceFactor` 3.
- `specialEnemyRandomTypeDict`: FLY/TIMES/ELEMENT/DOT/INVISIBLE/REFLECTION have count 3, weight 3. SPECIAL has count −1 (always available).
- Type names (`enemyTypeDatas`):
| type | name | description |
|---|---|---|
| SPECIAL | 特异 | high damage and toughness |
| FLY | 飞行 | many aerial units |
| TIMES | 频次 | needs a number of hits to kill |
| ELEMENT | 元素 | element damage |
| DOT | 持续 | damage over time |
| INVISIBLE | 隐匿 | stealth |
| REFLECTION | 折射 | refraction |
- `specialEnemyInfoDict` has 67 entries. Each is a trio: special key, `attachedNormalEnemyKeys`, `attachedEliteEnemyKeys`, plus `randomWeight` (8–15) and `isInFirstHalf`.
- `randomEnemyAttributeDict` has 324 entries: `enemyBattleEffectivenessFactor` (0.1–1.2), `isFlyEnemy`, and `extraEnemyKeyList` (summons).
- `modeDataDict.inactiveEnemyKey` bans 18 strong keys in FUNNY/NORMAL.
- wiki.gg: "Starting from Alliance there can be up to 3 enemy factions per Protocol, along with a boss chosen from a separate pool… enemies do not get strategies and only get generic stat buffs as rounds escalate."
**Recommended generation [ASSUMED reconstruction]** (also in `05-enemies.json → generation.recommendedAlgorithm`):
1. **Match start:** pick 3 of the 6 typed factions (uniform). Training uses FLY, TIMES and ELEMENT.
2. **Round r ≤ 7** uses entries with `firstHalf`=true. **Rounds 8–13** use `firstHalf`=false; these are the upgraded "_2" enemies.
3. **Ground entry G:** a weighted pick among the chosen non-FLY factions plus SPECIAL, for the current half.
4. **Fly entry F:** a weighted FLY pick, only if FLY was chosen. Otherwise the fly slots keep yokai / frostd / bombd.
5. **Replace:** S → G.key, N → G.N, E → G.E. SF → F.key, NF → F.N, EF → F.E. Literal keys stay unchanged (round-1 slime, boss escorts).
6. **Count:** each replaced template enemy becomes `k = clamp(round(be(template)/be(new)), 1, 5)` copies, where `be = (hp + 5·atk + 3·def + 3·res) / enemyBattleEffectivenessFactor`.
- With this formula almost all second-half specials map 1 : 1.
- Swarm enemies map up to 5 : 1: 炽焰/灼热源石虫, 萨科塔之翼, 步兵.
- `k` is precomputed in `specialEntries.rows[].kS / N[][2] / E[][2]`.
- Optionally cap the total spawn count per round at about 1.5 × the template count.
7. **Stat scaling [ASSUMED]:** season effects `aceffect_enemy_1..5` exist (急行军 +15 % speed, 补给线 +8 % HP, 补给线II +20 % HP, 攻坚装备 +10 % ATK / +20 % HP, 攻坚装备II ×0.8 / ×0.8, 攻坚装备III ATK ×0.7 / HP ×0.75), but no data links them to modes or rounds. Proposed mapping:
| when | effect |
|---|---|
| FUNNY | III |
| NORMAL | II |
| HARD | ×1 |
| ABYSS | 攻坚装备 + 急行军 |
| rounds 8–10 | +补给线 |
| rounds 11–13 | 补给线II |
“炎佑” (9012_acloon) and the 墨魂 minis (msf*) are excluded, following their `enemy_exclude` lists.
8. **Bounties (机变阶段 悬赏决策):** there are 129 ENEMY_GAIN effects (`bountyEffects`). Each adds N copies of an enemy to the chooser's next 1 / 2 / all battles.
- `kill_gain_coin`: the killer earns coin.
- `selfbattle_win_gain_coin`: pays coin if the chooser's own combat phase is perfect.
- 战术特训 enemies are at 70 % HP/ATK/DEF in single mode (PRTS patch note).
### 3.3 Unite phase (联防) template
`level_act1autochess_escaped_multi` has 8 SPAWN slots (N, E, S, NF, EF, SF, T, TF; up to 5 each). Its routes start on the partner half at (9,18) / (12,18) and cross to (9,2). The single variant is `escaped_single`. For the remake, spawning the leaked enemies at the helper's own gates is acceptable.
## 4. LP (目标生命值) rules
- **Start LP** is the band's `totalHp` (20–45; 歌利亚 training band = 45).
- **Normal rounds:**
- Unkilled enemies (reached the objective, or still alive at the time limit) cost LP. Official help: "根据未击倒敌人的数量…每回合最多扣除10点".
- The cap is `costPlayerHpLimit` = 10.
- **[ASSUMED]** Each counted enemy costs 1, excluding `notCountInTotal` units and boss parts. `lifePointReduce` is not used here, because several regular faction enemies (王庭军战士, 逐火战士, 萨科塔之翼 …) have `lpr` 0.
- **Team mode:**
- Leaks are held back. If at least one player leaked and at least one player cleared perfectly, a 联防 phase runs: up to 2 perfect players fight the union of all leaked enemies.
- Survivors of the 联防 phase then cost their owners' LP (cap 10).
- If nobody cleared perfectly, leaks cost LP immediately.
- Bonds do not stack during 联防.
- **Boss round:**
- All LP merge into one pool with no cap.
- After 150 s, every second costs 1 LP.
- **[ASSUMED]** Each leaked enemy costs its `lpr`: most bosses 2, 盐风主教昆图斯 1, 卢西恩 30, typical enemies 1.
- **Hidden core (round 15, NORMAL+):**
- Single: activated bond layers > 300 and LP > 1.
- Multi: total layers across all players > 1000 and LP sum > 1.
`lpr` distribution across the 326 exported enemies: 255 ×1, 42 ×0, 21 ×2, 5 ×5, 2 ×30, 1 ×3. Among act2-relevant enemies: 175 ×1, 27 ×0, 20 ×2.
## 5. Bosses (`bossInfoDict`: season weights / HP pool + global enemyId)
bloodPoint is the boss HP pool for each difficulty. Per the notices, it is **shared by all players/battlefields**. `boss_battle_multi_player` spawns a mirrored copy on the partner half that shares HP. **[ASSUMED]** Single mode uses the same value.
| id | enemy | name | w | bloodPoint FUNNY / NORMAL / HARD / ABYSS | DB hp / atk / def / res | lpr | key abilities (handbook) |
|---|---|---|---|---|---|---|---|
| boss_1 | 9013_acstmk | 假想敌:胄 | 6 | 247 500 / 675 000 / 1 800 000 / 3 600 000 | 600000/650/1000/25 | 2 | Random magic beam. 灭顶之灾: shell at the highest-ATK operator; 3×3 stun + physical DoT; the shell can be shot down. <20 % HP: damage taken ×0.5 and an extra shell. Every 50 s summons drones (yokai, `boss_summon_enemy`). |
| boss_2 | 9017_achunt | 假想敌:铳 | 6 | 225 000 / 400 000 / 800 000 / 3 000 000 | 120000/750/1100/60 | 2 | Targets highest DEF. Erosion damage. Unblockable. ASPD ramps on the same target (+80, 5 stacks). 最终之罚: charge at the highest-DEF unit. Two copies in multi. |
| boss_3 | 9021_acduml | 假想敌:管 | 6 | 285 000 / 708 750 / 2 000 000 / 4 000 000 | 85000/300/700/50 | 2 | Summons “余音” (9023_acdums) every 40 s (20 s below 50 % HP). 裂管之奏: triple hit on each 余音 (AoE magic + apoptosis). |
| boss_4 | 1521_dslily | 盐风主教昆图斯 | 5 | 307 500 / 708 750 / 2 100 000 / 4 200 000 | 100000/380/500/50 | 1 | Hits the 2 highest-DEF targets. 大潮 (global magic + neural), 崩坍, 断裂生殖 (子代 `trap_039_dstnta` stun). 物种爆发 kills everything after a long time. |
| boss_5 | 2016_csphtm | 卢西恩,“猩红血钻” | 10 | 200 000 / 390 000 / 780 000 / 3 000 000 | 80000/1500/600/40 | **30** | 40 % evade while unblocked. Blinks behind its blocker and leaves 不祥幻影 (2017_csphts). 40 % DEF penetration + neural. Two copies in multi. |
| boss_6 | 9032_aclionk | 阿利斯泰尔,帝国余晖 | 5 | 285 000 / 705 000 / 1 990 000 / 3 980 000 | 81000/820/1030/20 | 2 | 王权号令 (plus-shaped strike, drops equipment 10028/29/30). 莫非王土 animates the equipment. ≤50 %: +DEF, RES 45, 2 equipment at once. Eventually kills everything. |
| boss_7 | 9033_acdeer | “萨米的意志” | 5 | 277 500 / 787 500 / 2 000 000 / 4 000 000 | 240000/800/800/40 | 2 | 冰凌 (hits the whole column). 自然涌动 (stun + magic DoT). ≤50 %: damage taken ×0.4 and 2 targets. Training boss. |
| boss_8 | 9013_acstmk_2 | 假想敌:胄 (隐秘核心) | 50 | 750 000 / 937 500 / 3 600 000 / 7 200 000 | 1200000/750/1200/30 | 2 | Adds 斩胄之剑 / 破胄之锤 (9014/9015; invulnerable until shot down). |
| boss_9 | 9017_achunt_2 | 假想敌:铳 (隐秘核心) | 40 | 675 000 / 900 000 / 2 800 000 / 3 950 000 | 120000/750/1100/60 | 2 | Linked to 3 “碎铳之簧” (9018/19/20) that take damage for it. Damage taken ×0.2. |
| boss_10 | 9021_acduml_2 | 假想敌:管 (隐秘核心) | 40 | 825 000 / 1 012 500 / 3 800 000 / 7 600 000 | 85000/300/700/50 | 2 | Comes with 假想敌:弦 (9022_acdumm). |
Boss escorts per level (template keys still get replaced) are in `05-maps.json roundLevels.*h07*/h08*`:
- boss_1: 重装防御组长 ×14 (`1006_shield_2`)
- boss_2: 愧悔魂灵圣杯 (`1430_lrrook`, 100k HP, 0 ATK)
- boss_3: 堂皇 (`1209_sfden_2`)
- boss_4: 复核洋流使者 (`1438_dspred`)
- boss_5: 骇笑看客 (`2009_csaudc`) and 绯红歌伶 (`2010_csdcr`)
- boss_6: “萨科塔昂首” (`10085_hllevi_2`)
- boss_7: 冰爆源石虫 (`1067_snslime`) and 游击队盾卫 (`1081_sotisd`)
- hidden cores: “帝国的甲胄” (`10027_vtsk`), 假想敌:淤困 (`9007_acelem`), 假想敌:再生 (`9010_acpupp`)
## 6. Enemy stat tables (raw level-0; full data incl. skills/talents in `05-enemies.json.enemies`)
Columns: rk N/E/B; spd is moveSpeed; bat is base attack time in seconds; range in tiles (−1 or 0 means melee or no attack); mv W = walk, F = fly.
Official autochess overrides already applied:
- 灼藤 (10067_ftsjc): ATK 400
- 元核孽生者 (1439_dslntf): ATK 400, talent `3.hp_ratio` 0.2
Both come from `level_autochess_enemy_data`.
### Template placeholders (constData)
| key | name | rk | hp | atk | def | res | spd | bat | range | lpr | mv | dmg | slot / be |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1422_lrsldr | 萨卡兹枯朽前锋 | N | 4700 | 300 | 100 | 40 | 0.8 | 3 | -1 | 1 | W | PHYS/MAGI | Normal be=6620 |
| 1427_lrnazg | “灵幛” | E | 15000 | 900 | 500 | 50 | 0.7 | 5 | 1.3 | 1 | W | PHYS | Elite be=21150 |
| 1425_lrcmra | 孽罪奇美拉 | E | 20000 | 800 | 1000 | 50 | 0.6 | 5 | -1 | 1 | W | PHYS/MAGI | Special be=27150 |
| 1005_yokai | 妖怪 | N | 800 | 0 | 50 | 0 | 0.9 | 2.3 | 0 | 1 | F | NO_D | NormalFly be=950 |
| 1042_frostd | 寒霜 | E | 6500 | 100 | 600 | 30 | 0.9 | 1 | 2.5 | 1 | F | NO_D | EliteFly be=8890 |
| 1040_bombd | 暴鸰 | E | 4000 | 800 | 150 | 30 | 0.6 | 5 | 2 | 1 | F | PHYS | SpecialFly be=8540 |
| 1000_gopro_2 | 猎狗pro | N | 1700 | 260 | 0 | 20 | 1.9 | 1.4 | 0 | 1 | W | PHYS | Token be=3060 |
| 1041_lazerd | 法术大师A1 | N | 2700 | 220 | 100 | 65 | 1.5 | 2.15 | 1.8 | 1 | F | MAGI | TokenFly be=4772 |
### Special (特训) entries: S-slot enemy, with attached N / E (k = copies per replaced template enemy)
| key | name | rk | hp | atk | def | res | spd | bat | range | lpr | mv | dmg | type,half,w · kS · N(k) · E(k) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1041_lazerd | 法术大师A1 | N | 2700 | 220 | 100 | 65 | 1.5 | 2.15 | 1.8 | 1 | F | MAGI | FLY,1st,w10 · k2 · “萨科塔之翼”×1 · 妖怪MKII×2 |
| 1355_mrfly | 护障 | N | 6000 | 0 | 80 | 40 | 1.2 | 2 | 2.5 | 1 | F | NO_D | FLY,1st,w10 · k1 · 妖怪×1 · 妖怪MKII×2 |
| 1017_defdrn | 御4 | N | 4000 | 0 | 150 | 20 | 0.8 | 2 | 2.5 | 1 | F | NO_D | FLY,1st,w10 · k2 · 妖怪×1 · 妖怪MKII×2 |
| 10084_hlegle | “萨科塔之眼” | N | 5500 | 1000 | 150 | 30 | 0.7 | 3 | 1.3 | 0 | F | PHYS | FLY,1st,w10 · k1 · “萨科塔之翼”×1 · 妖怪MKII×2 |
| 1407_hummbd | 远眺 | N | 4500 | 250 | 120 | 20 | 0.7 | 3 | 2 | 1 | F | PHYS | FLY,1st,w10 · k1 · 妖怪×1 · 妖怪MKII×2 |
| 1005_yokai_3 | 威龙 | E | 30000 | 660 | 170 | 0 | 0.5 | 3.8 | 2 | 1 | F | PHYS | FLY,2nd,w10 · k1 · 妖怪×1 · 妖怪MKII×2 |
| 1112_emppnt | 帝国炮火先兆者 | E | 13000 | 1000 | 800 | 50 | 0.5 | 5 | 2 | 1 | F | PHYS | FLY,2nd,w10 · k1 · 妖怪×1 · 妖怪MKII×2 · banned FUNNY+NORMAL |
| 1041_lazerd_2 | 法术大师A2 | E | 6000 | 600 | 140 | 65 | 0.8 | 3.5 | 3 | 1 | F | MAGI | FLY,2nd,w10 · k1 · 妖怪MKII×1 · 寒霜×1 |
| 9009_acfort | 假想敌:黑云 | E | 24000 | 500 | 150 | 20 | 0.5 | 3 | 2.5 | 1 | F | PHYS | FLY,2nd,w10 · k1 · 妖怪MKII×1 · 威龙×1 |
| 1195_sfyin_2 | 明鉴 | N | 3100 | 390 | 0 | 20 | 1.5 | 1.5 | 0 | 1 | W | PHYS | TIMES,1st,w10 · k2 · 磨砻×1 · 俗心×1 |
| 1288_duskls_2 | 深池逐火精锐战士 | N | 6000 | 180 | 100 | 50 | 0.7 | 2 | 0 | 0 | W | MAGI | TIMES,1st,w10 · k3 · 深池逐火战士×1 · 深池逐火战士×2 |
| 1197_sfshu | 俗心 | N | 2500 | 320 | 60 | 0 | 1.4 | 1.4 | 0 | 1 | W | PHYS | TIMES,1st,w10 · k2 · 磨砻×1 · 俗心×1 |
| 1292_duskld | 深池逐火护卫 | E | 10000 | 350 | 550 | 60 | 0.5 | 3.5 | 0 | 0 | W | MAGI | TIMES,2nd,w10 · k1 · 深池逐火战士×1 · 深池逐火精锐战士×2 |
| 1207_sfji | 沉沙 | E | 11000 | 1100 | 600 | 30 | 1.3 | 2.2 | 0 | 1 | W | PHYS | TIMES,2nd,w10 · k1 · 磨砻×1 · 俗心×1 · banned FUNNY+NORMAL |
| 1199_sfjin | 身观 | E | 6500 | 700 | 750 | 0 | 0.8 | 4 | 0 | 1 | W | PHYS | TIMES,2nd,w10 · k1 · 磨砻×1 · 俗心×1 |
| 9010_acpupp | 假想敌:再生 | E | 12000 | 1100 | 300 | 40 | 0.5 | 5 | -1 | 1 | W | PHYS | TIMES,2nd,w10 · k1 · 俗心×1 · 雅气×1 |
| 1161_tidmag | 控潮术师 | N | 6000 | 200 | 200 | 60 | 0.7 | 2.5 | 2.2 | 1 | W | MAGI | ELEMENT,1st,w10 · k1 · 潜水员×1 · 码头水手×1 |
| 1305_mhslim_2 | 炽焰源石虫 | N | 1800 | 80 | 0 | 0 | 0.8 | 2 | 1.8 | 1 | W | MAGI | ELEMENT,1st,w10 · k5 · 灼热源石虫×5 · 炽焰源石虫×5 |
| 2025_syufo | 掠海漂移体 | E | 20000 | 500 | 250 | 40 | 0.6 | 4 | 2.6 | 1 | W | PHYS | ELEMENT,2nd,w10 · k1 · 骨海漂流体×1 · 骨海漂流体×4 |
| 1161_tidmag_2 | 领潮员 | N | 8000 | 260 | 220 | 60 | 0.7 | 2.5 | 2.2 | 1 | W | MAGI | ELEMENT,2nd,w10 · k1 · 潜水员×1 · 码头水手长×1 |
| 1439_dslntf | 元核孽生者 | E | 30000 | 400 | 0 | 70 | 0.25 | 3 | 0 | 1 | W | PHYS | ELEMENT,2nd,w10 · k1 · 底海滑动者×1 · 富营养的滑动者×4 |
| 1275_dwlock_2 | 萨卡兹王庭军精锐术师 | E | 14000 | 500 | 250 | 50 | 0.8 | 4 | 2.5 | 1 | W | MAGI | ELEMENT,2nd,w10 · k1 · 萨卡兹王庭军战士×1 · 萨卡兹王庭军精锐战士×2 · banned FUNNY+NORMAL |
| 9007_acelem | 假想敌:淤困 | E | 36000 | 600 | 200 | 40 | 0.5 | 1 | -1 | 1 | W | MAGI | ELEMENT,2nd,w10 · k1 · 萨卡兹王庭军战士×1 · 萨卡兹王庭军精锐战士×2 |
| 1234_dsubrl | 深溟巢涌者 | E | 10000 | 140 | 550 | 60 | 0.8 | 1 | 1.6 | 1 | W | MAGI | DOT,1st,w10 · k1 · 单核掠食者×1 · 异光体掠食者×2 |
| 1272_nhtank | 萨卡兹枯朽战车 | E | 12000 | 750 | 750 | 10 | 0.7 | 4.5 | 2.2 | 1 | W | PHYS | DOT,1st,w10 · k1 · 萨卡兹枯朽战士×1 · 萨卡兹枯朽战士组长×3 |
| 1270_nhstlk | 逐腐兽 | E | 12000 | 550 | 150 | 20 | 1.4 | 1.8 | 0 | 1 | W | PHYS/MAGI | DOT,1st,w10 · k2 · 萨卡兹枯朽战士×1 · 萨卡兹枯朽战士组长×3 |
| 1234_dsubrl_2 | 富营养的巢涌者 | E | 14000 | 160 | 550 | 60 | 0.8 | 1 | 1.6 | 1 | W | MAGI | DOT,2nd,w10 · k1 · 异光体掠食者×1 · 异光体掠食者×2 · banned FUNNY+NORMAL |
| 10122_uacann_2 | 集团军重型火炮 | N | 10000 | 500 | 350 | 0 | 0.8 | 5 | 10 | 1 | W | PHYS | DOT,2nd,w10 · k1 · 萨卡兹枯朽战士组长×1 · 萨卡兹枯朽战士组长×3 · banned FUNNY+NORMAL |
| 1272_nhtank_2 | 尖端萨卡兹枯朽战车 | E | 15000 | 850 | 750 | 10 | 0.7 | 4.5 | 2.2 | 1 | W | PHYS | DOT,2nd,w10 · k1 · 萨卡兹枯朽战士组长×1 · 疯狂的逐腐兽×1 · banned FUNNY+NORMAL |
| 9006_actoxi | 假想敌:蚀裂 | E | 15000 | 400 | 400 | 10 | 0.6 | 1.5 | -1 | 1 | W | MAGI | DOT,2nd,w15 · k1 · 萨卡兹枯朽战士组长×1 · 疯狂的逐腐兽×1 |
| 1299_ymkilr | 山海众头目 | E | 8500 | 800 | 250 | 20 | 1.1 | 2.5 | 1.2 | 1 | W | PHYS | INVISIBLE,1st,w10 · k1 · 宿主士兵×2 · 隐形弩手×3 |
| 10034_cnvsax | 节日爵士乐手 | E | 10000 | 650 | 300 | 10 | 0.7 | 1 | 2.5 | 1 | W | PHYS/MAGI | INVISIBLE,1st,w10 · k2 · 宿主士兵×2 · 隐形弩手×3 |
| 10042_prtrop_2 | 扶桥老手 | N | 8000 | 720 | 300 | 30 | 0.65 | 2 | -1 | 1 | W | PHYS | INVISIBLE,1st,w10 · k2 · 寻险水手×1 · 架桥船工×2 |
| 1389_winbab_2 | 访问团强攻冠军 | N | 7000 | 650 | 400 | 10 | 0.6 | 3 | 3.5 | 1 | W | PHYS | INVISIBLE,2nd,w10 · k1 · 访问团新兵×1 · 隐形弩手组长×2 |
| 1299_ymkilr_2 | 山海众秘使 | E | 11000 | 1000 | 300 | 20 | 1.1 | 2.5 | 1.2 | 1 | W | PHYS | INVISIBLE,2nd,w10 · k1 · 萨卡兹枯朽辟路前锋×1 · 隐形弩手组长×2 |
| 1404_msnip | 重弩突袭者 | N | 6000 | 400 | 180 | 60 | 0.7 | 3.5 | -1 | 1 | W | MAGI | INVISIBLE,2nd,w10 · k1 · 潜伏者×2 · 隐形弩手组长×2 |
| 9008_acbunn | 假想敌:骨刺 | E | 12000 | 360 | 300 | 60 | 0.5 | 4 | 2 | 1 | W | MAGI | INVISIBLE,2nd,w10 · k1 · 业余竞演者×1 · 隐形弩手组长×2 · banned FUNNY+NORMAL |
| 1174_duholy | 深池伙友卫队 | E | 15000 | 900 | 900 | 0 | 0.8 | 4 | 1.4 | 1 | W | PHYS | REFLECTION,1st,w10 · k1 · 深池侦察兵×1 · 深池重甲卫士×1 |
| 1168_dumage | 深池暗影术师 | N | 4500 | 300 | 150 | 20 | 1.1 | 3 | 2.5 | 1 | W | MAGI | REFLECTION,1st,w10 · k4 · 深池侦察犬×1 · 深池方阵战士×3 |
| 1170_dushld | 深池重甲卫士 | E | 7000 | 700 | 750 | 0 | 0.7 | 4 | 0 | 1 | W | PHYS | REFLECTION,1st,w10 · k2 · 深池侦察兵×1 · 深池方阵战士×3 |
| 1174_duholy_2 | 深池伙友卫队精英 | E | 20000 | 1100 | 1000 | 0 | 0.8 | 4 | 1.4 | 1 | W | PHYS | REFLECTION,2nd,w10 · k1 · 深池侦察队长×1 · 深池重甲卫士队长×1 · banned FUNNY+NORMAL |
| 1170_dushld_2 | 深池重甲卫士队长 | E | 9000 | 900 | 1100 | 0 | 0.7 | 4 | 0 | 1 | W | PHYS | REFLECTION,2nd,w10 · k1 · 深池侦察队长×1 · 深池方阵指挥官×2 |
| 1172_dugago | 守墓石像 | E | 10000 | 550 | 550 | 0 | 0.5 | 4 | 1.6 | 1 | W | PHYS/MAGI | REFLECTION,2nd,w10 · k1 · 深池侦察队长×1 · 深池方阵指挥官×2 |
| 9011_acrefr | 假想敌:镜膜 | E | 18000 | 1000 | 600 | 0 | 0.5 | 5.5 | -1 | 1 | W | PHYS | REFLECTION,2nd,w10 · k1 · 深池侦察队长×1 · 深池重甲卫士队长×1 |
| 1010_demon_2 | 萨卡兹大剑组长 | E | 15000 | 750 | 250 | 50 | 0.85 | 2 | 0 | 1 | W | PHYS | SPECIAL,1st,w10 · k1 · 萨卡兹刀兵×1 · 萨卡兹大剑手×2 |
| 1045_hammer | 粉碎攻坚手 | E | 10000 | 1000 | 1000 | 0 | 0.5 | 3.5 | 2.5 | 1 | W | PHYS | SPECIAL,1st,w10 · k1 · 宿主士兵×2 · 步兵×5 |
| 1118_lidbox_2 | 拳师囚犯 | E | 9500 | 400 | 400 | 0 | 1.6 | 1 | 0 | 1 | W | PHYS | SPECIAL,1st,w10 · k2 · 普通囚犯×1 · 老练囚犯×2 |
| 1325_cbgpro_2 | 高级军用猎狗 | N | 4600 | 360 | 0 | 30 | 1.7 | 1.5 | 0 | 1 | W | PHYS | SPECIAL,1st,w10 · k3 · 猎狗pro×2 · 军用猎狗×3 |
| 1121_lifbos | 重犯 | E | 20000 | 800 | 400 | 0 | 0.8 | 3 | 0 | 1 | W | PHYS | SPECIAL,1st,w10 · k1 · 普通囚犯×1 · 老练囚犯×2 |
| 1006_shield_3 | 重装五十夫长 | E | 40000 | 1000 | 1200 | 0 | 0.75 | 2.6 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · 宿主士兵×2 · 重装侦察兵×1 |
| 1329_cbshld | 弧光镜卫 | N | 10000 | 700 | 3300 | 60 | 0.75 | 3 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · Dor-1号失败品×1 · 莱茵生命防卫科高级成员×2 |
| 1045_hammer_2 | 粉碎攻坚组长 | E | 20000 | 1500 | 2000 | 0 | 0.5 | 3.5 | 2.5 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · 宿主重装士兵×1 · 宿主重装士兵×1 |
| 1387_winshd | 访问团持盾者 | E | 12000 | 500 | 1200 | 20 | 0.5 | 3 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · 访问团新兵×1 · 访问团老兵×1 |
| 1121_lifbos_2 | 传奇重犯 | E | 25000 | 1000 | 500 | 0 | 0.8 | 3 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · 老练囚犯×1 · 强壮囚犯×1 |
| 1320_wdrrl_2 | 萨卡兹悖谬暴虐兵长 | E | 25000 | 1200 | 800 | 20 | 0.8 | 2.9 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · 萨卡兹枯朽辟路前锋×1 · 萨卡兹大剑手×2 |
| 10124_uashld_2 | 集团军中坚盾卫 | E | 25000 | 950 | 1800 | 60 | 0.7 | 3.8 | 1 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · 宿主士兵×2 · 宿主重装士兵×1 |
| 1328_cbjedi | 弧光锋卫 | E | 10000 | 700 | 550 | 50 | 0.75 | 3 | 0 | 1 | W | MAGI | SPECIAL,1st,w10 · k1 · Dor-1号失败品×1 · 莱茵生命防卫科高级成员×2 |
| 1355_mrfly_2 | 护障·P | N | 10000 | 0 | 80 | 50 | 1.2 | 2 | 2.5 | 1 | F | NO_D | FLY,1st,w8 · k1 · 妖怪×1 · 御4×2 · banned FUNNY+NORMAL |
| 1197_sfshu_2 | 雅气 | N | 3350 | 410 | 70 | 0 | 1.4 | 2 | 0 | 1 | W | PHYS | TIMES,1st,w8 · k1 · 磨砻×1 · 雅气×1 · banned FUNNY+NORMAL |
| 10067_ftsjc | 灼藤 | E | 13000 | 400 | 250 | 65 | 0.85 | 3.5 | -1 | 1 | W | MAGI | ELEMENT,2nd,w8 · k1 · 卷心籽×1 · 灼藤×1 · banned FUNNY+NORMAL |
| 1270_nhstlk_2 | 疯狂的逐腐兽 | E | 16000 | 700 | 200 | 20 | 1.4 | 1.8 | 0 | 1 | W | PHYS/MAGI | DOT,2nd,w8 · k1 · 萨卡兹枯朽战士×1 · 萨卡兹枯朽战士组长×3 · banned FUNNY+NORMAL |
| 1283_sgkill | 家族灭迹人 | E | 12000 | 800 | 800 | 45 | 0.9 | 3 | 1.8 | 1 | W | PHYS | INVISIBLE,2nd,w8 · k1 · 业余竞演者×1 · 隐形弩手组长×2 · banned FUNNY+NORMAL |
| 1175_dushdo_2 | 深池伙友影刃精英 | E | 15000 | 580 | 550 | 0 | 0.8 | 2.1 | 1.4 | 1 | W | MAGI | REFLECTION,2nd,w8 · k1 · 深池侦察犬×1 · 深池伙友卫队精英×1 · banned FUNNY+NORMAL |
| 1254_lypa_2 | 测试用动力装甲 | E | 10000 | 850 | 800 | 20 | 1.2 | 1.3 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k2 · Dor-1号失败品×1 · 莱茵生命防卫科高级成员×2 · banned FUNNY+NORMAL |
| 1425_lrcmra_2 | 渎罪奇美拉 | E | 25000 | 1200 | 1200 | 50 | 0.6 | 5 | -1 | 1 | W | PHYS/MAGI | SPECIAL,2nd,w10 · k1 · 萨卡兹枯朽辟路前锋×1 · 萨卡兹大剑手×2 · banned FUNNY+NORMAL |
| 1329_cbshld_2 | 弧光镜卫长 | N | 13000 | 900 | 3500 | 60 | 0.75 | 3 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k1 · Dor-α号失败品×1 · Dor-β号复制体×2 · banned FUNNY+NORMAL |
| 1072_dlancer | 萨卡兹穿刺手 | E | 6000 | 450 | 150 | 40 | 0.25 | 4 | 0 | 1 | W | PHYS | SPECIAL,2nd,w10 · k3 · 萨卡兹刀兵×1 · 萨卡兹大剑组长×1 · banned FUNNY+NORMAL |
### Attached normal/elite enemies (N/E slots)
| key | name | rk | hp | atk | def | res | spd | bat | range | lpr | mv | dmg | abilities |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10083_hlbird | “萨科塔之翼” | N | 1500 | 0 | 0 | 30 | 0.9 | 1 | -1 | 0 | F | NO_D | 飞行单位; 生命值首次降至一半以下时,在数秒内陷入恐惧 |
| 1005_yokai_2 | 妖怪MKII | N | 1550 | 220 | 50 | 0 | 0.9 | 3 | 2 | 1 | F | PHYS | 飞行单位 |
| 1005_yokai | 妖怪 | N | 800 | 0 | 50 | 0 | 0.9 | 2.3 | 0 | 1 | F | NO_D | 飞行单位 |
| 1042_frostd | 寒霜 | E | 6500 | 100 | 600 | 30 | 0.9 | 1 | 2.5 | 1 | F | NO_D | 飞行单位; 周围的我方单位的攻击速度大幅度降低 |
| 1005_yokai_3 | 威龙 | E | 30000 | 660 | 170 | 0 | 0.5 | 3.8 | 2 | 1 | F | PHYS | 飞行单位 |
| 1195_sfyin | 磨砻 | N | 2000 | 310 | 0 | 20 | 1.5 | 1.5 | 0 | 1 | W | PHYS | 被击倒后生成2个<木制瑞印> |
| 1197_sfshu | 俗心 | N | 2500 | 320 | 60 | 0 | 1.4 | 1.4 | 0 | 1 | W | PHYS | 被击倒后生成3个<小说卷轴> |
| 1288_duskls | 深池逐火战士 | N | 5000 | 150 | 50 | 50 | 0.7 | 2 | 0 | 0 | W | MAGI | 位于燃烧的芦苇丛附近时攻击额外造成灼燃损伤; 被击倒时暂时变为隐匿、需要5次伤害击倒的<怨恨的余烬>,一段时间后重生 |
| 1288_duskls_2 | 深池逐火精锐战士 | N | 6000 | 180 | 100 | 50 | 0.7 | 2 | 0 | 0 | W | MAGI | 位于燃烧的芦苇丛附近时攻击额外造成灼燃损伤; 被击倒时暂时变为隐匿、需要5次伤害击倒的<暴怒的余烬>,一段时间后重生 |
| 1197_sfshu_2 | 雅气 | N | 3350 | 410 | 70 | 0 | 1.4 | 2 | 0 | 1 | W | PHYS | 被击倒后生成3个<诗画卷轴> |
| 1158_divman | 潜水员 | N | 3300 | 240 | 150 | 20 | 0.9 | 2.7 | 1.9 | 1 | W | PHYS | 免疫水蚀,在水中攻击力提升并获得隐匿 |
| 1160_hvyslr | 码头水手 | E | 10000 | 800 | 800 | 20 | 0.7 | 3.5 | 0 | 1 | W | PHYS | 能在数次攻击后晕眩我方单位; “水蚀”状态下快速损失生命 |
| 1305_mhslim | 灼热源石虫 | N | 1200 | 50 | 0 | 0 | 0.8 | 2 | 1.8 | 1 | W | MAGI | 攻击造成灼燃损伤 |
| 1305_mhslim_2 | 炽焰源石虫 | N | 1800 | 80 | 0 | 0 | 0.8 | 2 | 1.8 | 1 | W | MAGI | 攻击造成灼燃损伤 |
| 2021_syfish | 骨海漂流体 | N | 4000 | 250 | 150 | 10 | 1.2 | 2 | 0 | 1 | W | PHYS | 攻击额外造成侵蚀损伤 |
| 1160_hvyslr_2 | 码头水手长 | E | 14000 | 1000 | 1000 | 20 | 0.7 | 3.5 | 0 | 1 | W | PHYS | 能在数次攻击后晕眩我方单位; “水蚀”状态下快速损失生命 |
| 1148_dssbr | 底海滑动者 | N | 2800 | 280 | 130 | 10 | 1.1 | 2 | 0 | 1 | W | PHYS | 攻击额外造成神经损伤 |
| 1148_dssbr_2 | 富营养的滑动者 | N | 3600 | 360 | 150 | 10 | 1.1 | 2 | 0 | 1 | W | PHYS | 攻击额外造成神经损伤 |
| 1229_darmy | 萨卡兹王庭军战士 | N | 4200 | 240 | 220 | 40 | 0.9 | 2.5 | -1 | 0 | W | MAGI | 攻击造成凋亡损伤 |
| 1229_darmy_2 | 萨卡兹王庭军精锐战士 | N | 5500 | 380 | 280 | 40 | 0.9 | 2.5 | -1 | 0 | W | MAGI | 攻击造成凋亡损伤 |
| 1433_dsbasi | 单核掠食者 | N | 4300 | 280 | 0 | 10 | 1.6 | 1.1 | -1 | 1 | W | PHYS | |
| 1433_dsbasi_2 | 异光体掠食者 | N | 5700 | 350 | 0 | 10 | 1.6 | 1.1 | -1 | 1 | W | PHYS | |
| 1267_nhpbr | 萨卡兹枯朽战士 | N | 4500 | 380 | 100 | 20 | 0.8 | 2.5 | 0 | 1 | W | PHYS | 被击倒时释放污染秽蚀,使周围我方持续损失生命 |
| 1267_nhpbr_2 | 萨卡兹枯朽战士组长 | N | 5500 | 450 | 100 | 20 | 0.8 | 2.5 | 0 | 1 | W | PHYS | 被击倒时释放污染秽蚀,使周围我方持续损失生命 |
| 1270_nhstlk_2 | 疯狂的逐腐兽 | E | 16000 | 700 | 200 | 20 | 1.4 | 1.8 | 0 | 1 | W | PHYS/MAGI | 攻击使目标在一段时间内持续受到法术伤害,目标接受治疗时解除此效果 |
| 1043_zomsbr | 宿主士兵 | N | 2500 | 250 | 100 | 30 | 0.9 | 1.8 | 2.5 | 1 | W | PHYS | 能够自然回复生命 |
| 1019_jshoot | 隐形弩手 | N | 1800 | 260 | 100 | 20 | 0.9 | 2.7 | 2.2 | 1 | W | PHYS | 隐匿 |
| 10043_sailor | 寻险水手 | N | 5500 | 350 | 50 | 10 | 0.9 | 2 | -1 | 1 | W | PHYS | 经过悬索桥或被<盐坨子炮>传送时,移动速度短暂提升 |
| 10042_prtrop | 架桥船工 | N | 6000 | 600 | 150 | 30 | 0.65 | 2 | -1 | 1 | W | PHYS | 隐匿; 可以架设可通行的悬索桥 |
| 1381_winman | 访问团新兵 | N | 5800 | 300 | 100 | 0 | 0.9 | 1.7 | 0 | 1 | W | PHYS | |
| 1019_jshoot_2 | 隐形弩手组长 | N | 2700 | 310 | 130 | 20 | 0.9 | 2.7 | 2.2 | 1 | W | PHYS | 隐匿 |
| 1422_lrsldr_2 | 萨卡兹枯朽辟路前锋 | N | 5500 | 350 | 100 | 40 | 0.8 | 3 | -1 | 1 | W | PHYS/MAGI | 位于源石污染区内时,攻击造成法术伤害 |
| 1009_lurker | 潜伏者 | N | 2200 | 300 | 90 | 20 | 1 | 2 | 0 | 1 | W | PHYS | 隐匿 |
| 10031_cnvsld | 业余竞演者 | N | 4000 | 300 | 100 | 0 | 1.5 | 2 | -1 | 1 | W | PHYS | 隐匿; 狂欢时刻失去隐匿 |
| 1166_dusbr | 深池侦察兵 | N | 3000 | 280 | 110 | 0 | 1 | 2 | 0 | 1 | W | PHYS | 折射 |
| 1170_dushld | 深池重甲卫士 | E | 7000 | 700 | 750 | 0 | 0.7 | 4 | 0 | 1 | W | PHYS | 折射 |
| 1165_duhond | 深池侦察犬 | N | 2800 | 300 | 0 | 0 | 1.7 | 1.4 | 0 | 1 | W | PHYS | 折射 |
| 1169_duphlx | 深池方阵战士 | N | 4000 | 300 | 300 | 0 | 1 | 2.5 | 1.5 | 1 | W | PHYS | 其他方阵战士位于附近时,防御力提升(可叠加); 折射 |
| 1166_dusbr_2 | 深池侦察队长 | N | 4400 | 380 | 180 | 0 | 1 | 2 | 0 | 1 | W | PHYS | 折射 |
| 1170_dushld_2 | 深池重甲卫士队长 | E | 9000 | 900 | 1100 | 0 | 0.7 | 4 | 0 | 1 | W | PHYS | 折射 |
| 1169_duphlx_2 | 深池方阵指挥官 | N | 6000 | 450 | 400 | 0 | 1 | 2.5 | 1.5 | 1 | W | PHYS | 其他方阵指挥官位于附近时,防御力提升(可叠加); 折射 |
| 1071_dftman | 萨卡兹刀兵 | N | 2800 | 300 | 70 | 50 | 1 | 2.5 | 0 | 1 | W | PHYS | |
| 1010_demon | 萨卡兹大剑手 | E | 7500 | 600 | 230 | 50 | 0.85 | 2 | 0 | 1 | W | PHYS | |
| 1046_agent | 步兵 | N | 2500 | 280 | 85 | 40 | 1.1 | 2 | 0 | 1 | W | PHYS | |
| 1116_liprr | 普通囚犯 | N | 5500 | 280 | 60 | 0 | 1.1 | 2 | 0 | 1 | W | PHYS | 初始为禁锢状态,禁锢状态下攻击速度降低; 攻击数次后解放,解放后攻击力提升 |
| 1116_liprr_2 | 老练囚犯 | N | 7500 | 380 | 60 | 0 | 1.1 | 2 | 0 | 1 | W | PHYS | 初始为禁锢状态,禁锢状态下攻击速度降低; 攻击数次后解放,解放后攻击力提升 |
| 1000_gopro_2 | 猎狗pro | N | 1700 | 260 | 0 | 20 | 1.9 | 1.4 | 0 | 1 | W | PHYS | |
| 1325_cbgpro | 军用猎狗 | N | 3800 | 290 | 0 | 30 | 1.7 | 1.5 | 0 | 1 | W | PHYS | |
| 1402_tgshd_2 | 重装侦察兵 | E | 13000 | 700 | 1500 | 0 | 0.7 | 3 | -1 | 1 | W | PHYS | 被攻击时使攻击者暴露5秒; 暴露状态下的我方无法通过封阻物避开敌方视线,且受到的伤害提升 |
| 1251_lysyta | Dor-1号失败品 | N | 2750 | 310 | 0 | 50 | 1.5 | 1.8 | 0 | 1 | W | PHYS | 接触未激活的<R系列动力装甲>时,为其回复一定技力 |
| 1249_lysdb_2 | 莱茵生命防卫科高级成员 | N | 6000 | 485 | 300 | 0 | 1 | 2.8 | 0 | 1 | W | PHYS | 可以抵挡一次物理或法术伤害 |
| 1061_zomshd | 宿主重装士兵 | E | 10000 | 600 | 800 | 0 | 0.75 | 2.6 | 0 | 1 | W | PHYS | 能够自然回复生命 |
| 1381_winman_2 | 访问团老兵 | N | 7800 | 400 | 200 | 0 | 0.9 | 1.7 | 0 | 1 | W | PHYS | |
| 1119_vofsd | 强壮囚犯 | E | 11000 | 600 | 700 | 0 | 0.8 | 3.3 | 0 | 1 | W | PHYS | 初始为禁锢状态,禁锢状态下攻击速度降低; 攻击数次后解放,解放后攻击力提升、法术抗性提升且能够自然回复生命 |
| 1017_defdrn | 御4 | N | 4000 | 0 | 150 | 20 | 0.8 | 2 | 2.5 | 1 | F | NO_D | 飞行单位; 为周围敌军持续提供防御力加成 |
| 10065_ftzlc | 卷心籽 | N | 3800 | 240 | 0 | 5 | 1 | 1.7 | -1 | 1 | W | PHYS | 可以被<灼藤>或<灼芯>点燃; 点燃状态下死亡后对周围造成大量法术伤害 |
| 10067_ftsjc | 灼藤 | E | 13000 | 400 | 250 | 65 | 0.85 | 3.5 | -1 | 1 | W | MAGI | 攻击额外造成灼燃损伤; 首次攻击前,攻击力不断提升且首次攻击对目标和周围造成法术伤害和灼燃损伤 |
| 1174_duholy_2 | 深池伙友卫队精英 | E | 20000 | 1100 | 1000 | 0 | 0.8 | 4 | 1.4 | 1 | W | PHYS | 容易受到我方单位攻击; <深池伙友影刃精英>位于附近时,激活力场使周围我方攻击速度下降; 折射 |
| 1251_lysyta_2 | Dor-α号失败品 | N | 3200 | 360 | 50 | 50 | 1.5 | 1.8 | 0 | 1 | W | PHYS | 接触未激活的<R系列动力装甲>时,为其回复一定技力 |
| 1252_lysytb_2 | Dor-β号复制体 | N | 9000 | 420 | 50 | 50 | 0.9 | 3.2 | 2.2 | 1 | W | PHYS | 接触未激活的<R系列动力装甲>时,为其回复一定技力 |
| 1010_demon_2 | 萨卡兹大剑组长 | E | 15000 | 750 | 250 | 50 | 0.85 | 2 | 0 | 1 | W | PHYS | |
## 7. Open questions / assumptions to confirm
1. **Replacement algorithm.** Is there one special entry per round or one per spawn action? Is the k-copies formula right? It is inferred from `min/maxReplacedEnemyCount` and the BE factors, and is not documented anywhere.
2. **Enemy stat scaling.** It is unknown how `aceffect_enemy_*` map to modes and rounds. `difficultyFactorInfo` (1.0/1.6/1.7/1.7) and `modeFactorInfo` (1.25) look like **reward** coefficients: PRTS notes "获取卫戍认证系数提升至×1.25".
3. **Boss HP in single mode.** It may be the same bloodPoint or a scaled one. The multi pool is shared by 4 players across 2 battlefields.
4. **Leak weighting.** The rule is by count; whether `lpr` ever matters outside boss rounds is unknown.
5. **Row 8 / row 1 non-bench hand tiles:** exact use is unknown (temporary staging for 临时整备区?).
6. **4-player lobbies.** Normal rounds use 4 separate boards. Boss rounds put pairs on 2 boss battlefields. It is not known how partners are assigned for 联防 (proposal: the 2 perfect players with the lowest index).
---
## Addendum (critic)
Written 2026-09-27 by the completeness critic. It supersedes the conflicting lines above. Sources: round-level `options` re-read from the level JSONs, PRTS `卫戍协议:盟约_下半` §开始模拟 and §隐秘核心, PRTS `卫戍协议/帮助`, and BWIKI `盟约`.
| § | Was | Now | Tag |
|---|---|---|---|
| 2.2 "DP initialCost 0 / DP irrelevant" | from the `m0X` terrain files | The **round levels** (`01…h06`, `h07/h08`, `escaped_*`) have `initialCost` **10**, `costIncreaseTime` 1 s and `maxCost` 99. The value 0 belongs to the terrain files, which are not used for battle options. **DP matters:** initial deployment is free, but auto-redeploy after a knock-out needs DP ≥ the unit's `cost` (BWIKI "在再部署时间归零、地块可部署且部署费用足够后,将在原位置自动部署"). | VERIFIED |
| 2.2 row 8 "5 hand tiles [ASSUMED staging]" | – | They are the **5 temporary hand slots** (临时手牌区): "溢出的手牌会临时存放至整备区前方的5个空位上". | VERIFIED |
| 3.1 "FUNNY has 9 rounds" | – | Only **solo** FUNNY has 9 rounds (R9 = boss `h07_01_S` and its alternatives). **Multi FUNNY has 14 rounds** (R14 boss, no R15), with SP rounds 3 and 9. | VERIFIED (data) |
| 3.2 step 7, stat scaling [ASSUMED] | FUNNY III / NORMAL II … | **Replaced by the per-mode, per-round table in 01 Addendum A3** (`01-core-data.json → _criticAddendum.enemyStatMultipliers`). `atk × base × 1.1^k`, `hp × base × 1.2^k (× 1.08 in the ABYSS rounds marked there)`. DEF and RES unchanged. Leader HP pools are excluded. **ABYSS: all enemies move ×1.15 from R3.** Apply it after the special-enemy replacement, on the level-0 stats. | VERIFIED (PRTS, user-sourced) |
| 4 "Hidden core: >300 / >1000" | 上半 values | **下半: solo > 350 activated layers and LP > 1; multi team sum > 1200 and merged LP > 1.** Available on 险境 and harder. | VERIFIED |
| 4 boss round | – | The boss HP pool is not scaled by A3. Overtime −1 LP/s starts after `bossTurnHpReduceTime` = 150 s. The battle does not end at the level's 120 s ("计时结束后战斗仍然会继续"). Wave enemies that reach a goal subtract their `lifePointReduce` from the merged LP; at 0 the team fails at once. **Movable bosses spawn one per alive player's side**, and all of them share the pool. | VERIFIED |
| 3.1 timeout | "alive at the limit count as leaked" | Confirmed: "在作战时间结束后,如果战场中存在仍未被击杀的敌人,就无法达成完美作战。根据最终未击倒的敌方数量,扣除其来源玩家的目标生命值(每回合最多10点)". The combat ends at `maxPlayTime`. [ASSUMED] The limit is in game-time seconds; combat always runs at 2×. | VERIFIED |
| 3.3 联防 | helpers | At most 2 helpers. They keep their end-of-combat HP ratio, SP and positions. With 1 helper use `escaped_single`; with 2 use `escaped_multi`. Survivors cost their **source** player LP, capped at 10. | VERIFIED |
Still [ASSUMED] from this file:
- The special-enemy replacement counts `k`.
- The per-round faction pick.
- The spawn-lane mapping when a leaked enemy is replayed in 联防.
- Whether bounty and special enemies also receive the A3 multipliers. Recommend **yes**, using the current round's `k`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+872
View File
@@ -0,0 +1,872 @@
# 06 · Multiplayer (同盟模拟 / 联合模拟) and in-game UI/UX flow
Scope: how the co-op mode of 卫戍协议:盟约 (season `act2autochess`, "盟约·下半"; 上半 = `act1autochess`) works, and what each screen looks like, so we can build a similar but original UI.
Legend:
- **[DATA]** read from the official game data (`activity_table.json`, `autoChessData`, `act2autochess`, level JSONs, `display_meta_table.json`).
- **[OFF]** from an official Hypergryph announcement or the official gameplay-intro long image.
- **[WIKI]** from BWIKI or PRTS (community wikis that copy in-game text).
- **[COMM]** from community guides, videos or screenshots.
- **[SHOT]** seen directly in a screenshot (URLs in §15).
- **[ASSUMED]** our proposal where nothing authoritative was found.
Rich-text tags such as `<@ba.vup>…</>` are stripped. Rules this doc shares with the core loop (economy, bonds, LP) are covered in `01-core-rules.md` and `05-enemies-levels.md`. This doc only restates what multiplayer needs.
---
## 0. TL;DR: the 20 facts the architect needs
1. **This is co-op, not PvP.** 1–4 players (a "同盟", alliance) fight AI waves. Each player has their **own board, bench, shop, funds, shop level, bonds and LP**. Nothing attacks another player. The only rivalry is indirect: the **shared operator pool** ("抢牌/卡牌") and bounty kills. [OFF][WIKI][COMM]
2. **Entry.** 独立模拟 (solo) or 同盟模拟 (co-op). Co-op has two ways in:
- **联合模拟**: create a room and share a 同盟密钥 (room key), or join with a key or invite.
- **搜寻队友**: matchmaking, either 精确搜寻 (similar trophy level) or 快速搜寻 (fastest).
- Unticking 【房间未满4人时搜寻队友】 starts a room with fewer than 4 players. [OFF 5114][WIKI]
3. **Enemy scaling.** Every enemy's HP and ATK are ×0.70 in solo and ×0.80 in co-op. [WIKI PRTS 下半]
4. **Pre-game steps** (`enterStepList`) [DATA]:
- `INFO_CHECK` "确认本局信息": 25 s, warning at 5 s. Players press 准备就绪; the screen shows "已就绪 x/4".
- `BAND_CHECK` "选择策略": 50 s, warning at 15 s. A **draft in random order** shown on the left. In co-op each player may **skip once** ("联合模拟在选择策略时可以进行一次跳过"). On timeout 华法琳 (`band_bldsk`) is picked.
- `BATTLE_CHECK` "协议启动": 3 s.
5. **Round loop (co-op):** [机变 draft on SP rounds] → 休整期 prep (timer, with ready/unready) → simultaneous auto-combat → 联防 unite phase if needed → LP loss → next round. **Round 14 = 最终攻势** (Final Assault). **Round 15 = 隐秘核心** (Hidden Core, optional). 标准 difficulty ends at round 14 with no hidden core.
6. **Co-op prep timer** (`turnInfoDataDict.normalPhaseTime`, seconds, rounds 1→15) [DATA]: `65,65,65,95,95,100,105,105,125,125,125,125,150,195,195`. Round 15 is `215` in 绝境 and 终极.
- Solo was 300 s in 上半 and is **unlimited** in 下半, with pause, leave and resume allowed. [OFF 5114]
- Combat time limit = the round level's `maxPlayTime`: `45,45,55,55,55,70,85,85,95,95,115,115,115,120,120`.
7. **Ready check.** "全体参与者就绪之前可随时取消准备,准备战斗后将无法进行操作". You can un-ready until everyone is ready. Combat starts when all players are ready or when the timer runs out. [DATA tip]
8. **机变阶段** (special draft phase), co-op:
- Offers **6 cards**; each player **takes 1 in turn**, and a taken card is gone. 16 s per picker (`specialPhaseTime`=16), 30 s for the first picker. On timeout the pick is **auto-assigned**. [DATA][WIKI][SHOT]
- Solo offers 3 cards and has no time limit in 下半.
- It happens in the prep of rounds where `isSpPrepare`:
- co-op 标准: rounds 3, 9
- co-op 险境: rounds 3, 6, 9
- co-op 绝境/终极: rounds 3, 9, 11
That is, "after round 2/8", "2/5/8" and "2/8/10". [DATA]
9. **Team cards.** 战术决策 cards marked `icon_team_buff` give the effect **to the picker and to all teammates** ("若存在其他队友则他们也获得"). `icon_player_buff` cards are personal. [DATA]
10. **联防 (Unite / joint defense).** Runs when at least one player leaked and at least one player cleared perfectly (完美作战).
- **Up to 2** perfect players move their operators, in their end-of-combat state, onto a temporary field (临时阵地) and fight **the union of all leaked enemies**.
- Enemies that survive cost their **source** player LP, capped at **10 per round**.
- Bond stacking is disabled during 联防.
- A bounty target killed by a helper pays the helper. [WIKI][DATA][COMM]
11. **LP.** Each player's LP = their strategy's `totalHp` (20–45). Loss is capped at **10 per round** (`costPlayerHpLimit`=10). A player at 0 LP is **out**, and all their units go back to the shared pool at once. [DATA][COMM]
12. **Final Assault (round 14).**
- All players' remaining LP **merge into one pool** (no cap).
- Players are **paired 2 + 2 onto two merged battlefields**, each partner holding one half (left or right).
- **Everyone damages the same boss HP pool**: some leaders spawn on both sides of a field and share HP.
- After `bossTurnHpReduceTime`=150 s the pool loses **1 LP per second**.
- "最终攻势中,两名参与者会处于同一个战场,但无法查看另一组队友的战场情况". [DATA][OFF][WIKI]
13. **Hidden Core (round 15, 险境+).**
- Co-op: the sum of all players' activated bond stacks > **1200** and merged LP > 1.
- Solo: > 350 and LP > 1.
- In 上半 the thresholds were 1000 and 300.
- Failing round 15 does not affect clear status or rewards. [WIKI PRTS 下半]
14. **Shared pool** (co-op): copies per operator name by tier I..VI = **12 / 14 / 18 / 16 / 8 / 5** (Muelsyse 4).
- An elite occupies 3 copies. Shop-displayed cards don't count.
- The shop can't roll an operator whose owned copies (all players' boards, benches and effects) have reached the cap.
- Units of players who are eliminated or who quit return immediately. [COMM video BV1JWy3BkEpt/BV1ZTUjBaEyy]
15. **No free trading or gifting.** The only player-to-player transfers are item or strategy effects:
- 信标 (Beacon, from 芬's strategy): sends the original operator to the teammate with the most members of that bond. This fires the broadcast "X博士给你赠送了{1}".
- "神秘顾客" (鸭爵's strategy): passed to the next player.
- Team buff cards. [DATA]
16. **Communication is emotes only.** There is no text chat.
- `chatCD`=1 s between emotes; a bubble shows for `chatTime`=3 s.
- Room emotes: 10. Battle emotes: 6 per theme. Themes: 卫戍 basic, basic_2, 源石虫, plus 3 paid April-Fools themes.
- An auto **broadcast ticker** reports boss-damage %, big DPS, shop levels, elite promotions and gifts. [DATA]
17. **Disconnect / leave.**
- Solo: leave anytime and resume within 24 h (`singleReconnectTime`=86400).
- Co-op: the match continues. A "断线重连" button appears on the hub. Timers keep running and the missing player's choices auto-resolve (draft auto-assign, strategy default); their board keeps auto-fighting.
- Quitting counts as a defeat: units return to the pool and the boss HP pool shrinks.
- 模拟重载时间: ending a sim within 20 s of starting blocks the next start for a while (anti-abuse). [OFF][WIKI][SHOT][ASSUMED details]
18. **Trophies (奖杯), co-op only.** By rounds passed × difficulty (§10). Levels at 0 / 10 / 30 / 60 / 100 cumulative trophies (`medalDataList`). Trophy level feeds 精确搜寻 and is shown on name cards. [DATA][WIKI]
19. **Settlement per player.** Rounds passed, one **评语/title** out of 卫戍之星, 不朽盟约, 坚若磐石, 精英云集, 万事俱备, 挥金如土 (`playerTitleDataDict`), trophies, 卫戍认证 (tokens), 点赞 (like) and 举报 (report, 8 reasons, at most 3 reports). [DATA][WIKI]
20. **Visual language.**
- Near-black green-grey panels with **mint/teal (#4ed8af / #35d8b4 / #59f4ca)** accents, gold price badges (#ffc600), red for danger and exit.
- Seven-segment "COUNTDOWN" digits top-right; they go orange at ≤10 s.
- Round mint bond discs, roman-numeral tier chips, a hexagon coin badge and a green "tower" LP icon.
- A tilted 3D board with the shop as a bottom card bar. [DATA colors][SHOT]
---
## 1. Terminology (CN → proposed EN → data id)
| CN (in-game) | EN (ours) | Where / id |
|---|---|---|
| 独立模拟 | Solo Simulation | `modeType: SINGLE` |
| 同盟模拟 | Alliance / Co-op Simulation | `modeType: MULTI` |
| 联合模拟 | Private Alliance (room + key) | co-op sub-entry |
| 搜寻队友 (精确/快速) | Matchmaking (Precise / Quick) | co-op sub-entry |
| 同盟密钥 | Alliance Key (room code) | — |
| 入门协议 | Tutorial Protocol | `mode_training_1`, `modeType: LOCAL`. It runs with 3 NPC teammates (`trainingNpcList`). |
| 标准/险境/绝境/终极 模拟 | Standard / Perilous / Desperate / Ultimate | `FUNNY/NORMAL/HARD/ABYSS`; colors `f6a329 / e85a1a / e73118 / ff0024` |
| 确认本局信息 | Briefing | `INFO_CHECK` |
| 选择策略 / 策略 | Strategy (commander) | `BAND_CHECK`, `bandDataListDict` |
| 协议启动 | Protocol start | `BATTLE_CHECK` |
| 休整期 | Rest (prep) phase | `normalPhaseTime` |
| 机变阶段 | Contingency draft | `specialPhaseTime`, `isSpPrepare`, `effectChoiceInfoDict` |
| 作战期 / 各自行动阶段 | Combat / individual action phase | level `maxPlayTime` |
| 联防 / 联防阶段 | Unite (joint-defense) phase | `escapedBattleTemplateMap{Single,Multi}Player` |
| 完美作战 | Perfect clear | — |
| 目标生命值 | LP (target life) | `bandDataListDict.totalHp`, `costPlayerHpLimit` |
| 最终攻势 | Final Assault (round 14) | `isBossTurn`, `bossTurnHpReduceTime` |
| 隐秘核心 | Hidden Core (round "??" = 15) | `isHidingBoss` |
| 调度中心 | Dispatch Center (shop) | `shopLevelDataDict` |
| 整备区 / 临时整备区 | Standby area (bench, 10) / temp overflow (5) | `maxDeckChessCnt`=10 |
| 战场区 | Battlefield (max 8 units) | `maxBattleChessCnt`=8 |
| 冻结 / 解冻 | Freeze / Unfreeze (lock shop) | — |
| 刷新 | Refresh (reroll, 1 fund) | `shopRefreshPrice`=1 |
| 晋级 / 精锐 | Promote / Elite (3→1) | — |
| 奖杯 / 奖杯等级 | Trophies / Trophy level | `medalDataList` |
| 评语 | Commendation (title) | `playerTitleDataDict` |
| 卫戍认证 | Garrison Certificate (reward token) | `act2autochess_token_chess` |
| 交流 | Emote | `enabledEmoticonThemeIdList` |
---
## 2. Mode matrix
| | 独立模拟 (solo) | 同盟模拟 (co-op) |
|---|---|---|
| Players | 1 | 1–4. Normally 4; fewer if "房间未满4人时搜寻队友" is unticked. |
| Enemy HP/ATK factor | ×0.70 | ×0.80 |
| Prep timer | none (下半); 300 s (上半) | `normalPhaseTime` table (§4.2) |
| 机变 draft | 3 choose 1, no timer (data: 150 s in 上半) | 6 cards, 1 each in turn; 16 s each, 30 s first |
| SP rounds (`isSpPrepare`) | 标准 none (9-round mode); 险境 6, 9; 绝境/终极 3, 9, 11 | 标准 3, 9; 险境 3, 6, 9; 绝境/终极 3, 9, 11 |
| Rounds | 标准: 9 (boss on 9). Others: 14 + optional 15. | 标准: 14 (no 15). Others: 14 + optional 15. |
| 联防 | none | yes (§5) |
| Boss round | single field, all your lanes | pairs on merged fields, shared HP pool |
| Hidden core threshold | stacks > 350, LP > 1 | team stacks > 1200, merged LP > 1 |
| Pause / leave | yes, 24 h resume, 【放弃模拟】 to settle | no pause; reconnect via 断线重连 |
| Trophies | none ("只有在联合模拟中才能获得奖杯") | yes (§10) |
| Reward factor (`modeFactorInfo`) | ×1.25 (was ×1 before 11/18) | ×1.25 |
| Strategy unlock / mode unlock | Shared between solo and co-op in 下半 | Shared between solo and co-op in 下半 |
Mode unlocks (`preposedMode`) [DATA]:
- 标准 needs 入门协议.
- 险境 needs 标准 cleared (solo or co-op).
- 绝境 needs 险境 cleared.
- 终极 needs **2 clears** of 绝境.
"盟约数增加" means 标准 bans 10 bonds (`inactiveBondIdList`) and some enemies (`inactiveEnemyKey`).
---
## 3. Lobby, rooms and matchmaking
### 3.1 Hub → mode select [OFF intro image][SHOT]
- **Left column:** 成就统计 (stats), 报酬说明 (rewards info), 每日防卫目标 (daily 200 tokens → +300), 关键目标 (missions), 功勋兑换处 (milestone shop; 60 levels in `milestoneList`).
- **Right column:** S.W.E.E.P.报告 (codex), 物资调配处 (roster config), 模拟邀约 (pending invites).
- **Bottom-right mode buttons:**
- `独立模拟`
- a group labelled `同盟模拟` holding `联合模拟` and `搜寻队友`
- **Reconnect:** if you have a live co-op sim, the mode buttons are replaced by **【断线重连】**.
- **Tutorial:** 入门协议 is reachable bottom-left of the co-op screen and is forced on first entry.
### 3.2 联合模拟 (private room)
- **选择同盟方式** offers two options:
- **【创建同盟】**
- **输入同盟密钥 + 【加入同盟】** (join with a key)
- **Room screen** (one tall card per seat, 4 seats):
- Each card shows the player's assistant (secretary) art, a `博士 #1234` tag, the trophy-level chess icon and a "…" emote button.
- The creator's card is tagged **创建者**. A ready seat shows **☑ 已就绪**.
- **Header:**
- a lifetime countdown, "13:29 后同盟将自动销毁". Rooms self-destruct after about 15 min idle [ASSUMED 15:00].
- "当前延迟 11ms", the ping.
- **Footer:**
- a pill `[快速搜寻 | ♜ 标准模拟]` showing search mode and difficulty (difficulty tinted in mode color).
- "*同盟人数达标,准许进入模拟"
- a big mint **准备就绪** button.
- **Rules:**
- The creator can change difficulty and invite friends.
- Invites have a 60 s cooldown per invite (`invitationSendCd`=60).
- Unticking "房间未满4人时搜寻队友" lets the room start with fewer than 4. If it is ticked, the room fills the empty seats through matchmaking.
### 3.3 搜寻队友 (matchmaking)
- Pick difficulty on **选择模拟协议**:
- Left: a gold hologram of the fortress/barge.
- Right: stacked difficulty cards with "已选定" check or lock text such as "通关同盟模拟【标准模拟】后解锁".
- Pick the search mode from a dropdown, then press 【开始搜寻】:
- **精确搜寻**: "更容易搜寻到实力相近的队友", matched by trophy level/count.
- **快速搜寻**: "更快速地搜寻队友".
- Matching tips (`gameTipsList`, 21 tips, weight 50 each) rotate every 5 s (`matchingTipRotateInterval`).
- Matching timeout `matchTimeMax` = 120 s. What happens next is not documented. [ASSUMED] Offer "start with current members" or "keep searching"; in our build, fill with bots.
### 3.4 Ping display (`pingConds`) [DATA]
A pill next to the exit button shows `NNms`, colored by tier:
- `ping.low` for < 60 ms
- `ping.medium` for 60–199 ms
- `ping.high` for ≥ 200 ms
[ASSUMED] Colors mint / amber / red. Screenshots show the number in amber/orange at 58–85 ms.
### 3.5 Anti-abuse
- **模拟重载时间:** after starting a sim there are 20 s during which ending it blocks starting a new one immediately. [WIKI]
- Abandoning before the first prep phase gives no settlement plus a penalty wait (【等待模拟重载】). [WIKI]
---
## 4. Match flow in co-op (state machine)
```
LOBBY ─► MATCHED ─► INFO_CHECK(25s) ─► BAND_CHECK(50s draft) ─► BATTLE_CHECK(3s)
└─► for round r = 1..14(15):
[SP_DRAFT if isSpPrepare[r]] (6 cards, sequential picks, 16s/30s)
PREP(normalPhaseTime[r]) ── all ready? ──► COMBAT(maxPlayTime[r]) (all boards in parallel)
COMBAT_END: each board is PERFECT or LEAKED (alive enemies at timeout count as leaked)
if any LEAKED and any PERFECT: UNITE(联防) on temp field
LP_LOSS per source player (cap 10); players at 0 LP → ELIMINATED
r==14: FINAL_ASSAULT (merged LP, pairs, shared boss HP)
r==15 only if hidden-core condition met (险境+)
└─► SETTLEMENT (team page → personal page → rewards)
```
### 4.1 INFO_CHECK "1/2 确认本局信息" (25 s, hint 5 s) [DATA][SHOT]
Shows:
- the enemy leader (portrait, name, lore)
- up to 3 special enemy types (`specialEnemyNum`=3, e.g. 特训敌人·隐匿/飞行/折射)
- **核心盟约** row and **附加盟约** row of bond discs. Lit = active. Greyed with a badge = "部分盟约所含干员阵容不完整" (some of its operators won't appear).
- **本局禁用干员** (banned operators this match)
Footer: "已就绪 2/4" with 4 person pips, plus **准备就绪**. When everyone is ready or the timer ends, the draft starts.
### 4.2 BAND_CHECK "2/2 选择策略" (50 s, hint 15 s) [DATA][OFF][SHOT]
- **Left:** the seat list in **random draft order**. Each row: avatar, name#id, trophy badge, name-card art, and a state box:
- `…` waiting
- `⌛ 决策中` choosing
- chosen strategy icon + 🔍 + ✓ when done
- **Right:** a 4-column grid of 40 strategy portraits (`bandDataListDict`) and a detail pane: portrait, **初始生命值 N**, name and effect. Bottom: **确认选择**.
- An orange rook badge on a portrait = cleared with that strategy 3 times (`victorCount`=3, the corner badge "角标").
- **One skip per player** in co-op: pass your turn and pick later. [DATA tip]
- Timeout → `band_bldsk` 华法琳.
- Unlock conditions come from `bandDataDict.unlockDesc`.
- Whether two players may pick the same strategy is not documented. [ASSUMED] **Allowed** (the community only talks about "宣示主权", declaring a bond to teammates, not about locks). A picked strategy shows the picker's avatar on its portrait.
### 4.3 Prep phase 休整期 (co-op timer) [DATA]
| Round | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Prep s (`normalPhaseTime`, multi) | 65 | 65 | 65 | 95 | 95 | 100 | 105 | 105 | 125 | 125 | 125 | 125 | 150 | 195 | 195 (绝境/终极: 215) |
| Combat s (level `maxPlayTime`) | 45 | 45 | 55 | 55 | 55 | 70 | 85 | 85 | 95 | 95 | 115 | 115 | 115 | 120 | 120 |
| Wave level | 01 | 02 | 03 | 04 | 05 | 06 | 07 | h01 | h02 | h03 | h04 | h05 | h06 | h07_0X | h08_0X |
- 标准 co-op ends at round 14 and has no 15.
- The **countdown turns orange** at ≤10 s (`hintTimeNormalPhase`/`FightPhase`/`SpecialPhase`/`DotPhase` = 10). [SHOT: "01" orange]
- PRTS gives a simplified "各轮次均有70秒的决策时间". Use the data table.
- A full 4-player match takes about 29 min of prep, 21 min of combat, plus 联防 and drafts ≈ **50–60 min**. That is a known community pain point ("燒時間條"). We should support a fast-timer preset.
Prep rules relevant to co-op [DATA tips][WIKI]:
- **Ready:** 准备就绪 toggles ready. "全体参与者就绪之前可随时取消准备,准备战斗后将无法进行操作". Once ready you cannot act, but you can still un-ready until everyone is ready.
- **Overflow:** "若非主动溢出,溢出的手牌会临时存放至整备区前方的5个空位上,直到溢出情况排除才可开始进行作战". Overflowed bench cards sit on 5 temp slots and **block readying**. They are destroyed at the next round ("处于临时整备区的调度资源,在进入下一回合后会自动销毁").
- **Scouting:** "休整期可以查看队友的战场情况" and "…查看当前回合即将迎击的敌方单位". Tap a teammate's avatar in the left list to view their board (read-only). The 🔍▶▶ button shows the enemy preview board.
- **Promotion reward:** the reward operator can't be refreshed or frozen and doesn't carry over.
- **Upgrade cost** drops by 1 each round until 0 [SHOT: 5 → 4 → 3 over rounds 1–3].
### 4.4 Special draft 机变阶段 (co-op) [DATA][WIKI][SHOT]
- **Header:** `悬赏决策 | 选定悬赏目标,获取额外奖励。` with the sub-line "倒计时结束后仍未选定将自动分配".
- **Right header:** "当前轮到你决策" when it's your turn, or "{name} 正在决策… ⌛", plus the countdown.
- **Grid:** 3×2 = **6 cards**. A taken card greys out and shows the taker's avatar badge in its top-right corner.
- **Left list:** ✓ = picked, `…` = picking, exit-door icon = player left.
- **Pick order:** it is **not** simply seat order; screenshots show out-of-seat order. [ASSUMED] Random per draft; alternatively lowest-LP-first as a catch-up rule.
- **Card families** (`effectChoiceInfoDict`, `choiceType` / name / text color `#35d8b4`, or `#f2bd3e` for the "_s" special variant):
| choiceType | Name | Contents (see `04-items.md` / `05-enemies-levels.md`) |
|---|---|---|
| `BOUNTY_HUNT` | 悬赏决策 | Adds an enemy to *your* next combat. "将其击倒者获得N资金": the killer, including a 联防 helper, gets the funds. Multi-round 假想敌: "击倒它的你或队友获得1资金". 战术特训 variants: "若各自行动阶段就达成完美作战,获得N资金" (perfect in your own phase, not via 联防). |
| `EQUIP_FREE` | 道具补给 / 机密商店 | Free equipment (tier chip I–VI on card). |
| `BUFF_SELECT` | 战术决策 | `icon_team_buff`: you **and every teammate** get it (列装, the 8 "…的盟誓" +8/10/12 stacks, 财富 +1 fund, 补给 2 free refreshes, 自愈, 火力, 征召, 无瑕, 锐利). `icon_player_buff`: only you (整备, 升华, 7×"盟约驰援"). |
| `PERSONAL_CHOOSE` | 悬赏决策 (hunter strategy) | Personal-only variant. |
- The terrain cards (`terrain_m0X`, "模拟战场演变") change the map. [ASSUMED] They apply to the **picker's own board**.
### 4.5 Combat (各自行动阶段)
- All boards fight **at the same time** on the server. The client camera switches to the battle view. [SHOT]
- 2× speed. No manual control and no pause in co-op.
- **HUD center capsule:** `killed/total` (e.g. `4/9`), orange-bordered.
- **Right edge:** a DP counter `⊂ 71`. DP starts at 10, gains +1 per second (`costIncreaseTime`=1) and caps at 99. Redeploys cost DP.
- **When your board finishes:** the bottom bar reads **"⌛ 作战结束,等待队友完成作战"**. A bottom view switcher `‹ 自己 ›` cycles through teammates' live battles; you watch, you don't control.
- **Result per board:** PERFECT (all enemies dead) or LEAKED (enemies reached the goal, or were alive at timeout).
- [ASSUMED] Enemies that reach the protection point during combat are removed from your board and **queued as "leaked"** for 联防. They are **not** charged immediately. Charging happens after 联防.
---
## 5. 联防 Unite phase: exact rules and our implementation
Rules as published [WIKI BWIKI/PRTS][DATA tips][COMM]:
1. **Trigger:** at least one player leaked and at least one player is PERFECT. Only perfect players may help ("只有达成完美作战的队友可以进行联防").
2. **Helpers:** 1 or 2 perfect players ("达成完美作战的玩家(最多2名)将迎战所有突破了防线的敌人"). PRTS: "1~2名完美作战玩家的阵地以其当前状态,迎接所有非完美作战玩家未能击杀的敌人的集合".
3. **State carried over:** helpers' operators keep their **HP ratio, SP and deployment positions** from the end of their own combat and are moved to a **临时阵地**.
4. **Enemies:** the helpers face the **union** of all non-perfect players' leaked enemies.
5. **No stacking:** 【盟约】 stacking is disabled ("进行联防时,盟约不会继续叠加"; <战斗中> triggers don't fire). The HUD shows the bond row with the tooltip **"层数叠加已禁用"**. [SHOT]
6. **LP:** enemies still alive when 联防 ends deduct LP from **their source player**. The cap is 10 per player per round. A fully successful 联防 means the owner loses nothing.
7. **Bounties:** a bounty or 假想敌 carries its reward to whoever kills it, so a helper can collect a teammate's bounty. Band 鸭爵: "…击倒这些敌人者获得1资金(包括联防阶段)".
Data behind it [DATA]:
- The template maps are `level_act1autochess_escaped_single` (characterLimit 8) and `level_act1autochess_escaped_multi` (characterLimit 20). They share one 21×19 geometry: two lanes (left and right halves), a start at the right edge (row 9, col 18) and the goal at the left (row 9, col 2).
- Waves are **placeholders**: 5 × each of 8 template enemies (`templateEnemyNormal/Elite/Special(+Fly)/Token(+Fly)` in `autoChessData.constData`), spawned at 3/10/17 s on routes 0–7. The server swaps in the real leaked enemies by category.
- `maxPlayTime` = 1.0 is a placeholder, overridden at runtime.
- **[ASSUMED interpretation]** `escaped_single` is used with 1 helper and `escaped_multi` with 2 helpers (8+8 operators, plus summons, fit in 20).
What is not published, and what we propose [ASSUMED]:
| Question | Proposal |
|---|---|
| Which 2 helpers if 3 are perfect? | The two with the **most remaining LP**, ties broken by seat index. Alternative: random. Show "联防: A、B" in the banner. |
| Where do helpers stand? | Helper 1 keeps their layout on the **left half**, helper 2 on the **right half**. The single-helper map uses the left half. |
| Timer | Same as that round's combat `maxPlayTime`. [SHOT: countdown 64 during a round-12 联防, consistent with 115 s] |
| Spawn order | Group leaked enemies by type, spawn in 3 staggered batches (t = 3/10/17 s). Keep `sourcePlayerId` on each enemy. |
| What non-helpers do | Watch. The view switcher auto-focuses the 联防 field. Banner "联防阶段" (orange). The owners' LP counters tick down live as enemies escape. |
| Enemy stats | Unchanged from the source board's round/difficulty. |
| Helper operator death | Counts only for 联防. Helper boards reset normally for the next round. |
Visual [SHOT]: the banner is a dark strip with orange chevrons and orange-skull icons reading **联防阶段**. The top capsule shows the kill counter `1/4` and the round `回合 12`.
---
## 6. What is shared vs individual
| Element | Per player | Shared by the team |
|---|---|---|
| Board (8 deploy slots), bench (10 + 5 overflow), shop and shop level, funds, freeze state | ✔ | |
| Strategy (band) and its LP pool (`totalHp`) | ✔ (merged at round 14) | ✔ from the Final Assault on |
| Bonds and stacks | ✔ | Hidden-core check uses the **team sum** |
| Enemy waves per round | Same round template for everyone [ASSUMED same seed and composition]. Per-player additions from bounties/特训 are "为自身下场作战添加". | Special-enemy types and boss are the same for the whole match (briefing) |
| Operator pool (copies per name) | | ✔ (§7) |
| Leaked enemies → 联防 | | ✔ helpers fight them |
| Boss HP | | ✔ one pool across both boss fields |
| 机变 card set | | ✔ one 6-card pool, each takes 1 |
| Team-buff cards | | ✔ picker + teammates |
| Timers, ready gate | | ✔ everyone waits for the slowest (prep ends when all ready or timer) |
| Rewards / trophies | ✔ (own rounds passed) | Team result affects rounds passed |
---
## 7. Shared operator pool (co-op) [COMM, tested by players]
- **Copies per operator name:**
| Tier | I | II | III | IV | V | VI |
|---|---|---|---|---|---|---|
| Copies | 12 | 14 | 18 | 16 | 8 | 5 |
Exception: 缪尔赛思 (Muelsyse) has 4.
- A **normal** unit uses 1 copy and an **elite** uses 3.
- **Counted:** units on every player's battlefield, bench and temp bench, from any source (buy, reward, strategy effect, 拟态/copy effects).
- **Not counted:** cards currently displayed in any shop.
- When owned copies ≥ the cap, a refresh can't roll that name. Some effects fail when the cap is hit (杜遥夜's search; possibly 拟态物质 or 煌's sell effect).
- Selling (1 fund per bench unit) returns copies. A player's **defeat or quit returns all their copies immediately**.
- The server must own the pool (authoritative) and update it in real time for everyone.
---
## 8. Cross-player interactions (complete list found)
1. **Team buffs** from 战术决策 (§4.4).
2. **Bounties via 联防:** helpers can kill another player's bounty and take the funds.
3. **信标 Beacon.** Item `eff_acarm109` (normal) / `eff_acgarm109` (advanced); strategy 芬 【协力共进】 grants one on rounds 8, 10, 12 and 14.
- Equipping it destroys the carrier and the Beacon.
- You get a special refresh with 2 operators of the same tier and take 1 free.
- "若在同盟模拟中且存在其他队友,下个休整期向相应盟约人数最多的队友发送1个原干员;相应盟约人数相同则随机发送;无法装备给自编干员".
- The receiver sees the broadcast "{0}博士给你赠送了{1}" (`CHAR_GIFT`, priority 1).
4. **"神秘顾客".** Strategy 鸭爵 (`eff_acdisney`): "拖拽至场上使用后,选择一项特殊悬赏任务进行挑战;主动销毁时获得1份资金,并将"神秘顾客"传递给下一名玩家". From round 5, "你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>".
5. **Touch** 【外勤医疗】: "所有玩家场地上出现一名<预备干员-医疗>", which affects all players.
6. **Shared boss field (round 14/15):** partners' debuffs on the boss (e.g. 奥术 magic vulnerability) help each other. The community notes they don't stack: the higher value wins ("共享型buff會跟對面搶").
7. **Emotes** and the broadcast ticker (§9).
8. **No direct trading, gifting, lending of funds, or item passing.** "Giving back" in guides just means not buying, or selling so the copy returns to the pool.
**PvP:** none. There is no versus mode in 盟约 (上 or 下). Competition is only over the shared pool and the post-match comparisons (titles, likes, damage broadcasts). An older, unrelated mode `ENEMY_DUEL` exists elsewhere in the game.
---
## 9. Communication, broadcasts, reporting
### 9.1 Emotes [DATA display_meta_table][WIKI]
- **Where:** bottom-left **【交流】** button with a robot-face icon. It opens a panel of 6 (battle) or 10 (room) emotes; swipe left/right to switch theme.
- **Display:** a bubble near the sender's avatar in the left player list, and over their board [ASSUMED] for `chatTime`=3 s.
- **Cooldown:** `chatCD`=1 s.
- **Enabled themes** (`enabledEmoticonThemeIdList`): `emoticon_autochess_basic`, `emoticon_originium_slug`, `emoticon_autochess_basic_2`, and the paid `emoticon_foolsday_doctor/_amiya/_wisdel`.
- **Room scene** (`AUTOCHESS_ROOM`, basic theme), picIds:
- hello, thanks, happy, question, sorry
- scared, thinking, waiting, nice_cooperate, working
- **Battle scene** (`AUTOCHESS_BATTLE`):
- basic: happy, scared, sorry, thanks, thinking, nice_cooperate
- basic_2 (下半): **没问题! 敬礼! 欢呼! 酷! 伤心 快死了**
- 源石虫 theme: 合作愉快! 谢谢! 对不起! 再见! ??? 很快就好!
- Look: an **octagonal mint glyph face** on a dark tile. Our emote art must be original.
### 9.2 Broadcast ticker (`autoChessData.broadcastList`) [DATA]
A scrolling banner. The highest priority wins. It starts `broadcastBeginDelay`=1 s after the trigger.
| id | priority | Text (player name highlighted) | Trigger |
|---|---|---|---|
| comment_boss_hit_1/2/3 | 30 | {0}博士对敌方领袖造成的伤害超过20% / 50% / 80%! | player's share of the boss pool |
| comment_char_damage_1/2/3 | 20 | {0}博士的{1}干员造成的伤害量达到{2} | 200,000 / 500,000 / 1,000,000 |
| comment_shop_level_1..5 | 11 | {0}博士将调度中心等级提升为{1}级 | shop level 2..6 |
| comment_bond_effect | 10 | 叠加层数 | stack milestone [ASSUMED] |
| comment_golden_char | 2 | {0}博士的{1}干员晋升为精锐 | promotion |
| comment_char_gift | 1 | {0}博士给你赠送了{1} | Beacon transfer |
### 9.3 Like / report at settlement [DATA]
- 点赞 (like) teammates.
- 举报 (report), at most 3 reports (`reportMaxNum`). Reasons:
- 消极游戏: 挂机、不作为等消极行为
- 故意捣乱: 阻碍其他玩家游戏
- 中途退出: 游戏过程中离开
- 不当言论: 使用了不恰当的名称或签名
- 骚扰: 进行令人不快的行为
- 作弊: 使用非常规手段进行游戏
- 脚本: 利用第三方工具刷取奖励
- 其他: 其他举报原因
---
## 10. Final Assault, Hidden Core, elimination, settlement
### 10.1 Final Assault (round 14) [DATA][OFF][WIKI][SHOT]
- **Merged LP:** at the start, `teamLP = Σ remaining LP of alive players`. The HUD shows a single tower-LP number, e.g. `13`.
- **Pairing:** "博士们将被两两分组,每一组都会进入一个合并场地". Level maps `h07_0X`/`h08_0X`: a 21×19 field whose boss spawns in the middle (row 3, col 10) and whose ends are at the left (col 2–3) and right (col 17). Solo uses `…_s` maps with all routes going left.
- [ASSUMED] With 4 players: seats (1,2) and (3,4). With 3: a pair plus a single on an `_s`-style map. With 2: one pair.
- The left/right player assignment mirrors taunt/deploy priority for the right-side player ("你的最左邊是你的最右邊").
- **Each player** redeploys their own lineup onto their half. The camera has left/right/`全景` (panorama) views (`‹ 全景 ›` switcher).
- **Boss HP:** one pool per match (`act2autochess.bossInfoDict.bloodPoint*`):
| Boss | Enemy id | Standard | 险境 | 绝境 | 终极 | Hidden? | Weight |
|---|---|---|---|---|---|---|---|
| boss_1 | enemy_9013_acstmk | 247,500 | 675,000 | 1,800,000 | 3,600,000 | no | 6 |
| boss_2 | enemy_9017_achunt | 225,000 | 400,000 | 800,000 | 3,000,000 | no | 6 |
| boss_3 | enemy_9021_acduml | 285,000 | 708,750 | 2,000,000 | 4,000,000 | no | 6 |
| boss_4 | enemy_1521_dslily | 307,500 | 708,750 | 2,100,000 | 4,200,000 | no | 5 |
| boss_5 | enemy_2016_csphtm | 200,000 | 390,000 | 780,000 | 3,000,000 | no | 10 |
| boss_6 | enemy_9032_aclionk | 285,000 | 705,000 | 1,990,000 | 3,980,000 | no | 5 |
| boss_7 | enemy_9033_acdeer | 277,500 | 787,500 | 2,000,000 | 4,000,000 | no | 5 |
| boss_8 | enemy_9013_acstmk_2 | 750,000 | 937,500 | 3,600,000 | 7,200,000 | **yes** | 50 |
| boss_9 | enemy_9017_achunt_2 | 675,000 | 900,000 | 2,800,000 | 3,950,000 | **yes** | 40 |
| boss_10 | enemy_9021_acduml_2 | 825,000 | 1,012,500 | 3,800,000 | 7,600,000 | **yes** | 40 |
- "部分敌方领袖会同时出现在战场的左右两侧,两侧的敌方领袖共享生命值(敌方领袖的总生命值不变)". Both fields and both sides drain the same pool.
- The community reports fewer surviving players mean a smaller boss bar. [ASSUMED] `bossHP = bloodPoint × aliveAtRound14 / 4`, floored at 1/4.
- **Boss immunity:** the boss takes no 侵蚀 (erosion) damage ("最终攻势中,敌方领袖不会受到侵蚀损伤").
- **No stacking:** <战斗中> stacking is disabled in the boss fight. [COMM]
- **Overtime:** after `bossTurnHpReduceTime` = 150 s, the team LP drains 1/s.
- [ASSUMED] Minions that reach a goal also cost team LP (per-enemy LP cost; 2 for bosses per `lifePointReduce`), with no 10 cap.
- Team LP 0 → defeat.
- Boss dead → victory.
- **Broadcasts:** at 20/50/80 % of total boss HP dealt by one player.
- **Laggy clients:** the community notes "網速不好/幀率過低可能會導致boss戰傷害丟失". Our server-authoritative sim avoids this.
### 10.2 Hidden Core (round "??" = 15) [WIKI PRTS 下半][DATA]
- **Condition (险境/绝境/终极 only):**
- co-op: Σ over all players of activated bond stacks > 1200, and merged LP > 1
- solo: > 350 and LP > 1
- Enemies are "被源石所侵蚀" upgraded 假想敌 (boss_8..10).
- Failing doesn't change clear status or 卫戍认证.
- Clearing it gives extra trophies (§10.4) and a special notice.
- Timer: prep 195 s (215 s in 绝境/终极), combat 120 s.
### 10.3 Elimination and leaving [COMM][WIKI][SHOT]
- **Elimination:** a player at 0 LP before round 14 is eliminated ("战败"). Their units return to the pool and the boss pool shrinks.
- [ASSUMED] Eliminated players may stay and spectate (view switcher) or leave. Their settlement uses the rounds they passed.
- **Quitting** (中途退出) is treated the same, plus it can be reported.
- The left-list avatar becomes a red "exit door" icon. [SHOT Mb2mtd9]
- Two grey robot "x-eyes" placeholders are seen in a boss screenshot. [ASSUMED] They mean dead or left seats.
- **Disconnection** (not a quit):
- Official text: "若博士在模拟的作战回合中不慎失去神经同步,可以在一段时间内通过活动主页重新返回作战". The hub shows 【断线重连】. In-game error text: "神经网络连接丢失,请重新连接(错误号:400)".
- [ASSUMED] Meanwhile the player's board keeps its last lineup and auto-fights. Prep timers expire with no purchases, drafts auto-assign, and the strategy defaults to 华法琳 if disconnected before the draft.
### 10.4 Trophies (co-op only) [WIKI PRTS 下半 + BWIKI table]
| Rounds passed | 标准 | 险境 | 绝境 | 终极 |
|---|---|---|---|---|
| 0–4 | 0 | 0 | 0 | 0 |
| 5–8 | 1 | 1 | 1 | 1 |
| 9–11 | 2 | 2 | 2 | 2 |
| 12–13 | 2 | 3 | 3 | 3 |
| 14 (成功卫戍) | 3 | 4 | 5 | 6 |
| ?? (隐秘核心 cleared) | — | 5 | 7 | 8 |
Trophy level (`medalDataList`): Lv1 at 0, Lv2 at 10, Lv3 at 30, Lv4 at 60, Lv5 at 100 cumulative, with icons `trophy_level1..5_icon`. The art is chess pieces, rook → king, in bronze/silver/gold. The trophy level is shown in rooms, in the strategy draft and on the result card, and is used by 精确搜寻.
`roundScoreDataList` (round → 0,10,20,30,50,70,90,110,130,150,170,200,230,260,300) is a per-round score. [ASSUMED] It is a hidden MMR/"实力" input for 精确搜寻.
### 10.5 Commendations 评语 (`playerTitleDataDict`) [DATA; criteria ASSUMED]
The six titles, with our proposed award rules. The official conditions are unpublished.
| id | Title | Our rule |
|---|---|---|
| comment_1 | **卫戍之星** (Star of the Garrison) | Highest damage to the enemy leader. Only on a win. |
| comment_2 | **不朽盟约** | Highest total activated bond stacks. |
| comment_3 | **坚若磐石** | Least LP lost / most LP remaining. |
| comment_4 | **精英云集** | Most promotions to elite (晋级次数). |
| comment_5 | **万事俱备** | Most equipment fitted / merged [ASSUMED]. |
| comment_6 | **挥金如土** | Most funds spent. |
Evidence:
- A 176-game co-op player shows the six counts 20 / 32 / 25 / 26 / 29 / 19 (sum 151 ≈ 0.86 per game). This suggests **each player receives at most one title per match**: the category where they rank best relative to teammates, each title used at most once per match. [ASSUMED]
- 卫戍之星 is visibly rarer (about 25 % of wins for a strong player).
- The web stats page tracks exactly: 盟约最高叠加数, 单局最多消耗资金, 单局最多晋级次数, 单局最多击败数. This supports the mapping.
### 10.6 Settlement screens [WIKI][SHOT]
1. **Team summary:** "展示同盟的通过回合数,本局耗时和完成日期。同时会显示每个玩家的通过回合数及其获得评语". Each row gets like and report buttons. [layout ASSUMED]
2. **Personal page** [SHOT rZEmfyJ]:
- **Top left:** STRONGHOLD PROTOCOL logo, the difficulty tag (red "绝境模拟") and a big mint headline **模拟完成** (or failure text). "通过回合 **15**" in huge numerals. Boss medallions with ✓ (round-14 boss and hidden-core boss).
- **Player card:** avatar, `Dr.name#1234`, trophy-level badge with total (e.g. 180), "获得奖杯 **7**".
- **Right:** the final lineup as tier-chipped portrait cards. Empty slots read "NO INFO". Below it, a bond row with stacks (359 / 136 / 34 …).
- **Bottom bar:** "获得卫戍认证! **+425** 常规获得 **+300** 每日防卫目标", then "LV. 38 LEVEL UP! 125/200" (milestone progress bar), then **下一步**.
- **Background:** a dark topographic Terra map with a glowing orange "TARGET POINT".
3. **Rewards:** tokens = `baseReward[roundsPassed] × difficultyFactor × modeFactor`.
- Base per round 1..14 (`baseRewardDataList`): 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 120, 140, 160, 200.
- Difficulty (`difficultyFactorInfo`): FUNNY 1.0, NORMAL 1.6, HARD 1.7, ABYSS 1.7.
- Mode: ×1.25.
- Check: 200 × 1.7 × 1.25 = **425** ✓ (matches the screenshot).
- The daily target is 200 tokens → +300 bonus.
4. **Stats web page** (`ak-webview…/autoChess/act1autochess`):
- totals for solo and co-op: 总模拟数, 成功卫戍, 绝境突破
- a **hexagon radar** of the 6 titles
- 已获奖杯, 已获赞
- best stacks, most funds spent, most promotions, most kills
- top-3 bonds and strategies, and the badged strategies
---
## 11. UI layout of each screen (reference 1920×1080 landscape, from screenshots)
Coordinates are approximate fractions of a 16:9 screen. Everything is our own art; only the layout idea is borrowed.
### 11.1 Global chrome (all in-match screens)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│[⇦]58ms [🔍] 回合 [ 3 ] ( ⌂ 休息一下 ) ♜ 27 [🔍▶▶] ┃59┃≡ │
│ exit+ping info round phase / kill capsule LP enemy preview COUNTDOWN│
│ (◉3 灵巧)(◉0 迅捷)(◉ 精准)(◉ 助力) ← active-bond strip [☑ 准备就绪]│
│┌──┐ │
││P1│ ♜24 … ← player list: avatar, LP, state (…/✓/⌛/door) │
││P2│ ♜24 ✓ │
││P3│ ♜23 │
││Me│ ♜27 (green person badge = you) │
│└──┘ │
│ [☺ 交流] bottom-left │
└──────────────────────────────────────────────────────────────────────────────┘
```
- **Exit:** top-left 64 px dark-red square, white "door-arrow" glyph. Asks for confirmation. In co-op it offers 暂离 or 放弃.
- **Ping:** pill at about x = 4–7 %. Amber number + "ms".
- **Top HUD:** a thin dark bar with bracket frames `[ ]`, centered at about x = 25–75 %, y = 2–6 %. Small techno micro-labels ("RHODES ISLAND STATION SERVICE").
- The **center capsule** has a mint outline in prep and shows a phase label (e.g. "休息一下").
- In combat and 联防 it has an orange outline and shows `killed/total`.
- In the boss round it shows `killed/total` plus an orange boss HP bar with a boss glyph.
- **Round box:** "回合" + a white box number.
- **LP:** green rook-tower icon + number.
- **Enemy-preview button:** amber bracket frame `[🔍 ▶▶]`. It swaps the view to the enemy staging board (a separate tiled grid showing this round's enemies standing in formation). The same frame shows `[🔍 ◀◀]` to go back.
- **Countdown:** top-right, 7-segment digits about 90 px tall, mint (#5fe3b0-ish).
- "COUNTDOWN" microtext underneath.
- A vertical 5-bar gauge to the right that empties with time.
- Orange at ≤10 s.
- **准备就绪:** dark button with mint border and checkbox, under the countdown. When checked it fills mint.
- **Bond strip:** round mint discs (about 56 px) with a black glyph and an outer segmented ring.
- The stack count sits over the bottom of the disc and the name below.
- Sorted by stacks descending, left → right.
- Grey disc = present but not active.
- Tap → detail popup (thresholds and current effect).
- **Player list:** left edge, x ≈ 1–5 %, y ≈ 25–70 %. 4 square portraits (about 90 px) in dark frames with a green LP number.
- Status glyph to the right: `…` acting / not ready, ✓ ready or picked, ⌛ deciding, red door = left.
- Tap → view that player's board (read-only).
- Emote bubbles pop next to it.
### 11.2 Prep phase (休整期) [SHOT c32edaf / 4995084 / a7a712ab]
```
(board: tilted 3D grid, ~60 % of the screen, centered-left; enemy spawn = red wire-cube on the right,
protect point = blue wire-cube with ⚠ on the left; operators show a tier chip "I" above their heads)
wooden bench strip (10 pads) directly under the board = 整备区; +5 temp pads in front when overflowing
剩余可放置角色: 6 ← right, above buttons
[❄ 冻结] [⟳ 刷新 (1)] ← light-blue / amber
┌────────┬───────────┬───────────┬───────────┬──(item)──┬──────────────┐
│ (4) │ I (2) │ I (2) │ I (2) │ │ ◆◆◆ 5 │
│ LEVEL │ [art] │ [art] │ [art] │ │ 目前资金 │
│ 1 │ ⚙炎 │ ⚙助力 │ ⚙精准 │ │ ✕ 收起 │
│ 升级 │ ⚔ 惊蛰 │ ⚔ 暴行 │ ⚔ 跃跃 │ │ │
└────────┴───────────┴───────────┴───────────┴──────────┴──────────────┘
bottom shop bar ≈ x 38 %→98 %, y 74 %→97 %
```
- **Level card:** mint-filled card with "LEVEL" + a big number in a bracket frame and the label 升级. The upgrade cost is a hexagon badge on top. It greys out when you can't afford it.
- **Operator card:** dark card, teal 1 px border.
- Top-left: tier chip (I–VI, white on black; gold border for V/VI [ASSUMED]).
- Top-center: price hexagon badge. Gold number; `#59f4ca` when discounted, `#ff5454` when marked up (`constData.discountColor/premiumColor/normalColor`).
- Right: half-body art. Left: a watermark of the first bond's glyph.
- Bottom rows: bond icon + bond name(s), then class icon + name.
- A frozen card gets an icy-blue overlay and a ❄ at the bottom. A promotion reward shows price **0**.
- **Item card:** in 下半 every non-tutorial mode has `itemCount`=1 at every shop level, so one equipment slot is always shown. 入门 has it only from L5, as does the older tip "调度中心会在升至5级后开始售卖装备". Same card shape with an equipment icon and a tier chip.
- **Funds card:** hexagon badge with a coin glyph (three stacked gold triangles) and the amount, "目前资金", and **✕ 收起**, which collapses the shop to reveal the bench and board.
- **Freeze / refresh:** 冻结 is pale blue with a snowflake; it toggles to 解冻. 刷新 is amber with a cycle icon and a cost badge.
- **Deploy interaction:** drag a card or bench unit onto the board. Legal tiles light green, illegal red. While dragging, a red "✕ 点击取消" corner zone appears. Dragging a unit back to the bench retreats it. Selling is a separate sell action [ASSUMED drag onto the funds card]. Equipment: drag onto a unit (board or bench). There is no facing step in auto-chess. [ASSUMED]
- **Remaining deploy counter:** "剩余可放置角色: N" = 8 − deployed (`maxBattleChessCnt`).
### 11.3 Combat [SHOT 05db364c / fc6673cd]
- The camera zooms in on the lane.
- The capsule shows `n/m` kills. Right edge: DP `⊂ 59`.
- When finished: bottom-center message pill "⌛ 作战结束,等待队友完成作战", and the view switcher `‹ 自己 ›` (arrows cycle P1…P4).
- The bond strip stays visible. No shop.
### 11.4 联防 [SHOT GwtDIAW]
- Orange banner "联防阶段" at phase start.
- The HUD capsule shows `1/4` kills. The bond strip is dimmed with the tooltip "层数叠加已禁用".
- The field is a wide two-half arena. Helpers' operators keep their HP bars and SP.
- The view switcher defaults to the 联防 field. Non-helpers can switch to it.
- Owners' LP in the left list decrements live as enemies escape.
### 11.5 机变 draft overlay [SHOT Wrkhtxv / Mb2mtd9 / SEbySVl]
- A full-width dark translucent overlay over the board. The left player list stays visible.
- Title at top-left in large mint-white text with the family name (悬赏决策 / 道具补给 / 机密商店 / 战术决策), then `|`, then the grey description.
- Right: "当前轮到你决策" (mint), or "{name} 正在决策… ⌛", plus the countdown.
- 3×2 grid of cards (about 23 % × 26 % of the screen each), light grey-glass panels:
- icon (enemy portrait, equipment or team-buff tower glyph) and bold title
- description with keywords highlighted blue (`ba.vup`: "下场作战", numbers)
- an equipment tier chip in the top-left (orange frame, roman numeral)
- a taken card turns lighter/greyed and carries the taker's avatar in a round badge at its top-right
- Behind the overlay, the funds pill and "可放置角色" remain visible at the right.
### 11.6 Strategy draft and briefing
Covered in §4.1–4.2. Both are full-screen dark panels:
- a fine scan-line texture
- difficulty tag (colored glyph + text) at top-left next to exit/ping
- step indicator "**1**/2 确认本局信息" or "**2**/2 选择策略" (big mint numeral)
- countdown at top-right
- a mint primary button bottom-right (准备就绪 / 确认选择)
### 11.7 Final Assault [SHOT YRY8ar1]
- A wide single field seen from above-front with MARTHE-branded barge docks.
- The HUD capsule shows `4/9` kills and the boss HP bar (orange, with a boss glyph); the team LP number follows.
- The boss has its own red bar under its sprite.
- Bottom-center `‹ 全景 ›` switches left half / panorama / right half.
- The countdown keeps running. After the 150 s mark, show a red "DOT" warning; team LP drains 1/s (the `hintTimeDotPhase`=10 s warning before that).
- The damage-share broadcast ticker is at the top-center.
### 11.8 Room / matchmaking / result: see §3 and §10.6
---
## 12. Visual style guide (for an original look-alike)
**Palette.** Official hex values come from data; the rest are sampled by eye.
| Token | Hex | Use |
|---|---|---|
| bg-0 | #0c0f0e | page and backdrop (near-black green-grey) |
| bg-1 | #141816 | panels |
| bg-2 | #1e2421 | cards |
| line | #2f3a35 | 1 px borders, grid |
| mint-500 | #4ed8af [DATA training color] | primary accent, ready buttons, headings |
| mint-400 | #59f4ca [DATA discount] | highlights, discounted price |
| mint-600 | #35d8b4 [DATA choice text] | secondary accent text |
| mint-glow | #17f9b7 [WIKI header] | glows, countdown digits |
| gold | #ffc600 [DATA normalColor] | prices, coins, "当前轮到你" |
| gold-2 | #f2bd3e [DATA special choice] | special/rare card text |
| amber | #f6a329 [DATA 标准] | refresh button, 标准 difficulty |
| orange | #e85a1a [DATA 险境] | combat capsule border, 联防 banner, low-time digits |
| red | #e73118 [DATA 绝境] / #ff0024 [DATA 终极] / #ff5454 [DATA premium] | danger, exit button, markup |
| ice | #9fd4ff [ASSUMED] | freeze button and frozen overlay |
| text-hi | #f2f2f2 | primary text |
| text-lo | #8a948f | secondary |
| Shop-level tag colors (`shopLevelDisplayDataDict`) | L1 #434343, L2 #626654, L3 #445760, L4 #615B74, L5 #6C5E41, L6 #5d341e | level chip background |
**Typography** (original game: Novecento/Bender-style Latin, Source Han Sans CJK). Our choice [ASSUMED], using freely licensed fonts:
- CJK UI: "Noto Sans SC" 400/500/700.
- Latin display (microtext "STRONGHOLD PROTOCOL", "LEVEL", "COUNTDOWN"): "Rajdhani" or "Oxanium", uppercase, +8 % tracking.
- Countdown and big numerals: a 7-segment font ("DSEG7 Classic", npm `dseg`) or "Share Tech Mono".
- Numbers in badges: "Barlow Condensed" 700.
**Shapes and iconography:**
- Rectangles with 1–2 px mint borders and **corner brackets `[ ]`** instead of rounded boxes. Hexagon badges for prices and cost. Circles for bond discs. Chess pieces for trophy levels. A tower (rook) icon for LP.
- Diagonal "chevron" arrows (»») for flow. Hazard-stripe accents (yellow/black) only on the physical barge and board props.
- Scan-line and noise overlays on panels. Topographic-map and radar-circle decorations on the hub and result screens. Tiny techno labels in 8–10 px caps.
- Tier chips use roman numerals I–VI; elite units get a gold outline or glow.
**Motion:**
- Cards slide up from the bottom (shop).
- Bond disc rings fill clockwise as stacks grow.
- Countdown digits flicker at ≤10 s.
- The broadcast ticker slides in from the right. Emote bubbles pop with a 150 ms scale.
- Phase banners ("联防阶段", "最终攻势") wipe in horizontally with chevrons.
**Do not copy:** Arknights logos, the "MARTHE" branding, character art, the exact emote art, or fonts. Build our own glyph set in the same spirit.
---
## 13. Implementation notes for our Node authoritative server [ASSUMED design]
- **Match states:** `LOBBY → BRIEFING(25) → BAND_DRAFT(50) → BOOT(3) → {SP_DRAFT? → PREP → COMBAT → UNITE? → RESOLVE}×N → FINAL → (HIDDEN) → SETTLE`. All timers are server-side; clients only render `deadlineTs`.
- **Ready gate:**
- `PREP` ends when `every(alive players).ready` or the deadline passes.
- Ready is blocked while that player's temp-bench overflow is non-empty.
- At the deadline, destroy overflow units, then auto-ready.
- **Draft engine** (bands and 机变):
- Keep an order array and one `activeSeat`.
- Per-turn timer: bands [ASSUMED] 12 s each within the 50 s step; 机变 16 s, 30 s for the first.
- Allow one "skip" per player in the band draft (it moves the player to the end of the order).
- Timeout → auto-pick: bands → `band_bldsk`; 机变 → a random remaining card.
- **Combat:**
- Simulate all boards in one tick loop (same seed per round).
- Record leaked enemies with `sourcePlayerId`, type, remaining HP [ASSUMED: they re-enter at full HP] and bounty tag.
- Then run UNITE on a fresh sim instance with helper snapshots (position, HP %, SP).
- **Emotes:** `emote{seat,id}`, rate-limited to 1/s server-side, relayed to all.
- **Broadcasts** are server-generated from events.
- **Disconnect:**
- Keep the seat for the whole match (the real game allows a return "within a period"). While away, the bot policy is "do nothing, auto-ready at deadline, auto-pick drafts".
- Offer an **AI takeover** option: a simple buy/merge/deploy heuristic, like the tutorial NPCs (`trainingNpcList` = 华法琳/阿米娅/杜遥夜 bots with 120 trophies).
- Reconnect restores the full state snapshot.
- **Bots for empty seats:** the real tutorial is a 4-seat match with 3 NPCs. We can reuse that idea to let 1–3 humans play with bots.
- **Spectating:** read-only board views of any seat at any time. During FINAL, hide the other pair's field to match the official rule.
- **Anti-grief:** report and like at settlement, and the 20 s "重载" cooldown. Optional for us.
- **Timer presets:** "Official" (data table) and "Fast" (×0.5 prep, keep combat). Official matches run about 55 min.
---
## 14. PvP?
**No.** 盟约 has no versus mode (confirmed by all sources: official announcements, BWIKI, PRTS, guides). "同盟" means alliance. The teams never fight each other. Indirect competition only:
- the shared operator pool ("卡牌/抢牌")
- who kills a bounty during 联防
- the damage-share broadcasts
- the per-match commendations
---
## 15. Screenshot / reference URLs (not downloaded; for the UI designer)
Official:
- Official gameplay-intro long image (800×13685; hub, room, matchmaking, briefing, strategy draft, prep/shop, drag-deploy, freeze, promotion, equipment…): https://img.71acg.net/sykb~bbs/pc/1763033996225879 (mirrored from the 11/13 2025 official post "「卫戍协议:盟约」玩法介绍")
- Event banners:
- https://patchwiki.biligame.com/images/arknights/c/cc/t7tdfvs7j0xpzphw620i2iqi4qoh34v.jpg (下半)
- https://patchwiki.biligame.com/images/arknights/0/03/qeasnqofhr3ffa0bhlbbp0j9854smy9.jpg (上半)
- Icons:
- trophy lvl1: https://patchwiki.biligame.com/images/arknights/9/96/b2qsb5k5jss4gnzox3a6c0b1gv5uy5g.png
- team-buff icon: https://patchwiki.biligame.com/images/arknights/4/49/en8v6no3jtgdnvcydolnksu6pflv3co.png
- title icon 1: https://patchwiki.biligame.com/images/arknights/2/20/7ltxu8xxexf8nyegz3wrnplfnx28k15.png
- emote "合作愉快": https://patchwiki.biligame.com/images/arknights/0/03/rneluobt0fhtix8oy4xp2x33jelzkvn.png
In-game screenshots (players, CN/TW):
- Final Assault HUD (shared field, boss bar, `‹全景›`): https://i.meee.com.tw/YRY8ar1.png
- 联防 HUD ("层数叠加已禁用", player list with LP, `‹自己›`): https://i.meee.com.tw/GwtDIAW.png
- Briefing 1/2 (leader, special enemies, bonds, 已就绪 2/4): https://i.meee.com.tw/1BJDbxX.png
- Strategy draft 2/2 (order list, 决策中): https://i.meee.com.tw/BOzwbwn.png
- Enemy preview board: https://i.meee.com.tw/wdRAguq.png
- Info dropdown (strategy + bans per bond): https://i.meee.com.tw/9H7Kugy.png
- 机变 drafts:
- 悬赏决策: https://i.meee.com.tw/Wrkhtxv.png
- 机密商店 (with a left player): https://i.meee.com.tw/Mb2mtd9.png
- 战术决策: https://i.meee.com.tw/SEbySVl.png
- Bond strip: https://i.meee.com.tw/e3UUYq8.png
- Emote usage: https://i.meee.com.tw/86HwPAh.png
- Countdown turning orange: https://i.meee.com.tw/WcxNEfR.png
- Result / personal settlement: https://i.meee.com.tw/rZEmfyJ.png
- Roster config 物资调配处: https://i.meee.com.tw/KJGydN0.png
- Trophy table (PRTS 上半): https://i.meee.com.tw/LYFkxSZ.png
- Prep phase with shop (TW client):
- https://truth.bahamut.com.tw/s01/202606/forum/33651/c32edaf6528776921fa0587c77edc291.JPG
- https://truth.bahamut.com.tw/s01/202606/forum/33651/4995084674285fdfefc218c7015258c0.JPG
- https://truth.bahamut.com.tw/s01/202606/forum/33651/a7a712ab367e8855c5a177dd945464fd.JPG
- Combat end, "等待队友完成作战":
- https://truth.bahamut.com.tw/s01/202606/forum/33651/05db364c3424cfd55778af95ce5cc077.JPG
- https://truth.bahamut.com.tw/s01/202606/forum/33651/fc6673cd4ee25defcc599d17fc5d3f5d.JPG
- Stats web page (title hexagon radar): https://truth.bahamut.com.tw/s01/202604/forum/33651/a8947d6e34ee687641d438d4df66235d.JPG
- Strategy badges grid: https://truth.bahamut.com.tw/s01/202604/forum/33651/b5db3a1d005cf0b681814a9920b451af.JPG
---
## 16. Open questions and assumptions (review before spec freeze)
1. **联防 helper selection** when 3 or more players are perfect, and the exact 联防 timer. Proposed: highest LP, and the round's combat time.
2. **Boss HP scaling** with the number of alive players. Community-confirmed that it shrinks; the formula is unknown. Proposed: linear n/4.
3. **Pairing rule** for the Final Assault (seat order vs LP), and the 3-player layout.
4. **Strategy draft exclusivity.** Proposed: duplicates allowed. The per-turn time inside the 50 s step is also unknown.
5. **机变 pick order.** Screenshots show non-seat order. Proposed: random (alternative: lowest LP first).
6. **Title (评语) criteria** (§10.5) are inferred.
7. **Disconnect behavior** (idle vs AI) and the co-op reconnect window. The official text says only "一段时间内". Solo is 24 h.
8. **`storeCntMax`=6, `singleClosureStayTime`=15, `bossTrailerStartRound`=3, `specialPhaseStayTime`=1:** semantics are unclear. Guesses: max shop cards, the solo result-screen stay, the round from which the boss preview shows, and a 1 s pause between draft picks.
9. **Do leaked enemies keep their damage** when entering 联防? Proposed: full HP.
10. **Terrain 机变 cards** (`map_m0X`): do they apply to the picker only or to all boards? Proposed: picker only.
## Sources
- Official:
- https://ak.hypergryph.com/news/5114 (下半 update: fewer-than-4 start, 24 h solo leave, boss both sides with shared HP)
- https://ak.hypergryph.com/news/9697
- https://ak.hypergryph.com/news/8584
- https://ak.hypergryph.com/news/2830 (上半: "和另外三位玩家组队")
- BWIKI:
- https://wiki.biligame.com/arknights/盟约 (full play rules incl. 联防, 最终攻势, emotes, trophies, tips)
- https://wiki.biligame.com/arknights/盟约/S.W.E.E.P.报告
- PRTS: https://prts.wiki/w/卫戍协议:盟约_下半 and https://prts.wiki/w/卫戍协议:盟约 (70 %/80 % factors, 16 s/30 s draft, 6-choose-1, hidden core 350/1200, trophy table with 终极)
- arknights.wiki.gg:
- https://arknights.wiki.gg/wiki/Stronghold_Protocol
- https://arknights.wiki.gg/wiki/Stronghold_Protocol_Alliance
- Bahamut guides:
- https://forum.gamer.com.tw/C.php?bsn=33651&snA=12294
- https://forum.gamer.com.tw/C.php?bsn=33651&snA=12316
- https://forum.gamer.com.tw/C.php?bsn=33651&snA=12256
- https://forum.gamer.com.tw/C.php?bsn=33651&snA=12522
- Shared-pool videos: https://www.bilibili.com/video/BV1JWy3BkEpt/ and https://www.bilibili.com/video/BV1ZTUjBaEyy/
- Interview: https://www.bilibili.com/video/BV17L9MBXEBY/ (机核 × 大黄, design background)
- Data: `activity_table.json` (`autoChessData`, `act2autochess`), `levels/act1autochess/*escaped*`, `h07_*`, `display_meta_table.json` (saved to `scratchpad/gd/extra/multiplayer/`)
---
## Addendum (critic)
Written 2026-09-27 by the completeness critic. Sources: the official gameplay-intro long image (`img.71acg.net/sykb~bbs/pc/1763033996225879`, sliced in `scratchpad/gd/extra/critic/intro_*.jpg`), PRTS `卫戍协议/帮助`, PRTS `卫戍协议:盟约_下半` §开始模拟, BWIKI `盟约`, and a re-read of the §15 screenshots.
### D1. Corrections to the TL;DR
| # | Was | Now |
|---|---|---|
| 3 | "HP/ATK ×0.70 solo, ×0.80 co-op" | Too coarse. The multiplier depends on **mode and round**. 标准/险境 co-op start at 0.8; 绝境/终极 co-op start at 1.1¹/1.2¹ and grow to 1.1⁸/1.2⁸ by R14. Full table: 01 Addendum A3. |
| 4 | band draft | Confirmed: "以随机顺序进行策略轮选,顺序会显示在左侧". **All unlocked strategies are shown** (not a random 3). **Duplicates are allowed:** in the official illustration, the strategies already taken by P1/P2 are not greyed in P3's grid. One skip per player. |
| 6 | solo "unlimited" | Confirmed by BWIKI (下半): "休整期及机变阶段没有时间限制…24小时内随时返回". |
| 8 | 机变 | Funds for the round are already granted when the overlay opens: the funds pill reads 13/11/17 behind the three screenshots. The family (悬赏 / 道具补给 / 机密商店 / 战术决策) is the **same for all 6 cards** of one phase. Pick order is **not** seat order. Recommended per-round families: 01 Addendum A4. |
| 10 | 联防 | Confirmed: "所有防卫成功的玩家(最多2名)将以其阵地当前的状态,迎接所有防卫失败玩家未击倒敌人的集合". Helpers keep "生命比例、技力、部署位置". |
| 12 | Final Assault pairing | Players are paired, and **an odd player forms a group alone** ("如有落单的玩家将单独成组"). Movable leaders spawn **one per alive player's side**, all sharing HP. The HP pool is excluded from the round multipliers. The official intro shows R4 of the tutorial with a merged LP of 105 = 45 + 20 + 20 + 20. |
| 15 | gifting | Unchanged: 信标 is the only operator transfer. |
| new | – | **Per-match banned operators are shown to everyone.** At briefing: core and add-on rows with greyed bonds carrying the badge "部分盟约所含干员阵容不完整", plus a 本局禁用干员 portrait list. In match: the info dropdown shows "本局禁用干员情况" with a red person-✕ badge = number of banned operators in each bond. Rule: 01 Addendum A2. The pool is **shared**, so bans apply to the whole team. |
### D2. Answers to §16 open questions
1. **Helper selection (>2 perfect):** still unpublished. Keep "highest LP, then seat". The timer is the round's `maxPlayTime`.
2. **Boss HP vs player count:** still unpublished. What is known: bloodPoint is one pool, it is unaffected by multipliers, and each alive player has their own boss spawn. Keep `bloodPoint × alive/4`, with a solo value of 0.25 [ASSUMED].
3. **Pairing:** odd player alone (verified). Pair order: seats (1,2) and (3,4) among alive players [ASSUMED].
4. **Strategy exclusivity:** allowed duplicates (official illustration). Per-turn timer [ASSUMED] 12 s.
5. **机变 pick order:** random per phase (screenshots). "Lowest LP first" is not supported by any evidence.
8. **`storeCntMax` = 6** = 5 operator slots + 1 item slot at L4–L6. That reading is consistent with `shopLevelDataDict`.
9. **Leaked enemies in 联防:** unknown. Keep "full HP" [ASSUMED].
10. **Terrain cards:** picker only [ASSUMED]. Terrain cards exist only for the current stage (`terrain_m0X`).
### D3. UI facts read from the screenshots
- **Prep:**
- The shop bar holds, in order: `LEVEL n` card (upgrade cost hexagon on top), operator cards, the item card in 下半, and the funds card with "✕ 收起".
- A frozen shop tints **all** cards icy and the button toggles to "解冻".
- Reward cards after a merge show **price 0** with the next tier's chip.
- Above the buttons, "剩余可放置角色: N" = cap − deployed. The cap is 9 with 人事部文档.
- **Combat:**
- The capsule shows `killed/total`. The DP counter sits at the right edge.
- The bond strip stays visible. In 联防 and the boss round it carries the tooltip "层数叠加已禁用".
- **Final Assault:** eliminated seats show a grey robot "x-eyes" avatar in the left list. The view switcher reads `‹ 全景 ›`.
File diff suppressed because it is too large. Load diff
+478
View File
@@ -0,0 +1,478 @@
# 07 — Art and audio assets: sources, feasibility and the final plan
Scope: every art and audio asset the fan remake of **卫戍协议:盟约 (act2autochess)** needs, with **URL patterns that have been checked**, measured hit rates, file sizes and fallbacks.
Machine-readable companion: **`docs/research/07-assets.json`**. It maps every id to resolved URLs and byte sizes: 138 operators, 20 tokens, 200 enemies, 23 bonds, 59 shop items, 40 bands, the UI sprite groups and the audio banks.
**All 4,451 unique URLs in that JSON were HEAD-checked on 2026-09-27 and returned `200`.**
Probe artifacts are in the scratchpad at `…/scratchpad/gd/extra/assets/`:
- Git-tree listings: `y_*.txt`, `fx_spine.txt`, `am_enemies.txt`, `aa2_*.txt`, `aa2v_*.txt`.
- Id lists: `charids.txt`, `enemyids.txt`, `tokenids.txt`, `trapids.txt`.
- Spine sample: `spine_sample/`.
- Browser render proof: `spine_test.png`, produced from `spine_test.html`.
- Generator script: `gen_assets_json.py`.
---
## 0. TL;DR: the final plan
| Class | Source (primary) | Pattern | Hit rate (pool) | Typical file | Fallback |
|---|---|---|---|---|---|
| a) Avatar (square) | yuanyan3060/ArknightsGameResource `avatar/` | `{charId}.png`, E2: `{charId}_2.png` | **138/138**; E2 122/138 | 180×180 PNG, ~58 KB | E2 missing → base `{charId}.png` (the 16 misses are the 预备干员/Sharp/Pith/… `char_60x/61x` ids that have no E2 art) |
| b) Half-body portrait | same repo `portrait/` | `{charId}_1.png`, E2 `_2.png` | **138/138**; E2 122/138 | 180×360 PNG, ~117 KB | `_1` |
| b') Full art (detail panel only) | same repo `skin/` | `{charId}_1b.png`, E2 `_2b.png` | 138/138; E2 122/138 | 681×1317, ~0.8 MB (E2 ~3.2 MB) | portrait `_1`; lazy-load only |
| c) Skill icon | same repo `skill/` | `skill_icon_{skill.iconId ?? skillId}.png` (percent-encode `[` `]`) | **317/317** (all skills of pool operators; 144 are default skills) | 128×128, ~19 KB | `arts/ui/[uc]charcommon/skills/empty_skill.png` |
| d) Profession / sub-profession | ArknightsAssets/ArknightsAssets2 `@cn` `assets/dyn/arts/…` | `profession_hub/icon_{prof}.png`, `ui/subprofessionicon/sub_{subProfessionId}_icon.png` | 8/8, **58/58** | ~12 KB / ~2 KB | battlecard `icon_profession_*.png` |
| e) Enemy icon | yuanyan `enemy/` | `{enemyId}.png` | **200/200** | ~158×158, ~44 KB | none needed |
| f) **Bond icons: the real autochess ones** | AA2 `ui/autochess/[uc]autochesscommon/arts/bondicon/` | `{iconId.toLowerCase()}.png`, e.g. `icon_yanship.png` | **23/23** | ~110×98 white glyph, ~4 KB | `arts/camplogo/logo_{nation}.png` (see §4) |
| g) **Item / trap icons: the real autochess ones** | AA2 `…/arts/shopitemicon/` | `{trapId}.png` | **59/59** (56 EQUIP + 3 MAGIC) | ~137×157, ~19 KB | none needed |
| g') Band (strategy) icons | AA2 `…/arts/bandicon/` | `icon_{bandId minus "band_"}.png` | **40/40** | 180×180, ~69 KB | — |
| h) Map tiles | **none usable**: the game map is a 3D Unity scene | — | — | — | **Draw tiles procedurally** (§7). Use real autochess backdrops for the lobby and prep screens. |
| i) UI: rarity, elite, chess-level, shop, HUD | AA2 `ui/autochess/…` (537 PNGs) + `arts/rarity_hub`, `elite_hub` | see JSON `autochessUi`, `arts` | 100% | 0.3–250 KB | CSS-drawn panels (Unity 9-slice borders are not in the dump) |
| j) Fonts | Google Fonts + self-hosted Bender / Novecento | §8 | — | — | system CJK |
| k) BGM + SFX: **the real game audio** | AA2 `@voice` branch `assets/dyn/audio/sound_beta_2/…` | mapped by `audio_data.json` banks | BGM 3/3; per-operator SFX **138/138**; enemies 185/200; autochess UI SFX 47 banks | mp3; BGM ~2 MB, SFX 5–60 KB | WebAudio-synthesized blips (§6.4) |
| l) **Battle Spine (in-battle chibi)** | operators: **fexli/ArknightsResource `spine/`**; enemies: **isHarryh/Ark-Models `models_enemies/`** | `spine/{id}/{id}/{Front\|Back}/{id}.{skel,atlas,png}`; enemies via `models_data.json` `assetList` | ops Front **138/138**, Back 135/138; enemies **198/200** | median op front+back ≈ 690 KB; enemy ≈ 134 KB | missing Back → mirrored Front; missing enemy → static icon sprite (§5.6) |
| Spine runtime | **PixiJS 7.4.2 + pixi-spine 4.0.6** (UMD from jsDelivr) | — | render checked in headless Chrome | 456 KB + 362 KB | — |
**Size budget, all classes, only what the pool needs**
| | Size |
|---|---|
| Raw | ≈ 200 MB (≈ 700 MB if full art is included, so leave full art out of the bundle) |
| After build-time optimization | **≈ 65–75 MB**: WebP for images, gzip/brotli for skel |
| Initial page load (lazy-loading Spine per board) | **< 6 MB** |
---
## 1. Id inventory (from `act2autochess`)
| Set | Count | Notes |
|---|---|---|
| Operators (`charShopChessDatas[*].charId ∪ backupCharId`) | **138 unique charIds** over 133 chess | 74 PRESET, 55 NORMAL, 4 DIY. DIY chess have `charId=null`, so the operator is chosen at runtime and uses the chosen operator's assets. Chess levels 1–6: 20/19/21/26/25/22. |
| Most chess are `evolvePhase=PHASE_2` | 94 PHASE_2 (+ 39 PHASE_1) non-golden; every golden chess is PHASE_2 | → Prefer **E2 avatar/portrait** (`_2`) and fall back to `_1`. |
| Tokens/summons referenced by pool operators | 20 | `displayTokenDict` and `overrideTokenKey`. Bond summon "炎佑" (Yan 6/9) has no token id in the data, see §5.6. |
| Enemies | **200** | Union of `enemyInfoDict`, `specialEnemyInfoDict`, `bossInfoDict`-linked ids and level files. All 200 are in `enemy_handbook_table`. |
| Bonds | 23 | `bondInfoDict[*].iconId`: `icon_yanShip` … `icon_suntShip` |
| Shop items | 59 | `trapShopChessDatas[*].trapId` = `trap_10xx_acarmNNN` and friends |
| Bands (strategies) | 40 | `bandDataListDict` keys `band_*` |
| Sub-professions in pool | 58 | e.g. `fastshot`, `protector`, `ritualist`, `primprotector`, `counsellor`, `hunter`… |
| Nations in pool | 18 + None | bolivar, columbia, egir, higashi, iberia, kazimierz, kjerag, laterano, leithanien, lungmen, rhodes, rim, sami, sargon, siracusa, ursus, victoria, yan |
Newest operators in the pool, all verified present in every class: `char_4211_snhunt` 雪猎, `char_4207_branch` 折桠, `char_4196_reckpr` 录武官, `char_4194_rmixer` 信仰搅拌机, `char_4193_lemuen` 蕾缪安, `char_4191_tippi` 蒂比, `char_1047_halo2` 溯光星源, `char_1046_sbell2` 圣聆初雪, `char_1045_svash2` 凛御银灰, and the activity-only `char_616_pithst` 盟约·辅助干员 and `char_617_sharp2` 领主·Sharp.
---
## 2. Sources evaluated
| Repo | Branch | Updated | What it has | Verdict |
|---|---|---|---|---|
| `yuanyan3060/ArknightsGameResource` | `main` | 2026-09-24 | avatar (incl. `token_*`, non-autochess `trap_*`), portrait, skin (full art `b`), skill, enemy, item, item_rarity_img, map (not autochess) | **Primary for 2D portraits and icons.** AGPL-3.0 applies to its code; the art is © Hypergryph. |
| `ArknightsAssets/ArknightsAssets2` | `cn` (art), `voice` (all audio) | 2026-09-23 | Full ArknightsStudio dump: `assets/dyn/ui/autochess/**` (real autochess UI), `activity/[uc]act2autochess/**`, `arts/**` (camplogo, profession, rarity, elite, subprofession, loading illustrations), `battle/[pack]common/**`, and all audio under `@voice` | **Primary for autochess-specific icons, UI and ALL audio.** Battle spine here is incomplete: atlas/skel without PNGs. |
| `fexli/ArknightsResource` | `main` | 2026-09-20 | `spine/{charId}/{charId}/{Front,Back}/` battle models + base (`build_*`) models + skins; `camplogo`, `charpor`, `skills`… | **Primary for operator battle Spine** |
| `isHarryh/Ark-Models` | `main` | 2026-08-02 | `models_enemies/` enemy battle Spine (PC build, PMA forced), `models/` base (基建) chibis only, `models_illust/` dynamic illustrations; `models_data.json` index | **Primary for enemy battle Spine.** Its operator models are base chibis, not battle chibis. |
| `Aceship/Arknight-Images` | `main` | **2024-05** (stale) | avatars etc. | Hit rate **89/138**. It lacks every 2024+ operator. Not recommended. |
| PRTS `media.prts.wiki/{md5[0]}/{md5[0:2]}/{文件名}` | — | live | e.g. `头像_德克萨斯.png` returns 200 | Last-resort fallback only: Chinese-name keyed, and hotlinking is discouraged. `torappu.prts.wiki` timed out from this host. |
| `web.hycdn.cn` (official CDN) | — | — | guessed avatar path → 404 | not usable |
| `TimWangZi/The-font-of-Arknights` | `master` | — | Bender, Novecento Wide, 思源宋体 | font source (§8) |
**CDN behaviour, all checked**
- `raw.githubusercontent.com` returns `access-control-allow-origin: *` with `cache-control: max-age=300`.
- `cdn.jsdelivr.net/gh/<repo>@<branch>/…` works with CORS `*` and a 7-day cache for yuanyan, fexli, Ark-Models and AA2 `@cn`.
- **jsDelivr returns 404 for the AA2 `@voice` branch.** Use raw for audio.
- Do **not** hotlink at runtime (GitHub rate limits, availability, and it is impolite). **Mirror at build time** (§9).
**Path gotchas**
- `[uc]` → `%5Buc%5D` and `[pack]` → `%5Bpack%5D`.
- Skill icons such as `skill_icon_skcom_atk_up[1].png` need `%5B1%5D`. curl's globbing also breaks on unencoded brackets.
- Skin ids contain `#` → `%23`. Skins are not needed.
- Bond `iconId` is camelCase in data (`icon_yanShip`) but **lower-case on disk** (`icon_yanship.png`).
- Band icon = `icon_` + bandId minus `band_`.
- Liskarm's default battle model lives at `spine/char_107_liskam/char_107_liskarm/…`, a dump-side typo.
- `char_1012_skadi2` has a single `Spine/` folder instead of `Front/` and `Back/`.
- Enemy spine filenames can differ from the folder name: `1043_zomsbr/enemy_1043_zomsabr.*`, `1169_duphlx/enemy_1169_duphx.*`. **Always use `models_data.json → data[key].assetList`.** `07-assets.json` already resolves this.
---
## 3. 2D portraits and icons (classes a–e, i)
### a) Avatars
- URL: `https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/avatar/{charId}.png`
- E2 variant: `{charId}_2.png`.
- Mirror: `https://cdn.jsdelivr.net/gh/yuanyan3060/ArknightsGameResource@main/avatar/…`.
- Checked by curling all 138 charIds (xargs -P16): **138/138** 200. E2 variant: 122/138.
- The 16 without E2 are `char_600_cpione, 601_cguard, 602_cdfend, 603_csnipe, 604_ccast, 605_cmedic, 606_csuppo, 607_cspec, 608_acpion, 609_acguad, 610_acfend, 611_acnipe, 612_accast, 613_acmedc, 614_acsupo, 615_acspec`. They have no E2 art in-game, so use the base image.
- Size 180×180 RGBA, avg 58 KB (8.4 MB for the set, preferring E2). WebP q88 comes to about 15 KB each.
- Tokens: `avatar/{tokenId}.png` hits **15/20**.
- Missing: `token_10012_rosmon_shield`, `token_10039_ulpia_block`, `token_10040_siege2_vlion`, `token_10057_svash2_eagle`, `token_10058_sbell2_icetgt`.
- Fallback: owner operator avatar with a small "召唤物" badge, or the battlecard `icon_profession_token.png`.
### b) Portraits
- `…/portrait/{charId}_1.png` (E2 `_2.png`), 180×360 半身像 (the squad-card crop used on shop cards). **138/138**; E2 122/138. Avg 117 KB, about 31 KB as WebP q88.
- Full art `…/skin/{charId}_1b.png` is 681×1317, avg 787 KB; E2 `_2b` averages 3.2 MB. Use it only in the operator detail modal, lazy-loaded. Keep it out of the bundle; fetch on demand from jsDelivr.
### c) Skill icons
- `…/skill/skill_icon_{iconId}.png`, where `iconId = skill_table[skillId].iconId ?? skillId`. **317/317** (every skill of every pool operator, after percent-encoding brackets). 128×128, avg 19 KB.
- The shop only shows the chess's default skill (`defaultSkillIndex`), which is 144 distinct icons (≈ 2.7 MB raw).
### d) Profession and sub-profession
- `https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/arts/profession_hub/icon_{caster|medic|pioneer|sniper|special|support|tank|warrior}.png`
- Large white variant: `profession_large_hub/icon_profession_{p}_large_white.png`.
- Small battle-card variant: `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_{p}.png`, which also includes `…_token.png`.
- Sub-profession: `…/arts/ui/subprofessionicon/sub_{subProfessionId}_icon.png`. **58/58**, ~2 KB white glyphs.
### e) Enemies
- `…yuanyan…/enemy/{enemyId}.png`, **200/200**, avg 44 KB.
- The boss HP-bar portrait style exists as `arts/ui/[uc]battlecommon/ui_battle_new/enemybossinfo/…` for only a few bosses. Use the enemy icon inside `sprite_enemy_boss_avatar_bg.png`.
### i) Rarity, elite and misc UI (all 200)
- Rarity stars: `arts/rarity_hub/rarity_{0..5}.png`, plus `rarity_yellow_{i}`, `rarity_black_{i}` and `rarity_left_{i}`.
- Autochess-specific: `ui/autochess/[uc]autochessouter/arts/charraritysprite/rarity_{0..5}.png`.
- Elite icons: `arts/elite_hub/elite_{0..3}.png`, `_large`, `_card`.
- Autochess `eliteIconId` values (`shopCharChessInfoData[*].eliteIconId`) map to `…/[uc]autochessouter/arts/charelitesprite/{char_elite_base_1|char_elite_base_2|char_elite_gold_2}.png`. Also available: `char_elite_detail_*`.
- Chess tier (等阶 1–6): `…/chesslevelsprite/chess_level_icon_{0..6}.png`, `chess_level_detail_{0..6}.png`.
- Shop card: `…/autochess_shop_card_item/img_chess_level_{1..6}.png`, `frame_lv{1..3}.png`, `bg_common.png`, `ice_matte.png` (frozen), `icon_ice.png`.
- Shop level (调度中心): `…/shoplevelsprite/shop_level_icon_{1..6}.png`, `shop_level_tag_{1..6}.png` (150×595 vertical banners). Tag bg colours in data: `#434343, #626654, #445760, #615B74, #6C5E41, #5d341e`.
- Garrison (特质) type icons: `…/garrisontypeicon/{icon_battle|icon_bond|icon_gold|icon_support}.png`, plus `s_` small versions. These match `garrisonDataDict[*].eventTypeIcon`.
- Mode icons `mode_{normal|hard|abyss|funny|training}_icon.png`. Buff icons `icon_{boss_debuff|enemy_debuff|player_buff|stage_buff|team_buff}.png`, matching `effectDecoIconId`.
- Title icons `comment_icon_{1..6}.png` (playerTitle `picId`). Trophies `trophy_level{1..5}_icon.png`. Enemy-type icons `{fly|times|dot|element|invisible|reflection|special}_icon.png`, matching `enemyInfoDict` keys.
- Bond board pips: `…/[uc]autochessbattle/arts/bondboardicon/{n}_{k}.png` (1_0 … 4_4).
- HUD: `…/hud/autochess_hud_panel/*.png` (55 files: `icon_hp`, `icon_battle`, `icon_boss`, `icon_coop`, `icon_rest`, `hp_bar`, `bg_*`…).
- Shop: `…/shop/autochess_shop/*.png` (`refresh_icon`, `frozen_icon`, `upgrade_max`, `bg_money`, `cost_free`…).
- Equip slot: `…/act2autochess_equip_slot_panel/*.png`. Round banner: `…/dialog/round_start_dialog/*.png`.
- Loading illustrations (1920-class, ~0.9 MB each): `arts/loadingillusts/loading_ac_{core|normal|hard|abyss|funny|prototype|train|training}.png`.
- `modeDataDict.loadingPicId` values such as `loading_AC_core` → lower-case the id.
- Lobby / backdrop art:
- `activity/[uc]act2autochess/prefabs/act2autochess_activity_stage/{entry_bkg_01,entry_bkg_02,bkg_01,bkg_02,bg_mountains_tiled,season_logo,img_coin}.png`.
- Mode select: `ui/autochess/[uc]autochessouter/modechoice/auto_chess_mode_choice_state/{normal,hard,abyss,funny}_rhodes_island.png`.
- Battle ready: `…/battleready/auto_chess_battle_ready_state/{bg_mountain1,bg_mountain2,bg_terrain,rhodes}.png`.
- Battle common sprites: `battle/[pack]common/sprites/{sprite_shadow,sprite_direction_arrow,sprite_direction_ring,sprite_box_shadow}.png` and `…/projectiles/projectile_arrow.png`.
- Battle UI: `arts/ui/[uc]battlecommon/ui_battle_new/{btn_speed_1x,btn_speed_2x,btn_pause,slider_hp_back,slider_hp_fill,sprite_skill_ready…}.png`.
- Caveat: Unity sprite 9-slice borders are not in the dump. Use the art for icons, badges and backdrops, and draw frames and panels in CSS or canvas.
---
## 4. Bond icons (f): the real ones are available
`https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/ui/autochess/%5Buc%5Dautochesscommon/arts/bondicon/{iconId lowercased}.png`
All 23 are verified: white glyphs on transparent, so tint via CSS `filter` or canvas `globalCompositeOperation='source-in'`. Nation fallbacks come from `arts/camplogo/logo_{id}.png` (44 logos, 20–110 KB). The non-nation fallbacks below are [ASSUMED] suggestions.
| bondId | Name | Real icon file | Bytes | Fallback |
|---|---|---|---|---|
| yanShip | 炎 | `icon_yanship.png` | 5466 | camplogo/logo_yan.png |
| sargonShip | 萨尔贡 | `icon_sargonship.png` | 2845 | camplogo/logo_sargon.png |
| victoriaShip | 维多利亚 | `icon_victoriaship.png` | 4267 | camplogo/logo_victoria.png |
| kjeragShip | 谢拉格 | `icon_kjeragship.png` | 5255 | camplogo/logo_kjerag.png |
| lateranoShip | 拉特兰 | `icon_lateranoship.png` | 4725 | camplogo/logo_laterano.png |
| egirShip | 阿戈尔 | `icon_egirship.png` | 4715 | camplogo/logo_egir.png |
| siracusaShip | 叙拉古 | `icon_siracusaship.png` | 6956 | camplogo/logo_siracusa.png |
| kazimierzShip | 卡西米尔 | `icon_kazimierzship.png` | 4288 | camplogo/logo_kazimierz.png |
| preciShip | 精准 | `icon_preciship.png` | 4872 | sub_fastshot_icon |
| swiftShip | 迅捷 | `icon_swiftship.png` | 3675 | icon_profession_pioneer |
| skillfulShip | 灵巧 | `icon_skillfulship.png` | 4166 | sub_executor_icon |
| arcaneShip | 奥术 | `icon_arcaneship.png` | 4114 | icon_profession_caster |
| steadShip | 坚守 | `icon_steadship.png` | 2702 | icon_profession_tank |
| deputShip | 助力 | `icon_deputship.png` | 3726 | icon_profession_support |
| visiShip | 远见 | `icon_visiship.png` | 3451 | garrisontypeicon/icon_gold |
| miraShip | 奇迹 | `icon_miraship.png` | 4945 | garrisontypeicon/icon_gold (tinted) |
| investShip | 投资人 | `icon_investship.png` | 2058 | garrisontypeicon/icon_gold |
| raidShip | 突袭 | `icon_raidship.png` | 2982 | sub_executor_icon |
| indomShip | 不屈 | `icon_indomship.png` | 3439 | sub_unyield_icon |
| maniShip | 调和 | `icon_maniship.png` | 3124 | garrisontypeicon/icon_bond |
| emptyShip | 协防干员 | `icon_emptyship.png` | 2762 | battlecard icon_profession_token |
| soloShip | 独行 | `icon_soloship.png` | 2169 | sub_lord_icon |
| suntShip | 绝技 | `icon_suntship.png` | 3366 | elite_hub/elite_2.png |
Band / strategy icons (g'): `…/[uc]autochesscommon/arts/bandicon/icon_{bldsk|amiya|duyaoy|sarkazb|orchid|justin|ermengard|lmlee|kirara|pepe|harold|sciurus|paganini|clementia|emperor|mberry|humus|quintus|yu|doberm|cathy|malkie|qalaisa|chen|damaztic|pith|dusk|cannot|ducklord|lisa|vodfox|ioleta|jesica|mlyss|makiri|fang|mlynar|chiave|narant|amedic}.png`. All **40/40** are 180×180 "STRATEGY" cards, ~69 KB each.
## 5. Items / traps (g)
`…/[uc]autochesscommon/arts/shopitemicon/{trapId}.png`, **59/59**, ~137×157 on a transparent background, avg 19 KB, e.g. `trap_1041_acarm041` 维式重锤.
- The 3 non-shop special traps are effect ids with no icons: `trap_copy_front_char`, `trap_create_self_choice`, `trap_disney_special`. Use the icon of the item that grants them.
- Rarity backgrounds for item frames: yuanyan `item_rarity_img/sprite_item_r{1..6}.png`.
- Level-placed traps (`trap_1104_aclasert`, `trap_1105_accrate`, `trap_1112_acblzd`, `trap_098_mire`, `trap_013_blower`, `trap_042_tidectrl`) have no autochess icon. Draw them as tile overlays.
---
## 5b. Battle Spine (l): the real in-battle animated chibis
### 5.1 Sources and verification
- **Operators (fexli):** `https://raw.githubusercontent.com/fexli/ArknightsResource/main/spine/{charId}/{charId}/{Front|Back}/{charId}.{skel|atlas|png}`
- jsDelivr: `https://cdn.jsdelivr.net/gh/fexli/ArknightsResource@main/spine/…`.
- **Front 138/138. Back 135/138**: missing for `char_4134_cetsyr` 魔王, `char_291_aglina` 安洁莉娜 and `char_1012_skadi2` 浊心斯卡蒂. Skadi2 has one model in a `Spine/` folder. These are supporters that are often shown only from the front.
- One texture page per model.
- **Enemies (Ark-Models):** `https://raw.githubusercontent.com/isHarryh/Ark-Models/main/models_enemies/{enemyId minus "enemy_"}/{assetList[.skel|.atlas|.png]}`. **198/200.**
- Missing: `enemy_1305_mhslim(_2)`. AA2 has its atlas and skel but not its PNG.
- **Sample downloaded:** `char_102_texas` Front and Back plus `enemy_1007_slime` → `…/scratchpad/gd/extra/assets/spine_sample/`.
- Texas Front: skel 187,826 B, atlas 4,903 B, png 65,126 B (512×512).
- Texas Back: 73,065 / 3,346 / 47,290 B (256×256).
- Slime: 16,567 / 552 / 11,659 B (128×128).
- **Skel header:** read with a varint-string parser, and again with the pixi-spine 3.8 `SkeletonBinary` in Node. Every sampled file reports **version `3.8.99`**: 11 operators and 5 enemies.
- **Browser proof:** headless Chrome rendered Texas Front (Attack_Loop), Texas Back, 雪猎 Front and enemy “自在” (`enemy_1517_xi`, boss) with PixiJS 7.4.2 and pixi-spine 4.0.6 loaded **directly from jsDelivr**, with no errors. Screenshot: `…/scratchpad/gd/extra/assets/spine_test.png`.
### 5.2 Sizes
| Set | Raw | Optimized (skel+atlas gzip ≈ 24%, PNG → lossless WebP ≈ 50%) |
|---|---|---|
| Operators Front (138) | skel 44.3 + png 20.6 + atlas 1.2 = 66 MB | — |
| Operators Back (135) | skel 24.6 + png 16.7 + atlas 0.8 = 42 MB | — |
| **Operators total** | **108 MB**; median op 690 KB; max `char_427_vigil` 3.1 MB | **≈ 36 MB** |
| **Enemies (198)** | skel 14.9 + png 11.1 + atlas 0.5 = **26.6 MB**; avg 134 KB | **≈ 9 MB** |
Gzip measurements: texas skel 188 KB → 46 KB; snhunt 659 KB → 122 KB; ghost2 933 KB → 184 KB. A texas 512² PNG goes from 65 KB to 31 KB as lossless WebP.
**Estimate for 133 ops + 60 enemies on one server: about 110 MB raw, about 38 MB served.** A client only downloads the models on its board or in its shop, typically 10–25 models = 5–15 MB.
### 5.3 Runtime: use pixi-spine, not official spine-ts 3.8
- CDN:
- `https://cdn.jsdelivr.net/npm/pixi.js@7.4.2/dist/pixi.min.js` (456 KB)
- `https://cdn.jsdelivr.net/npm/pixi-spine@4.0.6/dist/pixi-spine.js` (362 KB, UMD → `PIXI.spine`, auto-detects 3.7/3.8/4.0/4.1)
- Or `@pixi-spine/all-3.8@4.0.6/dist/pixi-spine-3.8.js` (148 KB) for a 3.8-only build.
- npm: `pixi.js@7`, `pixi-spine@4.0.6`. pixi-spine 4 needs PixiJS **7**, not 8.
- Load with `PIXI.Assets.load(url.skel)`. The `.atlas` and `.png` must sit next to it with the same basename. Then `new PIXI.spine.Spine(res.spineData)` and `spine.state.setAnimation(0, name, loop)`.
- **Why not official spine-ts 3.8** (`cdn.jsdelivr.net/gh/EsotericSoftware/spine-runtimes@3.8/spine-ts/build/spine-webgl.js`, reachable):
1. Its atlas parser only treats `rotate: true|90` as rotated. **Arknights atlases use `rotate: 270`** (seen in ghost2 and ulpia), so those regions render wrong.
2. The fexli atlases **omit the `size:` line**. pixi-spine falls back to the real texture size; spine-ts 3.8 divides by page width 0.
- Atlas quirks to normalize at build time:
- Insert `size: W,H` after the page name, read from the PNG.
- If PNGs are converted to WebP, rename the page line.
- **Alpha:**
- fexli operator textures are **straight alpha**: a 512² texas texture has 193 pixels with RGB > A, up to 108 over. Load them normally.
- Ark-Models enemy textures are **premultiplied** (README: "强制启用 PMA"; slime has 1 violation of ≤2). Add a `pma: true` page line to those atlases (pixi-spine honours it and sets `ALPHA_MODES.PMA`), or set `baseTexture.alphaMode = PIXI.ALPHA_MODES.PMA`. Otherwise edges come out dark.
- Licensing note: pixi-spine is MIT, but it embeds Spine Runtime code under the Spine Runtimes License, which technically expects a Spine Editor licence. This is commonly tolerated for non-commercial fan tools, and should be noted in the credits.
### 5.4 Animation names (parsed; the naming varies, so build a resolver)
| Unit | Animations |
|---|---|
| char_103_angel (F) | Attack, Default, Die, Idle, Start |
| char_102_texas (F) | Attack_Start/Loop/End, Default, Die, Idle, Skill, Start. **Back model has no Die, no Skill.** |
| char_172_svrash (F) | Attack, Combat, Combat_Down, Default, Die, Idle, Skill, Start |
| char_350_surtr (F) | Attack, Die, Idle, Skill_2, Skill_2_Down, Skill_3_Begin/Idle/Loop, Start |
| char_2015_dusk (F) | Attack, Die, Idle, Skill2_Begin/Loop/End, Skill3_Attack, Start. No underscore after "Skill". |
| char_4211_snhunt (F) | Attack_Begin/Loop/End, Attack_Down_*, Reload_*, Skill_1, Skill_2_Begin/Loop/End, Skill_Down_*, Die, Idle, Start. Events: `BeastOnAttack`, `OnAttack`, `OnStart`. |
| char_1023_ghost2 (F) | Attack, Attack_Down, Die, Die_B, Idle, Idle_B, Skill_1, Skill_3_*, Start, Start_B… |
| char_4134_cetsyr (F) | no Attack; Skill_{1,2,3}_Begin/Loop/End, Idle, Die, Stun, Stun_Begin, Start |
| char_1012_skadi2 (Spine) | no Attack; Skill_{n}_Begin/Loop, Idle, Die, Stun, Start |
| char_600_cpione, char_616_pithst | Attack, Default, Die, Idle, Start |
| enemy_1007_slime | Attack, Default, Die, Idle, Move_Begin/Loop/End |
| enemy_1000_gopro_2 | … + Run_Begin/Loop/End |
| enemy_1502_crowns | Appear, Attack, Die, Disappear, Idle, Move |
| enemy_1517_xi (boss) | Attack_01, Attack_02, Die, Idle, Move, Revive_01, Skill_01, Skill_01_02, Skill_02_Begin/Loop/End. Events: OnAttack, OnPlayAudio. |
| enemy_9009_acfort | Attack, Die, Idle, Move, Skill_1_Begin/Loop/End, Skill_2 |
**Resolver.** Precompute it at build time by parsing every skel in Node with `@pixi-spine/runtime-3.8`; see `spinetest/parse.cjs` in the scratchpad. Emit `anims.json`, then resolve each role in order:
- `idle`: `Idle` → `Default`.
- `deploy`: `Start` (fall back to `Idle`).
- `attack`:
- `Attack`;
- else `Attack_Start|Attack_Begin` → `Attack_Loop` → `Attack_End`;
- else `Combat`;
- else (pure supporter) `Skill_1_Loop`, or just `Idle` with a flash.
- Prefer non-`_Down` variants. Use `_Down` when the target is below the unit on screen.
- `skill`, for the chess's default skill index n (1-based):
- `Skill_{n}_Begin` → `Skill_{n}_Loop` (while active) → `Skill_{n}_End`;
- else `Skill{n}_Begin/Loop/End`;
- else `Skill_{n}`;
- else `Skill`;
- else fall back to `attack`.
- During a skill, `Skill_{n}_Idle` replaces `idle` when present.
- `die`: `Die` (if the Back model lacks it, swap to the Front model for the death animation), else fade out over 0.5 s.
- `move` (enemies): `Move_Begin` → `Move_Loop` → `Move_End`; else `Move`; else `Run_Loop`.
- `stun`: `Stun_Begin` → `Stun`, else freeze the current track at `timeScale = 0`.
- **Hit timing:** apply damage and spawn projectiles on the Spine event `OnAttack` (operators and enemies). If no event fires, use 50% of the attack animation. Scale the attack animation's `timeScale` to `animDuration / attackInterval` so it matches the simulated attack speed.
### 5.5 Direction and scale
- **Operators:**
- Front model when facing right or down.
- Front mirrored (`scale.x = -1`) when facing left.
- Back model when facing up; if there is no Back model, use Front.
- **Enemies:** single model; flip x by the sign of horizontal velocity. The model faces right by default. [ASSUMED from standard Arknights behaviour; verify by eye]
- **Scale [ASSUMED]:** skeleton height is ~360–450 units for a standard chibi. Start with `scale = tilePx / 300` so a chibi stands about 1.2 tiles tall, and tune by eye. Anchor at the feet (Spine origin 0,0 is at the feet).
- Put `battle/[pack]common/sprites/sprite_shadow.png` under each unit.
### 5.6 Gaps and fallbacks
- **Tokens/summons:** fexli has the **default** battle Spine for only 1/20 tokens (`token_10000_silent_healrb`); the rest exist only as skin variants (e.g. `…vodfox_doll_witch_2`). AA2 has their atlas and skel but no PNGs.
- Options, in order of preference:
1. Use the first skin-variant Spine, listed in `tokens[*].battleSpineSkinVariantsOnly` in the JSON. It looks slightly off-model, but it animates.
2. Draw the token avatar (15/20) as a circular sprite with a bob tween.
3. Draw a procedural glyph.
- **"炎佑" (Yan-bond summon), [ASSUMED]:** no token id is in the data. Render it with the `token_10015_dusk_drgn` skin-variant Spine tinted orange-red, or with the Yan bond glyph plus fire particles.
- **enemy_1305_mhslim:** reuse `enemy_1007_slime` Spine with a hue shift, or fall back to the icon sprite.
- **General policy:** if any Spine load fails, render the square avatar or enemy icon in a rarity-coloured diamond with a tween (idle bob, lunge on attack, flash on hit). The game must never block on Spine.
---
## 6. Audio (k): the real audio is available in AA2 `@voice`
Base: `https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/dyn/audio/sound_beta_2/` (CORS `*`; jsDelivr 404s on this branch). The mapping comes from `excel/audio_data.json`, downloaded to `…/extra/assets/audio_data.json`. For an `asset` like `Audio/Sound_Beta_2/CustomSE/act1autochess/act1autochess_b_ui_roundstart`, strip the prefix, lower-case it and append `.mp3`.
### 6.1 BGM (verified)
| Level bgmEvent / context | Files | Size |
|---|---|---|
| act2autochess `m01–m04` use `bgmEvent=act1autochess_shop` → bank `battle.ON_GAME_READY.act1autochess_shop` | `music/act1autochess/m_bat_act1autochess_loop.mp3` | 2.0 MB |
| Lobby: `sys.ON_ACTIVITY_LOADED.act1autochess` | `music/act1autochess/m_sys_act1autochess_intro.mp3`, then loop `m_sys_act1autochess_loop.mp3` | 1.1 + 1.1 MB |
| `h07_05` levels use `bgmEvent=rglk1phantomcastle` | `music/rogue_1/m_bat_rglk1phantomcastle_intro.mp3` + `_loop.mp3` | 1.0 + 1.5 MB |
Play the intro, then crossfade to a looping `AudioBufferSourceNode` (`crossfade: 1.0` s in the bank).
### 6.2 Autochess UI and battle SFX (47 banks, all resolved, in `07-assets.json → audio.autochessSfxBanks`)
Key bank names, which make good event names for our code:
- **Shop:** `ui.ON_ACT1AUTOCHESS_SHOP_UPGRADE`, `…SHOP_LOCK`, `…GETMONEY`.
- **Equipment and bonds:** `battle.ON_ACT1AUTOCHESS_EQUIP_DONE`, `…CHAR_BONUS` (3-merge upgrade), `…EQUIP_BONUS`, `…ADD_BOND` (buffup).
- **Round flow:** `ui.…ROUNDSTART`, `…BATTLESTART`, `…BATTLESTART_BOSS`, `…REST`, `…YOURTURN`, `…YOURTURN_CIRCLE`, `…COUNTDOWN`.
- **Boss rounds:** `…BOSSROUND_{TEAM,SINGLE,SECRET}`, `…KILLBOSS{,_ALL,_NORMAL}`, `…DEFENCE_{START,UNITE}`.
- **End of battle and settlement:** `…BATTLEOVER_{REDUCE,NOREDUCE,NORMAL}`, `…SETTLEMENT_{SUCCEED,FAIL,TEAM,BOSSSIGN}`.
- **Matchmaking and lobby:** `…MATCH_{SUCCEED,FAIL,CANCEL}`, `…PLAYER_{JOINROOM,READY}`, `…STRATEGY`, `…LOAD`, `…START`, `…GOFIRST`, `…DISCONNECT`, `…BROADCASTHINT`, `…ENTER_DANGER`.
- **Other:** `battle.ON_CUSTOM_TRIGGER.autochess_kill_gain_coin`, `battle.ON_ACT1AUTOCHESS_MAGIC_PLACE_BATTLE`.
- Files are under `customse/act1autochess/*.mp3` (46 files, 5–75 KB). Generic sounds: `battle/b_char/b_char_set.mp3` (deploy), `battle/b_enemy/b_enemy_dead_{n,h}.mp3`, `battle/b_ui/b_ui_{win,lose}.mp3`, `general/g_ui/g_ui_btn_{h,u}.mp3`, `g_ui_confirm_h.mp3`.
### 6.3 Per-unit combat SFX (exact, from the data)
Banks are keyed by unit id:
- `battle.ON_ABILITY_START.{charId}.attack` (swing or fire)
- `battle.ON_ABILITY_HIT.{charId}.attack` (impact)
- `battle.ON_UNIT_BORN.{id}`, `battle.ON_UNIT_DEAD.{id}`
- `battle.ON_SKILL_START.{skillId}` (skill activation)
Coverage: **138/138 operators** and **185/200 enemies** have at least one bank; the remaining 15 enemies use a generic `e_atk`/`e_imp` sound. `07-assets.json` lists the resolved URLs per unit (`operators[*].sfx`, `operators[*].skills[*].sfx`, `enemies[*].sfx`).
The pool's needed set is **691 mp3 = 8.7 MB**:
- `player/p_atk`, `p_imp`, `p_skill`: 452 files, 6.8 MB
- `enemy/e_*`: 223 files, 2.6 MB
- `battle/`: 0.9 MB
- `customse/`: 1.3 MB
Pitch-randomize using the bank's `minPitch`/`maxPitch` (typically 0.8–1.2). Respect `maxSoundAllowed`, which caps concurrent instances per bank at 1–2 (`popOldest`). This matters in a 20-unit fight.
### 6.4 Optional and fallback audio
- Voice (optional, off by default): `voice_cn/{charId}/cn_{NNN}.mp3`, about 30 lines and ~1 MB per operator.
- Useful lines, by the standard charword ids [ASSUMED mapping, confirm with `charword_table.json`]: `cn_021`/`cn_022` 选中干员, `cn_023`/`cn_024` 部署, `cn_025`–`cn_028` 作战中.
- Japanese voice is under `voice/`.
- **WebAudio synthesis fallback**, used if the fetch fails or the user mutes "game audio":
- UI click: 1.2 kHz square, 30 ms, exponential decay.
- Coin: two sines, 1.5→2.2 kHz, 80 ms.
- Hit: white-noise burst through a band-pass (1–3 kHz), 60 ms.
- Deploy: low 110 Hz sine thump plus a noise click.
- BGM fallback: silence or an ambient pad. Do not try to imitate the OST.
- Monster Siren (official streaming, `monster-siren.hypergryph.com/api/songs`) returns JSON but **no CORS headers**, so the browser cannot use it directly. Not recommended.
---
## 7. Map tiles and battle background (h)
- The game data has **no 2D map texture**. Autochess maps are 19×21 tile grids rendered as a 3D Unity scene.
- The AA2 root PNGs `assets/tile_floor.png`, `tile_road.png` and similar are 64×64 **editor debug placeholders** (green, wood planks). Do not use them.
- yuanyan `map/` previews do not include autochess stages.
- act2autochess level tile keys: `tile_road`, `tile_floor`, `tile_wall`, `tile_forbidden`, `tile_fence_bound`, `tile_start`, `tile_end`, `tile_telin`, `tile_telout`, **`tile_achand`** (autochess bench/hand area), `tile_mire` (m02), `tile_smog` (m03), `tile_deepsea` (m04).
- `heightType` is LOWLAND or HIGHLAND; `buildableType` is ALL, MELEE, RANGED or NONE.
- **Procedural tile renderer, in the Arknights look [ASSUMED palette, tune by eye]:**
- Oblique top-down view; each tile is an inset square with a 1 px lighter top edge and a darker bottom bevel.
- LOWLAND road `#3b4048` with subtle 45° noise. Deployable lowland gets a faint blue-grey outline `#6f8aa3` at 40% when a melee chess is dragged.
- HIGHLAND (`tile_wall`) is a raised block: top `#6b7280`, side face `#3a3f47` 10 px tall, with a white 1 px top rim.
- `tile_forbidden`: `#22262c` with a diagonal hatch. `tile_fence_bound`: dark plus a yellow-black edge stripe.
- `tile_start` (enemy spawn): red `#d0453b` pulsing frame with an arrow glyph. `tile_end` (protection point): blue `#2d7fd6` with a house glyph.
- `tile_telin`/`telout`: purple ring. `tile_mire`: murky green overlay. `tile_smog`: animated grey fog alpha. `tile_deepsea`: teal wave overlay.
- `tile_achand`: bench slots with a dashed outline.
- Attack-range preview: `arts/ui/[uc]battlecommon/ui_battle_new/attack_range_attack.png` and `attack_range_stand.png`, or draw orange `#ff9c33` squares at 35% alpha.
- **Backdrop:** a dark gradient `#15171b → #262a31`, with `bg_mountains_tiled.png` (act2 entry, 112 KB) parallaxed at 20% opacity. Lobby: `entry_bkg_01.png` (0.99 MB) and `season_logo.png`.
---
## 8. Fonts (j)
| Use | Font | Source | Size |
|---|---|---|---|
| Chinese UI and body | **Noto Sans SC** 400/500/700/900 (思源黑体) | Google Fonts, `fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&display=swap` (200); npm `@fontsource/noto-sans-sc@5` | sliced unicode-range, ~50–100 KB per used slice |
| Chinese titles (formal, serif as in-game) | Noto Serif SC 600/900 (思源宋体) | Google Fonts (200) | same |
| Numbers, HUD, costs, timers (in-game **Bender**) | **Bender** (Jovanny Lemonad, free incl. commercial) | `https://raw.githubusercontent.com/TimWangZi/The-font-of-Arknights/master/font/Bender/BENDER.OTF` (200, 52 KB); also Light and Thin. Convert to woff2 and self-host. | 52 KB |
| English headers (in-game **Novecento Wide**) | Novecento Sans Wide Normal | same repo, `font/Novecento-Wide-Normal-2.otf` (200, 45 KB); self-host | 45 KB |
| Google-only fallbacks for Bender / Novecento | **Rajdhani** 600/700; **Saira Condensed** 500/700; Barlow Condensed; Teko; Oswald; Chakra Petch | Google Fonts, all 200 | — |
Recommended CSS stacks:
- Numerals and English UI: `font-family: Bender, Rajdhani, 'Noto Sans SC', sans-serif`.
- Headlines: `'Novecento Wide', 'Saira Condensed', 'Noto Sans SC'`.
Avoid redistributing Georgia and Times New Roman (Microsoft fonts) from that repo. The 11 MB 思源宋体 OTFs are unnecessary because Google Fonts serves them subset.
---
## 9. Build and delivery plan (recommended)
1. **Keep assets out of git.** Add `client/public/assets/` to `.gitignore`.
2. Write `scripts/fetch-assets.mjs`. It reads `docs/research/07-assets.json` and downloads, with concurrency of about 16, retry ×3 and a jsDelivr mirror when raw fails:
- `assets/char/avatar/{charId}.webp`, choosing E2 if present, else base.
- `assets/char/portrait/{charId}.webp`.
- `assets/skill/{iconId}.webp`.
- `assets/enemy/icon/{enemyId}.webp`.
- `assets/ui/…`, `assets/bond/{bondId}.png`, `assets/item/{trapId}.webp`, `assets/band/{bandId}.webp`.
- `assets/spine/op/{charId}/{front|back}/{charId}.{skel,atlas,webp}` and `assets/spine/enemy/{enemyId}/…`.
- `assets/audio/{bgm|sfx}/…mp3`.
- Full art is not downloaded; fetch it lazily from jsDelivr at runtime.
3. **Post-process:**
- Icons and portraits → WebP q88. Spine textures → **lossless WebP**, then rewrite the atlas page line.
- Inject `size:` into fexli atlases and `pma: true` into Ark-Models atlases.
- Normalize every filename to lowercase ASCII.
- Precompute `anims.json` (animation list + resolver result per model) and `sfx.json` (per unit: attack start and hit URLs, skill start, die).
- Serve `.skel` and `.atlas` with gzip or brotli via Node `compression`.
4. **Runtime loading:**
- Preload UI, icons, avatars and portraits: about 6 MB.
- Load Spine on demand when a chess enters the shop, bench or board, or when an enemy wave is announced. Cache with `PIXI.Assets` and an LRU of about 60 models.
- Show the avatar-sprite fallback until the Spine loads.
5. **Budgets after optimization:**
| Asset group | Size |
|---|---|
| Avatars | ~2 MB |
| Portraits | ~4 MB |
| Default-skill icons | ~0.8 MB |
| Enemy icons | ~2 MB |
| Bonds, items, bands, UI subset | ~4 MB |
| Loading illustrations | ~2 MB |
| Op Spine | ~36 MB |
| Enemy Spine | ~9 MB |
| Audio (BGM + SFX) | ~15 MB |
| **Total on the server** | **≈ 75 MB** |
| Per client (typical session) | ≈ 20–30 MB |
## 10. Licensing note
- All images, Spine models and audio are **© Hypergryph (上海鹰角网络); overseas publisher Yostar**.
- Every mirror repo states this: fexli "All files … copyrighted by Hypergryph … solely for educational and research purposes", and Ark-Models "不得用于商业用途".
- No official permissive fan-game policy was found. Hypergryph and Yostar do enforce copyright.
- Therefore:
- Non-commercial only; no ads, donations or paywall.
- Show a credits screen: "Arknights © Hypergryph / Yostar. This is an unofficial fan project; all game assets belong to their owners."
- Keep it private or small-group, and remove on request.
- Do not redistribute bulk asset archives; fetch at deploy time.
- Also credit the asset dumpers and tooling: yuanyan3060, fexli/ArkResourceAutoUpdateBot, isHarryh/ArkUnpacker, ArknightsAssets/ArknightsStudio, Kengxxiao/ArknightsGameData, pixi-spine (MIT) and the Spine Runtimes License.
- Fonts: Noto (SIL OFL); Bender (free by author); Novecento Sans (free licence by Synthview).
## 11. Open questions and [ASSUMED] items
1. **Token spines:** only 1 of 20 defaults is available (§5.6). Approve the skin-variant or avatar fallback.
2. **炎佑 summon visual** [ASSUMED]: the data has no dedicated model.
3. **Tile palette and 3D look** [ASSUMED]: procedural. Tune against in-game screenshots (PRTS map pages or bwiki screenshots of 卫戍协议).
4. **Spine scale** `tilePx/300` and enemy default facing [ASSUMED]: verify visually during build.
5. **PMA** for Ark-Models enemy textures: add `pma: true`, then check visually for dark halos. The test render looked acceptable even without it.
6. Voice line index mapping (`cn_021`–`cn_028`) [ASSUMED]: confirm against `charword_table.json` if voices get enabled.
7. The Unity 9-slice metadata for UI panels is not in the dump, so panels are drawn in CSS. Only icons and backdrops use the real sprites.
---
## Addendum (critic)
Written 2026-09-27 by the completeness critic.
- **Spot check.** 40 URLs were sampled at random from the 4,469 unique URLs in `07-assets.json`: avatars, enemy skel, audio on `@voice`, and others. Each got a `curl -I` on 2026-09-27. **Result: 40/40 returned `200`.** The URL patterns in §0 hold.
- **UI reference captured** for the designer. It is not to be shipped: it is official art and layout.
- The official gameplay-intro long image, 800×13685, sliced into `scratchpad/gd/extra/critic/intro_00..09.jpg`.
- 11 in-game screenshots (`*.jpg` in the same folder): prep with shop at R1/R2/R3, briefing, strategy draft, the three 机变 families, 联防, Final Assault, and the banned-operator dropdown.
- They confirm these layout facts:
- bottom shop bar: LEVEL card | operator cards | item card | funds card;
- freeze and refresh buttons above the bar;
- "剩余可放置角色" label;
- 3×2 机变 grid;
- bond strip with layer numbers under the discs;
- `‹ 自己 ›` / `‹ 全景 ›` view switcher.
- **Icons that are also needed:**
- Disabled-bond badge: the person-✕ glyph on greyed bond discs.
- "层数叠加已禁用" state of the bond strip.
- Price-0 reward card.
- Candidates are in the AA2 `ui/autochess/…` groups already listed in §3 i). If none match, draw them in CSS or SVG.
+621
View File
@@ -0,0 +1,621 @@
# 08 · Official waves, enemy counts, routes and preview (卫戍协议:盟约 下半)
Written 2026-09-28 in response to the user's test report. The user asked for enemy kinds, counts, routes and numbers to follow the official game (items 1, 2, 3, 4 and 9).
**This file supersedes** 05-enemies-levels.md §3.2 steps 1–7 (the generation algorithm and the `k` copies) and 00-INDEX §8 #12. It also supersedes every `[ASSUMED]` pathing statement in 05 §1 and §2.
## Tags
- **[DATA]**: official game data or official client code.
- Game data: Kengxxiao zh_CN `activity_table`, `levels/*`, `enemy_database`.
- Client code means the user's local Windows client. The method names come from `global-metadata.dat` (IL2CPP v29, not encrypted). The method bodies come from disassembling `GameAssembly.dll`. The IL2CPP method RVAs are listed in Appendix B.
- **[WIKI]**: PRTS or BWIKI text.
- **[VIDEO]**: in-game screenshots. These are the PRTS `战场一览` map images, taken at round-1 prep.
- **[ASSUMED]**: not proven.
---
## 0. TL;DR
1. **Our enemy counts are 2–4× too high, and our faction mixing is wrong.**
- Official rule [DATA, client `RandomEnemyGenerater`]: each round uses exactly **one** special-enemy entry.
- Every placeholder spawn **action** (not every enemy) is replaced by `clamp(roundHalfEven(n·BE_template / BE_new), 1, 5)` enemies in total.
- The placeholders of the other movement class (fly placeholders in a ground round, walk placeholders in a FLY round) are **not spawned at all**.
- Official multi 险境 R3 has **6–10 enemies** (average 7.7). Ours averages 28 (max 68). R13 is ≈ 37 officially vs ours ≈ 81 (max 115).
2. **Factions per match.** 3 random types. Each is given **exactly 3 of the 15 round slots**; SPECIAL gets the other 6. The slot order is shuffled, so there is one type per round [DATA].
3. **Lanes.** Official pathing is a **4-directional** SPFA flow field (unit cost, crate tiles 1000), followed by line-of-sight smoothing [DATA]. We use 8-directional A* with √2 diagonals.
- That is why upper-gate enemies drop into the lower lane on 战场#02 (act1 m02) and 战场#04 (act1 m04).
- Our data also turns on four act1 m02 crates that are hidden in the 下半 level and absent in the 下半 screenshot. Those crates block the top road.
4. **Preview.** Each spawn action is previewed at its route's start gate + 6 rows [DATA]:
- Upper gate (12,10) → zone rows 17–18.
- Lower gate (9,10) → zone rows 14–15.
- Up to 50 models are shown. Elites and bosses are always shown.
5. **Custom balance to remove:**
- All of `data/tuning.json` `enemyHpMul` / `enemyAtkMul` / `bossHpMul`.
- The `k`-copies logic and the kept fly placeholders.
- The extra ×0.7 on solo bounties.
- The custom 联防 slot grouping.
- The details are in §6 and §7.
---
## 1. Sources and method
- **Game data [DATA]:**
- `autoChessData.constData`: `min/maxReplacedEnemyCount` 1/5, `templateEnemy*`, `maxLevelCnt` 15, `specialEnemyNum` 3, `enemyTypeIdentifierToFillRandom` 1, and the BE factors 1/5/3/3.
- `act2autochess.specialEnemyInfoDict` (67 entries) and `specialEnemyRandomTypeDict` (count 3 / weight 3 per random type; SPECIAL −1).
- `modeDataDict[*].inactiveEnemyKey`, the round templates `level_act1autochess_01…h06` and `h07_*`/`h08_*`/`escaped_*`, the stage files `level_act{1,2}autochess_m0X`, and `enemy_database`.
- **Client code [DATA].** Class and field names come from `global-metadata.dat`. The x64 bodies were disassembled with capstone and annotated with IL2CPP method names.
- `RandomEnemyGenerater` (sic): `_GenerateSpecialEnemyTypesLocal`, `_GenerateRandomEnemyData`, `_DoReplaceLevelDataClient`, `_DoReplaceActionDataClient`, `_CalculateActionBattleEffectiveness`, `_CalculateEnemyCountByBattleEffectiveness`, `_GetEnemyAttrPower`, `_InitData`, `ShouldActionUpToServer`, `_GenerateRoundEnemyData`.
- `Torappu.Battle.SPFA`: `_GenerateNextMapImpl`, `_PostprocessAndMakeNextMapSmoothly`, `_RaycastSegmentLine`, `_RaycastBresenhamLine`.
- Also `Torappu.Battle.Tile.get_moveCost`, the `GridPosition` static constructor, `AutoChessEnemyPreviewManager`, and `AutoChessLevelEnemyManager`.
- **Wiki [WIKI]:** PRTS `卫戍协议:盟约 下半` (§开始模拟, the enemy multiplier table), `卫戍协议/帮助`, `卫戍协议:盟约` (上半 update log), `…下半/战场一览` and `…下半/PRTS盟约记录`, and BWIKI `盟约`.
- **Screenshots [VIDEO]:** PRTS `卫戍协议盟约 战场01…08 (ver2) 地图.png`. These are round-1 prep screenshots, and they show the round-1 enemies in the preview pen (§2.6).
- **No community per-round enemy tables exist.** PRTS and BWIKI list only the faction pools (`战术特训敌人`).
- **Scratch scripts** (not project files) are in `…/scratchpad/research2/waves/`:
- `comp.py` / `official_counts.py`: the count algorithm.
- `simofficial.py`: the distribution.
- `akpath.py` / `akpath2.py`: the official pathing.
- `pathtest.mjs` / `ourcounts.mjs`: our current behaviour.
- `akdis.py`: the disassembler.
---
## 2. Official per-round enemy composition
### 2.1 Match setup: the type schedule [DATA, `_GenerateSpecialEnemyTypesLocal`]
```
types = [t for t in autoChessData.enemyTypeDatas if t.involveRandom] // FLY TIMES ELEMENT DOT INVISIBLE REFLECTION
shuffle(types, battleRng); types = first specialEnemyNum (3) of them // uniform, distinct
slots = []
for t in types: repeat specialEnemyRandomTypeDict[t].count (3) times: slots.push(t.typeIdentifier)
while slots.length < maxLevelCnt (15): slots.push(enemyTypeIdentifierToFillRandom = 1 = SPECIAL)
shuffle(slots, battleRng) // slots[r-1] = type of round r
```
- Every match therefore has **9 rounds** of its 3 chosen types (3 each) and **6 rounds of SPECIAL**. The positions are random across R1–R15.
- R14 (leader) and R15 (hidden core) consume slots 14 and 15.
- The solo 标准 leader round R9 consumes slot 9.
- The briefing shows "三种特训敌人的类型" [WIKI 帮助: "您将事先得知本次战斗即将迎战的领袖单位,三种特训敌人的类型"]. SPECIAL is the filler and is not one of the three.
- In co-op the server sends the per-round types (`AutoChessBattleRoundEnemyInfo{enemyType, enemyKey, actionIndex, count, round}`). The local generator is used when none are sent. The whole team shares one composition per round, because the round data has no player index [DATA].
### 2.2 Per-round pick [DATA, `_GenerateRandomEnemyData`]
```
for round r in 1..15:
typeId = slots[r-1]; if typeId == 0: skip
half = r <= maxLevelCnt/2 (=7) // R1–R7 first half, R8–R15 second half
cands = [e in specialEnemyInfoDict if typeOf(e.type).typeIdentifier == typeId
and e.isInFirstHalf == half
and e.specialEnemyKey ∉ mode.inactiveEnemyKey]
e = weightedPick(cands, e.randomWeight) // weights 8/10/15 as in data
special[r] = e.specialEnemyKey
normal[r] = e.attachedNormalEnemyKeys[randInt(len)] // always 1 element in 下半 data
elite[r] = e.attachedEliteEnemyKeys[randInt(len)] // always 1 element
```
- `inactiveEnemyKey` filters **only the special key**. Attached normal and elite keys are not filtered. For example, 疯狂的逐腐兽 can still appear as the elite of 假想敌:蚀裂 in 险境. This matches the 上半 note [WIKI]: "以上调整仅针对战术特训敌人,不影响悬赏决策出场".
- In every entry the special, normal and elite share the same fly flag [DATA check]: FLY entries are all flyers, every other entry is all walkers.
### 2.3 Template action → actual spawns [DATA, `_DoReplaceLevelDataClient` / `_DoReplaceActionDataClient`]
The placeholder map is built from constData in `_InitData`:
| template key | class |
|---|---|
| `enemy_1422_lrsldr` | NormalWalk |
| `enemy_1005_yokai` | NormalFly |
| `enemy_1427_lrnazg` | EliteWalk |
| `enemy_1042_frostd` | EliteFly |
| `enemy_1425_lrcmra` | SpecialWalk |
| `enemy_1040_bombd` | SpecialFly |
Token placeholders (`1000_gopro_2`, `1041_lazerd`) are **not** in the map. They are only used by the 联防 templates (§5).
For every SPAWN action of `waves[0].fragments[0].actions` (processed last to first):
```
if action.key not in placeholderMap: // literal (e.g. R1 源石虫 ×2, boss escorts): keep as is
keep
else:
cls, isFlySlot = placeholderMap[action.key]
newKey = {Normal: normal[r], Elite: elite[r], Special: special[r]}[cls]
if isFly(newKey) != isFlySlot: // walk slot but FLY round, or fly slot but ground round
action.isValid = false // NOT spawned, NOT sent to server, NOT previewed
else:
originBE = action.count * P(action.key) / f(action.key) // float32
newBE = P(newKey) / f(newKey)
n' = clamp(RoundHalfToEven(originBE / newBE), minReplacedEnemyCount 1, maxReplacedEnemyCount 5)
action.interval = action.count * action.interval / n' // same time window
action.key = newKey; action.count = n'
P(k) = maxHp*1 + atk*5 + def*3 + magicResistance*3 // enemy_database, level = randomEnemyAttributeDict[k].level (all 0)
f(k) = randomEnemyAttributeDict[k].enemyBattleEffectivenessFactor
```
Notes on the count rule:
- The **cap of 5 applies per action.** One template action (for example R3 "N ×10") becomes at most 5 enemies. It never becomes 10·k.
- **Rounding** is C# `Math.Round(double)`, which is banker's rounding. The only exact .5 case in the data is 2 × 灵幛 → 隐形弩手组长: ratio 4.5 gives **4**.
- P uses `enemy_database` values. The `level_autochess_enemy_data` overrides (灼藤 and 元核孽生者 ATK 400) are **not** used for the count. They still apply to the spawned stats.
- Spawn timing [DATA, `AutoChessLevelEnemyManager._CalculateActionPredelay`]: the i-th unit of an action spawns at `preDelay + i·step`.
- `step = max(window/n', 0.05·window)`, where `window = originalCount·originalInterval`. `MIN_ACTION_INTERVAL_RATIO` = 0.05.
- For n' ≤ 20 this equals the rescaled interval.
- `ShouldActionUpToServer` [DATA] excludes:
- invalid actions,
- boss enemies (`AutoChessBattleUtil.IsBossEnemy`),
- non-SPAWN actions,
- actions with a `randomSpawnGroupKey`.
**Kinds per round:** at most 3 faction kinds (normal, elite, special), plus the literal 源石虫 in R1. They are all flyers (FLY round) or all walkers (every other type).
### 2.4 Leader and hidden rounds [DATA]
- The `h07_*` / `h08_*` templates contain only **E and EF** placeholders (escorts), for example h07_01: `lrnazg` 1+1 walk and `frostd` 1+2 fly. They are replaced with slot 14 / 15's elite by the same rule.
- Ground round: E becomes the elite and **EF is dropped**.
- FLY round: the reverse.
- Literal escorts (重装防御组长 ×14 and the like) and the boss itself are kept.
- The leader's HP is not scaled [WIKI: "领袖单位于服务器的生命值加成不受上述加成影响"].
### 2.5 Enemy stat scaling
- The per-mode, per-round `atk × 1.1^kA`, `hp × 1.2^kH` table with base 0.7 / 0.8 / 1 is the **only** source [WIKI, PRTS 下半 §开始模拟, flagged "敌方属性加成情况由用户提供"].
- `data/config.json → modes[*].enemyScale` reproduces it. I spot-checked 险境 multi R1/R2/R4/R8/R9; docs/BALANCE.md §2 reports every mode and round as matching.
- The same table is in 01-core-data.json `_criticAddendum.enemyStatMultipliers`.
- Base values [WIKI, mode descriptions]:
- Solo: "所有敌人的生命值、攻击力降低至70%". 绝境 80 %; 终极 not reduced.
- Co-op: 80 % in 标准 and 险境; 绝境 and 终极 not reduced.
- **DEF:** the 上半 11/18 note said "敌人生命值、攻击力和防御力降至70%/80%". The 下半 text names only HP and ATK. We apply HP/ATK only, which matches 下半. Adding DEF would be optional [ASSUMED].
- 终极: every enemy moves at ×1.15 from R3 [WIKI].
- The scaling applies to wave, faction and bounty enemies. It does **not** apply to the leader's server HP pool [WIKI].
- The BE count rule (§2.3) uses **unscaled** DB stats [DATA]. Scaling happens at spawn.
### 2.6 Evidence from official screenshots [VIDEO]
The PRTS map images are R1 prep screenshots, 1 280 × 720, with the preview pen at the top right (rows 14–15). They match §2.3 exactly and **contradict our k-copies rule**.
| image | visible R1 enemies | official prediction (entry → R1) | ours would give |
|---|---|---|---|
| 战场02 ver2 (act1 m02) | 源石虫 ×2, 猎狗pro ×4, **no drones** | SPECIAL 高级军用猎狗 → 源石虫 ×2 + 猎狗pro ×4 (N 2 × 6620/3060 = 4.33 → 4) | 源石虫 ×2 + 猎狗pro ×4 + 妖怪 ×2 |
| 战场07 ver2 (act2 m03) | 源石虫 ×2, 猎狗pro ×4 | same | same as above |
| 战场04 (act1 m04) | 源石虫 ×2, 灼热源石虫 ×5 | ELEMENT 炽焰源石虫 → 源石虫 ×2 + 灼热源石虫 ×5 (2 × 6620/1450 = 9.1 → cap 5) | 源石虫 ×2 + 灼热源石虫 ×10 + 妖怪 ×2 |
| 战场02 (上半) / 06 ver2 | 源石虫 ×2 + 3 humanoid walkers (上半: white Sarkaz swordsmen) | e.g. SPECIAL 萨卡兹大剑组长 → 萨卡兹刀兵 ×3 (2 × 6620/4660 = 2.84 → 3); 粉碎攻坚手 / 山海众头目 → 宿主士兵 ×3 also fit | +妖怪 ×2 |
| 战场01 | 源石虫 ×2 + drone(s), **no ground N** | a FLY round (walk N dropped, NF replaced) | ground N ×2 + flyers |
| 战场03 / 05 ver2 / 08 | 源石虫 ×2 + 2 walkers, no drones | e.g. TIMES/DOT/INVISIBLE picks with N ×2 | +妖怪 ×2 |
Every screenshot shows only one movement class besides the 源石虫. This confirms that the placeholders of the other class are dropped.
### 2.7 Resulting counts
**Distribution: official vs ours**, 同盟 险境 (`mode_multi_normal`), enemies per board per round.
- Official: 4 000 simulated matches of §2.1–2.3.
- Ours: `server/match/waves.js` over 200 seeds.
| R | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| official avg | 4.7 | 7.7 | 7.7 | 10.3 | 20.0 | 23.2 | 26.2 | 18.2 | 20.3 | 22.2 | 23.6 | 35.0 | 37.5 |
| official min–max | 3–7 | 4–10 | 6–10 | 7–20 | 13–30 | 14–30 | 17–35 | 8–29 | 8–33 | 8–35 | 10–35 | 14–53 | 12–57 |
| ours avg | 7.1 | 17.6 | 28.3 | 24.5 | 42.8 | 50.5 | 58.5 | 39.8 | 42.1 | 47.1 | 51.4 | 80.1 | 81.0 |
| ours max | 14 | 39 | **68** | 48 | 93 | 113 | 134 | 54 | 60 | 65 | 69 | 107 | 115 |
- 绝境 and 终极 differ only by the unbanned keys: R12 ≈ 36.8, R13 ≈ 38.7 on average.
- A FLY round happens in ≈ 10 % of rounds (3 of 15 slots in the half of the matches that draw FLY).
**Totals per possible round pick** [DATA, computed with §2.3 on the real templates] follow. Per-action breakdowns for all 67 entries are regenerable with `comp.py`, and §7 step 1 describes the test fixtures.
**First half (R1–R7).** Total enemies spawned per board when this entry is the round pick. ⛔ = special key banned in 标准/险境.
| type | special (key) | w | attached N / E | R1 | R2 | R3 | R4 | R5 | R6 | R7 |
|---|---|---|---|---|---|---|---|---|---|---|
| SPECIAL | 萨卡兹大剑组长 (`1010_demon_2`) | 10 | 萨卡兹刀兵 / 萨卡兹大剑手 | 5 | 8 | 7 | 9 | 19 | 23 | 27 |
| SPECIAL | 粉碎攻坚手 (`1045_hammer`) | 10 | 宿主士兵 / 步兵 | 5 | 10 | 10 | 12 | 24 | 27 | 28 |
| SPECIAL | 拳师囚犯 (`1118_lidbox_2`) | 10 | 普通囚犯 / 老练囚犯 | 4 | 6 | 7 | 8 | 17 | 21 | 25 |
| SPECIAL | 高级军用猎狗 (`1325_cbgpro_2`) | 10 | 猎狗pro / 军用猎狗 | 6 | 10 | 8 | 14 | 27 | 30 | 33 |
| SPECIAL | 重犯 (`1121_lifbos`) | 10 | 普通囚犯 / 老练囚犯 | 4 | 6 | 7 | 7 | 16 | 19 | 22 |
| SPECIAL | 弧光锋卫 (`1328_cbjedi`) | 10 | Dor-1号失败品 / 莱茵生命防卫科高级成员 | 5 | 8 | 7 | 9 | 21 | 24 | 26 |
| FLY | 法术大师A1 (`1041_lazerd`) | 10 | “萨科塔之翼” / 妖怪MKII | 3 | 7 | 9 | 14 | 18 | 22 | 23 |
| FLY | 护障 (`1355_mrfly`) | 10 | 妖怪 / 妖怪MKII | 4 | 9 | 9 | 14 | 19 | 23 | 23 |
| FLY | 御4 (`1017_defdrn`) | 10 | 妖怪 / 妖怪MKII | 4 | 9 | 9 | 16 | 21 | 26 | 25 |
| FLY | “萨科塔之眼” (`10084_hlegle`) | 10 | “萨科塔之翼” / 妖怪MKII | 3 | 7 | 9 | 11 | 15 | 19 | 21 |
| FLY | 远眺 (`1407_hummbd`) | 10 | 妖怪 / 妖怪MKII | 4 | 9 | 9 | 15 | 20 | 24 | 23 |
| FLY | 护障·P ⛔ (`1355_mrfly_2`) | 8 | 妖怪 / 御4 | 4 | 9 | 9 | 14 | 19 | 22 | 23 |
| TIMES | 明鉴 (`1195_sfyin_2`) | 10 | 磨砻 / 俗心 | 4 | 6 | 6 | 7 | 16 | 20 | 24 |
| TIMES | 深池逐火精锐战士 (`1288_duskls_2`) | 10 | 深池逐火战士 / 深池逐火战士 | 4 | 4 | 7 | 9 | 16 | 19 | 23 |
| TIMES | 俗心 (`1197_sfshu`) | 10 | 磨砻 / 俗心 | 4 | 6 | 6 | 7 | 16 | 19 | 23 |
| TIMES | 雅气 ⛔ (`1197_sfshu_2`) | 8 | 磨砻 / 雅气 | 4 | 6 | 6 | 6 | 14 | 17 | 21 |
| ELEMENT | 控潮术师 (`1161_tidmag`) | 10 | 潜水员 / 码头水手 | 5 | 8 | 6 | 8 | 18 | 21 | 25 |
| ELEMENT | 炽焰源石虫 (`1305_mhslim_2`) | 10 | 灼热源石虫 / 炽焰源石虫 | 7 | 10 | 10 | 20 | 30 | 30 | 35 |
| DOT | 深溟巢涌者 (`1234_dsubrl`) | 10 | 单核掠食者 / 异光体掠食者 | 4 | 4 | 7 | 7 | 13 | 14 | 17 |
| DOT | 萨卡兹枯朽战车 (`1272_nhtank`) | 10 | 萨卡兹枯朽战士 / 萨卡兹枯朽战士组长 | 4 | 6 | 8 | 8 | 17 | 19 | 21 |
| DOT | 逐腐兽 (`1270_nhstlk`) | 10 | 萨卡兹枯朽战士 / 萨卡兹枯朽战士组长 | 4 | 6 | 8 | 9 | 18 | 21 | 24 |
| INVISIBLE | 山海众头目 (`1299_ymkilr`) | 10 | 宿主士兵 / 隐形弩手 | 5 | 10 | 8 | 10 | 24 | 27 | 28 |
| INVISIBLE | 节日爵士乐手 (`10034_cnvsax`) | 10 | 宿主士兵 / 隐形弩手 | 5 | 10 | 8 | 11 | 25 | 28 | 30 |
| INVISIBLE | 扶桥老手 (`10042_prtrop_2`) | 10 | 寻险水手 / 架桥船工 | 4 | 6 | 7 | 8 | 17 | 21 | 25 |
| REFLECTION | 深池伙友卫队 (`1174_duholy`) | 10 | 深池侦察兵 / 深池重甲卫士 | 5 | 8 | 6 | 8 | 19 | 22 | 26 |
| REFLECTION | 深池暗影术师 (`1168_dumage`) | 10 | 深池侦察犬 / 深池方阵步兵 | 5 | 8 | 8 | 13 | 24 | 27 | 32 |
| REFLECTION | 深池重甲卫士 (`1170_dushld`) | 10 | 深池侦察兵 / 深池方阵步兵 | 5 | 8 | 8 | 11 | 22 | 26 | 29 |
**Second half (R8–R13).** Same columns.
| type | special (key) | w | attached N / E | R8 | R9 | R10 | R11 | R12 | R13 |
|---|---|---|---|---|---|---|---|---|---|
| SPECIAL | 重装五十夫长 (`1006_shield_3`) | 10 | 宿主士兵 / 重装侦察兵 | 24 | 30 | 30 | 28 | 39 | 41 |
| SPECIAL | 弧光镜卫 (`1329_cbshld`) | 10 | Dor-1号失败品 / 莱茵生命防卫科高级成员 | 26 | 29 | 31 | 33 | 51 | 50 |
| SPECIAL | 粉碎攻坚组长 (`1045_hammer_2`) | 10 | 宿主重装士兵 / 宿主重装士兵 | 9 | 10 | 13 | 15 | 20 | 26 |
| SPECIAL | 访问团持盾者 (`1387_winshd`) | 10 | 访问团新兵 / 访问团老兵 | 15 | 16 | 18 | 19 | 32 | 38 |
| SPECIAL | 传奇重犯 (`1121_lifbos_2`) | 10 | 老练囚犯 / 强壮囚犯 | 14 | 16 | 18 | 18 | 30 | 35 |
| SPECIAL | 萨卡兹悖谬暴虐兵长 (`1320_wdrrl_2`) | 10 | 萨卡兹枯朽辟路前锋 / 萨卡兹大剑手 | 18 | 22 | 23 | 24 | 33 | 37 |
| SPECIAL | 集团军中坚盾卫 (`10124_uashld_2`) | 10 | 宿主士兵 / 宿主重装士兵 | 23 | 30 | 30 | 29 | 38 | 43 |
| SPECIAL | 测试用动力装甲 ⛔ (`1254_lypa_2`) | 10 | Dor-1号失败品 / 莱茵生命防卫科高级成员 | 29 | 31 | 34 | 36 | 57 | 55 |
| SPECIAL | 渎罪奇美拉 ⛔ (`1425_lrcmra_2`) | 10 | 萨卡兹枯朽辟路前锋 / 萨卡兹大剑手 | 19 | 22 | 24 | 26 | 37 | 41 |
| SPECIAL | 弧光镜卫长 ⛔ (`1329_cbshld_2`) | 10 | Dor-α号失败品 / Dor-β号复制体 | 23 | 27 | 30 | 28 | 42 | 48 |
| SPECIAL | 萨卡兹穿刺手 ⛔ (`1072_dlancer`) | 10 | 萨卡兹刀兵 / 萨卡兹大剑组长 | 26 | 29 | 32 | 36 | 50 | 49 |
| FLY | 威龙 (`1005_yokai_3`) | 10 | 妖怪 / 妖怪MKII | 19 | 19 | 20 | 25 | 39 | 31 |
| FLY | 帝国炮火先兆者 ⛔ (`1112_emppnt`) | 10 | 妖怪 / 妖怪MKII | 19 | 19 | 20 | 25 | 39 | 31 |
| FLY | 法术大师A2 (`1041_lazerd_2`) | 10 | 妖怪MKII / 寒霜 | 12 | 12 | 12 | 15 | 24 | 22 |
| FLY | 假想敌:黑云 (`9009_acfort`) | 10 | 妖怪MKII / 威龙 | 8 | 8 | 8 | 10 | 14 | 12 |
| TIMES | 深池逐火护卫 (`1292_duskld`) | 10 | 深池逐火战士 / 深池逐火精锐战士 | 19 | 18 | 23 | 28 | 43 | 45 |
| TIMES | 沉沙 ⛔ (`1207_sfji`) | 10 | 磨砻 / 俗心 | 17 | 21 | 21 | 21 | 32 | 33 |
| TIMES | 身观 (`1199_sfjin`) | 10 | 磨砻 / 俗心 | 17 | 21 | 21 | 22 | 32 | 35 |
| TIMES | 假想敌:再生 (`9010_acpupp`) | 10 | 俗心 / 雅气 | 11 | 10 | 14 | 17 | 25 | 31 |
| ELEMENT | 掠海漂移体 (`2025_syufo`) | 10 | 骨海漂流体 / 骨海漂流体 | 24 | 26 | 29 | 30 | 45 | 48 |
| ELEMENT | 领潮员 (`1161_tidmag_2`) | 10 | 潜水员 / 码头水手长 | 20 | 24 | 26 | 28 | 34 | 35 |
| ELEMENT | 元核孽生者 (`1439_dslntf`) | 10 | 底海滑动者 / 富营养的滑动者 | 26 | 31 | 31 | 31 | 45 | 41 |
| ELEMENT | 萨卡兹王庭军精锐术师 ⛔ (`1275_dwlock_2`) | 10 | 萨卡兹王庭军战士 / 萨卡兹王庭军精锐战士 | 16 | 18 | 20 | 18 | 32 | 35 |
| ELEMENT | 假想敌:淤困 (`9007_acelem`) | 10 | 萨卡兹王庭军战士 / 萨卡兹王庭军精锐战士 | 15 | 18 | 19 | 17 | 30 | 32 |
| ELEMENT | 灼藤 ⛔ (`10067_ftsjc`) | 8 | 卷心籽 / 灼藤 | 20 | 24 | 25 | 25 | 31 | 29 |
| DOT | 富营养的巢涌者 ⛔ (`1234_dsubrl_2`) | 10 | 异光体掠食者 / 异光体掠食者 | 15 | 18 | 18 | 17 | 30 | 32 |
| DOT | 集团军重型火炮 ⛔ (`10122_uacann_2`) | 10 | 萨卡兹枯朽战士组长 / 萨卡兹枯朽战士组长 | 20 | 20 | 23 | 27 | 42 | 42 |
| DOT | 尖端萨卡兹枯朽战车 ⛔ (`1272_nhtank_2`) | 10 | 萨卡兹枯朽战士组长 / 疯狂的逐腐兽 | 13 | 15 | 17 | 20 | 25 | 27 |
| DOT | 假想敌:蚀裂 (`9006_actoxi`) | 15 | 萨卡兹枯朽战士组长 / 疯狂的逐腐兽 | 15 | 15 | 19 | 23 | 31 | 34 |
| DOT | 疯狂的逐腐兽 ⛔ (`1270_nhstlk_2`) | 8 | 萨卡兹枯朽战士 / 萨卡兹枯朽战士组长 | 24 | 25 | 28 | 29 | 47 | 49 |
| INVISIBLE | 访问团强攻冠军 (`1389_winbab_2`) | 10 | 访问团新兵 / 隐形弩手组长 | 18 | 18 | 21 | 22 | 38 | 44 |
| INVISIBLE | 山海众秘使 (`1299_ymkilr_2`) | 10 | 萨卡兹枯朽辟路前锋 / 隐形弩手组长 | 20 | 23 | 24 | 25 | 38 | 41 |
| INVISIBLE | 重弩突袭者 (`1404_msnip`) | 10 | 潜伏者 / 隐形弩手组长 | 29 | 33 | 35 | 35 | 53 | 57 |
| INVISIBLE | 假想敌:骨刺 ⛔ (`9008_acbunn`) | 10 | 业余竞演者 / 隐形弩手组长 | 20 | 23 | 25 | 24 | 38 | 39 |
| INVISIBLE | 家族灭迹人 ⛔ (`1283_sgkill`) | 8 | 业余竞演者 / 隐形弩手组长 | 20 | 23 | 25 | 24 | 38 | 39 |
| REFLECTION | 深池伙友卫队精英 ⛔ (`1174_duholy_2`) | 10 | 深池侦察队长 / 深池重甲卫士队长 | 18 | 20 | 22 | 24 | 34 | 39 |
| REFLECTION | 深池重甲卫士队长 (`1170_dushld_2`) | 10 | 深池侦察队长 / 深池方阵指挥官 | 22 | 23 | 26 | 29 | 46 | 49 |
| REFLECTION | 守墓石像 (`1172_dugago`) | 10 | 深池侦察队长 / 深池方阵指挥官 | 20 | 23 | 24 | 24 | 38 | 39 |
| REFLECTION | 假想敌:镜膜 (`9011_acrefr`) | 10 | 深池侦察队长 / 深池重甲卫士队长 | 18 | 20 | 22 | 24 | 34 | 39 |
| REFLECTION | 深池伙友影刃精英 ⛔ (`1175_dushdo_2`) | 8 | 深池侦察犬 / 深池伙友卫队精英 | 20 | 24 | 26 | 28 | 34 | 35 |
**Two concrete example matches** (同盟 险境). Per round: slot type, the special entry, the total, and the kinds and counts **per gate** (what the preview shows).
Example A. Types INVISIBLE / FLY / REFLECTION; slots `SPEC INVI SPEC FLY REFL REFL SPEC FLY INVI SPEC FLY SPEC REFL SPEC INVI`.
| R | type | entry | total | lower gate (9,10) | upper gate (12,10) |
|---|---|---|---|---|---|
| 1 | SPECIAL | 萨卡兹大剑组长 | 5 | 萨卡兹刀兵 ×3, 源石虫 ×2 | — |
| 2 | INVISIBLE | 节日爵士乐手 | 10 | 宿主士兵 ×10 (2 actions × 5) | — |
| 3 | SPECIAL | 重犯 | 7 | 普通囚犯 ×5, 老练囚犯 ×2 | — |
| 4 | FLY | 法术大师A1 | 14 | 妖怪MKII ×5, 法术大师A1 ×4, “萨科塔之翼” ×3 | “萨科塔之翼” ×2 |
| 5 | REFLECTION | 深池伙友卫队 | 19 | 深池侦察兵 ×8, 深池重甲卫士 ×3, 深池伙友卫队 ×1 | 深池侦察兵 ×7 |
| 8 | FLY | 法术大师A2 | 12 | 妖怪MKII ×2, 寒霜 ×2, 法术大师A2 ×1 | 寒霜 ×3, 妖怪MKII ×2, 法术大师A2 ×2 |
| 12 | SPECIAL | 集团军中坚盾卫 | 38 | 宿主士兵 ×21, 宿主重装士兵 ×6, 集团军中坚盾卫 ×3 | 宿主重装士兵 ×4, 宿主士兵 ×3, 集团军中坚盾卫 ×1 |
| 13 | REFLECTION | 深池重甲卫士队长 | 49 | 深池方阵指挥官 ×15, 深池重甲卫士队长 ×14, 深池侦察队长 ×12 | 深池方阵指挥官 ×4, 深池重甲卫士队长 ×3, 深池侦察队长 ×1 |
Example B. Types DOT / TIMES / ELEMENT.
| R | type | entry | total | lower gate | upper gate |
|---|---|---|---|---|---|
| 1 | SPECIAL | 高级军用猎狗 | 6 | 猎狗pro ×4, 源石虫 ×2 | — |
| 3 | TIMES | 俗心 | 6 | 磨砻 ×5, 俗心 ×1 | — |
| 4 | SPECIAL | 拳师囚犯 | 8 | 普通囚犯 ×2, 老练囚犯 ×2, 拳师囚犯 ×2 | 普通囚犯 ×2 |
| 7 | DOT | 逐腐兽 | 24 | 萨卡兹枯朽战士 ×8, 萨卡兹枯朽战士组长 ×5, 逐腐兽 ×5 | 萨卡兹枯朽战士 ×6 |
| 8 | ELEMENT | 假想敌:淤困 | 15 | 萨卡兹王庭军战士 ×5, 萨卡兹王庭军精锐战士 ×2, 假想敌:淤困 ×1 | 萨卡兹王庭军精锐战士 ×4, 萨卡兹王庭军战士 ×2, 假想敌:淤困 ×1 |
| 11 | DOT | 假想敌:蚀裂 | 23 | 萨卡兹枯朽战士组长 ×8, 假想敌:蚀裂 ×6, 疯狂的逐腐兽 ×4 | 萨卡兹枯朽战士组长 ×4, 假想敌:蚀裂 ×1 |
| 12 | SPECIAL | 弧光镜卫 | 51 | Dor-1号失败品 ×18, 莱茵生命防卫科高级成员 ×15, 弧光镜卫 ×8 | 莱茵生命防卫科高级成员 ×5, Dor-1号失败品 ×3, 弧光镜卫 ×2 |
---
## 3. Routes and lanes
### 3.1 Official ground pathfinding [DATA, client `Torappu.Battle.SPFA` + `Tile` + `GridPosition`]
1. **Flow field per destination.** The engine computes one flow field per destination (a route's end, or a MOVE checkpoint) over the **whole map grid**.
- `PathRequest` is `{targetPos, motionMode, allowDiagonalMove}`.
- It uses a FIFO queue (SPFA), seeded with the destination at `dist` 0.
2. **Neighbours.** The neighbour order is `GridPosition.GRID_FOUR_WAYS` = **UP (row+1), RIGHT (col+1), DOWN (row−1), LEFT (col−1)**. There are **no diagonal edges**.
3. **Edge cost.** The cost is `Tile.moveCost` of the neighbour: **1**, or **1000** if the tile is "obstacle-like" (a tile holding an active 阻隔工事). FLY always costs 1.
4. **Passability.** A neighbour is used only if it is passable for the motion. For WALK that is `passableMask` ALL (floor, road, start, end, telin, telout, mire, smog, deepsea, infection). `fence_bound`, wall and forbidden tiles are **not** walkable.
5. **Relaxation.** Relax only on strict improvement: `if unvisited or dist[cur]+cost < dist[nb]: dist[nb] = dist[cur]+cost; parent[nb] = cur; enqueue if not queued`. With equal costs, a tile keeps the first parent that reached it. That tie-break comes from the BFS order plus the UP/RIGHT/DOWN/LEFT neighbour order.
6. **Smoothing** (`_PostprocessAndMakeNextMapSmoothly`):
- Visit tiles row 0→H−1, col 0→W−1, updating in place.
- For each tile n: `b = parent[n]; while parent[b] exists and LOS(n, parent[b]): b = parent[b]; parent[n] = b`.
- Every autochess route has `allowDiagonalMove: true`, so LOS is the **Bresenham** variant (`_RaycastBresenhamLine`). Every tile on the line must be valid and not obstacle-like (`Node.CheckObstacleLikeOrInvalid`). A diagonal step also needs both orthogonal neighbours clear.
- With `allowDiagonalMove: false`, LOS is true only for the same row or column (`_RaycastSegmentLine`).
7. **Movement.** An enemy moves from its current tile straight toward `parent[tile]`. The field is rebuilt when tiles change, for example when a crate dies (`ClearCache` / `GenerateNextMap`).
8. **Crates** (`trap_1105_accrate` 阻隔工事: "改变敌人的行进路线 / 如果阻隔工事阻挡了敌人,敌人会破坏此工事" [DATA]).
- They are passable at cost 1000, so enemies route around them when any detour is < 1000 tiles longer.
- If a crate is the only way, the enemy walks into it, is blocked, and destroys it.
- Moving up and back down is not special-cased. Lane choice is purely this BFS.
### 3.2 Expected lanes per stage (official algorithm, crates as in each level file's non-hidden predefines)
| stage | upper gate (12,10) → goal (9,2) | lower gate (9,10) → goal |
|---|---|---|
| act1 m01 #01 | **row 12** → (12,4) → col 4 → (9,4) → E. Waypoints (12,10)→(12,4)→(9,4)→(9,2) | (9,9) → col 9 up to row 12 → row 12 → col 4 → E. Merges into the upper road. |
| act1 m02 #02 (no crates in 下半) | **row 12 all the way** → (12,3) → col 3 → (9,3) → E | (9,9) → (11,9) → **row 11** → (11,6) → col 6 → row 9 → E |
| act1 m03 #03 | row 12 → col 6 → **row 10** → (10,3) → (9,3) → E. If 射击台 blocks (10,3)/(10,4) [ASSUMED], it goes (10,5) → (9,5) → row 9. | **row 9** straight |
| act1 m04 #04 | (12,9) → (11,9) → **row 11** → (11,4) → (10,4) → (9,4) → E. Smoothed waypoints: (12,10) → (12,9) → (11,9) → (11,4) → (9,4) → E, so it stays on row 11 to col 4. | (9,9) → (10,9) → **row 10** → (10,4) → (9,4) → E |
| act2 m01 #05 | one snake for both gates: col 9 down → row 9 → col 7 up → row 12 → col 5 down → row 9 → E | same snake (enters at (9,9)) |
| act2 m02 #06 | row 12 → **col 7 (mire)** → row 9 → E. Crates on col 9 at (10,9)/(11,9). | **row 9** straight |
| act2 m03 #07 | **row 12** → col 4 → E | (9,9) → (10,9) → row 10 → (10,6) → (9,6) → row 9 → E |
| act2 m04 #08 | **row 12** → (12,4) → col 4 → E | row 9 → (9,6) → col 6 up (deep water) → row 11 → (11,4) → col 4 → E |
The raw BFS chains and smoothed waypoints are in scratch `lanes_official.txt`.
**Remake deviation (user playtest #2, 2026-09-29).** The user reported the 战场#01 lower-gate enemies walking up the
col-9 floor lane, where no operator can block them. `server/sim/grid.js` keeps every official route LENGTH but breaks
ties between equal-length routes towards the fewest non-blockable (floor / gate) tiles, and its smoothing never cuts
across floor the grid route does not walk. Lanes that change against the table above:
- act1 m01 #01, lower gate: (9,10) → (9,8) → **col 8 road up** → (12,8) → row 12 → col 4 → E (was col 9 floor).
- act1 m04 #04, lower gate: (9,10) → (9,8) → (10,8) → **row 10** → (10,4) → (9,4) → E (no longer cuts across (10,9)).
- Boss fields: the exits from (2,10) take the road on 战场#01 / #04 and the right half of 战场#05, and cross less floor on
战场#02 / #07. Floor the terrain forces stays (gates, (12,9) beside the upper gate, the col-9 floor where walls or a
crate block the road: #02 lower (10,9)/(11,9), #04 upper (11,9), #05 upper (11,9)/(10,9), #07 lower (10,9)).
Full audit: test/sim/pathing-blockable.test.js.
### 3.3 What we do and the root cause of "upper-gate enemies walk the lower lane"
- **Our pathfinder.** `server/sim/grid.js` is **8-directional A\*** with diagonal cost √2 and a no-corner-cut rule. It is limited to the field rect (rows 9–12, cols 0–10). Neighbour order is W, E, S, N, then diagonals.
- **Crates and platforms.** Crates and platforms/mounds are **impassable** (with an `ignoreObstacles` fallback) instead of cost 1000.
| stage | ours, upper gate | official | effect |
|---|---|---|---|
| act1 m02 | 12,10 → 12,9 → 12,8 → 12,7 → 12,6 → **11,6 → 10,6 → 9,6** → row 9 (lower road) | row 12 → col 3 (top road) | **Wrong lane.** Two causes: (a) `data/stages.json` marks crates `trap_1105_accrate#001–004` at (12,4), (12,5), (11,7), (11,8) as `active: true`, although the level file has them `hidden: true` and the PRTS 下半 screenshot shows none, so the top road is blocked; (b) even without crates, the 8-dir metric makes col 6 (10.41) shorter than the top road (11). |
| act1 m04 | 12,10 → 12,9 → 11,9 → **10,8** → row 10 | row 11 (upper row of the 2-wide band) | Upper-gate enemies join the lower-gate lane at once. |
| act1 m03 | … (10,6) → **(9,5)** → row 9 | row 10 → (10,3) (if platforms are walkable) | joins the lower lane 3 tiles early (platform rule [ASSUMED]) |
| act1 m01 (lower gate) | (9,9) → (10,8) → col 8 up | col 9 up | small visual difference |
| act2 m01–m04 | same lanes as official (diagonal corner cuts only) | — | OK |
The historical note on act1 m02 [DATA]:
- The **上半** PRTS image shows crates at (12,4) and (12,5).
- The **下半** `ver2` image shows none. The current level file has all m02 crates and platforms `hidden`.
- The terrain effects `map_m02_1/2` ("阻隔工事变为射击台" / "移除全部阻隔工事") still exist in the 下半 `effectInfoDataDict`. The 战术决策 card pools are server-side, so they may still be offered.
- On 下半 m02, applying them can only remove crates that are already hidden, or show the hidden 射击台 via the platform aliases [ASSUMED].
- They are no reason to start with crates.
- **The 下半 m02 has no crates at start.**
### 3.4 Pathing spec to implement (replaces `Grid.findPath` for ground enemies)
```
flowField(dest, {obstacleCost = 1000, allowDiagonal = true}): // cache per (dest, grid.version)
dist = Int32Array(ROWS*COLS).fill(-1); parent = Int32Array(...).fill(-1); inQ = Uint8Array
q = FIFO; dist[dest] = 0; q.push(dest)
while q: cur = q.shift(); inQ[cur] = 0
for (dr,dc) of [[+1,0],[0,+1],[-1,0],[0,-1]]: // UP, RIGHT, DOWN, LEFT — do not reorder
nb = cur+(dr,dc); if !inMap(nb) || tile(nb).pass !== 'ALL': continue
nd = dist[cur] + (isCrate(nb) ? obstacleCost : 1)
if dist[nb] < 0 || nd < dist[nb]: dist[nb] = nd; parent[nb] = cur; if !inQ[nb]: q.push(nb); inQ[nb]=1
// smoothing, row-major, in place
for r in 0..ROWS-1: for c in 0..COLS-1: n=(r,c); if dist[n]<0 || parent[n]<0: continue
b = parent[n]; while parent[b] >= 0 && los(n, parent[b]): b = parent[b]; parent[n] = b
los(a,b): Bresenham from a to b; every visited tile passable && !crate; on a diagonal step also both
orthogonal neighbours passable && !crate (allowDiagonal=false ⇒ same row or same col only)
```
- A ground enemy at tile t with a leg to D moves straight toward `parent_D[t]`. It re-reads the field on entering a new tile, or when `grid.version` changes.
- The whole 19×21 map may be used as the official does. Keeping the field rect is harmless for normal rounds, because no shorter path leaves it.
- `remainingDistance` (targeting "closest to goal") should use `dist_D[t]` plus the leg tail. The official exposes `TryCalculatePathFindingDistance` on the same field.
- **Performance.** One BFS over 399 tiles per destination and grid version replaces one A\* per enemy leg. It is also deterministic across clients.
---
## 4. Enemy preview (prep phase) [DATA, client `AutoChessEnemyPreviewManager`]
### 4.1 Which enemies use which gate
The gate is the start of the action's route. R1–R3 use only the lower gate. Notation: `class count @ preDelay / interval` (game seconds). E.g. `N3@7/5` = 3 enemies of the N slot, the first at 7 s, then one every 5 s. Counts are template counts before replacement (§2.3).
| R | tpl | lower gate (9,10) | upper gate (12,10) |
|---|---|---|---|
| 1 | 01 | 源石虫2@3/5 N2@12/3 NF2@12/3 | — |
| 2 | 02 | N3@3/5 N3@15/7 NF4@3/7 NF5@15/7 | — |
| 3 | 03 | N10@6/6 E1@18/10 NF9@6/7 EF2@10/10 | — |
| 4 | 04 | N2@12/4 E1@13/12 S1@29/7 NF2@3/6 NF3@26/6.5 EF3@17/9 SF2@18/9 | N2@3/6 NF3@7/8 |
| 5 | 05 | N3@7/5 N3@27/5 E2@14/7 S1@25/9 NF3@3/6 NF4@16/5 EF3@17/1 SF2@31/6 | N2@3/10 N3@22/8 NF3@5/6 NF3@22/6 |
| 6 | 06 | N4@7/8 N3@16/8 E2@20/12 S2@28/14 NF4@2/5 NF4@20/7 NF4@27/5 EF3@17/1 SF3@29/5 | N3@2/8 N3@19/8 NF4@7/8 |
| 7 | 07 | N4@8/5 N4@22/9 E4@5/17 S2@12/14 S1@40/14 EF3@17/11 SF1@9/12 SF1@13/12 EF2@41/11 NF2@2/10 NF8@27/4 | N3@2/8 N3@18/10 NF4@5/5 NF1@30/5 |
| 8 | h01 | N3@2/3 N3@28/5 N2@62/5 E1@37/9 S3@42/9 NF2@3/5 NF2@25/5 EF2@17/5 SF1@10/20 | N3@11/1 E2@17/10 S1@9/12 NF2@5/7 NF2@13/5 EF3@39/5 SF3@28/12 |
| 9 | h02 | N3@2/5 N3@42/7 N3@65/5 E2@17/10 S1@13/16 S1@40/5 NF2@3/3 EF3@28/5 | N3@3/5 N3@39/5 E1@41/10 NF1@3/1 NF2@13/5 NF3@20/5 EF2@41/5 SF1@7/7 SF3@21/12 |
| 10 | h03 | N3@3/5 N3@25/5 N3@49/5 E3@17/10 E1@41/10 S1@57/5 NF2@7/5 EF3@28/6 | N2@3/4 N4@5/5 S3@17/13 NF2@3/4 NF3@5/5 NF2@13/5 EF2@41/5 SF1@10/10 SF3@25/10 |
| 11 | h04 | N2@5/2 N3@22/2 N2@45/5 N2@50/5 E1@10/9 E3@54/7 S2@17/5 S4@42/5 NF3@27/2 EF3@28/2 EF2@52/2 SF2@17/2 | N2@5/5 N2@10/5 S1@6/20 NF2@3/3 NF2@5/5 NF4@9/5 EF1@20/5 SF1@7/10 SF2@21/10 |
| 12 | h05 | N3@5/3 N2@30/3 N2@10/3 N3@35/5 N3@51/4 E2@12/10 E2@37/9 E2@53/7 S3@2/10 S3@10/10 S2@31/10 NF3@10/3 NF3@27/2 EF3@28/2 EF3@47/6 SF2@12/5 SF3@22/6 SF2@35/5 | N2@5/3 E3@10/10 S2@21/12 NF2@5/1 NF3@8/4 NF3@21/5 NF3@43/5 EF2@13/5 EF2@15/5 SF2@17/10 |
| 13 | h06 | N3@7/3 N3@10/5 N3@39/5 N3@62/4 E3@11/9 E4@37/9 E3@55/9 S4@2/10 S4@10/7 S3@32/10 NF3@27/2 EF4@28/2 EF3@55/7 SF2@10/5 SF3@22/5 SF2@35/5 | N1@7/1 E2@3/10 S2@21/15 NF2@5/1 NF3@21/4 NF3@50/5 EF2@13/5 EF1@15/5 SF3@17/11 |
- FLY actions also start at a gate (their routes start at (9,10) or (12,10)), so they are previewed at that gate.
- Only the **valid** actions of the round's movement class appear (§2.3).
- Leader rounds use the boss field gates (2,10) and (5,10) with a row offset of 13, which lands in the same pen.
### 4.2 Placement algorithm
1. **Preview entries.** For each valid round action there is one entry `{preDelay, startPosition = routes[action.routeIndex].startPosition, enemyKey, count}`. The boss action is added in leader rounds. Entries are sorted by `preDelay` (`_CompareActionByTime`).
2. **Thinning.** Let `total = Σ count`.
- `showRatio = min(1, 50/total)` and `totalShowCnt = min(50, total)`. `MAX_PREVIEW_CNT` = 50.
- Per entry, `n = max(1, round(count·showRatio))`.
- An enemy whose handbook `enemyLevel` ≠ NORMAL (elites, bosses) always uses ratio 1.
- **Each enemy is shown as its own model.** The preview has no count badge.
3. **Pen and zones.**
- The pen is `enemy_place_rect ((14,7),(18,13))` (stage `configBlackBoard`).
- The zone anchors are the `tile_start` tiles in the rect: **(15,7)** and **(18,7)**.
- Every other pen tile with `passableMask` ALL and without `previewNotAlloed` belongs to the Manhattan-nearest anchor. Row 16 is excluded.
- **Lower zone** = rows 14–15 (13 tiles). **Upper zone** = rows 17–18 (13 tiles). Each list is in row-major order (low row first, col 7→13).
4. **Zone choice.**
- Target = `(start.row + enemy_preview_row_offset, start.col)`. The offset is 6 by default (13 in leader rounds); neither key is set in the stages, so the defaults apply.
- The zone is the anchor with the smallest `|anchor.row − target.row| + |anchor.col − target.col|` among anchors with `anchor.row ≤ target.row`.
- Result: lower gate (9,10) → the (15,7) zone; upper gate (12,10) → the (18,7) zone.
5. **Tile choice.** The k-th model sent to a zone goes to `zoneTiles[clamp(round(k/totalShowCnt·len) + Random.Range(−1,0))]`. Several models may share a tile. When no zone fits, a random pen tile is used.
6. **Camera.** In the official 3D camera the pen sits top-right, beyond the gates.
- For our 2D board, render the pen rows 14–18 (the stage already has them).
- Alternatively, render two gate-anchored zones next to the gates: the upper zone beside (12,10) and the lower zone beside (9,10). That is what the user remembers ("右侧上下红门后方").
- Keep the grouping exact: one zone per gate.
---
## 5. Secondary: bounty and 联防 spawn mapping [DATA, client `AutoChessLevelEnemyManager`; partially decoded]
**Bounty (悬赏) and extra enemies** (`_InsertSpActionToNormal`, `_GetSpEnemyActionData`):
- Each added enemy is attached to the first action of the round's **original** template whose key is `templateEnemyNormal` (walker) or `templateEnemyNormalFly` (flyer). Summoned tokens use `templateEnemyToken` / `templateEnemyTokenFly` instead.
- If no action matches, it uses action 0.
- All units attached to that action are then spread evenly over its window: `preDelay + i·max(window/n, 0.05·window)`.
- So a walking bounty enters at the round's first N action gate. That is the lower gate in every round, because action N#1 always starts at (9,10).
**联防:**
- Each leaked enemy maps to the escaped template's action as follows:
- walkers → the `lrsldr` action (route 3: WALK from (9,18) via (9,10), or from (9,10) in `escaped_single`; preDelay 3, interval 8),
- flyers → the `yokai` action (route 0),
- tokens → `gopro_2` / `lazerd`.
- Elite and special leakers are **not** routed through the E/S actions (the class is ignored).
- Timing is recomputed per owner uid (`_CalculateActionPredelayConsiderUid`; constants `ACTION_INTERVAL_FOR_UID` 0.5 s, `MAX_ACTION_INTERVAL_FOR_UID` 5 s). The exact formula was not decoded [ASSUMED: 0.5 s spacing within an owner, capped at 5 s].
- Helper order [WIKI 帮助]: most units on the field (downed included) > has an active bond (?) > highest bond layers (?) > most undowned units. The first helper takes the right-hand field. With 3 perfect players, the top 2 by the same order help.
---
## 6. Deviations from official numbers (custom balance and assumptions)
| # | Where (ours) | Current | Official | Source | Action |
|---|---|---|---|---|---|
| 1 | `server/match/waves.js` `replaceSlot` / `templateSpawns`; `data/factions.json` `generation.kFormula` | each template **enemy** → k copies (k ≤ 5), 0.5 s stagger | each template **action** → `clamp(roundHalfEven(n·BE_t/BE_new),1,5)` enemies, interval `n·iv/n'` | [DATA] | replace (§7-1) |
| 2 | `setupMatchWaves` / `pickEntries` | 3 factions per match; **every** round: a ground entry from the chosen types + SPECIAL, **and** a FLY entry | 15-slot schedule (3 types × 3 + 6 SPECIAL, shuffled); **one** entry per round | [DATA] | replace |
| 3 | `flyPlaceholders` 'keep' (BALANCE §2.2) | 寒霜 / 暴鸰 / 妖怪 stay in ground rounds (the #1 leak source) | the other movement class is **not spawned** (`isValid=false`) | [DATA] + [VIDEO] | drop the knob; implement the rule |
| 4 | `pickEntries` `inactiveIn`, `pickReplacement` filters attached keys | inactive list also filters N/E replacements | only `specialEnemyKey` is filtered | [DATA], [WIKI] 上半 note | change |
| 5 | `data/tuning.json → modes[*].enemyHpMul / enemyAtkMul` (all 8 modes, e.g. multi 险境 R13 HP ×0.55) | custom per-round multipliers from `tools/balance.mjs` | none; only the PRTS table (`config.enemyScale`) | [WIKI] | **delete** (keep `titles.comment_3`, which is unrelated) |
| 6 | `data/tuning.json → mode_single_funny.bossHpMul 0.6` | leader pool ×0.6 in solo 标准 | none | — | **delete** |
| 7 | `config.bossHpScale` `bloodPoint × alive/4`, solo ×0.25 [ASSUMED] | pool scales with alive players | co-op: split leaders share HP, "敌方领袖的总生命值不变" → pool = `bloodPoint` (no alive scaling found); **solo: unknown** | [WIKI] notice 5114 / PRTS 下半 | co-op: use `bloodPoint` as is; solo: keep a config value, flagged unknown |
| 8 | `waves.js bountySpawns` `soloMul 0.7` for "perfect" bounties | bounty HP/ATK/DEF ×0.7 **on top of** the solo 0.7 base | no bounty-specific rule; the "70 %" note is the global solo base, already in `enemyScale` | [WIKI] 上半 11/18 log | remove `soloMul` |
| 9 | `bountySpawns` timing | t = 4 s, +5 s per bounty, interval 3, `routeByMotion` | attached to the template's first N / NF action window (§5) | [DATA] | change |
| 10 | `buildUniteWave` | grouped by slot class N/E/S/…, compressed into 40 % of the time limit | walkers → `lrsldr` action, flyers → `yokai`, tokens → T/TF; per-owner 0.5 s spacing | [DATA] (partial) | change |
| 11 | `config.unite.helperOrder` `highestLpThenSeat` [ASSUMED] | by LP | most units on the field > active bond > layers > undowned units | [WIKI] 帮助 | change |
| 12 | `gamedata.combatTimeLimit` ×2 (`COMBAT_TIME_SCALE`) | `maxPlayTime` read as real seconds | unit not stated. The official count rule keeps each action's window, e.g. FLY R2 still spawns its last flyer at ≈ 43 s of "45", so ×2 remains necessary. | [ASSUMED], supported by spawn schedules | keep, but re-check after the count fix |
| 13 | `server/sim/grid.js` 8-dir A\*, √2 | see §3.3 | 4-dir SPFA flow field + LOS smoothing | [DATA] | replace (§7-3) |
| 14 | `data/stages.json` act1 m02 crates `trap_1105_accrate#001–004` `active:true` | top road blocked | hidden in the 下半 level; absent in the 下半 screenshot | [DATA] + [VIDEO] | set `active:false` |
| 15 | crates impassable + `ignoreObstacles` fallback | — | passable at cost 1000, block LOS; blocked enemy destroys the crate | [DATA] | change |
| 16 | 射击台 / mounds block ground movement [ASSUMED] | — | unknown (crate and platform token data are identical; behaviour is in the prefab) | — | keep, flagged |
| 17 | `config.enemyScale` (HP/ATK only) | — | 下半 text: HP/ATK; 上半 11/18 also named DEF | [WIKI] | keep; optional DEF |
| 18 | `economy.income` `min(3+r, 12)` | R4+ assumed | BWIKI (hidden text, older season): "从第2回合开始每回合获得资金数+1,上限12" | [WIKI], weak | keep |
| 19 | `previewOf` | one list per key, no gate | per-action models placed in a gate zone (§4) | [DATA] | change |
Everything in `docs/BALANCE.md` §4 (the tuned tables) and the `tools/balance.mjs --tuning` path becomes obsolete once #1–#6 are applied. The "competent board" model can still be used to *measure* difficulty, but no longer to tune it.
---
## 7. Changes required in our implementation (precise diffs vs current behaviour)
1. **`server/match/waves.js` + `data/factions.json`** (and the data generator in `tools/`):
- **Match setup.** `setupMatchWaves` returns `{stageId, factions: [3 types], typeSlots: [15 types], bossId, hiddenBossId}`.
- Shuffle the 6 `involveRandom` types and take 3.
- Push each `count` (3) times, fill to `maxLevelCnt` (15) with SPECIAL, then Fisher-Yates-shuffle with the match rng.
- Store `typeSlots` in match state. It is visible in the briefing as the 3 types, and the slot order stays hidden.
- **Round pick.** Add `pickRoundEntry(gd, rng, typeSlots, r)`:
- half = `r <= 7`;
- candidates = `specialEnemyInfoDict` of that type and half, whose **special** key is not in `mode.inactiveEnemyKeys`;
- weighted by `randomWeight`;
- `normal = attachedNormal[randInt]`, `elite = attachedElite[randInt]`.
- Generate the picks for all 15 rounds at match start (like the client), so the preview and the server agree.
- **Replacement.** Replace `templateSpawns` / `replaceSlot` as follows:
- For each template SPAWN action whose key is a template placeholder: if `isFly(newKey) !== isFlySlot`, **skip the action** (no spawn, no preview).
- Otherwise `n' = clamp(roundHalfEven(f32(f32(n·P(t))/f(t)) / f32(P(new)/f(new))), 1, 5)`, and each unit i spawns at `time + i·max(n·iv/n', 0.05·n·iv)`.
- `P = hp + 5·atk + 3·def + 3·res` from the `enemy_database` level-0 attributes (not the autochess overrides).
- `roundHalfEven(x) = (x − floor(x) === 0.5) ? (floor(x) % 2 ? floor(x)+1 : floor(x)) : Math.floor(x + 0.5)`.
- Keep literal keys and the T/TF tokens unchanged.
- Remove `COPY_STAGGER`, the `k` fields (`kS`, `N[].k`, `E[].k`), `flyPlaceholders`, `groundRouteLike` and the `inactiveIn` filtering of attached keys.
- **Leader rounds.** `buildBossWave` uses the pick of slot 14 (or 15, or 9 in solo 标准) with the same rule, so only E **or** EF escorts spawn.
- **Unit tests** (fixtures from §2.6 / §2.7):
- R1 高级军用猎狗 → {源石虫 2, 猎狗pro 4};
- R1 炽焰源石虫 → {源石虫 2, 灼热源石虫 5};
- R1 萨卡兹大剑组长 → {源石虫 2, 萨卡兹刀兵 3};
- R3 FLY 护障 → {妖怪 5, 妖怪MKII 4} and no walkers;
- an elite action "2 × 灵幛" → 隐形弩手组长 ×**4** (the tie case);
- multi 险境 R3 total ≤ 10 for every entry.
2. **Stat scaling and balance.**
- Delete every `enemyHpMul`, `enemyAtkMul` and `bossHpMul` in `data/tuning.json`. Keep the `titles` block.
- Remove the `soloMul` in `bountySpawns`.
- `config.bossHpScale`: co-op pool = `bloodPoint[difficulty]` with no alive-player factor; solo stays a flagged config value.
- Keep `enemyScale` (PRTS table) and the 终极 speed ×1.15 from R3.
- Re-run `tools/balance.mjs --tuning off` only to report, not to tune.
3. **Pathing** (`server/sim/grid.js`, `server/sim/ai.js`, `server/sim/Battle.js`):
- Add `Grid.flowField(dest)` as in §3.4, cached per `(dest, version)`.
- `planLeg` for WALK: follow the smoothed parents from the enemy's tile to the leg target and re-plan on version change. That is the same trigger as today.
- Crates: `setObstacle` marks a cost-1000 obstacle-like tile, not an impassable one. The existing "blocked enemy attacks the crate" logic stays.
- `data/stages.json`: set act1 m02 `trap_1105_accrate#001…#004` to `active:false`.
- Keep platforms and mounds blocking [ASSUMED].
- Add tests: the upper gate on act1 m02 reaches E through (12,3)→(9,3), and on act1 m04 it stays on row 11 down to col 4.
4. **Preview** (`previewOf` and the client prep view):
- Emit one entry per valid action: `{enemyKey, count, gate: route.start, preDelay, elite}`.
- The client places models with §4.2: two zones, ≤ 50 models, elites always shown.
- Show upper-gate enemies in the upper zone and lower-gate enemies in the lower zone.
- The HUD count stays Σ count.
5. **Bounty and 联防**:
- `bountySpawns`: attach bounties to the round template's first `lrsldr` / `yokai` action (gate, preDelay, window spread).
- `buildUniteWave`: walkers → the escaped template's `lrsldr` action route; flyers → `yokai`; tokens → `gopro_2` / `lazerd`; 0.5 s spacing per owner.
- Change `unite.helperOrder` to the PRTS rule (§5).
6. **Performance (user item 4).**
- Items 1 and 3 cut enemies per board by ≈ 55–70 % (R12: 80 → 35) and replace per-enemy A\* with one BFS per destination.
- Measure again before adding other optimisations.
---
## 8. Open questions
| # | Question | Default |
|---|---|---|
| 1 | Do 射击台 (act1 m03 (10,3)/(10,4)) and 土石结构 block ground movement? | Block [ASSUMED] |
| 2 | Leader HP pool in solo, and in co-op with fewer than 4 alive players | co-op `bloodPoint`; solo config value (flagged) |
| 3 | Unit of `maxPlayTime` | Real seconds (×2 game) [ASSUMED] |
| 4 | Exact 联防 predelay formula (`_CalculateActionPredelayConsiderUid`) | 0.5 s per owner [ASSUMED] |
| 5 | DEF reduction in the 70 % / 80 % bases | HP/ATK only (下半 text) |
Everything else in §2–§4 is decoded from official data or client code.
---
## Appendix A. Official client/server split for battles (relevant to user item 5) [DATA, metadata names]
- The protocol types show that **each client simulates its own battle locally** and reports events to the server:
- `AutoChessBattleSceneSelfBattleKillEnemyUp {enemyInstId, attackerInstId, damageSrc}`
- `…SelfBattleEnemyEscapeUp {enemyInstId, isToken}`
- `…SelfBattleAddBondUp {charInstId, layerDelta, bondIndexList}`
- `…SelfBattleFinishUp {round, escapedInstIds, escapedTokenInstIds, killedInsts, charBattleStatusList}`
- `…SelfBattleInfoUp {round, charBattleStatusList, charBattleInfoList}`
- 联防 uses the same pattern:
- `…HelpBattleKillEnemyUp {enemyInstId, isToken, killedByPlayer, attackerInstId, damageSrc}`
- `…HelpBattleEnemyEscapeUp`
- `…HelpBattleFinishUp {round, escapedEnemies, killInfos}`
- The server sends:
- `AutoChessBattleSelfBattleInfo {enemyInfos: [{enemyId, actionIndex, instIdList}]}`
- `AutoChessBattleHelpBattleInfo {escapedEnemies: [{ownerPlayerIndex, enemyId, enemyInstId, isToken}], players: [{deployment, charBattleStatusList}]}`
- **The helper client receives the partner's deployment and end-of-battle status and replays 联防 locally.**
- Round enemy data is generated client-side with a shared seed (`RandomEnemyGenerater`). Actions go to the server only if `ShouldActionUpToServer`.
## Appendix B. Client method RVAs (Windows build in the user's CrossOver bottle, metadata v29) for re-verification
| method | RVA |
|---|---|
| `RandomEnemyGenerater._DoReplaceActionDataClient` | 0x180b640f0 |
| `_CalculateEnemyCountByBattleEffectiveness` | 0x180b63e70 |
| `_CalculateActionBattleEffectiveness` | 0x180b63d50 |
| `_GetEnemyAttrPower` | 0x180b65e10 |
| `_GenerateSpecialEnemyTypesLocal` | 0x180b655c0 |
| `_GenerateRandomEnemyData` | 0x180b64870 |
| `_DoReplaceLevelDataClient` | 0x180b64580 |
| `_InitData` | 0x180b663f0 |
| `SPFA._GenerateNextMapImpl` | 0x180893720 |
| `SPFA._PostprocessAndMakeNextMapSmoothly` | 0x1808940c0 |
| `SPFA._RaycastSegmentLine` | 0x180894c80 |
| `Tile.get_moveCost` | 0x18089cdc0 (obstacle-like ? 1000 : 1) |
| `GridPosition..cctor` | 0x18210a800 (FOUR_WAYS = UP, RIGHT, DOWN, LEFT) |
| `AutoChessEnemyPreviewManager._GeneratePreviewData` | 0x180b3cad0 |
| `AutoChessEnemyPreviewManager._GetBetterGridToSpawn` | 0x180b3d180 |
| `AutoChessEnemyPreviewManager._InitIfNot` | 0x180b3d710 |
| `AutoChessEnemyPreviewManager..ctor` | 0x180b3e190 (row offsets 6 / 13) |
| `<_SpawnPreviewEnemies>d__16.MoveNext` | 0x180b4fc40 (cap 50) |
| `AutoChessLevelEnemyManager._CalculateActionPredelay` | 0x180b453c0 (ratio 0.05) |
| `AutoChessLevelEnemyManager._GetSpEnemyActionData` | 0x180b466f0 |
`Il2CppCodeGenModule` for Assembly-CSharp is at VA 0x186e69af0 (217 790 method pointers at 0x188216460).
+471
View File
@@ -0,0 +1,471 @@
# 09 · Official prep and co-op UX: facing, enemy preview pen, spectating and the combat model, emotes
Written 2026-09-28. This is research only; no code was changed. It answers the user's test feedback on items 2, 5, 6 and 7, and the part of item 9 about UX ("按照官方原版来").
**Tags**
- **[DATA]**: read from the official client or its game data. This covers `activity_table`, the level JSON, `display_meta_table`, the local Windows client's UI bundles and its il2cpp `global-metadata.dat` (class names, field names and constants, parsed with a v29 metadata reader).
- **[WIKI]**: BWIKI 盟约, the official 玩法介绍 long image, and the in-game guidebook pages.
- **[VIDEO]**: frames from bilibili gameplay videos, or screenshots.
- **[COMM]**: community posts on NGA or Bahamut.
- **[ASSUMED]**: our inference or proposal.
**Precedence:** where this file disagrees with `06-multiplayer-ux.md` or `DESIGN.md` about facing, spectating, selling or the enemy preview, **this file wins**. See §7.
Evidence was extracted to the scratchpad `research2/`: guide pages, video frames, sprite sheets, `emoji_table.txt` and `meta_*.txt`.
---
## 0. TL;DR
| # | Question | Official answer | Tag |
|---|---|---|---|
| 1 | Can the facing be set? | **Yes, with the standard Arknights 4-direction deploy wheel.** Drop a unit on a tile and a diamond appears. Swipe toward UP, RIGHT, DOWN or LEFT; the rotated attack range previews live. Release to confirm. Tap "✕ 点击取消" or release in the centre to cancel. Moving a deployed unit (drag it to a tile) goes through the same step. The facing is stored per chess (`ChessPositionInfo.direction`) and persists between rounds. | DATA + VIDEO + WIKI |
| 2 | Enemy preview | The enemies of the coming round stand physically in a **preview pen** behind the gates: rect `((14,7),(18,13))`. Row 16 is kept empty. The upper-gate group stands in rows 17–18, the lower-gate group in rows 14–15. They are placed in spawn-time order, fill outward from the pen's gate column, and several may share a tile. At most **50** are shown. They idle and face left. The pen is visible at the top edge of the prep camera; the HUD button **[🔍▶▶]** pans there in 0.25 s. | DATA + VIDEO |
| 3a | Spectating | In prep you can view teammates' boards: tap an avatar, then **前往查看**. After your own combat ends ("⌛ 作战结束,等待队友完成作战") you can do the same to watch a teammate still fighting. In the Final Assault you cannot see the other pair. There is **no** always-on ‹ › switcher during normal combat. The ‹ › pill exists only inside the shared 联防 and Final Assault fields (left half / 全景 / right half). | DATA + WIKI + VIDEO |
| 3b | Where combat is computed | **On each client.** The client simulates its own battle and reports it to the scene server: kills, escapes, finish, bond-layer gains, operator HP/SP. The server owns economy, shop, board and phases, and sends a shared `stageSeed`, the enemy lists, the 联防 setup (leaked enemies plus helpers' deployment and HP/SP) and the boss setup. | DATA (client network classes) |
| 4 | Emotes | Image only, no text (`ChatReq{emojiGroup, emojiId}`). The pop bubble holds only the icon. There are 6 in-match themes × 6 = **36** battle emotes. The 交流 button sits bottom-left; the panel shows 6 emotes and you swipe to change theme. Cooldown 1 s, bubble 3 s. The bubble appears beside the sender's row in the left player list. | DATA + WIKI |
| 5 | Other | Sell with the **出售 +1** button on the tapped unit's selection diamond, not by dragging. Items: **销毁** only. Buying and upgrading take two taps (first tap shows the confirm state). The strategy draft marks a teammate's pick as **队友已选**. | DATA + WIKI |
---
## 1. Facing and direction in prep
### 1.1 Evidence
1. **Official 玩法介绍 long image, 干员 section [WIKI]** (https://img.71acg.net/sykb~bbs/pc/1763033996225879, y≈6850–7080).
- A 3-frame strip: card on the bench → unit on a tile with an **orange wedge** at the right of its ground ring → dragging a deployed unit to a new tile.
- The third frame shows a **white diamond** centred on the target tile and a red **"✕ 点击取消"** tag on the diamond's upper-left edge. Green highlights mark the legal tiles, and a ghost of the unit sits in the centre.
- Caption: "拖拽干员即可将干员从整备区部署到战场区,也可以直接拖拽已经部署完毕的干员来改变他们的作战位置。"
2. **In-game tutorial of 卫戍协议 (上半), BV1tFUAYnEfZ, 4:56–5:14 [VIDEO].**
- Amiya says: "**干员的部署方式与其他模式没有区别**,通过拖拽将干员从手牌区部署至战场。"
- Frames at 5:08–5:11 show the standard AK deploy wheel: a white diamond, a smaller inner diamond with 4 corner chevrons, "✕ 点击取消" at the upper-left, and the finger swiping right.
- The right chevron lights up and the **attack range appears as orange/yellow striped tiles** in that direction. A tooltip reads **"拖回中心区域取消"**.
- After release, the unit stands with an orange "›" wedge on its ground ring.
- At 5:13: "已部署的干员可以直接拖拽改变部署位置。"
3. **Client network data [DATA].**
- `Torappu.Battle.AutoChess.ChessPositionInfo { instId, position, chessType, direction }` is what the client sends in `AutoChessBattleSceneChangePositionUp { deployCount, unitPositionInfo }` and in `AutoChessBattleSceneUseMagicUp { instId, deployCount, unitPositionInfo }` (Arts too).
- The server echoes `ChangePositionDn { boardStatus: AutoChessBattleBoardStatus { uidIndex, positions, updatedBonds } }`.
- The prep state `AutoChessBattlePreparationStatus.positions` holds these entries.
- So facing is a stored, server-known, per-chess field. It persists until the unit is moved again.
4. **Selection diamond (underframe) of the autochess character menu [DATA]**, bundle `ui/autochess/[uc]autochessbattle.ab`.
- `act2autochess_panel_character_menu/btn_panel_root/root/group_masks/{mask_up, mask_right, mask_left, mask_down, cancel_mask, block_mask}`: the 4 direction quadrants and a cancel mask.
- `util_btn` has sprite `icon_sell`, a `price_root/price_txt` ("+1"), a `coin_panel`, a `pc_key_holder`, and funcId `autochessSale`.
- Code fields: `_underFramePanelBtnParamSellPos` and `_underFramePanelBtnParamDestroyPos`.
5. **Facing matters in the rules [DATA][COMM].**
- The act2 m01 blowers (`trap_013_blower`, dir DOWN) give operators facing **with** the airflow +30 % ATK and **against** it −30 % (`blower_s_character[equal].atk`). This only works with UP/DOWN facing.
- 叙拉古正装: "左右两格" perpendicular to the facing. 歌利亚头盔: "前方一格".
- 画卷 range `1-1`: its own tile plus the tile in front.
- Garrisons that target "身前一格": 寒克 "给身前一格的干员挂上一个特质", 魔王 "使身前一格的干员特质…" [VIDEO BV1h6DjBoE3T 0:12–0:30]. 铃兰 and 赛媽 are "对着" stackers [COMM Baha 12294].
- NGA t45645958: "最前一排可部署位不管朝向哪边都可以攻击到 [the boss]". Players pick facings, and write layouts as "空→←白".
### 1.2 Spec (implement exactly)
| Aspect | Official behaviour | Tag |
|---|---|---|
| Directions | 4 directions: `UP`, `RIGHT`, `DOWN`, `LEFT` (AK `Direction`). The range grid (`[dRow, dCol]`, facing RIGHT) is rotated per DATA.md: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)`. Row 0 is the bottom. | DATA |
| Placing from the bench | 1. Drag the card or bench unit; legal tiles glow green (melee/ranged rules unchanged). 2. Release on a legal tile: the board **keeps the ghost on the tile** and opens the direction diamond. The diamond is centred on the tile, about 3 tiles across its diagonal, with 4 chevrons and a red "✕ 点击取消" tag on its upper-left edge. 3. Press and drag from the centre toward a side; past the centre dead-zone (~0.5 tile) that side's chevron lights and the **rotated range tiles are shown striped orange**. While outside the centre, the tooltip "拖回中心区域取消" is shown. 4. Release: the unit is committed with that direction. Release inside the centre, or tap ✕, and the placement is cancelled (the unit goes back to where it came from). | VIDEO + WIKI |
| Moving a deployed unit | Drag the unit to any legal tile, **including its own tile**. The same diamond opens. The original stays visible until confirmed; cancelling leaves the unit and its facing unchanged. Re-orienting in place = drag the unit onto its own tile and swipe a new direction. | WIKI + ASSUMED (own-tile case) |
| Tap a deployed unit | Opens the selection diamond (underframe) around the unit and shows its **attack range tiles** on the board (orange). Buttons on the diamond: **撤退** (red runner icon, upper-left; unit returns to the bench) and **出售 +1** (coin badge, upper-right). The left side shows the detail card: portrait, tier, LV, ATK/DEF/RES/block/cost, an "攻击范围" mini-grid, bond icons, and tabs 特质/技能/特性/天赋/装备. You cannot rotate from this menu. | DATA + WIKI |
| Default facing | Every player-initiated placement goes through the wheel, so there is no silent default. Server-created board units (effects such as 外勤医疗 or 预备干员, bots, and back-compat) default to **RIGHT**, toward the gates. | ASSUMED |
| Persistence | Facing is stored with the position and survives round changes. At combat start units auto-deploy on their tiles **with their facing**, in order **top→bottom, then left→right** (盟约下). | DATA (positions) + COMM (order) |
| Melee, ranged, medic | All use the same wheel. The range preview uses the unit's own rangeGrid (heal range for medics). Blocking and "front" logic use the facing vector. | DATA |
| Summons (tokens) | Each summon is its own bench card ("干员及其召唤物会自动加入整备区"), placed and moved freely with the same wheel. Retreating the owner also retreats its summons. | WIKI |
| Equipment | Dragged onto a unit (board or bench). No direction step. A third item opens the equip-replace dialog (`UseEquipUp { charChessInstId, equipChessInstId, isChangeEquip, unloadInstId }`). | DATA |
| Arts (画卷 / 教鞭 / 神秘顾客) | Dragged onto a map tile. `UseMagicUp` carries `unitPositionInfo` including `direction`, so an Art also gets the direction step. For 画卷 (range `1-1`) the direction picks **which neighbouring tile** is "in front" and copied. | DATA + ASSUMED (wheel shown for Arts) |
| Range preview elsewhere | The shop card and detail panel "攻击范围" show a mini-grid (facing RIGHT). On the board the rotated range appears only in the wheel and on the selected unit. | DATA + VIDEO |
| Facing indicator | While not selected, an orange wedge "›" on the unit's ground ring points in the facing direction (prep and combat). The model is Front for RIGHT and DOWN, Front mirrored for LEFT, and Back for UP when a Back model exists. | VIDEO + DATA (ASSETS.md) |
| Final Assault, right side | The right-hand player's board is **mirrored left↔right**: `ConvertChessPositionInfoToBossMap` [DATA]; "分到右边只是把你的场地镜像翻转了过来,本来的蓝门方向和接敌方向并不会变" [COMM NGA t45645958]. Mapping: col `c → 20−c`, RIGHT↔LEFT, UP and DOWN unchanged. FA prep takes place on your own half of the shared field (camera `right_boss_prepare_camera_param (5.93,−6.68,−2.46,1)`), with the same wheel. | DATA + COMM |
---
## 2. Enemy preview pen in prep
### 2.1 Evidence
- **Level config, identical in all 11 stages [DATA]:** `configBlackBoard` has `enemy_place_rect "((14,7),(18,13))"`, `enemy_camera_param "(0,7.86,0.9,1)"` (camera shift toward the pen), `left_prepare_camera_param "(-4.67,0.3,-2.46,1)"` and `move_time 0.25`.
- **Pen tiles:** row 16 of the pen carries `previewNotAlloed=1`. Pen starts `S` sit at **(18,7)** and **(15,7)**.
- **Client class `Torappu.Battle.AutoChess.AutoChessEnemyPreviewManager` [DATA]:**
- Fields: `MAX_PREVIEW_CNT` (const, decoded **50**), `m_mapRowOffset`, `m_bossMapRowOffset`, `m_betterGridToSpawn`, `m_backUpTiles`, `m_gridSpawnStatus`, `m_previewEnemyData`, `m_spawnedEnemy`.
- Methods: `_GeneratePreviewData`, `_GetBetterGridToSpawn`, `_SpawnPreviewEnemies`, `_CompareActionByTime`.
- Related buff key: `autochess_enemy_preview_buff`. Config key name: `enemy_preview_row_offset`.
- Server data behind it:
- `AutoChessBattleRoundEnemyInfo { enemyType, enemyKey, actionIndex, count, round }`
- `AutoChessBattleEffectEnemyInfo { effectInstId, enemyId, count }`, the per-player extras (bounties, 特训) in `PreparationStatus.effectEnemies`.
- **Screenshot of the pen view [VIDEO]** (https://i.meee.com.tw/wdRAguq.png, round 4):
- A 7×5 tiled pen with red wire-cube gates at its left edge (top row and 4th row).
- The upper group stands by the top gate and the lower group by the lower gate. Row 16 is empty.
- Enemies stand individually in formation. Several share a tile as small clusters of the same kind. They face **left** and idle. No count badges, no HP bars.
- HUD: the left button shows **[🔍◀◀]** (mint) and the right button **[🔍]** (grey).
- **Prep screenshots [VIDEO]** (Bahamut c32edaf…, 4995084…; BV1H3wtziEbY):
- At the normal prep camera, the pen's front rows with their enemies are visible **at the top-right edge, behind the upper red gate**.
- The HUD shows **[🔍]** (mint, left of the round box) and **[🔍▶▶]** (amber, right of the LP).
- **Community tip [COMM Baha 12294]:** "前三回合怪只走下面的紅門(可以看右上角的放大鏡了解出怪路線)"; "右上角看敵方出怪(靠上面代表走上面那路,4T後才會出上路怪)".
- **HUD bindings [DATA]:**
- `btn_check_enemy` (funcId `autochessViewEnemy`, `OnToggleEnemyInfo`): sprites `btn_check_enemy` (🔍▶▶ amber) and `btn_check_enemy_unfold` (grey).
- `btn_check_player` (`OnTogglePlayerInfo`): sprites `btn_check_player_normal` (🔍 mint; opens the **本局信息** dialog `autochess_hud_player_info_dialog`: mode, strategy, disabled bonds and banned operators) and `btn_check_player_back` (🔍◀◀; returns from the pen).
- **Boss rounds:** `m_bossMapRowOffset` exists [DATA]. In the official FA prep image the boss stands on the boss field with its HP bar during prep [WIKI intro image, 最终攻势 section].
### 2.2 Spec
| Aspect | Rule | Tag |
|---|---|---|
| Content | Every enemy of **your** coming round: the wave-template actions after special-enemy replacement, plus your `effectEnemies` (bounties, 特训, 教鞭/神秘顾客). One figure per enemy instance, including flyers. Capped at **50** figures. | DATA (+ASSUMED that effect enemies are included) |
| Location | Pen rect rows 14–18 × cols 7–13 on the stage grid. Row 16 is never used. Enemies whose route starts at the **upper gate (12,10)** go to rows **17–18**; **lower gate (9,10)** to rows **14–15**. This matches a +6 row offset from the board gate to the pen gate (row 18/15). | DATA + VIDEO |
| Order and packing | Sort by spawn time (`_CompareActionByTime`: wave `preDelay + i·interval`). Fill from the tile nearest the pen gate (col 7) outward along the two rows (col 7→13). Allow several enemies per tile, offset slightly (the screenshot shows 3 on one tile). Proposal: up to 3 per tile before moving on, then `m_backUpTiles` = any free pen tile. | DATA (method names) + VIDEO + ASSUMED (3/tile) |
| Look | Enemy spine in **Idle**, facing **left**, not moving, no HP bar, no targeting, normal colours. No count badge and no bounty marker was observed. | VIDEO + ASSUMED (no bounty marker) |
| Visibility | The pen is part of the scene, so its front rows show at the top edge of the prep camera, behind the upper gate. **[🔍▶▶]** pans the camera to the pen (tween 0.25 s); **[🔍◀◀]** or the same button pans back. It is available only in prep (休整期; the tip says "休整期可以查看当前回合即将迎击的敌方单位"). The pen empties when combat starts. | DATA + VIDEO |
| Intel | Tap an enemy in the pen for its detail card (name, stats, abilities), as with a tapped enemy in battle. There is no separate list panel in the official game. The briefing and S.W.E.E.P. handbook list the match's special enemies and the leader. | ASSUMED (tap) / DATA (no list UI found) |
| Teammates | When you view a teammate's board in prep, their pen (their enemies) is shown too. | WIKI intro image |
| Boss round | During the boss round's prep, the leader and its escorts preview on the **boss field** (`m_bossMapRowOffset`). The boss stands with its HP bar next to its spawn. | DATA + WIKI |
---
## 3. Spectating, waiting, 联防, Final Assault, and the combat model
### 3.1 Official UI facts
**Data tips [DATA]:**
- "休整期可以查看队友的战场情况"
- "最终攻势中,两名参与者会处于同一个战场,但无法查看另一组队友的战场情况"
- "只有达成完美作战的队友可以进行联防"
- "进行联防时,盟约不会继续叠加"
**BWIKI 盟约 [WIKI]:** "如果感到无聊,博士可以在下一个休整期开始之前,通过点击队友头像前往其场地,查看他们的作战情况。"
**Official intro image [WIKI]:**
- Prep: "博士可以在休整期通过点击队友头像前往其场地,查看队友们的部署情况。"
- Screenshot: tapping a teammate's avatar expands a mint **"前往观战/前往查看"** button under it.
- While viewing, your own row becomes a white **"返回战场"** button, the viewed row gets an eye badge, and a bottom-centre pill shows **"👁 华法琳#"**.
- 联防 screenshot: the pill reads **"‹ 👁 阿米娅#"**. The HUD capsule shows `0/1` plus an orange runner tag `×1` (escaped count, `tag_miss`), and "层数叠加已禁用" under the bond disc.
**Player list item (`player_status_item`) [DATA]:**
- Nodes: `emoji_ref_rect`, `connect_status/{mask_dead, mask_reconnect, mask_quit}`, `icon_self` (green person), `icon_watch_other` (eye), `player_hp`, `action_status/{complete_part ✓, moving_part •••}`.
- `normal_ob_part/btn_ob_other` "前往查看"; `disable_ob_part` (`EventOnBtnObNotAvail`); `cancel_ob_part` "返回战场".
**Observe panel (`AutoChessBattleObInfoPanel`) [DATA]:**
- Nodes: `_textNickName`, `_textNickNumber`, `_obOtherPartGO` (👁 name#1234), `_obSelfPartGO` ("你自己"), `_obEmptyPartGO` ("无人在家"), `_obOverviewPartGO` (overview; design text "全屏", the shipped screenshot says **"全景"**), `_leftArrowGo`, `_rightArrowGo`.
- A player-quit overlay reads "当前玩家已离开 / 可自行切换查看其他玩家".
- Toasts: `AUTO_CHESS_BATTLE_ALREADY_OBSERVE` and `AUTO_CHESS_BATTLE_OBSERVE_NOT_AVAIL`.
**View model (`AutoChessBattlePlayerStatusGroupModel`) [DATA]:** `canObserveOther`, `canObLeft/canObRight`, `m_leftBattleObIdx/m_rightBattleObIdx`, `m_mapLayer` (`AutoChessBattleMapLayer {START, LEFT, MID, RIGHT, END}`), `isObOverview`, `FindFirstAvailObTarget`, `CheckIfCancelObValid`.
**Waiting and phases [DATA][VIDEO]:**
- Bottom tips (`panel_bottom_tips`): `TIP_WAIT_FINISH` = **"⌛ 作战结束,等待队友完成作战"**, plus `TIP_READY`, `TIP_HAND_FULL` and `TIP_HAND_OVERFLOW`.
- Bahamut screenshots after combat show only that pill, with **no** view switcher. Finished teammates show ✓ and fighting ones show •••.
- Round result dialog: "作战结束" + "全员无伤!" (perfect), or "生命值减少" plus an LP loss.
- HUD phase capsule: 休息一下 / 开始作战 / 联防开始.
- `battle_waiting_dialog` reads "模拟即将继续 / 正在等待当前阶段结束……" (reconnect or late join).
- Elimination: `self_dead_dialog` has KEEP_WATCH and QUIT_GAME options, and auto-observe via `DeadAutoObUp`/`DeadAutoObDn { obIndex, state, preparation }`.
**Spec**
| Situation | What you see and can do | Tag |
|---|---|---|
| Prep | Tap a teammate avatar → "前往查看" → their board, read-only, including their pen and bonds. Pill "👁 name#1234". "返回战场" or your own avatar returns. `ObserveUp{obIndex}` / `CancelObserveUp`. | DATA + WIKI |
| Own combat running | You watch **your own** field. Observing others is **not offered** ("如果感到无聊…下一个休整期开始之前" and `canObserveOther`). Tapping shows the NOT_AVAIL toast. | ASSUMED (consistent with WIKI and DATA) |
| Own combat finished, others still fighting | Pill "⌛ 作战结束,等待队友完成作战". Your field freezes as it ended. Avatars become tappable → "前往查看" to watch a teammate's live battle. | WIKI + VIDEO |
| 联防 (helpers) | Banner/dialog "联防阶段" (help_battle_dialog with the orange assist robot), capsule "联防开始", kill capsule `n/m`, bond strip "层数叠加已禁用". Helpers fight on the shared escaped field (`escaped_single` for 1 helper, `escaped_multi` for 2). With 2 helpers the enemies enter at col 18 and cross the **right half** (helper 2) and then the **left half** (helper 1), in series like the Final Assault. The pill offers ‹ LEFT half / 全景 / RIGHT half ›: "你自己" on your half, "👁 name#" on the partner's. | DATA + VIDEO |
| 联防 (non-helpers) | Same banner. The camera goes to the 联防 field as an observer, with the ‹ 👁 helper# › pill. Owners' LP drops live as enemies escape; the capsule's `tag_miss` shows the escaped count. | VIDEO + DATA |
| Final Assault | Your pair's field only. The pill offers ‹ LEFT / 全景 / RIGHT ›. The other pair cannot be observed (NOT_AVAIL). Team LP and the shared boss HP bar are in the capsule. After 150 s the team LP drains 1/s. | DATA + VIDEO |
| Eliminated or quit | Choose keep-watching or quit. Keep-watching auto-observes the first available field and can switch freely. A quit target shows "当前玩家已离开". | DATA |
### 3.2 Official combat computation model [DATA: client il2cpp class and field names]
The scene server is separate. `ActAutoChessSyncInfoBattleInfo { sceneId, address, token, modeId, endTime, curRound }` gives a dedicated address and token.
**Server → client:**
| Message | Contents / use |
|---|---|
| `AllStateSyncDn` | `{ modeId, stageId, bossId, hiddenBossId, **stageSeed**, uidIndex, bannedBonds, staticPlayers, sceneDetail }` |
| `PreparationStatusDn` | `{ uidIndex, hp, round, store, charChess, equipChess, trapChess, positions, bonds, selfChoice, maxDeploymentCnt, effectEnemies, roundAnalytics }`: the server is authoritative for prep. |
| `ChangePositionDn` | `{ boardStatus }`: every player's board, so every client knows every board. |
| `StepDn` | `{ seq, duration, actions: [{ playerUidIndex, operate, paramList }] }`, where `operate` ∈ ENEMY_KILLED, ENEMY_ESCAPED, COST_BOSS_HP, SUMMONED_ENEMY_ESCAPED, ENEMY_APPEAR, SUMMONED_ENEMY_APPEAR, ENEMY_FINISHED, BOSS_STATE_CHANGED, BATTLE_STATE_CHANGED, PLAYER_STATUS_CHANGED. |
| `HistoryDn` | `{ steps }`, for catch-up. |
| `ChatDn`, `BroadcastDn`, `PlayerStatusDn`, `PlayerKickDn` | Emotes, ticker, player status, kicks. |
| Per-round battle data | `SelfBattleInfo { enemyInfos: [{ enemyId, actionIndex, instIdList }] }` |
| 联防 setup | `HelpBattleInfo { escapedEnemies: [{ ownerPlayerIndex, enemyId, enemyInstId, isToken }], players: [{ deployment, charBattleStatusList: [{ instId, hp, tech }] }] }` |
| Boss setup | `BossRoundInfo { isHiddenBoss, bossId, hp, groupInfos: [{ group, players }] }` and `BossBattleInfo { group, bossHp, enemyCount, enemyGroup, players }` |
**Client → server:**
| Message | Contents / use |
|---|---|
| Prep intents | `ShopBuyChessUp{slotId,isSpecial}`, `ShopRefreshUp`, `ShopFrozeUp{isFrozen}`, `ShopUpgradeUp`, `ChangePositionUp{deployCount,unitPositionInfo}`, `UseEquipUp`, `UseMagicUp`, `DestroyChessUp{chessInstId,chessType}`, `SelfChoicePickUp`, `SpPreparationPickUp`, `PreparationReadyUp{readyType: READY/CANCEL_READY}`, `ActionUp{seq,actions}` |
| Own combat | `SelfBattleKillEnemyUp{record{enemyInstId,attackerInstId,damageSrc}}`, `SelfBattleEnemyEscapeUp{enemyInstId,isToken}`, `SelfBattleAddBondUp{charInstId,layerDelta,bondIndexList}` (<战斗中> stacks), `SelfBattleInfoUp{round,charBattleStatusList,charBattleInfoList}`, `SelfBattleFinishUp{round,fromRejoin,escapedInstIds,escapedTokenInstIds,killedInsts,charBattleStatusList}` |
| 联防 | `HelpBattleKillEnemyUp{enemyInstId,isToken,killedByPlayer,attackerInstId,damageSrc}`, `HelpBattleEnemyEscapeUp`, `HelpBattleFinishUp{round,fromRejoin,escapedEnemies,killInfos}` |
| Other | `ObserveUp{obIndex}`, `ChatReq{emojiGroup,emojiId}`, `BroadcastUp{uIdx,broadcastId,strParams}` (clients announce their own damage milestones), `LoadingReadyUp{roundEnemies}`, `PauseUp`/`ResumeUp` (solo only), `QuitUp` |
**What this means:**
1. **Combat is client-simulated.** The server never sends unit positions. It receives outcome events and relays them in `StepDn`, which drives the teammates' capsules and LP.
2. Every client holds everything needed to **replay any teammate's battle deterministically**: `stageSeed`, all boards with directions, and enemy lists. Observing a teammate most likely runs a local replica sim, with `StepDn` as the authoritative counters [ASSUMED].
3. The 联防 is set up by the server from the reported leaks and the helpers' end-of-combat HP/SP (`tech`). The helpers' clients simulate it and report `killedByPlayer`, which drives the bounty payout to whichever helper killed.
4. The boss HP is a server-side pool. Pair clients report `COST_BOSS_HP`. Bahamut 12294 notes "網速不好/幀率過低可能會導致boss戰傷害丟失" [COMM].
5. This also matches the general Arknights design: single-player battles have only `battleStart`/`battleFinish` endpoints in every community server emulator (LocalArknights, OpenDoctoratePy) [COMM].
---
## 4. Emotes
### 4.1 Official rules
| Item | Value | Tag |
|---|---|---|
| Message | `AutoChessBattleSceneChatReq { emojiGroup, emojiId }` → `ChatDn`; `AutoChessChatData { uid, emojiGroup, emojiId }`. There is no free text anywhere in 盟约. | DATA |
| Bubble | `autochess_emoji_pop_item = bkg (emoji_bubble_bkg 108×97, a dark rounded square with a tail pointing left) + emoji_icon`. **Icon only, no text.** Anchored at `player_status_item/emoji_ref_rect`, i.e. to the right of the sender's avatar in the left player list. `AutoChessEmojiPopPanel { _popupTime, _chatItemAnim, … }`: pop animation, shown for `chatTime` = **3 s**. A new emote replaces the old one (`m_switchTween`). | DATA |
| Button | Bottom-left **交流** (`emoji_btn` 151×55 with a robot-face icon; `AUTO_CHESS_EMOJI_BTN`). It is greyed (`emoji_btn_disable`) during the cooldown `chatCD` = **1 s** (`_DisableEmojiBtnForAWhile`). | DATA |
| Panel | `autochess_emoji_panel`: `emoji_bkg` (313×231) with a horizontal `scroll_pager`, one page **per theme**, `dot_content` pager dots, and 6 cells (`emoji_cell_bkg` 85×85, 3×2). Swipe left/right to change theme ("在表情发送界面左右滑动来切换主题" [WIKI]). Tapping a cell sends it and closes the panel, with a click animation. The client remembers the last-used theme (`ClearLastUseEmoticonThemeId`). | DATA + WIKI |
| Scenes | `AUTOCHESS_ROOM` = room/lobby (10 emotes, basic theme). `AUTOCHESS_BATTLE` = in match (6 per theme). Themes are enabled by `enabledEmoticonThemeIdList`; the free ones are `isBasic: true`. We unlock all. | DATA |
### 4.2 All emotes (`display_meta_table.emoticonData`, zh_CN) [DATA]
`desc` is `null` for every autochess emote, so any label is ours and is used only as a tooltip or aria-label, never displayed. The suggested labels come from BWIKI where one exists: basic_2 and slug from the BWIKI tabs, basic ones [ASSUMED].
**Theme `emoticon_autochess_basic`** (sortId 100000, isBasic)
| id | scene | sortId | picId | label (ours) |
|---|---|---|---|---|
| autochess_battle_happy | BATTLE | 1001 | pic_happy_battle | 开心 |
| autochess_battle_scared | BATTLE | 1002 | pic_scared_battle | 害怕 |
| autochess_battle_sorry | BATTLE | 1003 | pic_sorry_battle | 对不起 |
| autochess_battle_thanks | BATTLE | 1004 | pic_thanks_battle | 谢谢 |
| autochess_battle_thinking | BATTLE | 1005 | pic_thinking_battle | 思考 |
| autochess_battle_nice_cooperate | BATTLE | 1006 | pic_cooperate_battle | 合作愉快 |
| autochess_room_hello … working (10) | ROOM | 1–10 | pic_hello, pic_thanks, pic_happy, pic_question, pic_sorry_2, pic_scared, pic_thinking, pic_waiting, pic_cooperate, pic_working | room only; **sprites not present in the local client bundles** |
**Theme `emoticon_autochess_basic_2`** (sortId 100001, isBasic; new in 下半)
| id | sortId | picId | label (BWIKI) |
|---|---|---|---|
| autochess_battle_noproblem | 1007 | pic_noproblem_battle | 没问题! |
| autochess_battle_respect | 1008 | pic_respect_battle | 敬礼! |
| autochess_battle_call | 1009 | pic_call_battle | 欢呼! |
| autochess_battle_playingcool | 1010 | pic_playingcool_battle | 酷! |
| autochess_battle_sad | 1011 | pic_sad_battle | 伤心 |
| autochess_battle_dying | 1012 | pic_dying_battle | 快死了 |
**Theme `emoticon_originium_slug`** (sortId 1001; 促融共竞#1 reward)
| id | sortId | picId | label (BWIKI) |
|---|---|---|---|
| slug_autochess_battle_nice_work | 2001 | pic_nice_work_battle | 合作愉快! |
| slug_autochess_battle_thanks | 2002 | pic_thanks_battle | 谢谢! |
| slug_autochess_battle_sorry | 2003 | pic_sorry_battle | 对不起! |
| slug_autochess_battle_bye | 2004 | pic_bye_battle | 再见! |
| slug_autochess_battle_distrust | 2005 | pic_distrust_battle | ??? |
| slug_autochess_battle_very_soon | 2006 | pic_very_soon_battle | 很快就好! |
**Theme `emoticon_foolsday_doctor`** (sortId 1002; paid April Fools)
| id | sortId | picId |
|---|---|---|
| autochess_battle_fooldoctor_01 | 1020 | pic_fooldoctor_01_battle |
| autochess_battle_fooldoctor_02 | 1021 | pic_fooldoctor_02_battle |
| autochess_battle_fooldoctor_03 | 1022 | **pic_fooldoctor_04_battle** |
| autochess_battle_fooldoctor_04 | 1023 | **pic_fooldoctor_05_battle** |
| autochess_battle_fooldoctor_05 | 1024 | **pic_fooldoctor_06_battle** |
| autochess_battle_fooldoctor_06 | 1025 | **pic_fooldoctor_08_battle** |
Note that ids 03–06 map to pics 04/05/06/08. Use `picId`, never the id suffix.
**Theme `emoticon_foolsday_amiya`** (sortId 1003): `autochess_battle_foolamiya_01…06`, sortId 1040–1045, `pic_foolamiya_01…06_battle`.
**Theme `emoticon_foolsday_wisdel`** (sortId 1004): `autochess_battle_foolwisdel_01…06`, sortId 1060–1065, `pic_foolwisdel_01…06_battle`.
**Page order:** proposed as `enabledEmoticonThemeIdList` order, i.e. basic, slug, basic_2, fooldoctor, foolamiya, foolwisdel [ASSUMED]. Alternatively sort by theme sortId.
### 4.3 Local client sprites (all contain **no text**) [DATA]
All in `StreamingAssets/AB/Windows/ui/emoticon/theme/`:
| Bundle | Sprites |
|---|---|
| `[uc]emoticon_autochess_basic.ab` | pic_happy_battle, pic_scared_battle, pic_sorry_battle, pic_thanks_battle, pic_thinking_battle, pic_cooperate_battle (120×120 each; mint octagon glyphs) |
| `[uc]emoticon_autochess_basic_2.ab` | pic_noproblem_battle, pic_respect_battle, pic_call_battle, pic_playingcool_battle, pic_sad_battle, pic_dying_battle (120×120) |
| `[uc]emoticon_originium_slug.ab` | pic_nice_work_battle, pic_thanks_battle, pic_sorry_battle, pic_bye_battle, pic_distrust_battle, pic_very_soon_battle (≈102–112×79–99), plus room and pick pics (pic_hello, pic_bye, pic_coming, pic_distrust, pic_exchange, pic_hurry, pic_mefirst, pic_nice_work, pic_sorry, pic_thanks, pic_ufirst, pic_very_soon) and pic_bg |
| `[uc]emoticon_foolsday_doctor.ab` | pic_fooldoctor_{01,02,04,05,06,08}_battle (≈114×115) |
| `[uc]emoticon_foolsday_amiya.ab` | pic_foolamiya_0{1..6}_battle (110–115×115) |
| `[uc]emoticon_foolsday_wisdel.ab` | pic_foolwisdel_0{1..6}_battle (112×115) |
The autochess **room** pics (pic_hello, pic_question, pic_waiting, pic_working, pic_sorry_2, …) are **not** in any local bundle. Keep room emotes out of scope, or reuse the battle glyphs.
---
## 5. Other prep interactions we may have missed
| Item | Official | Tag |
|---|---|---|
| Sell | Tap the unit (board **or bench**) → underframe → **出售** with a "+1" coin badge (funcId `autochessSale`; PC hotkey slot `pc_key_holder`). There is no drag-to-sell. | DATA + WIKI |
| Retreat | Drag the unit onto the bench (整备区) ("拖拽干员至整备区可以直接撤退干员"), or use the underframe 撤退 button. | DATA + WIKI |
| Items and Arts | Cannot be sold. Tap → underframe **销毁** (`_underFramePanelBtnParamDestroyPos`, `icon_destory`) → `DestroyChessUp`. | DATA |
| Buy | Two taps: the first tap on a shop card shows its detail and the **确认购买** state (`EventOnFirstClick`); the second buys (`EventOnConfirm`). If it can't be bought the card shows **无法购买**. | DATA |
| Upgrade | The same two-step confirm (`EventOnFirstClick` → `EventOnUpgrade`). At level 6 it shows **已满级**; a disabled click without funds gives `EventOnUpgradeNotEnough`. | DATA |
| Ready | 准备完成 / 取消准备 (`PreparationReadyUp` READY / CANCEL_READY), until everyone is ready. | DATA |
| Strategy draft | A strategy picked by a teammate is marked **队友已选** (guidebook page 策略与轮选, `autochess_handbook_4`), so duplicate strategies are **not** allowed. This corrects the "Allowed" assumption in 06 §4.2. | DATA (guide image) |
| Auto-arrange | None found. | DATA (no such UI) |
| Deploy order at combat start | Top→bottom, then left→right (盟约下; it matters for taunt). The prep pieces vanish and redeploy with the deploy animation. | COMM + VIDEO |
| Info | The left 🔍 opens 本局信息: mode, your strategy, disabled bonds with the banned operators. | DATA |
---
## 6. Changes required in our implementation
### 6.1 Facing (user item 7)
1. **Protocol.**
- `g.move {uid, to:{area:'board',row,col}, dir}` with `dir ∈ 'UP'|'RIGHT'|'DOWN'|'LEFT'`; the server defaults to `'RIGHT'` when absent, for bots and old clients.
- `g.art {itemUid,row,col,dir}`.
- Broadcast `dir` in the board state (public and private views, snapshot `UnitInfo.dir`).
2. **`server/match/PlayerState.js`.**
- Store `piece.dir`, preserved across rounds.
- In `_moveChessToBoard` and `_moveTokenToBoard`, `if (occ === piece) return OK;` must become "update `piece.dir`, recompute, OK". This is the in-place re-orient.
- Swaps keep each piece's own dir.
- Pieces spawned onto the board by effects get `'RIGHT'`.
- `useArt` passes dir to the 画卷 "front" lookup.
3. **Sim (`server/sim`).**
- Replace scalar `facing ±1` with a direction vector `(fr, fc)` ∈ {(1,0) UP, (0,1) RIGHT, (−1,0) DOWN, (0,−1) LEFT} (row 0 = bottom). Keep `facing` as a derived helper only where a horizontal sign is needed for sprite flip.
- `targeting.absoluteRangeKeys(grid, r, c, dir, extend)`: apply `rangeExtend` along +dCol **before** rotating. Rotation per DATA.md: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)`.
- Every "in front / behind / left-right of" helper uses the vector:
- `bonds/core.js` `S.frontTile` and the devour order ("更靠左" = back-most along the facing)
- `professions.js` front checks (the `(x − unit.x)·facing` tests)
- `skills.js` trigger grids
- `tokens.js` placement grids and Mech/device helpers
- kits tier3 (`displace` direction, 薄绿 pull tile) and tier4 device placement
- `content/devices.js` blower relation: `equal` if the operator dir == blower dir, `opposite` if reversed, otherwise none. Today only fx is compared, so the DOWN blowers never buff anyone.
- Items 叙拉古正装 (perpendicular pair), 歌利亚头盔 (front tile), 画卷 `1-1`.
4. **`server/match/finalAssault.js`.** The right-side mirror becomes col `c→20−c` with dir RIGHT↔LEFT; UP and DOWN are kept (not a flat `facing=−1`). FA prep on the right half shows the mirrored layout, and moves there go through the wheel in mirrored coordinates.
5. **Client.**
- `public/js/render/drag.js` + `ui/gameLogic.js`: after a legal drop on a board tile (also the same tile), enter a **direction step** instead of sending `g.move` immediately. Draw the white diamond with 4 chevrons, the red "✕ 点击取消" tag at its upper-left, and live orange-striped rotated range tiles. Show the tooltip "拖回中心区域取消" when outside the centre. Mouse or touch drag from the centre with a ~0.5-tile dead-zone; release sends `g.move{…,dir}`; release in the centre or tap ✕ cancels.
- The same flow applies to Arts (`g.art`).
- `render/units.js`: the orange ground wedge rotates to `dir`; model choice Back for UP (as ASSETS.md already says) and Front/mirror otherwise. Remove the "no facing step" comments.
- **Tap a board unit:** show its range tiles and the underframe with 撤退 / 出售 +1 (see 6.5).
6. **Docs.** `DESIGN.md` §3 "Facing: operators have no facing step" and `06-multiplayer-ux.md` §11.2 "There is no facing step in auto-chess [ASSUMED]" are **wrong**. Replace them with §1.2 of this file.
### 6.2 Enemy preview pen (user item 2)
1. **Server.** Extend the private prep state `nextEnemies` to entries `{enemyKey, gate:'upper'|'lower', t (spawn time s), fly, source:'wave'|'effect'}`, one per instance or grouped with counts. Include bounty, 特训, 教鞭 and 神秘顾客 extras. For boss rounds add `{boss:true}` entries targeting the boss field.
2. **Renderer (`public/js/render/app.js`).** The pen rows 14–18 are already drawn as scenery (`bandFor` [6,18]).
- Add a `PreviewPen` layer that spawns idle, left-facing enemy spines per §2.2: rect ((14,7),(18,13)), skip row 16, upper rows 17–18, lower rows 14–15, time-sorted, ≤3 per tile with jitter, cap 50.
- No HP bars and no simulation. Clear it at combat start.
- Enemy spines are already loaded per round, so reuse the impostor/LRU budget (≤50 extra cheap skeletons; use `impostor.js` beyond ~20 for performance).
3. **Camera.** Make the prep preset keep the pen's front edge visible at the top (official look). Add `setCamera('pen')`, a 0.25 s pan toward rows 14–18, driven by a HUD toggle: right **[🔍▶▶]**, amber; in the pen view the left button becomes **[🔍◀◀]** and the right one greys out.
4. **UI.** `ui/enemyDrawer.js` (our list drawer) is not official. Make the pen the primary view and keep the drawer only as the tap-an-enemy detail source, or behind the left 🔍 "本局信息" dialog. When viewing a teammate in prep, render **their** pen.
### 6.3 Spectating and combat flow (user item 5, UX part)
1. Remove the always-visible `‹ 自己 ›` switcher from normal combat (`ui/combatHud.js`).
2. Normal combat, own battle running: team avatars are **not** watchable (toast "当前无法查看").
3. After your own battle ends:
- pill "⌛ 作战结束,等待队友完成作战"
- tapping an avatar expands a "前往查看" button; while observing, your row shows "返回战场", the target row an eye badge, and the bottom pill "👁 {name}#{number}"
4. Prep: the same avatar → 前往查看 flow (read-only board + pen).
5. 联防 and FA fields: the ‹ › pill cycles LEFT half / 全景 / RIGHT half, with captions "你自己" / "👁 name#" / "全景". Non-helpers are auto-moved to the 联防 field as observers. In FA the other pair's field is not selectable.
6. Eliminated players: a dialog offers 继续观战 (auto-observe the first live field, free switching) or 退出.
7. `server/match/Match.js watch()` must enforce the same rules (prep: any teammate; combat: only if your own field finished; FA: own pair only; dead: anything).
### 6.4 Combat computation model (user item 5, architecture) [proposal based on §3.2]
Mirror the official split. This lets a low-power Windows mini-PC host the game.
1. **Server (authoritative, cheap).**
- Lobby, economy, shop, pool, boards with dir, 机变, bounties, LP, phase timers, seeds.
- Per round it sends each client `{seed, round, boards of all players, per-player enemy list (instIds), stage}`.
- It **does not run battles**. It keeps the existing `server/sim` for bots-only rooms and for optional verification (run a player's battle at low priority after the round and compare the kill/escape set; flag the player on mismatch; don't block).
2. **Client.** Runs `server/sim` in a Web Worker. It is already deterministic: no `Math.random`/`Date.now`; only `simdata.js` uses `node:fs` and needs a fetch-based loader.
- Fixed tick 1/30 at 2×.
- Report `kill{enemyInstId, attackerUid, src}`, `escape{enemyInstId}` and `bondDelta{uid, delta, bonds}` as they happen.
- At the end report `finish{round, escapedInstIds, killedInstIds, unitStatus:[{uid,hpRatio,sp}], stats}`.
- The server relays counters to teammates, like the official `StepDn`.
3. **Divergence rules.**
- The server only accepts instIds it issued.
- An instId is counted once, first report wins.
- A missing `finish` by `maxPlayTime + grace(10 s)` means everything not reported killed is escaped (the official timeout rule: "超出一定时间,则视为剩余敌人入侵成功").
- Reconnect: the client resubmits with `fromRejoin`, or the server re-sims that board from the seed.
4. **联防.**
- The server builds `{escapedEnemies:[{ownerId, enemyKey, instId}], helpers:[{playerId, deployment(with dir), unitStatus(hp,sp)}], seed}` and sends it to **all** clients.
- One helper (the lowest seat) is the **authoritative simulator**. Other clients, including the second helper, run a replica for display only.
- The server accepts the authority's kill/escape/finish reports. On authority disconnect the next helper takes over, or the server sims it.
5. **Final Assault.** Per pair field, the lower seat is authoritative and reports `bossDamage` deltas and kills. The server owns the shared boss HP (sum of both fields) and the team LP drain, and broadcasts the HP at ~5 Hz.
6. **Observing.** The observer runs a local replica of the target field from the same inputs, fast-forwarded to the server's elapsed round time. Official-style counters come from relayed events. This needs no snapshot streaming from the server, so bandwidth ≈ 0.
7. **Compatibility.** Keep `b.snap` streaming as a fallback for very weak clients (a server-side sim flag per room).
### 6.5 Prep interactions
1. **Sell.** Tap the unit (board or bench) → underframe with **出售 +1** (and 撤退 on board units). Remove any drag-to-sell zone.
2. **Items and Arts.** Tap → **销毁** only.
3. **Shop.** First tap shows the card detail and "确认购买"; the second tap buys.
4. **Upgrade.** First tap arms "升级" confirm; the second tap upgrades.
5. **Strategy draft.** Disallow a strategy already taken by a teammate; show "队友已选".
### 6.6 Emotes (user item 6)
1. `shared/constants.js`:
- Replace `EMOTES/EMOTE_THEME/EMOTE_TEXT` with the 6 themes × 6 battle ids of §4.2 (`{themeId, id, sortId, picId}`).
- Delete `EMOTE_TEXT` from display paths. Keep only an aria-label map.
- Validate `g.emote {theme, id}` server-side, with a 1 s cooldown (already there).
2. `public/js/ui/emotes.js`:
- `EmoteBubble` renders **the picture only**: dark rounded square, tail toward the avatar, 3 s, pop animation, replace on a new emote.
- `EmoteWheel`: a pager with one page per theme (3×2 grid of 6), pager dots, swipe or arrow keys, remembering the last theme (localStorage).
- The button is disabled for 1 s after sending.
- Fallback when art is missing: a neutral glyph, never text.
3. `tools/local-extract/extract.py` gains 4 more bundles:
- `ui/emoticon/theme/[uc]emoticon_originium_slug.ab` → `emoticon/slug` (only the `*_battle` sprites)
- `[uc]emoticon_foolsday_doctor.ab` → `emoticon/fooldoctor`
- `[uc]emoticon_foolsday_amiya.ab` → `emoticon/foolamiya`
- `[uc]emoticon_foolsday_wisdel.ab` → `emoticon/foolwisdel`
- Paths are keyed by `picId` (note fooldoctor 03–06 → pic 04/05/06/08).
- Update `data/local-assets.json` and `emoteArtPath(theme, picId)`.
4. Bot emotes (if any) use the same ids.
---
## 7. Corrections to earlier research
| File | Old statement | Correct |
|---|---|---|
| 06 §11.2 / DESIGN §3 | "There is no facing step in auto-chess" | 4-direction AK deploy wheel (§1) |
| 06 §11.2 | "Selling [ASSUMED drag onto the funds card]" | 出售 +1 on the unit's selection diamond (§5) |
| 06 §4.5 / §11.3 | "A bottom view switcher ‹ 自己 › cycles through teammates' live battles" | No switcher in normal combat; avatar → 前往查看 after your own combat ends; ‹ › only in 联防/FA fields (§3.1) |
| 06 §4.5 | "All boards fight at the same time on the server" | Each client simulates its own board; the server aggregates events (§3.2) |
| 06 §11.1 | "Enemy-preview button … a separate tiled grid" | The pen is part of the stage scene behind the gates (rows 14–18), visible at the top edge in prep; the button pans the camera (§2) |
| 06 §9.1 | 3 free themes + 3 paid, emote wording "没问题!…" | Correct themes; bubbles are image-only; `desc` is null; the room pics are missing from the local client (§4) |
| DESIGN §3 | "Enemy preview pen … (we show the preview in DOM instead)" | Render the preview in the pen (§6.2) |
| 06 §4.2 | "Duplicate strategies [ASSUMED] Allowed" | Not allowed: "队友已选" (§5) |
---
## 8. Sources
**Local client (Windows build, CrossOver bottle).**
- `…/AB/Windows/ui/autochess/[uc]autochessbattle.ab`: GameObjects, Text ids, sprites, onClick bindings.
- `…/ui/emoticon/theme/*.ab`
- `…/arts/guidebookpages/[pack]autochess.ab`: 19 in-game help pages, including 休整期 (orange facing wedge) and 策略与轮选 (队友已选).
- `…/Arknights_Data/il2cpp_data/Metadata/global-metadata.dat` (v29): class and field names and the `MAX_PREVIEW_CNT` constant.
**Game data.**
- `.cache/gamedata/excel/activity_table.json` `autoChessData` (gameTipsList, constData chatCD/chatTime, enabledEmoticonThemeIdList)
- `levels/activities/act*autochess/level_*_m0*.json` configBlackBoard
- `level_act1autochess_escaped_{single,multi}.json`
- `display_meta_table.json` emoticonData (Kengxxiao zh_CN, downloaded to the scratchpad)
**Official.** 「卫戍协议:盟约」玩法介绍 long image: https://img.71acg.net/sykb~bbs/pc/1763033996225879
**Wiki.** BWIKI 盟约: https://wiki.biligame.com/arknights/盟约 (互动, 联防, 观看队友)
**Videos.**
- BV1tFUAYnEfZ (新手引导实况, 4:56–5:14: deploy wheel)
- BV1H3wtziEbY (全攻略教程, 下半 prep and pen)
- BV1h6DjBoE3T (身前一格 garrisons)
- BV1NbCiBHELD
- BV11cg36qEfA
**Screenshots.**
- https://i.meee.com.tw/wdRAguq.png (pen)
- GwtDIAW (联防)
- YRY8ar1 (FA ‹全景›)
- Bahamut c32edaf…, 4995084…, 05db364c…, fc6673cd… (prep and "等待队友完成作战")
**Community.**
- Bahamut https://forum.gamer.com.tw/C.php?bsn=33651&snA=12294 (放大镜出怪路线, boss-damage loss, deploy order)
- Bahamut https://forum.gamer.com.tw/C.php?bsn=33651&snA=12316
- NGA https://nga.178.com/read.php?tid=45645958 (FA mirror, 朝向)
+2133
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "stronghold-protocol-covenant",
"version": "0.1.0",
"private": true,
"description": "明日方舟「卫戍协议:盟约」同人联机网页复刻",
"type": "module",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"test": "node --test",
"vendor": "node tools/vendor.mjs",
"build-data": "node tools/build-data.mjs",
"assets": "node tools/vendor.mjs && node tools/fetch-assets.mjs",
"postinstall": "node tools/vendor.mjs",
"setup": "node tools/setup.mjs",
"doctor": "node tools/doctor.mjs",
"launch": "node scripts/launch.mjs"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"htm": "^3.1.1",
"pixi-spine": "^4.0.6",
"pixi.js": "^7.4.2",
"preact": "^10.29.8",
"three": "^0.186.1",
"ws": "^8.22.0"
},
"devDependencies": {
"puppeteer-core": "^23.11.1"
}
}
+938
View File
@@ -0,0 +1,938 @@
/* ==========================================================================================
components.css — styles for js/ui/components.js and js/ui/toasts.js.
Sizes in rem (1rem = 100 design px @1080p). Motion 150–250 ms.
========================================================================================== */
/* ---- icon ------------------------------------------------------------------------------------ */
.icon { fill: currentColor; flex: none; display: inline-block; vertical-align: middle; }
/* ---- button ------------------------------------------------------------------------------------ */
.btn {
--btn-bg: #1a201d;
--btn-bg-hover: #222a26;
--btn-fg: var(--text-hi);
--btn-bd: var(--line-2);
--btn-bd-hover: var(--mint-700);
--btn-glow: rgba(78, 216, 175, .25);
--bk-c: var(--mint-500);
--bk-l: .1rem;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .1rem;
height: .5rem;
min-width: .5rem;
padding: 0 .26rem;
border: 1px solid var(--btn-bd);
background: var(--btn-bg);
color: var(--btn-fg);
font-size: .18rem;
font-weight: 700;
letter-spacing: .06em;
line-height: 1;
white-space: nowrap;
outline-offset: 3px;
transition: background-color var(--t-fast) var(--ease-out), border-color var(--t-fast) var(--ease-out),
color var(--t-fast) var(--ease-out), box-shadow var(--t-med) var(--ease-out), transform var(--t-fast) var(--ease-out),
filter var(--t-fast) var(--ease-out);
}
.btn::after {
/* hover bracket corners */
content: '';
position: absolute;
inset: -4px;
pointer-events: none;
opacity: 0;
background:
linear-gradient(var(--bk-c), var(--bk-c)) top left / var(--bk-l) 2px no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) top left / 2px var(--bk-l) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) top right / var(--bk-l) 2px no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) top right / 2px var(--bk-l) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom left / var(--bk-l) 2px no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom left / 2px var(--bk-l) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom right / var(--bk-l) 2px no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom right / 2px var(--bk-l) no-repeat;
transform: scale(1.04);
transition: opacity var(--t-fast) var(--ease-out), transform var(--t-med) var(--ease-out);
}
.btn:not(:disabled):hover {
background-color: var(--btn-bg-hover);
border-color: var(--btn-bd-hover);
box-shadow: 0 0 .18rem var(--btn-glow);
}
/* two rules: a selector list with :focus-visible is dropped whole by Safari < 15.4 */
.btn:not(:disabled):hover::after { opacity: 1; transform: scale(1); }
.btn:focus-visible::after { opacity: 1; transform: scale(1); }
/* touch screens have no hover: a tap must not leave the hover glow stuck on */
@media (hover: none) { .btn:not(:disabled):hover::after { opacity: 0; transform: none; } }
.btn:not(:disabled):active { transform: translateY(1px); filter: brightness(.94); }
.btn:focus-visible { outline: none; }
.btn__icon { width: 1.15em; height: 1.15em; }
.btn__label { position: relative; }
.btn__spin {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* filled variants carry a hatch strip on the right end (in-game "准备就绪" look) */
.btn--primary, .btn--amber, .btn--ice, .btn--danger {
background-image:
repeating-linear-gradient(-55deg, rgba(0, 0, 0, .16) 0 2px, transparent 2px 6px),
linear-gradient(180deg, rgba(255, 255, 255, .18), rgba(255, 255, 255, 0) 45%);
background-size: .34rem 100%, 100% 100%;
background-position: right center, 0 0;
background-repeat: no-repeat;
}
.btn--primary {
--btn-bg: var(--mint-500);
--btn-bg-hover: var(--mint-400);
--btn-fg: var(--text-on-mint);
--btn-bd: #6fe8c4;
--btn-bd-hover: #9dffe2;
--btn-glow: rgba(23, 249, 183, .45);
--bk-c: var(--mint-400);
}
.btn--amber {
--btn-bg: var(--amber);
--btn-bg-hover: #ffb640;
--btn-fg: #1d1203;
--btn-bd: #ffc15e;
--btn-bd-hover: #ffd58c;
--btn-glow: rgba(246, 163, 41, .45);
--bk-c: var(--amber);
}
.btn--ice {
--btn-bg: var(--ice);
--btn-bg-hover: #b9e0ff;
--btn-fg: #0b1b29;
--btn-bd: #c4e5ff;
--btn-bd-hover: #e2f2ff;
--btn-glow: rgba(159, 212, 255, .45);
--bk-c: var(--ice);
}
.btn--danger {
--btn-bg: #a3241a;
--btn-bg-hover: #c42d20;
--btn-fg: #fff;
--btn-bd: #c83a2c;
--btn-bd-hover: #ff5a47;
--btn-glow: rgba(231, 49, 24, .45);
--bk-c: var(--red-premium);
}
.btn--secondary { --btn-bg: rgba(20, 24, 22, .9); }
.btn--ghost {
--btn-bg: transparent;
--btn-bg-hover: rgba(78, 216, 175, .06);
--btn-fg: var(--text-lo);
--btn-bd: transparent;
--btn-bd-hover: transparent;
--btn-glow: transparent;
}
.btn--ghost:not(:disabled):hover { color: var(--text-hi); }
.btn.is-active { --btn-bd: var(--mint-500); --btn-fg: var(--mint-400); box-shadow: inset 0 0 0 1px var(--mint-a35); }
.btn--primary.is-active { --btn-fg: var(--text-on-mint); }
.btn:disabled {
cursor: not-allowed;
--btn-bg: #171c1a;
--btn-fg: var(--text-dim);
--btn-bd: var(--line);
background-image: none;
box-shadow: none;
}
.btn--primary:disabled {
--btn-bg: #0f1d19;
--btn-fg: var(--mint-700);
--btn-bd: var(--mint-800);
background-image: repeating-linear-gradient(-55deg, rgba(78, 216, 175, .06) 0 2px, transparent 2px 6px);
background-size: .34rem 100%;
background-position: right center;
background-repeat: no-repeat;
}
.btn.is-loading { cursor: progress; --btn-fg: var(--text-lo); }
.btn--primary.is-loading { --btn-bg: var(--mint-700); --btn-fg: var(--text-on-mint); }
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: .15rem; gap: .07rem; }
.btn--lg { height: .64rem; min-width: .64rem; padding: 0 .34rem; font-size: .22rem; gap: .12rem; }
.btn--xl { height: .84rem; min-width: .84rem; padding: 0 .48rem; font-size: .28rem; letter-spacing: .24em; gap: .16rem; }
.btn--block { display: flex; width: 100%; }
.btn--square { padding: 0; width: var(--sq, .5rem); }
.btn--square.btn--sm { --sq: .36rem; }
.btn--square.btn--lg { --sq: .64rem; }
.btn--square.btn--xl { --sq: .84rem; }
.btn--square .btn__icon { width: 50%; height: 50%; }
/* ---- panel ------------------------------------------------------------------------------------- */
.panel {
--bk-c: var(--mint-500);
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .016) 0 1px, transparent 1px 3px),
linear-gradient(180deg, rgba(30, 36, 33, .94), rgba(18, 22, 20, .94));
border: 1px solid var(--line);
box-shadow: 0 .12rem .3rem rgba(0, 0, 0, .35);
}
.panel--mint { --bk-c: var(--mint-500); }
.panel--gold { --bk-c: var(--gold); }
.panel--amber { --bk-c: var(--amber); }
.panel--orange { --bk-c: var(--orange-hi); }
.panel--red { --bk-c: var(--red-premium); }
.panel--ice { --bk-c: var(--ice); }
.panel--dim { --bk-c: var(--line-3); }
.panel--glow { border-color: var(--mint-800); box-shadow: 0 0 0 1px rgba(78, 216, 175, .12), 0 0 .3rem rgba(23, 249, 183, .12), 0 .12rem .3rem rgba(0, 0, 0, .35); }
.panel__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: .16rem;
padding: .16rem .22rem .12rem;
border-bottom: 1px solid var(--line);
}
.panel__titles { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.panel__title { font-size: .21rem; font-weight: 700; letter-spacing: .04em; }
.panel__actions { display: flex; gap: .08rem; align-items: center; }
.panel__body { position: relative; padding: .22rem; min-height: 0; }
.panel--flush .panel__body { padding: 0; }
/* ---- chevrons ------------------------------------------------------------------------------------ */
.chevrons { --cv: var(--mint-500); display: inline-flex; align-items: center; gap: .03rem; height: .28rem; }
.chevrons svg { height: 100%; width: calc(var(--ch-h, .28rem) * .6); fill: var(--cv); opacity: .9; }
.chevrons--left svg { transform: scaleX(-1); }
.chevrons.is-animated svg { animation: chev-flow 1.2s linear infinite; animation-delay: calc(var(--i) * 140ms); opacity: .15; }
.chevrons--gold { --cv: var(--gold); }
.chevrons--amber { --cv: var(--amber); }
.chevrons--orange { --cv: var(--orange-hi); }
.chevrons--red { --cv: var(--red-premium); }
.chevrons--ice { --cv: var(--ice); }
.chevrons--dim { --cv: var(--line-3); }
@keyframes chev-flow { 0% { opacity: .15; } 25% { opacity: 1; } 55%, 100% { opacity: .15; } }
/* ---- hex badge ------------------------------------------------------------------------------------ */
.hex {
--hex-c: var(--gold);
--hex-bg: #15130b;
--hex-fg: var(--gold);
--hex-h: .34rem;
--hex-slope: .1rem;
display: inline-grid;
height: var(--hex-h);
padding: 1.5px;
background: var(--hex-c);
clip-path: polygon(var(--hex-slope) 0, calc(100% - var(--hex-slope)) 0, 100% 50%, calc(100% - var(--hex-slope)) 100%, var(--hex-slope) 100%, 0 50%);
filter: drop-shadow(0 0 3px rgba(0, 0, 0, .6));
vertical-align: middle;
}
.hex__fill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .05rem;
min-width: calc(var(--hex-h) * .95);
padding: 0 calc(var(--hex-slope) + .04rem);
background: radial-gradient(120% 90% at 50% 0%, rgba(255, 255, 255, .08), transparent 60%), var(--hex-bg);
color: var(--hex-fg);
clip-path: polygon(var(--hex-slope) 0, calc(100% - var(--hex-slope)) 0, 100% 50%, calc(100% - var(--hex-slope)) 100%, var(--hex-slope) 100%, 0 50%);
}
.hex__value { font-family: var(--font-num); font-weight: 700; font-size: calc(var(--hex-h) * .62); line-height: 1; font-variant-numeric: tabular-nums; }
.hex__icon { width: calc(var(--hex-h) * .5); height: calc(var(--hex-h) * .5); }
.hex--sm { --hex-h: .26rem; --hex-slope: .075rem; }
.hex--lg { --hex-h: .46rem; --hex-slope: .13rem; }
.hex--mint { --hex-c: var(--mint-500); --hex-fg: var(--mint-400); --hex-bg: #0b1814; }
.hex--discount { --hex-c: var(--mint-400); --hex-fg: var(--mint-400); --hex-bg: #0b1814; }
.hex--premium { --hex-c: var(--red-premium); --hex-fg: var(--red-premium); --hex-bg: #1b0c0b; }
.hex--red { --hex-c: var(--red); --hex-fg: #fff; --hex-bg: #6d170e; }
.hex--amber { --hex-c: var(--amber); --hex-fg: var(--amber); --hex-bg: #1b1307; }
.hex--ice { --hex-c: var(--ice); --hex-fg: var(--ice); --hex-bg: #0c1823; }
.hex--dark { --hex-c: var(--line-3); --hex-fg: var(--text-hi); --hex-bg: #111513; }
/* ---- tier chip ------------------------------------------------------------------------------------- */
/* Official octagon chip (sprite img_chess_level_N) when available; otherwise a CSS octagon in the same colours. */
.tier {
--tc: var(--tier-1);
--tier-h: .28rem;
--oct: polygon(27% 0, 73% 0, 100% 27%, 100% 73%, 73% 100%, 27% 100%, 0 73%, 0 27%);
position: relative;
isolation: isolate;
display: inline-grid;
place-items: center;
flex: none;
width: var(--tier-h);
height: var(--tier-h);
background: var(--tc);
clip-path: var(--oct);
color: var(--tc);
font-family: var(--font-num);
font-weight: 700;
font-size: calc(var(--tier-h) * .5);
line-height: 1;
letter-spacing: -.02em;
vertical-align: middle;
}
.tier::before {
content: '';
position: absolute;
inset: max(1.5px, calc(var(--tier-h) * .08));
z-index: -1;
background: #2a2f2d;
clip-path: var(--oct);
}
.tier--1 { --tc: var(--tier-1); }
.tier--2 { --tc: var(--tier-2); }
.tier--3 { --tc: var(--tier-3); }
.tier--4 { --tc: var(--tier-4); }
.tier--5 { --tc: var(--tier-5); }
.tier--6 { --tc: var(--tier-6); }
.tier--sm { --tier-h: .22rem; }
.tier--lg { --tier-h: .38rem; }
.tier--golden { --tc: var(--gold); }
.tier--golden::before { background: #3b2e05; }
.tier--img { background: none; clip-path: none; }
.tier--img::before { content: none; }
.tier--img img { width: 100%; height: 100%; display: block; object-fit: contain; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .8)); }
.tier--img.tier--golden img { filter: drop-shadow(0 0 .04rem rgba(255, 198, 0, .95)) drop-shadow(0 0 .02rem rgba(255, 198, 0, .9)); }
/* ---- bond disc ------------------------------------------------------------------------------------ */
.bond {
--disc: .56rem;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: .05rem;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
}
.bond__disc { position: relative; width: var(--disc); height: var(--disc); display: grid; place-items: center; }
.bond__ring {
position: absolute;
inset: calc(var(--disc) * -.1);
width: calc(var(--disc) * 1.2);
height: calc(var(--disc) * 1.2);
overflow: visible;
}
.bond__ring circle { fill: none; stroke-width: 5; stroke: rgba(255, 255, 255, .13); transition: stroke var(--t-med); }
.bond__ring circle.on { stroke: var(--mint-400); filter: drop-shadow(0 0 2px rgba(23, 249, 183, .8)); }
.bond__core {
width: 100%;
height: 100%;
border-radius: 50%;
display: grid;
place-items: center;
background: radial-gradient(circle at 50% 30%, #46524d, #1d2421 72%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .1), inset 0 -3px 6px rgba(0, 0, 0, .5), 0 2px 6px rgba(0, 0, 0, .55);
transition: transform var(--t-fast) var(--ease-out), box-shadow var(--t-med), background var(--t-med);
}
.bond__icon { width: 66%; height: 66%; object-fit: contain; filter: brightness(0) invert(.82); pointer-events: none; }
.bond__glyph { font-weight: 900; font-size: calc(var(--disc) * .42); line-height: 1; color: #cfd6d3; }
.bond.is-active .bond__core {
background: radial-gradient(circle at 50% 28%, #b6ffea, var(--mint-500) 52%, #16906e 100%);
box-shadow: 0 0 .16rem rgba(23, 249, 183, .5), inset 0 0 0 1px rgba(255, 255, 255, .45), inset 0 -4px 8px rgba(0, 60, 40, .45);
}
.bond.is-active .bond__icon { filter: brightness(0) opacity(.85); }
.bond.is-active .bond__glyph { color: #06221a; }
.bond__count {
position: absolute;
left: 50%;
bottom: calc(var(--disc) * -.1);
transform: translateX(-50%);
font-family: var(--font-num);
font-weight: 700;
font-size: calc(var(--disc) * .32);
line-height: 1;
color: #fff;
text-shadow: 0 0 3px #000, 0 1px 2px #000, 0 0 6px #000;
font-variant-numeric: tabular-nums;
}
.bond__name { font-size: calc(var(--disc) * .27); color: var(--text-md); text-shadow: 0 1px 2px #000; white-space: nowrap; }
.bond.is-active .bond__name { color: var(--text-hi); }
.bond.is-disabled .bond__core { filter: grayscale(1) brightness(.55); }
.bond.is-disabled .bond__name { color: var(--text-dim); }
.bond__ban {
position: absolute;
top: calc(var(--disc) * -.12);
right: calc(var(--disc) * -.12);
width: calc(var(--disc) * .36);
height: calc(var(--disc) * .36);
display: grid;
place-items: center;
background: #d32a1b;
color: #fff;
border: 1px solid #ff7a6b;
box-shadow: 0 0 4px rgba(0, 0, 0, .6);
}
.bond__ban .icon { width: 80%; height: 80%; }
.bond.is-frozen { opacity: .5; }
.bond.is-clickable { cursor: pointer; }
.bond.is-clickable:hover .bond__core { transform: scale(1.07); }
.bond--sm { --disc: .42rem; }
.bond--lg { --disc: .76rem; }
/* ---- seven segment & countdown ------------------------------------------------------------------- */
.seg {
--seg-h: .72rem;
--seg-on: var(--mint-glow);
--seg-glow: rgba(23, 249, 183, .55);
display: inline-flex;
align-items: stretch;
gap: calc(var(--seg-h) * .06);
height: var(--seg-h);
filter: drop-shadow(0 0 calc(var(--seg-h) * .07) var(--seg-glow));
}
.seg__char { height: var(--seg-h); width: calc(var(--seg-h) * .615); overflow: visible; }
.seg__char--colon { width: calc(var(--seg-h) * .2); }
.seg .on { fill: var(--seg-on); }
.seg .off { fill: var(--seg-on); opacity: .07; }
.seg--orange { --seg-on: var(--orange-hi); --seg-glow: rgba(255, 110, 40, .6); }
.seg--gold { --seg-on: var(--gold); --seg-glow: rgba(255, 198, 0, .5); }
.seg--red { --seg-on: var(--red-premium); --seg-glow: rgba(255, 84, 84, .55); }
.seg--ice { --seg-on: var(--ice); --seg-glow: rgba(159, 212, 255, .5); }
.seg.is-flicker { animation: seg-flicker 1s steps(1, end) infinite; }
@keyframes seg-flicker {
0%, 44% { opacity: 1; }
45% { opacity: .45; }
50% { opacity: 1; }
53% { opacity: .6; }
56%, 100% { opacity: 1; }
}
.countdown { --cd-h: .72rem; --cd-c: var(--mint-glow); display: inline-flex; align-items: flex-start; gap: .1rem; }
.countdown .seg { --seg-h: var(--cd-h); }
.countdown__main { display: flex; flex-direction: column; align-items: flex-end; }
.countdown__label {
margin-top: .05rem;
font-family: var(--font-display);
font-size: calc(var(--cd-h) * .14);
letter-spacing: .22em;
color: var(--mint-700);
line-height: 1;
}
.countdown__gauge { display: flex; flex-direction: column; justify-content: space-between; width: calc(var(--cd-h) * .3); height: var(--cd-h); }
.countdown__gauge i { display: block; height: 12%; background: rgba(23, 249, 183, .1); transition: background var(--t-med), box-shadow var(--t-med); }
.countdown__gauge i.on { background: var(--cd-c); box-shadow: 0 0 6px rgba(23, 249, 183, .55); }
.countdown.is-warn { --cd-c: var(--orange-hi); }
.countdown.is-warn .countdown__label { color: var(--orange); }
.countdown.is-warn .countdown__gauge i { background: rgba(255, 122, 51, .12); }
.countdown.is-warn .countdown__gauge i.on { background: var(--orange-hi); box-shadow: 0 0 6px rgba(255, 110, 40, .6); }
.countdown--sm { --cd-h: .44rem; }
.countdown--lg { --cd-h: 1.1rem; }
/* ---- modal ---------------------------------------------------------------------------------------- */
.modal {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: .3rem;
background: rgba(3, 5, 4, .72);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
animation: modal-fade var(--t-med) var(--ease-out);
}
.modal__box {
--bk-c: var(--mint-500);
--mstripe: var(--mint-500);
width: min(6.4rem, 94vw);
max-height: 88vh;
display: flex;
flex-direction: column;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .016) 0 1px, transparent 1px 3px),
linear-gradient(180deg, #1c2320, #111513);
border: 1px solid var(--line-2);
box-shadow: 0 .3rem .9rem rgba(0, 0, 0, .65), 0 0 .4rem rgba(23, 249, 183, .06);
animation: modal-in var(--t-slow) var(--ease-out);
}
.modal__box--red { --bk-c: var(--red-premium); --mstripe: var(--red); }
.modal__box--amber { --bk-c: var(--amber); --mstripe: var(--amber); }
.modal__box--orange { --bk-c: var(--orange-hi); --mstripe: var(--orange); }
.modal__box--gold { --bk-c: var(--gold); --mstripe: var(--gold); }
.modal__box--ice { --bk-c: var(--ice); --mstripe: var(--ice); }
.modal__stripe {
flex: none;
height: .05rem;
background: repeating-linear-gradient(-45deg, var(--mstripe) 0 .06rem, transparent .06rem .13rem);
opacity: .55;
}
.modal__head { display: flex; flex-direction: column; gap: .04rem; padding: .22rem .3rem .04rem; }
.modal__title { font-size: .26rem; font-weight: 700; letter-spacing: .04em; }
.modal__body { padding: .14rem .3rem .26rem; color: var(--text-md); overflow: auto; }
.modal__text { font-size: .18rem; line-height: 1.7; }
.modal__actions {
flex: none;
display: flex;
justify-content: flex-end;
gap: .14rem;
padding: .18rem .3rem;
border-top: 1px solid var(--line);
background: rgba(0, 0, 0, .22);
}
@keyframes modal-fade { from { opacity: 0; } }
@keyframes modal-in { from { opacity: 0; transform: translateY(.14rem) scale(.985); } }
/* ---- tooltip -------------------------------------------------------------------------------------- */
.tt-anchor { display: inline-flex; }
.tt-anchor--block { display: flex; }
.tooltip {
position: fixed;
z-index: var(--z-tooltip);
max-width: 3.8rem;
padding: .09rem .13rem;
background: rgba(10, 13, 12, .97);
border: 1px solid var(--line-2);
border-left: 2px solid var(--mint-500);
box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .55);
color: var(--text-md);
font-size: .15rem;
line-height: 1.55;
pointer-events: none;
opacity: 0;
transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
white-space: pre-line;
}
.tooltip.is-shown { opacity: 1; }
.tooltip--top.is-shown { animation: tip-up var(--t-fast) var(--ease-out); }
.tooltip--bottom.is-shown { animation: tip-down var(--t-fast) var(--ease-out); }
@keyframes tip-up { from { transform: translateY(4px); } }
@keyframes tip-down { from { transform: translateY(-4px); } }
/* ---- progress bar ----------------------------------------------------------------------------------- */
.pbar { --pc: var(--mint-500); --ph: .1rem; display: flex; flex-direction: column; gap: .05rem; min-width: .8rem; }
.pbar__meta { display: flex; justify-content: space-between; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); }
.pbar__value { font-family: var(--font-num); font-weight: 700; color: var(--text-hi); font-size: .16rem; }
.pbar__value small { color: var(--text-dim); font-size: .8em; }
.pbar__track { position: relative; height: var(--ph); background: #080a09; border: 1px solid var(--line); overflow: hidden; }
.pbar__fill {
height: 100%;
background: linear-gradient(90deg, transparent, var(--pc)); background: linear-gradient(90deg, color-mix(in srgb, var(--pc) 45%, transparent), var(--pc));
box-shadow: 0 0 8px var(--pc); box-shadow: 0 0 8px color-mix(in srgb, var(--pc) 60%, transparent);
transition: width var(--t-slow) var(--ease-out);
}
.pbar__segs {
position: absolute;
inset: 0;
background: repeating-linear-gradient(90deg, transparent 0 calc(100% / var(--segs) - 2px), #080a09 calc(100% / var(--segs) - 2px) calc(100% / var(--segs)));
}
.pbar--gold { --pc: var(--gold); }
.pbar--amber { --pc: var(--amber); }
.pbar--orange { --pc: var(--orange-hi); }
.pbar--red { --pc: var(--red-premium); }
.pbar--ice { --pc: var(--ice); }
.pbar--sm { --ph: .06rem; }
.pbar--lg { --ph: .16rem; }
/* ---- tabs -------------------------------------------------------------------------------------------- */
.tabs { display: flex; gap: .04rem; border-bottom: 1px solid var(--line); }
.tabs__tab {
position: relative;
display: inline-flex;
align-items: center;
gap: .07rem;
padding: .1rem .22rem .12rem;
border: 0;
background: none;
color: var(--text-lo);
font-size: .17rem;
font-weight: 500;
letter-spacing: .04em;
transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast);
}
.tabs__tab:hover:not(:disabled) { color: var(--text-hi); background: rgba(78, 216, 175, .04); }
.tabs__tab:disabled { color: var(--text-dim); cursor: not-allowed; }
.tabs__tab::after {
content: '';
position: absolute;
left: .12rem;
right: .12rem;
bottom: -1px;
height: 2px;
background: var(--mint-500);
box-shadow: 0 0 8px rgba(23, 249, 183, .7);
transform: scaleX(0);
transition: transform var(--t-med) var(--ease-out);
}
.tabs__tab.is-active { color: var(--mint-400); }
.tabs__tab.is-active::after { transform: scaleX(1); }
.tabs__badge { font-family: var(--font-num); font-size: .13rem; padding: .01rem .06rem; background: var(--bg-3); color: var(--text-md); }
.tabs--sm .tabs__tab { font-size: .15rem; padding: .07rem .16rem .09rem; }
/* ---- spinner -------------------------------------------------------------------------------------- */
.spinner { --sp: .36rem; --sc: var(--mint-500); display: inline-flex; align-items: center; gap: .1rem; color: var(--text-lo); font-size: .15rem; }
.spinner svg { width: var(--sp); height: var(--sp); animation: spin 1.6s linear infinite; overflow: visible; }
.spinner__track { fill: none; stroke: var(--line-2); stroke-width: 3; }
.spinner__arc { fill: none; stroke: var(--sc); stroke-width: 3; stroke-dasharray: 34 98; animation: spinner-dash 1.1s var(--ease-in-out) infinite; filter: drop-shadow(0 0 3px var(--sc)); }
.spinner--sm { --sp: .22rem; }
.spinner--lg { --sp: .56rem; }
.spinner--gold { --sc: var(--gold); }
.spinner--amber { --sc: var(--amber); }
.spinner__label { font-family: var(--font-display); letter-spacing: .2em; font-size: .12rem; text-transform: uppercase; }
@keyframes spinner-dash { 0% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: -132; } }
/* ---- avatar frame ---------------------------------------------------------------------------------- */
.avatar {
--av: .9rem;
--seat-hue: 162;
--bk-c: var(--line-3);
--bk-l: calc(var(--av) * .16);
position: relative;
width: var(--av);
height: var(--av);
flex: none;
padding: calc(var(--av) * .045);
background: #0a0d0c;
border: 1px solid var(--line);
}
.avatar__img {
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
place-items: center;
background:
repeating-linear-gradient(45deg, rgba(255, 255, 255, .03) 0 2px, transparent 2px 7px),
radial-gradient(circle at 30% 18%, hsl(var(--seat-hue) 42% 30%), hsl(var(--seat-hue) 30% 11%) 72%);
}
.avatar__img img { width: 100%; height: 100%; object-fit: cover; }
.avatar__glyph {
font-size: calc(var(--av) * .44);
font-weight: 900;
line-height: 1;
color: hsl(var(--seat-hue) 70% 82%);
text-shadow: 0 0 calc(var(--av) * .12) hsl(var(--seat-hue) 80% 50% / .45);
}
.avatar__bot { width: 56%; height: 56%; color: #a9c3d8; filter: drop-shadow(0 0 6px rgba(159, 212, 255, .35)); }
.avatar__empty { width: 36%; height: 36%; color: var(--text-dim); }
.avatar__badge {
position: absolute;
z-index: 2;
top: calc(var(--av) * -.1);
left: calc(var(--av) * -.1);
width: max(.22rem, calc(var(--av) * .28));
height: max(.22rem, calc(var(--av) * .28));
display: grid;
place-items: center;
clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%);
}
.avatar__badge .icon { width: 62%; height: 62%; }
.avatar__badge--host { background: var(--gold); color: #1d1400; }
.avatar__badge--self { left: auto; right: calc(var(--av) * -.1); background: var(--mint-500); color: var(--text-on-mint); }
.avatar__tag {
position: absolute;
z-index: 2;
right: calc(var(--av) * .06);
bottom: calc(var(--av) * .06);
padding: .01rem .05rem;
background: var(--ice);
color: #0b1a26;
font-family: var(--font-display);
font-weight: 700;
font-size: max(.1rem, calc(var(--av) * .13));
letter-spacing: .08em;
line-height: 1.2;
}
.avatar.is-self { --bk-c: var(--mint-500); border-color: var(--mint-800); }
.avatar.is-ready { --bk-c: var(--mint-400); }
.avatar.is-offline .avatar__img, .avatar.is-dead .avatar__img { filter: grayscale(1) brightness(.45); }
.avatar.is-empty { border-style: dashed; border-color: var(--line-2); background: transparent; --bk-c: transparent; }
.avatar.is-empty .avatar__img { background: rgba(255, 255, 255, .015); }
.avatar--sm { --av: .48rem; }
.avatar--lg { --av: 1.4rem; }
.avatar--xl { --av: 2rem; }
/* ---- phase banner ------------------------------------------------------------------------------------ */
.pbanner { --pb: var(--mint-500); --pb-glow: rgba(23, 249, 183, .4); width: 100%; pointer-events: none; }
.pbanner--overlay { position: fixed; left: 0; right: 0; top: 50%; transform: translateY(-50%); z-index: var(--z-banner); }
.pbanner--orange { --pb: var(--orange-hi); --pb-glow: rgba(255, 110, 40, .45); }
.pbanner--red { --pb: var(--red-premium); --pb-glow: rgba(255, 60, 60, .45); }
.pbanner--gold { --pb: var(--gold); --pb-glow: rgba(255, 198, 0, .4); }
.pbanner--ice { --pb: var(--ice); --pb-glow: rgba(159, 212, 255, .4); }
.pbanner__band {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
padding: .24rem 0 .26rem;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .025) 0 1px, transparent 1px 3px),
linear-gradient(90deg, transparent, rgba(7, 10, 9, .93) 16%, rgba(7, 10, 9, .93) 84%, transparent);
overflow: hidden;
transform-origin: center;
animation: pb-open var(--t-med) var(--ease-out) backwards;
}
.pbanner__band::before, .pbanner__band::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--pb) 18%, var(--pb) 82%, transparent);
box-shadow: 0 0 8px var(--pb-glow);
animation: pb-line 380ms var(--ease-out) backwards;
}
.pbanner__band::before { top: 0; }
.pbanner__band::after { bottom: 0; }
.pbanner__text {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: .04rem;
animation: pb-wipe 380ms 90ms var(--ease-out) backwards;
}
.pbanner__text::after {
/* one-shot light sweep */
content: '';
position: absolute;
top: -20%;
bottom: -20%;
left: -30%;
width: 20%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .22), transparent);
transform: skewX(-20deg);
animation: pb-sweep 700ms 250ms var(--ease-out) forwards;
opacity: 0;
}
.pbanner__micro { font-family: var(--font-display); font-size: .12rem; letter-spacing: .5em; color: var(--pb); padding-left: .5em; }
.pbanner__title {
font-size: .56rem;
font-weight: 900;
letter-spacing: .16em;
padding-left: .16em;
line-height: 1.1;
color: #fff;
text-shadow: 0 0 .22rem var(--pb-glow), 0 2px 0 rgba(0, 0, 0, .5);
}
.pbanner__sub { font-size: .16rem; color: var(--text-lo); letter-spacing: .12em; }
.pbanner__chev { --ch-h: .42rem; height: .42rem; flex: none; }
.pbanner--inline .pbanner__band { animation-duration: var(--t-slow); }
.pbanner.is-leaving .pbanner__band { animation: pb-close 240ms var(--ease-in-out) forwards; }
@keyframes pb-open { from { transform: scaleY(0); opacity: 0; } }
@keyframes pb-close { to { transform: scaleY(0); opacity: 0; } }
@keyframes pb-line { from { transform: scaleX(0); } }
@keyframes pb-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes pb-sweep { 0% { left: -30%; opacity: 1; } 100% { left: 120%; opacity: 0; } }
/* ---- ping pill ------------------------------------------------------------------------------------- */
.ping {
--pc: var(--mint-400);
display: inline-flex;
align-items: center;
gap: .04rem;
height: .32rem;
padding: 0 .14rem 0 .11rem;
border-radius: 99px;
background: rgba(0, 0, 0, .5);
border: 1px solid var(--line);
color: var(--pc);
font-family: var(--font-num);
font-size: .18rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.ping__icon { width: .15rem; height: .15rem; margin-right: .03rem; }
.ping__unit { font-size: .13rem; color: var(--text-lo); font-weight: 500; }
.ping--low { --pc: var(--mint-400); }
.ping--medium { --pc: var(--amber); }
.ping--high { --pc: var(--red-premium); }
.ping--off { --pc: var(--text-dim); }
/* ---- difficulty tag ---------------------------------------------------------------------------------- */
.dtag { --d-color: var(--amber); display: inline-flex; align-items: center; gap: .06rem; color: var(--d-color); font-weight: 700; font-size: .18rem; letter-spacing: .04em; white-space: nowrap; }
.dtag__icon { width: 1.15em; height: 1.15em; filter: drop-shadow(0 0 4px var(--d-color)); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--d-color) 65%, transparent)); }
.dicon { object-fit: contain; image-rendering: auto; }
.dtag__code { font-family: var(--font-num); font-size: .72em; font-weight: 500; color: var(--text-dim); letter-spacing: .1em; margin-left: .03rem; }
.dtag--sm { font-size: .15rem; }
.dtag--lg { font-size: .24rem; }
/* ---- text field ------------------------------------------------------------------------------------ */
.field { display: flex; flex-direction: column; gap: .07rem; min-width: 0; }
.field__label { display: flex; align-items: baseline; gap: .1rem; font-size: .15rem; color: var(--text-lo); font-weight: 500; letter-spacing: .04em; }
.field__box {
--bk-c: var(--line-3);
--bk-l: .1rem;
display: flex;
align-items: center;
gap: .1rem;
height: .56rem;
padding: 0 .16rem;
background: rgba(5, 7, 6, .78);
border: 1px solid var(--line);
transition: border-color var(--t-fast), box-shadow var(--t-med);
}
.field__box:hover { border-color: var(--line-2); }
.field__box:focus-within { border-color: var(--mint-700); --bk-c: var(--mint-400); box-shadow: 0 0 0 1px rgba(78, 216, 175, .15), 0 0 .18rem rgba(23, 249, 183, .15); }
.field__input {
flex: 1;
min-width: 0;
height: 100%;
background: none;
border: 0;
outline: none;
color: var(--text-hi);
font-size: .2rem;
letter-spacing: .04em;
}
.field__input::placeholder { color: var(--text-dim); font-weight: 400; letter-spacing: .04em; }
.field__icon { width: .22rem; height: .22rem; color: var(--text-dim); }
.field__box:focus-within .field__icon { color: var(--mint-500); }
.field__hint { font-size: .13rem; color: var(--text-dim); }
.field--lg .field__box { height: .68rem; }
.field--lg .field__input { font-size: .26rem; font-weight: 700; }
.field--code .field__box { height: .64rem; }
.field--code .field__input { font-family: var(--font-num); font-size: .34rem; font-weight: 700; letter-spacing: .42em; text-transform: uppercase; }
.field--code .field__input::placeholder { font-family: var(--font-cjk); font-size: .18rem; letter-spacing: .06em; font-weight: 400; text-transform: none; }
.field.is-invalid .field__box { border-color: var(--red); --bk-c: var(--red-premium); }
.field.is-disabled { opacity: .5; }
/* ---- toasts -------------------------------------------------------------------------------------------- */
.toast-host {
position: fixed;
top: .22rem;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
align-items: center;
gap: .08rem;
pointer-events: none;
width: max-content;
max-width: 92vw;
}
.toast {
--tc: var(--mint-500);
pointer-events: auto;
display: flex;
align-items: center;
gap: .1rem;
min-width: 2.4rem;
max-width: 7.2rem;
padding: .11rem .2rem .11rem .14rem;
background: rgba(9, 12, 11, .96);
border: 1px solid var(--line-2);
border-left: 3px solid var(--tc);
box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem transparent; box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem color-mix(in srgb, var(--tc) 25%, transparent);
color: var(--text-hi);
font-size: .17rem;
line-height: 1.45;
cursor: pointer;
animation: toast-in var(--t-med) var(--ease-out);
}
.toast__icon { width: .2rem; height: .2rem; fill: var(--tc); flex: none; }
.toast__text { flex: 1; }
.toast__count { font-family: var(--font-num); font-size: .14rem; color: var(--text-lo); }
.toast--success { --tc: var(--mint-400); }
.toast--warn { --tc: var(--amber); }
.toast--error { --tc: var(--red-premium); }
.toast.is-leaving { animation: toast-out var(--t-med) var(--ease-in-out) forwards; }
@keyframes toast-in { from { opacity: 0; transform: translateY(-.12rem); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(-.08rem); } }
/* ---- connection banner (main.js) --------------------------------------------------------------------- */
.conn-banner {
position: fixed;
left: 50%;
bottom: .24rem;
transform: translateX(-50%);
z-index: var(--z-conn);
display: flex;
align-items: center;
gap: .14rem;
padding: .1rem .12rem .1rem .2rem;
background: rgba(24, 12, 8, .94);
border: 1px solid rgba(232, 90, 26, .55);
box-shadow: 0 .08rem .3rem rgba(0, 0, 0, .6), 0 0 .2rem rgba(232, 90, 26, .15);
color: var(--text-hi);
font-size: .16rem;
/* own keyframes: toast-in's transform would drop the translateX(-50%) centring mid-animation */
animation: conn-in var(--t-med) var(--ease-out);
}
/* transitional states (connecting / re-handshaking) only appear if they last longer than .6 s */
.conn-banner--soft { animation: conn-in-soft var(--t-med) var(--ease-out) .6s backwards; }
@keyframes conn-in { from { opacity: 0; transform: translate(-50%, .12rem); } }
@keyframes conn-in-soft { from { opacity: 0; visibility: hidden; transform: translate(-50%, .12rem); } }
/* In a match the top-centre capsule (round / phase / kills), the ticker strip under it and the bottom centre
(combat view switcher, shop bar) are taken: toasts and the connection banner sit below the ticker instead
(toasts below the banner while it shows). */
/* (classes set by js: html.sp-in-match — the match screen is mounted; html.sp-conn — the banner shows. No :has(): Firefox
ESR 115 / Safari < 15.4 lack it) */
.sp-in-match .toast-host { top: calc(1.34rem + var(--sa-t, 0px)); }
.sp-in-match .conn-banner { top: calc(1.34rem + var(--sa-t, 0px)); bottom: auto; }
.sp-in-match.sp-conn .toast-host { top: calc(2.0rem + var(--sa-t, 0px)); }
.conn-banner .icon { width: .22rem; height: .22rem; color: var(--orange-hi); }
.conn-banner__sub { color: var(--text-lo); font-family: var(--font-num); font-size: .15rem; }
/* ---- misc shared bits ------------------------------------------------------------------------------ */
.kv { display: flex; align-items: baseline; gap: .08rem; }
.kv__k { color: var(--text-lo); font-size: .14rem; }
.kv__v { font-family: var(--font-num); font-weight: 700; }
.divider { height: 1px; background: linear-gradient(90deg, transparent, var(--line-2) 15%, var(--line-2) 85%, transparent); }
.status-dot { width: .09rem; height: .09rem; border-radius: 50%; background: var(--text-dim); flex: none; display: inline-block; }
.status-dot.is-on { background: var(--mint-400); box-shadow: 0 0 6px var(--mint-glow); }
.status-dot.is-warn { background: var(--amber); box-shadow: 0 0 6px var(--amber); animation: blink 1s steps(2) infinite; }
.status-dot.is-bad { background: var(--red-premium); box-shadow: 0 0 6px var(--red-premium); }
@keyframes blink { 50% { opacity: .35; } }
/* ---- shared screen chrome: top bar, section labels, player chip, crash screen ------------------------ */
.topbar {
position: relative;
z-index: 3;
flex: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: .24rem;
height: 1.1rem;
padding: 0 .44rem;
background: linear-gradient(180deg, rgba(8, 10, 9, .92), rgba(10, 13, 12, .55));
border-bottom: 1px solid var(--line);
}
.topbar::after {
content: '';
position: absolute;
left: .44rem;
right: .44rem;
bottom: -1px;
height: 1px;
background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700));
}
.topbar__left { justify-self: start; display: flex; align-items: center; gap: .18rem; min-width: 0; }
.topbar__center { display: flex; flex-direction: column; align-items: center; gap: .03rem; text-align: center; }
.topbar__title { display: flex; align-items: center; gap: .18rem; font-size: .3rem; font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
.topbar__sep { width: 1px; height: .28rem; background: var(--line-2); }
.topbar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: .2rem; font-weight: 700; letter-spacing: .08em; }
.section-label__idx { font-size: .15rem; color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
.me-chip { display: flex; align-items: center; gap: .12rem; padding: .06rem .18rem .06rem .06rem; border: 1px solid var(--line); background: rgba(0, 0, 0, .35); }
.me-chip__text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.me-chip__name { font-weight: 700; font-size: .18rem; max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crash { align-items: center; justify-content: center; }
.crash__box {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: .14rem;
width: 6rem;
padding: .36rem .4rem;
background: rgba(16, 20, 18, .95);
border: 1px solid var(--line-2);
--bk-c: var(--red-premium);
}
.crash__box h2 { font-size: .3rem; }
+137
View File
@@ -0,0 +1,137 @@
/* ==========================================================================================
devices.css — multi-device layer (loaded last). Desktop Chrome / Edge / Firefox / Safari, iPad and Android tablets
in landscape, phones in landscape (≥ 640×360 CSS px). js/ui/device.js puts the feature classes on <html>
(sp-touch, sp-coarse, sp-hover / sp-no-hover, sp-fs, sp-standalone, sp-reduced-motion) — never UA sniffing.
1. no page zoom / scroll / pull-to-refresh while playing (touch-action, overscroll-behavior, callouts)
2. safe-area insets (notches, rounded corners, home indicator): the HUD and every screen stay inside them while the
board and the backdrops stay full-bleed
3. touch: every small control gets an invisible ≥ 44 px hit area (::before, no layout change); on short touch
screens (phones) the most used controls also grow visually
4. reduced motion extras; fullscreen button
========================================================================================== */
:root {
--sa-t: env(safe-area-inset-top, 0px);
--sa-r: env(safe-area-inset-right, 0px);
--sa-b: env(safe-area-inset-bottom, 0px);
--sa-l: env(safe-area-inset-left, 0px);
--tap-min: 44px;
}
/* ---- 1. gestures ------------------------------------------------------------------------------------------------- */
html, body { overscroll-behavior: none; }
/* pan only: no pinch-zoom / double-tap zoom anywhere (scrollable lists still scroll); the field canvas uses none */
html { touch-action: pan-x pan-y; }
body { -webkit-touch-callout: none; }
button, a, label, summary, [role="button"], [role="tab"], [role="option"], .scard, .lvcard, .dband, .bond, .pavatar { touch-action: manipulation; }
img { -webkit-user-drag: none; }
.gm__field, .gm__field canvas, .ff, .fwheel, .fwheel__dia { touch-action: none; }
/* the DOM fallback board drags with pointer events too */
.ff-piece, .ff-slot, .ff-tile { touch-action: none; -webkit-user-select: none; user-select: none; }
input, textarea, select { -webkit-user-select: text; user-select: text; }
/* ---- 2. safe areas ------------------------------------------------------------------------------------------------ */
/* the in-match HUD layer sits inside the safe area; the field (canvas) below it stays full-bleed */
.gm__hud { top: var(--sa-t); right: var(--sa-r); bottom: var(--sa-b); left: var(--sa-l); }
/* the selected unit's underframe (ui/underframe.js) is placed at the tile's CLIENT px (view.tileScreen) inside the HUD
layer: step back over the HUD's safe-area offset so it stays on its unit on a notched phone */
.gm__hud > .uframe { margin-left: calc(-1 * var(--sa-l)); margin-top: calc(-1 * var(--sa-t)); }
.gm > .spov { left: calc(2.8rem + var(--sa-l)); right: var(--sa-r); }
/* every other screen is laid out inside the safe area (the app backdrop fills the notch strip) */
.screen:not(.gm) { top: var(--sa-t); right: var(--sa-r); bottom: var(--sa-b); left: var(--sa-l); }
.guide, .awayov { padding: var(--sa-t) var(--sa-r) var(--sa-b) var(--sa-l); }
/* (in a match the toasts sit under the top bar: components.css .sp-in-match .toast-host already adds --sa-t) */
html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
.conn-banner { margin-bottom: var(--sa-b); }
.rotate-hint { padding: calc(24px + var(--sa-t)) calc(24px + var(--sa-r)) calc(24px + var(--sa-b)) calc(24px + var(--sa-l)); }
/* ---- 3. touch targets ---------------------------------------------------------------------------------------------- */
/* An invisible hit area of at least --tap-min around small controls on touch screens: centred, never smaller than the
control, no layout change. Only controls that do not use ::before themselves are listed. */
:where(.sp-coarse) :where(.tapx, .gtop__exit, .gtop__iconbtn, .enemybtn, .readybtn, .gm__gear, .fsbtn, .ewheel__btn, .ewheel__nav,
.toolbtn, .funds__collapse, .team__btn, .effect, .bslot .bond, .vswitch__arrow, .edrawer__close, .dpanel__close, .bpop__close,
.modal__close, .uf__btn, .chud__back, .tabs__tab, .btn, .pill-btn, .scard__detail, .title-guide, .stephead__guide, .set-toggle, .set-seg button) {
position: relative;
}
.sp-coarse :is(.tapx, .gtop__exit, .gtop__iconbtn, .enemybtn, .readybtn, .gm__gear, .fsbtn, .ewheel__btn, .ewheel__nav,
.toolbtn, .funds__collapse, .team__btn, .effect, .bslot .bond, .vswitch__arrow, .edrawer__close, .dpanel__close, .bpop__close,
.modal__close, .uf__btn, .chud__back, .tabs__tab, .btn, .pill-btn, .scard__detail, .title-guide, .stephead__guide, .set-toggle, .set-seg button)::before {
content: '';
position: absolute; left: 50%; top: 50%; z-index: 0;
width: max(100%, var(--tap-min)); height: max(100%, var(--tap-min));
transform: translate(-50%, -50%);
background: transparent; border: 0; box-shadow: none; clip-path: none; pointer-events: auto;
}
/* The 交流 panel's pager (‹ dots ›) sits in the strip under the emote cells (panel y 1.97–2.31rem, css/emotes.css). Its
hit areas stay inside that strip and never overlap each other: a centred 44 px area of ‹ / › covered the centres of
the bottom-left / bottom-right emotes (a tap there turned the page or hit the disabled ‹), and 28 px dot areas 0.21rem
apart covered the neighbouring dot (a tap on dot N opened page N+1). ‹ / › grow outwards (away from the dots), the
dots only up to half the gap. Selectors ≥ the specificity of the generic hit-area rule above. */
.sp-coarse .ewheel__panel .ewheel__nav::before { top: .03rem; bottom: -.15rem; height: auto; transform: none; }
.sp-coarse .ewheel__panel .ewheel__nav.is-prev::before { left: auto; right: 0; }
.sp-coarse .ewheel__panel .ewheel__nav.is-next::before { left: 0; right: auto; }
.sp-coarse .ewheel__dot::before { inset: -.02rem -.05rem -.2rem; width: auto; height: auto; transform: none; }
/* Phones in landscape (short touch screens: 1rem ≈ 40 px here): the most used controls grow a little so they are
comfortable to hit and to read, without changing the official layout. */
@media (max-height: 600px) and (pointer: coarse), (max-height: 600px) and (any-pointer: coarse) {
.readybtn { height: max(.56rem, 34px); }
.toolbtn { min-height: max(.5rem, 30px); }
.gm__gear { width: max(.56rem, 34px); height: max(.56rem, 34px); }
.gtop__exit { min-width: max(.56rem, 34px); min-height: max(.56rem, 34px); }
.gtop__iconbtn.has-sprite, .enemybtn.has-sprite { height: max(.46rem, 26px); width: calc(max(.46rem, 26px) * 35 / 29); }
.gtop__iconbtn.has-sprite.is-wide, .enemybtn.has-sprite.is-wide { width: calc(max(.46rem, 26px) * 61 / 29); }
.gtop__iconbtn:not(.has-sprite), .enemybtn:not(.has-sprite) { height: max(.46rem, 26px); min-width: max(.46rem, 26px); }
.ewheel__panel { scale: 1.3; transform-origin: 0 100%; }
.fwheel__cancel { min-height: 30px; }
.set-toggle, .set-seg button { height: max(.38rem, 30px); }
/* a taller range input whose visible track stays thin: transparent borders are part of the hit area */
.set-range { height: 30px; border: 11px solid transparent; border-left-width: 0; border-right-width: 0; background-clip: padding-box; box-shadow: inset 0 0 0 1px var(--line-2); }
.field--lg .field__box { height: max(.68rem, 40px); }
}
/* ---- 4. misc ------------------------------------------------------------------------------------------------------ */
.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; }
/* standalone / fullscreen: the browser bars are gone, nothing to reserve */
.sp-standalone .rotate-hint__micro::after { content: ' · PWA'; }
/* reduced motion: camera-free UI pieces that animate continuously stop (the global rule in theme.css shortens the rest) */
@media (prefers-reduced-motion: reduce) {
.team__status.is-gold, .ff-tile--gate, .title-bg__sweep, .boot__hex { animation: none !important; }
}
.sp-reduced-motion .team__status.is-gold, .sp-reduced-motion .ff-tile--gate { animation: none; }
/* the shop card's ⓘ corner: a smaller hit area (the card itself is the main target; its first tap shows the detail) */
.sp-coarse .scard__detail::before { width: 30px; height: 30px; }
/* Narrow viewports (< 768 CSS px: the root scale is clamped at 40 px, so the layout is narrower than the 19.2 rem
design): the shop bar would cover the corner's ⚙ / 📖 / ⛶ buttons — they wrap into a row above the 交流 button. */
/* The left-docked detail card then ends above that two-row corner (.24rem + 交流 + .1rem gap + ⚙ row + .1rem): at its
desktop bottom (1.02rem) it hid ⚙ / 📖 / ⛶. (gameLogic panelSlots keeps the taller desktop slot: conservative.) */
@media (max-width: 767px) {
.gm__corner { flex-wrap: wrap-reverse; align-content: flex-start; width: max(72px, calc(3 * .56rem + .2rem)); }
.dpanel:not(.dpanel--right) { bottom: calc(.44rem + max(1.51rem, 72px) * 55 / 151 + .56rem); }
}
@media (max-width: 767px) and (pointer: coarse), (max-width: 767px) and (any-pointer: coarse) {
.gm__corner { width: max(96px, calc(3 * max(.56rem, 34px) + .2rem)); }
.dpanel:not(.dpanel--right) { bottom: calc(.44rem + max(1.51rem, 96px) * 55 / 151 + max(.56rem, 34px)); }
}
/* Short landscape phones (≤ 460 CSS px tall, 1rem = 40 px): the lobby's two columns are compressed so the
创建同盟 button and the join panel stay on screen without scrolling. */
@media (max-height: 460px) {
.lobby-body { padding-top: .2rem; padding-bottom: .2rem; gap: .36rem; }
.mode-cards { margin-bottom: .2rem; }
.mode-card { min-height: 0; gap: .12rem; padding: .22rem .26rem .2rem; }
.mode-card__icon { width: .66rem; height: .66rem; }
.mode-card__name { font-size: .4rem; }
.mode-card__points, .diff-card__effects, .tips, .create-box__hint { display: none; }
.diff-list { gap: .1rem; }
.diff-card { padding-top: .12rem; padding-bottom: .12rem; }
.create-box { margin-top: .18rem; }
.create-box .btn--xl { height: .8rem; }
}
+150
View File
@@ -0,0 +1,150 @@
/* Emotes (交流) — public/js/ui/emotes.js. Research 09 §4: picture-only bubbles, a 3×2 pager per theme, 1 s cooldown.
Sizes follow the official sprites at 1 px = .01rem (1920-wide reference): emoji_btn 151×55, emoji_bkg 313×231,
emoji_cell_bkg 85×85, emoji_bubble_bkg 108×97. With the local ui/battle extraction the official sprites are used
(inline --ewheel-* / --ebubble-bg); without it CSS draws look-alikes. Loaded after the screen sheets, so these rules
also override the v1 emote rules that may still sit in screens/game.css. */
/* ---- picture / fallback glyph ------------------------------------------------------------------------------- */
.eart { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; user-select: none; -webkit-user-drag: none; }
.eart--glyph {
display: grid; place-items: center; border-radius: 22%;
background: radial-gradient(circle at 50% 42%, rgba(78, 216, 175, .10), rgba(78, 216, 175, 0) 70%);
color: rgba(195, 203, 199, .55);
}
.eart--glyph .icon { width: 58%; height: 58%; }
.eart--pending { visibility: hidden; }
/* ---- 交流 button -------------------------------------------------------------------------------------------- */
.ewheel { position: relative; }
.ewheel__btn {
position: relative; display: flex; align-items: center; justify-content: flex-start; gap: .08rem;
height: .56rem; min-width: 1.51rem; padding: 0 .2rem 0 .14rem;
background: rgba(8, 11, 10, .85); border: 1px solid var(--line-2); border-radius: .04rem;
color: var(--text-md); font-size: .18rem; font-weight: 700; letter-spacing: .1em; line-height: 1;
transition: color var(--t-fast), border-color var(--t-fast), filter var(--t-fast), opacity var(--t-fast);
}
.ewheel__btn .icon { width: .3rem; height: .3rem; color: var(--mint-500); }
.ewheel__btn:hover:not(:disabled), .ewheel__btn.is-on { color: var(--mint-400); border-color: var(--mint-700); }
.ewheel__btn:disabled { opacity: .5; cursor: not-allowed; }
/* emoji_btn 151×55: plate x 3–147, y 0–45 (a shadow below); the robot face + '<' chevron span x 22–79. The label gets
its own box right of the chevron (x ≥ 57 %), so a font clamped up by a browser minimum font size (CJK Chrome: 12 px)
or a small root can never run into the icon; the button never shrinks below 72 px (96 px on touch screens). */
.ewheel__btn.has-sprite {
--ewb-w: max(1.51rem, 72px);
width: var(--ewb-w); height: calc(var(--ewb-w) * 55 / 151); min-width: 0; padding: 0; display: block;
background: var(--ewheel-btn) left bottom / 100% 100% no-repeat; border: 0; border-radius: 0; color: #f2f2f2;
}
.ewheel__btn.has-sprite .ewheel__label {
position: absolute; left: 57%; right: 3%; top: 0; height: 82%;
display: flex; align-items: center; justify-content: center; overflow: hidden;
font-size: calc(var(--ewb-w) * 18 / 151); letter-spacing: .06em; line-height: 1;
}
.ewheel__btn.has-sprite:hover:not(:disabled), .ewheel__btn.has-sprite.is-on { color: #fff; filter: brightness(1.18); border: 0; }
.ewheel__btn.has-sprite:disabled { opacity: 1; color: rgba(242, 242, 242, .45); }
.ewheel__btn.is-cooling { cursor: wait; }
.ewheel__label { white-space: nowrap; }
/* ---- panel: one page per theme ----------------------------------------------------------------------------- */
/* emoji_bkg is 313×231 with a soft shadow margin; its opaque plate spans x 15–297, y 13–212. The cells, the pager
and the CSS fallback plate all use that geometry. */
.ewheel__panel {
position: absolute; left: -.15rem; bottom: calc(100% + .02rem); z-index: 5;
width: 3.13rem; height: 2.31rem; padding: 0; box-sizing: border-box;
background: none; border: 0; border-radius: 0; box-shadow: none;
transform-origin: .45rem 100%;
animation: emo-panel-in 160ms var(--ease-out);
touch-action: pan-y; user-select: none; -webkit-user-select: none;
}
.ewheel__panel::before { /* CSS look-alike of the emoji_bkg plate */
content: ''; position: absolute; left: .15rem; top: .13rem; width: 2.82rem; height: 1.99rem; border-radius: .06rem;
background: rgba(19, 19, 19, .92); box-shadow: 0 .04rem .16rem rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .07);
}
.ewheel__panel.has-sprite { background: var(--ewheel-bg) center / 100% 100% no-repeat; }
.ewheel__panel.has-sprite::before { content: none; }
.ewheel__viewport { position: absolute; left: .205rem; top: .19rem; width: 2.71rem; height: 1.78rem; overflow: hidden; touch-action: pan-y; cursor: grab; }
.ewheel__viewport:active { cursor: grabbing; }
.ewheel__page {
display: grid; grid-template-columns: repeat(3, .85rem); grid-auto-rows: .85rem; gap: .08rem;
justify-content: space-between; align-content: start; height: 100%;
animation: emo-page-in 180ms var(--ease-out);
will-change: transform;
}
.ewheel__page.is-from-right { animation-name: emo-page-from-right; }
.ewheel__page.is-from-left { animation-name: emo-page-from-left; }
.ewheel__page.is-dragging { animation: none; transition: none; opacity: .92; }
.ewheel__item {
position: relative; display: grid; place-items: center; width: .85rem; height: .85rem; padding: .07rem; margin: 0; box-sizing: border-box;
background: rgba(255, 255, 255, .16); border: 0; border-radius: .08rem; color: inherit; font-size: 0; flex-direction: initial; gap: 0;
transition: background-color var(--t-fast), filter var(--t-fast), transform 90ms var(--ease-out);
}
.ewheel__panel.has-cell .ewheel__item { background: var(--ewheel-cell) center / 100% 100% no-repeat; border-radius: 0; }
.ewheel__item:hover:not(:disabled) { background-color: rgba(255, 255, 255, .26); filter: brightness(1.12); transform: none; border-color: transparent; }
/* the official cell is a rounded plate with transparent corners: brighten it, never fill its square box */
.ewheel__panel.has-cell .ewheel__item:hover:not(:disabled) { background-color: transparent; filter: brightness(1.35); }
.ewheel__item:active:not(:disabled) { transform: scale(.9); }
.ewheel__item:focus-visible { outline: 2px solid var(--mint-500); outline-offset: 1px; }
.ewheel__item:disabled { opacity: .45; cursor: wait; }
/* pager: ‹ dots › in the strip under the cells */
.ewheel__dots { position: absolute; left: .15rem; width: 2.82rem; top: 1.99rem; height: .12rem; display: flex; align-items: center; justify-content: center; gap: .1rem; }
.ewheel__dot {
position: relative; width: .11rem; height: .12rem; padding: 0; border: 0; border-radius: .015rem; background: rgba(255, 255, 255, .28);
transition: background-color var(--t-fast), transform var(--t-fast);
}
.ewheel__dot:hover { background: rgba(255, 255, 255, .55); }
.ewheel__dot.is-on { background: #fff; transform: scale(1.12); }
.ewheel__dot::before { content: ''; position: absolute; inset: -.05rem -.05rem; } /* bigger hit area */
.ewheel__nav {
position: absolute; top: 1.94rem; width: .26rem; height: .22rem; display: grid; place-items: center; padding: 0; z-index: 1;
background: none; border: 0; color: rgba(255, 255, 255, .6); transition: color var(--t-fast), opacity var(--t-fast);
}
.ewheel__nav .icon { width: .18rem; height: .18rem; }
.ewheel__nav.is-prev { left: .7rem; }
.ewheel__nav.is-next { right: .7rem; }
.ewheel__nav:hover:not(:disabled) { color: #fff; }
.ewheel__nav:disabled { opacity: .18; cursor: default; }
/* ---- bubble beside the sender's avatar (picture only) ------------------------------------------------------ */
/* emoji_bubble_bkg is 108×97: plate x 22–97, y 8–85, tail tip at x 9, y ≈ 46 (pointing left, toward the avatar). */
.ebubble {
--ebubble-ttl: 3000ms;
--ebubble-age: 0ms; /* set inline for a bubble mounted after its emote arrived: resume the timeline, never restart it */
position: relative; display: block; box-sizing: border-box; width: 1.08rem; height: .97rem; max-width: none; padding: 0;
background: none; border: 0; clip-path: none; color: inherit; font-size: 0; white-space: normal; filter: none;
transform-origin: .09rem 47%;
animation: emo-bubble-pop 240ms var(--ease-out) calc(0ms - var(--ebubble-age)) both,
emo-bubble-out 300ms var(--ease-in-out) calc(var(--ebubble-ttl) - 300ms - var(--ebubble-age)) forwards;
pointer-events: none; z-index: 3;
}
/* CSS look-alike of emoji_bubble_bkg: dark rounded square + tail */
.ebubble::before {
content: ''; position: absolute; left: .22rem; top: .08rem; width: .76rem; height: .78rem; border-radius: .08rem;
background: rgba(24, 24, 24, .86); box-shadow: 0 .02rem .08rem rgba(0, 0, 0, .45);
}
.ebubble::after {
content: ''; position: absolute; left: .09rem; top: calc(47% - .08rem); width: .14rem; height: .16rem;
background: rgba(24, 24, 24, .86); clip-path: polygon(100% 0, 0 50%, 100% 100%);
}
.ebubble.has-sprite { background: var(--ebubble-bg) center / 100% 100% no-repeat; }
.ebubble.has-sprite::before, .ebubble.has-sprite::after { content: none; }
/* in the team panel the bubble is anchored beside the row (placement: screens/game.css .team__bubble) */
.ebubble.team__bubble { position: absolute; }
.ebubble__icon { position: absolute; left: .26rem; top: .12rem; width: .68rem; height: .70rem; display: grid; place-items: center; }
.ebubble .eart { width: 100%; height: 100%; }
@keyframes emo-panel-in { from { opacity: 0; transform: translateY(.06rem) scale(.96); } }
@keyframes emo-page-in { from { opacity: 0; } }
@keyframes emo-page-from-right { from { opacity: .2; transform: translateX(45%); } }
@keyframes emo-page-from-left { from { opacity: .2; transform: translateX(-45%); } }
@keyframes emo-bubble-pop { 0% { opacity: 0; scale: .35; } 60% { opacity: 1; scale: 1.1; } 100% { opacity: 1; scale: 1; } }
@keyframes emo-bubble-out { to { opacity: 0; scale: .92; } }
@media (pointer: coarse) {
.ewheel__btn.has-sprite { --ewb-w: max(1.51rem, 96px); }
}
@media (prefers-reduced-motion: reduce) {
.ewheel__panel, .ewheel__page, .ewheel__page.is-from-right, .ewheel__page.is-from-left { animation: none; }
.ebubble { animation: emo-bubble-out 1ms linear calc(var(--ebubble-ttl) - 1ms - var(--ebubble-age)) forwards; }
}
+100
View File
@@ -0,0 +1,100 @@
/* ==========================================================================================
screens/briefing.css — INFO_CHECK briefing (js/screens/briefing.js) + the pre-game step header
shared with the band draft (js/ui/matchChrome.js StepHeader). Research 06 §4.1 / §11.6.
========================================================================================== */
/* ---- shared pre-game backdrop + header ---- */
.brief__bg {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .018) 0 1px, transparent 1px 4px),
radial-gradient(90% 70% at 20% 30%, rgba(232, 90, 26, .07), transparent 60%),
radial-gradient(80% 70% at 85% 60%, rgba(78, 216, 175, .07), transparent 60%);
}
.stephead {
position: relative; z-index: 2; flex: none;
display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: .24rem;
height: 1.16rem; padding: 0 .36rem 0 .26rem;
background: linear-gradient(180deg, rgba(8, 10, 9, .94), rgba(10, 13, 12, .6)); border-bottom: 1px solid var(--line);
}
.stephead::after { content: ''; position: absolute; left: .3rem; right: .3rem; bottom: -1px; height: 1px; background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700)); }
.stephead__left { display: flex; align-items: center; gap: .16rem; }
.stephead__meta { display: flex; flex-direction: column; gap: .06rem; align-items: flex-start; }
.stephead__center { display: flex; flex-direction: column; align-items: center; gap: .04rem; }
.stephead__title { display: flex; align-items: baseline; gap: .02rem; font-size: .34rem; font-weight: 900; letter-spacing: .12em; }
.stephead__title b { font-size: .56rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .2rem rgba(23, 249, 183, .4); }
.stephead__of { font-size: .26rem; color: var(--text-dim); margin-right: .18rem; }
.stephead__right { justify-self: end; display: flex; align-items: center; gap: .24rem; }
.stephead__right .countdown { --cd-h: .64rem; }
.brief-h { display: flex; align-items: baseline; gap: .12rem; font-size: .21rem; font-weight: 700; letter-spacing: .1em; margin-bottom: .12rem; }
.brief-h::before { content: ''; width: .06rem; height: .2rem; background: var(--mint-500); align-self: center; }
/* ---- briefing layout ---- */
.brief__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 6.2rem 1fr; gap: .36rem; padding: .3rem .5rem .1rem; }
.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; }
.brief__right { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
.brief-boss {
--bk-c: var(--orange-hi);
position: relative; display: grid; grid-template-columns: 2rem 1fr; gap: .2rem; padding: .22rem .24rem;
background: linear-gradient(135deg, rgba(60, 18, 8, .55), rgba(12, 14, 13, .85) 60%); border: 1px solid rgba(255, 110, 40, .3);
}
.brief-boss > .micro { position: absolute; left: .24rem; top: .1rem; }
.brief-boss__art { position: relative; width: 2rem; height: 2rem; display: grid; place-items: center; margin-top: .12rem; }
.brief-boss__ring { position: absolute; inset: 0; border-radius: 50%; border: 2px solid rgba(255, 110, 40, .5);
background: radial-gradient(circle, rgba(255, 60, 20, .25), transparent 65%); box-shadow: 0 0 .4rem rgba(255, 80, 30, .25), inset 0 0 .3rem rgba(255, 80, 30, .3); }
.brief-boss__ring::after { content: ''; position: absolute; inset: .14rem; border-radius: 50%; border: 1px dashed rgba(255, 110, 40, .45); animation: spin 18s linear infinite; }
.brief-boss__sil { position: relative; width: 74%; height: 74%; object-fit: cover; border-radius: 50%;
filter: grayscale(1) sepia(1) hue-rotate(-28deg) saturate(3.2) brightness(.62) contrast(1.35);
box-shadow: 0 0 0 2px rgba(255, 110, 40, .7), 0 0 .3rem rgba(255, 80, 30, .5); }
.brief-boss__art::after { content: ''; position: absolute; inset: 13%; border-radius: 50%; pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, .22) 0 2px, transparent 2px 4px), radial-gradient(circle, transparent 45%, rgba(40, 8, 2, .75) 100%); }
.brief-boss__q { font-size: 1rem; font-weight: 900; color: rgba(255, 110, 40, .6); }
.brief-boss__text { display: flex; flex-direction: column; gap: .08rem; padding-top: .2rem; min-width: 0; }
.brief-boss__name { font-size: .34rem; font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
.brief-boss__when { font-size: .15rem; color: var(--orange-hi); }
.brief-boss__abil { margin: .04rem 0 0; padding-left: .18rem; display: flex; flex-direction: column; gap: .04rem; font-size: .14rem; line-height: 1.6; color: var(--text-md); }
.brief-stage { display: flex; align-items: center; justify-content: space-between; gap: .2rem; padding: .12rem .18rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-stage__k { display: flex; align-items: baseline; gap: .12rem; min-width: 0; }
.brief-stage__pool { font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
.brief-stage__name { display: flex; align-items: center; gap: .08rem; font-size: .19rem; font-weight: 700; }
.brief-stage__name .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.brief-factions { display: flex; flex-direction: column; gap: .1rem; min-height: 0; }
.brief-faction { display: flex; gap: .14rem; align-items: center; padding: .12rem .16rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); border-left: 3px solid var(--amber); }
.brief-faction__icon { width: .72rem; height: .72rem; flex: none; display: grid; place-items: center; background: radial-gradient(circle, rgba(246, 163, 41, .16), transparent 70%); }
.brief-faction__icon img { width: 100%; height: 100%; object-fit: contain; }
.brief-faction__text { display: flex; flex-direction: column; gap: .03rem; }
.brief-faction__text b { font-size: .2rem; color: var(--amber); }
.brief-faction__text span { font-size: .15rem; color: var(--text-lo); }
.brief-bonds { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-bonds__row { display: flex; flex-wrap: wrap; gap: .16rem .1rem; }
.brief-bond { position: relative; width: 1rem; display: flex; justify-content: center; }
.brief-bond .bond { --disc: .66rem; }
.brief-bond .bond__name { font-size: .16rem; }
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: .12rem; font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
.brief-bond__ban .icon { width: .12rem; height: .12rem; }
.brief-bond.is-partial .bond__core { box-shadow: 0 0 0 2px rgba(255, 84, 84, .5); }
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: .14rem; color: var(--text-dim); margin-top: -.06rem; }
.brief-legend__off { width: .16rem; height: .16rem; border-radius: 50%; background: #3a403d; border: 2px solid #555; }
.brief-legend__ban { display: inline-grid; place-items: center; width: .2rem; height: .18rem; background: var(--red); color: #fff; }
.brief-legend__ban .icon { width: .12rem; height: .12rem; }
.brief-banned { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-banned__n { margin-left: auto; font-size: .22rem; color: var(--red-premium); }
.brief-banned__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(.6rem, 1fr)); gap: .08rem; }
.brief-banned__grid .uthumb { --ut: .6rem; }
.brief__foot { position: relative; z-index: 2; flex: none; display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .18rem .5rem .3rem; }
.brief-ready { display: flex; align-items: center; gap: .16rem; }
.brief-ready__txt { font-size: .2rem; color: var(--text-lo); }
.brief-ready__txt b { font-size: .4rem; color: var(--mint-400); margin: 0 .02rem; }
.brief-ready__txt span { font-size: .24rem; color: var(--text-dim); }
.brief-ready__pips { display: flex; gap: .06rem; }
.brief-ready__pips i { width: .3rem; height: .3rem; display: grid; place-items: center; color: var(--text-dim); }
.brief-ready__pips i .icon { width: 100%; height: 100%; }
.brief-ready__pips i.on { color: var(--mint-400); filter: drop-shadow(0 0 .06rem rgba(23, 249, 183, .6)); }
.brief-ready__pips i.me { border-bottom: 2px solid var(--mint-700); }
+77
View File
@@ -0,0 +1,77 @@
/* ==========================================================================================
screens/draft.css — BAND_DRAFT strategy pick (js/screens/bandDraft.js). Research 06 §4.2:
draft order on the left, portrait grid, detail pane with 初始生命值 and 确认选择 / 跳过.
========================================================================================== */
.draft__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 3.9rem 1fr 5rem; gap: .3rem; padding: .3rem .42rem .36rem; }
.draft-order { display: flex; flex-direction: column; gap: .1rem; min-height: 0; }
.dorder {
position: relative; display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: .12rem;
padding: .1rem .12rem .1rem .08rem; background: rgba(12, 15, 14, .82); border: 1px solid var(--line);
transition: border-color var(--t-med), background var(--t-med);
}
.dorder .pavatar { --pa: .66rem; }
.dorder__idx { font-size: .22rem; color: var(--text-dim); width: .26rem; text-align: center; }
.dorder__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.dorder__name { font-size: .18rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: .06rem; }
.dorder__ai { font-family: var(--font-num); font-size: .11rem; padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
.dorder__state { font-size: .14rem; }
.dorder__state span { display: inline-flex; align-items: center; gap: .04rem; }
.dorder__state .icon { width: .16rem; height: .16rem; }
.dorder__box { position: relative; width: .6rem; height: .6rem; display: grid; place-items: center; border: 1px dashed var(--line-2); }
.dorder__check { position: absolute; right: -.06rem; bottom: -.06rem; width: .22rem; height: .22rem; display: grid; place-items: center; background: var(--mint-500); color: var(--text-on-mint); }
.dorder__check .icon { width: .16rem; height: .16rem; }
.dorder__finger { width: .44rem; height: .44rem; animation: finger 1s ease-in-out infinite; }
@keyframes finger { 50% { transform: translateX(-.06rem); } }
.dorder.is-cur { border-color: var(--gold); background: linear-gradient(90deg, rgba(255, 198, 0, .12), rgba(12, 15, 14, .82)); box-shadow: 0 0 .2rem rgba(255, 198, 0, .15); }
.dorder.is-done { border-color: var(--mint-800); }
.dorder.is-self .dorder__name { color: var(--mint-400); }
.draft-order__tip { margin-top: auto; font-size: .13rem; line-height: 1.6; color: var(--text-dim); }
.draft-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(1.36rem, 1fr)); grid-auto-rows: min-content; gap: .12rem;
min-height: 0; overflow: auto; padding: .04rem .08rem .04rem .04rem; align-content: start;
}
.dband {
position: relative; display: flex; flex-direction: column; align-items: center; gap: .05rem; padding: .08rem .06rem .1rem;
background: rgba(12, 15, 14, .82); border: 1px solid var(--line); color: var(--text-md);
transition: transform var(--t-fast), border-color var(--t-fast), background var(--t-fast);
}
.dband:hover { transform: translateY(-.03rem); border-color: var(--line-3); }
.dband .bandicon { --bi: 1.1rem; border: 1px solid var(--line-2); }
.dband__name { font-size: .16rem; font-weight: 700; color: var(--text-hi); white-space: nowrap; }
.dband__lp { display: inline-flex; align-items: center; gap: .04rem; font-size: .15rem; color: var(--mint-400); }
.dband__lp i { width: .12rem; height: .14rem; background: var(--mint-500); clip-path: polygon(0 0, 30% 0, 30% 20%, 45% 20%, 45% 0, 55% 0, 55% 20%, 70% 20%, 70% 0, 100% 0, 100% 35%, 85% 50%, 85% 80%, 100% 90%, 100% 100%, 0 100%, 0 90%, 15% 80%, 15% 50%, 0 35%); }
.dband__who { position: absolute; right: .04rem; top: .04rem; display: flex; flex-direction: column; gap: .02rem; }
.dband__who .pavatar { --pa: .3rem; border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
.dband.is-sel { border-color: var(--mint-400); background: linear-gradient(180deg, rgba(78, 216, 175, .18), rgba(12, 15, 14, .85)); box-shadow: 0 0 0 1px var(--mint-400), 0 0 .24rem rgba(23, 249, 183, .2); }
.dband.is-mine::after { content: '已选择'; position: absolute; left: 0; top: 0; font-size: .11rem; font-weight: 700; padding: 0 .06rem; background: var(--mint-500); color: var(--text-on-mint); }
.draft-detail {
display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .26rem .26rem .22rem; min-height: 0;
background: linear-gradient(180deg, rgba(20, 30, 26, .9), rgba(10, 13, 12, .92)); border: 1px solid var(--line-2);
}
.draft-detail__art { position: relative; padding: .06rem; border: 1px solid var(--mint-800); background: radial-gradient(circle, rgba(78, 216, 175, .14), transparent 70%); }
.draft-detail__art .bandicon { --bi: 2rem; }
.draft-detail__hp { display: flex; align-items: center; gap: .12rem; font-size: .16rem; color: var(--text-lo); }
.draft-detail__name { font-size: .42rem; font-weight: 900; letter-spacing: .1em; }
.draft-detail__eff { align-self: stretch; display: flex; flex-direction: column; gap: .06rem; padding: .12rem .14rem; background: rgba(0, 0, 0, .3); border-left: 3px solid var(--mint-500); flex: 1; min-height: 0; overflow: auto; }
.draft-detail__eff b { font-size: .2rem; color: var(--mint-400); }
.draft-detail__desc { font-size: .16rem; line-height: 1.7; color: var(--text-md); }
.draft-detail__actions { align-self: stretch; display: flex; flex-direction: column; gap: .1rem; }
.draft-detail__status { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.draft-detail__status .icon { width: .18rem; height: .18rem; }
.draft-detail__btns { display: flex; gap: .1rem; }
.draft-detail__btns .btn { flex: 1; }
.dorder__secs { margin-left: .06rem; font-size: .15rem; color: var(--gold); }
/* 队友已选 (research 09 §5): a strategy a teammate picked cannot be chosen again */
.dband.is-taken { cursor: not-allowed; border-color: rgba(246, 163, 41, .35); }
.dband.is-taken .bandicon, .dband.is-taken .dband__name, .dband.is-taken .dband__lp { filter: grayscale(.8) brightness(.62); }
.dband.is-taken:hover { transform: none; }
.dband__taken {
position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) rotate(-8deg); white-space: nowrap; pointer-events: none;
padding: .02rem .12rem; font-size: .15rem; font-weight: 900; letter-spacing: .16em; color: #1d1400; background: var(--amber);
box-shadow: 0 .03rem .1rem rgba(0, 0, 0, .6);
}
.draft-detail__status--taken { color: var(--amber); }
+504
View File
@@ -0,0 +1,504 @@
/* ==========================================================================================
screens/game-panels.css — rich text, unit thumbnails & glyphs (gameComponents.js), detail panel,
bond popup, enemy / match-info drawer, 机变 overlay.
========================================================================================== */
/* ---- rich text (official markup, js/ui/richText.js) ---- */
.rt { white-space: normal; }
.rt-vup { color: #3fb6ff; }
.rt-vdown { color: #ff6a4a; }
.rt-rem { color: #f49800; }
.rt-note { color: var(--text-lo); }
.rt-kw { color: #2fd0ff; }
.rt-mint { color: var(--mint-400); }
.rt-hl { color: var(--gold-2); }
.rt-term { color: #9fd4ff; border-bottom: 1px dotted rgba(159, 212, 255, .6); }
/* ---- unit thumbnail ---- */
.uthumb {
--ut: .72rem; --tc: var(--tier-1);
position: relative; display: inline-block; width: var(--ut); height: var(--ut); flex: none;
}
.uthumb--xs { --ut: .34rem; } .uthumb--sm { --ut: .54rem; } .uthumb--lg { --ut: 1.1rem; }
.uthumb--t1 { --tc: var(--tier-1); } .uthumb--t2 { --tc: var(--tier-2); } .uthumb--t3 { --tc: var(--tier-3); }
.uthumb--t4 { --tc: var(--tier-4); } .uthumb--t5 { --tc: var(--tier-5); } .uthumb--t6 { --tc: var(--tier-6); }
.uthumb__art {
position: absolute; inset: 0; overflow: hidden; display: grid; place-items: center;
background: linear-gradient(160deg, #1a201d, #111513); background: linear-gradient(160deg, color-mix(in srgb, var(--tc) 30%, #1a201d), #111513);
border: 1px solid var(--tc); border: 1px solid color-mix(in srgb, var(--tc) 60%, var(--line-2));
clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--ut) * .16), calc(100% - var(--ut) * .16) 100%, 0 100%);
}
.uthumb__art img { width: 100%; height: 100%; object-fit: cover; }
.uthumb--item .uthumb__art img { object-fit: contain; width: 84%; height: 84%; }
.uthumb--enemy .uthumb__art { background: linear-gradient(160deg, #3a1c18, #140d0c); border-color: rgba(255, 90, 74, .45); }
.uthumb__glyph { font-weight: 900; font-size: calc(var(--ut) * .44); color: var(--text-md); }
.uthumb__tier { position: absolute; left: -.03rem; top: -.03rem; transform: scale(.82); transform-origin: top left; }
.uthumb--xs .uthumb__tier, .uthumb--sm .uthumb__tier { transform: scale(.66); }
.uthumb.is-golden .uthumb__art { border-color: var(--gold); box-shadow: inset 0 0 calc(var(--ut) * .18) rgba(255, 198, 0, .45); }
.uthumb.is-golden::after { content: ''; position: absolute; inset: -2px; border: 1px solid rgba(255, 198, 0, .7); pointer-events: none; clip-path: inherit; }
.uthumb.is-dim .uthumb__art img { filter: grayscale(.85) brightness(.55); }
.uthumb__tag { position: absolute; right: 0; bottom: 0; font-size: calc(var(--ut) * .17); line-height: 1.2; padding: 0 .03rem; background: #a9c3d8; color: #0b1520; font-weight: 700; }
.bglyph { display: inline-grid; place-items: center; width: .24rem; height: .24rem; flex: none; }
.bglyph img { width: 100%; height: 100%; object-fit: contain; filter: brightness(0) invert(.86) sepia(.4) hue-rotate(110deg) saturate(2.5); }
.bglyph__txt { font-weight: 900; font-size: .15rem; color: var(--mint-400); }
.bandicon { --bi: .6rem; display: inline-grid; place-items: center; width: var(--bi); height: var(--bi); flex: none; overflow: hidden; background: #111513; }
.bandicon img { width: 100%; height: 100%; object-fit: cover; }
.bandicon__txt { font-weight: 900; font-size: calc(var(--bi) * .44); color: var(--mint-400); }
.bandicon--xs { --bi: .26rem; } .bandicon--sm { --bi: .42rem; } .bandicon--lg { --bi: 1.14rem; } .bandicon--xl { --bi: 2.2rem; }
.sprite { object-fit: contain; }
/* ---- detail panel ---- */
.dpanel {
--bk-c: var(--mint-500);
position: absolute; left: 2.3rem; top: 2.1rem; bottom: 1.02rem; width: 4.9rem; max-height: 8.4rem; z-index: 9;
display: flex; flex-direction: column;
background: rgba(11, 15, 13, .97); border: 1px solid var(--line-2); box-shadow: 0 .14rem .4rem rgba(0, 0, 0, .65);
animation: panel-in var(--t-slow) var(--ease-out);
}
@keyframes panel-in { from { opacity: 0; transform: translateX(-.3rem); } }
/* docked right (the selected unit's underframe is on the left: gameLogic panelSide / panelSlots — keep the insets in
sync with PANEL_RIGHT_GAP / PANEL_RIGHT_BOTTOM(_SHOP)); above the shop bar while it is open */
.dpanel--right { left: auto; right: .9rem; bottom: 1.02rem; animation-name: panel-in-r; }
.dpanel--right.is-shop { bottom: 3.78rem; }
@keyframes panel-in-r { from { opacity: 0; transform: translateX(.3rem); } }
.dpanel__close { position: absolute; right: .08rem; top: .08rem; z-index: 2; width: .38rem; height: .38rem; display: grid; place-items: center; background: rgba(0, 0, 0, .5); border: 1px solid var(--line-2); color: var(--text-lo); }
.dpanel__close:hover { color: #fff; border-color: var(--line-3); }
.dpanel__close .icon { width: .2rem; height: .2rem; }
.dpanel__scroll { flex: 1; min-height: 0; overflow: auto; padding: .18rem .2rem .2rem; display: flex; flex-direction: column; gap: .14rem; }
.dhead { display: flex; gap: .16rem; }
.dhead__art { position: relative; width: 1.3rem; height: 1.9rem; flex: none; overflow: hidden; background: linear-gradient(180deg, #1c2421, #0e1210); border: 1px solid var(--line-2); }
.dhead__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
.dhead__art.is-golden { border-color: var(--gold); box-shadow: 0 0 .16rem rgba(255, 198, 0, .35); }
.dhead__art .uthumb { margin: .4rem auto; display: block; }
.dhead__icon { width: 1.1rem; height: 1.1rem; flex: none; display: grid; place-items: center; background: radial-gradient(circle, #26302c, #111513); border: 1px solid var(--line-2); }
.dhead__icon img { width: 88%; height: 88%; object-fit: contain; }
.dhead__icon .icon { width: .5rem; height: .5rem; color: var(--mint-500); }
.dhead__icon.is-golden { border-color: var(--gold); }
.dhead__icon--enemy { background: radial-gradient(circle, #3a1c18, #140d0c); border-color: rgba(255, 90, 74, .45); }
.dhead__icon--enemy.is-boss { border-color: var(--orange-hi); box-shadow: 0 0 .18rem rgba(255, 110, 40, .45); }
.dhead__icon--enemy.is-elite { border-color: var(--gold-2); }
.dhead__info { display: flex; flex-direction: column; gap: .05rem; min-width: 0; padding-top: .02rem; flex: 1; }
.dhead__chips { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; }
.dhead__name { font-size: .32rem; font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
.dhead__en { font-family: var(--font-display); font-size: .12rem; letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
.dhead__flavor { font-size: .13rem; color: var(--text-lo); font-style: italic; }
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); margin-top: .06rem; }
.dhead__class .sep { width: 1px; height: .16rem; background: var(--line-2); }
.dhead__prof, .dhead__sub { width: .24rem; height: .24rem; object-fit: contain; }
.dhead__pos { font-size: .12rem; padding: 0 .06rem; border: 1px solid var(--line-2); color: var(--text-lo); }
.dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
.dtag-elite { background: var(--gold); color: #1d1400; }
.dtag-token { background: #a9c3d8; color: #0b1520; }
.dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
.drank--normal { background: var(--bg-4); color: var(--text-md); }
.drank--elite { background: var(--gold-2); color: #1d1400; }
.drank--boss { background: var(--orange); color: #fff; }
.dhp { position: relative; height: .18rem; margin-top: .08rem; background: #0a0d0c; border: 1px solid var(--line-2); }
.dhp i { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #2bb65a, #56e08a); }
.dhp--enemy i { background: linear-gradient(90deg, #b5261a, #ff5a4a); }
.dhp span { position: absolute; inset: 0; display: grid; place-items: center; font-size: .12rem; color: #fff; text-shadow: 0 1px 2px #000; }
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: .14rem; line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
.dtrait .icon { width: .16rem; height: .16rem; margin-top: .03rem; color: var(--text-lo); flex: none; }
.dstats-wrap { display: flex; gap: .12rem; align-items: stretch; }
.dstats { flex: 1; display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.dstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0f1311; }
.dstat__k { font-size: .13rem; color: var(--text-lo); white-space: nowrap; }
.dstat__v { font-size: .18rem; color: #fff; }
.dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; }
.drange { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .06rem; padding: .06rem .1rem; background: #0f1311; border: 1px solid var(--line); min-width: 1.1rem; }
.rgrid { --rg: .14rem; display: grid; gap: 2px; }
.rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); }
.rgrid i.on { background: rgba(246, 163, 41, .25); border-color: var(--amber); }
.rgrid i.self { background: var(--mint-500); border-color: var(--mint-400); }
.dsec { display: flex; flex-direction: column; gap: .08rem; }
.dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: .17rem; font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); }
.dtext { font-size: .15rem; line-height: 1.65; color: var(--text-md); }
.dskill { display: flex; gap: .12rem; align-items: center; }
.dskill__icon { width: .62rem; height: .62rem; flex: none; object-fit: cover; border: 1px solid var(--line-3); background: #0a0d0c; }
.dskill__icon--empty { display: grid; place-items: center; }
.dskill__icon--empty b { font-family: var(--font-display); font-size: .2rem; color: var(--mint-400); letter-spacing: .02em; }
.dskill__slot { display: inline-block; margin-right: .06rem; padding: 0 .05rem; vertical-align: .02rem; font-family: var(--font-display);
font-size: .12rem; line-height: 1.5; color: var(--text-lo); border: 1px solid var(--line-3); }
.dskill__meta { display: flex; flex-direction: column; gap: .05rem; min-width: 0; }
.dskill__name { font-size: .19rem; }
.dskill__tags { display: flex; flex-wrap: wrap; gap: .06rem; align-items: center; }
.dsp { font-size: .12rem; padding: 0 .06rem; line-height: 1.6; font-weight: 700; }
.dsp--time { background: #7fd37a; color: #0b1a0a; }
.dsp--atk { background: #ff8a3d; color: #1f0c00; }
.dsp--def { background: #f2bd3e; color: #1f1400; }
.dsp--trig { border: 1px solid var(--line-3); color: var(--text-lo); font-weight: 500; }
.dsp__num { display: inline-flex; align-items: center; gap: .03rem; font-size: .13rem; color: var(--text-lo); }
.dsp__num b { color: #fff; }
.dsp__num .icon { width: .14rem; height: .14rem; color: var(--gold); }
.dtalent { display: flex; flex-direction: column; gap: .02rem; }
.dtalent b { font-size: .15rem; color: var(--text-hi); }
.dgarrison { display: flex; flex-direction: column; gap: .06rem; }
.dgarrison__type { display: inline-flex; align-self: flex-start; align-items: center; gap: .05rem; font-size: .13rem; color: var(--gold-2); padding: .01rem .08rem .01rem .04rem; border: 1px solid rgba(242, 189, 62, .4); }
.dgarrison__icon { width: .2rem; height: .2rem; object-fit: contain; }
.dbonds { display: flex; flex-wrap: wrap; gap: .08rem; }
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: .15rem; background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
/* the unit's bonds in the header's right column, right under name / class (user playtest #2 item 9): icon, name,
count / next threshold, tier pips — visible as soon as the card opens */
.dbonds--top { gap: .05rem; margin-top: .06rem; }
.dhead__info .dbonds--top .dbond { font-size: .14rem; padding: .02rem .07rem .02rem .03rem; }
.dhead__info .dbond__icon { width: .24rem; height: .24rem; }
.dbonds--top .dbond { gap: .06rem; padding: .03rem .08rem .03rem .04rem; font: inherit; font-size: .15rem; color: var(--text-md);
background: rgba(255, 255, 255, .03); border: 1px solid var(--line-2); cursor: default; }
.dbonds--top button.dbond { cursor: pointer; }
.dbonds--top button.dbond:hover { border-color: var(--mint-500); color: #fff; }
.dbonds--top .dbond.is-active { color: var(--mint-400); background: rgba(78, 216, 175, .1); border-color: var(--mint-700); }
.dbonds--top .dbond.is-core .dbond__name { font-weight: 700; }
.dbond__icon { width: .26rem; height: .26rem; }
.dbond:not(.is-active) .dbond__icon img { filter: brightness(0) invert(.6); }
.dbond__count { font-size: .15rem; color: #fff; }
.dbond__count small { font-size: .11rem; color: var(--text-dim); }
.dbond__count.is-max { color: var(--gold-2); }
.dbond__tiers { display: inline-flex; gap: 2px; margin-left: .02rem; }
.dbond__tiers i { width: .05rem; height: .12rem; background: rgba(255, 255, 255, .16); transform: skewX(-18deg); }
.dbond__tiers i.on { background: var(--mint-400); box-shadow: 0 0 .04rem rgba(78, 216, 175, .8); }
.dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: .11rem; font-weight: 700; letter-spacing: .06em;
padding: 0 .06rem; line-height: 1.6; background: var(--mint-500); color: #062019; }
.dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: .15rem; }
.dmodule__name { color: var(--text-hi); }
/* official module type icon (local-client art, white glyph) on a dark tile; the type letter without it */
.dmodule__icon { flex: none; display: inline-grid; place-items: center; width: .4rem; height: .4rem; background: linear-gradient(180deg, #2a322f, #151a18); border: 1px solid var(--gold-2); border: 1px solid color-mix(in srgb, var(--gold-2) 60%, transparent); }
.dmodule__icon img { width: 80%; height: 80%; object-fit: contain; }
.dmodule__icon b { font-size: .2rem; font-weight: 700; line-height: 1; color: var(--gold-2); }
.dmodule__type { font-family: var(--font-display); font-size: .12rem; letter-spacing: .1em; padding: 0 .06rem; border: 1px solid var(--line-3); color: var(--text-lo); }
.dmodule.is-none .dmodule__name { color: var(--text-dim); font-weight: 500; }
.ditem { display: flex; gap: .1rem; align-items: flex-start; }
.ditem__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.ditem__text b { font-size: .15rem; }
.ditem__desc { font-size: .13rem; line-height: 1.55; color: var(--text-lo); }
.dempty { font-size: .14rem; }
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: .14rem; color: var(--text-lo); line-height: 1.6; }
.dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; }
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
.dsell { margin-left: .06rem; color: #1d1400; }
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: .13rem; color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
.dfaction img { width: .22rem; height: .22rem; object-fit: contain; }
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: .15rem; line-height: 1.6; color: var(--text-md); }
/* ---- bond popup ---- */
.bpop {
position: absolute; left: 2.3rem; top: 2.1rem; width: 5.4rem; max-height: calc(100% - 2.4rem); z-index: 8; overflow: auto;
display: flex; flex-direction: column; gap: .12rem; padding: .2rem .22rem;
background: rgba(11, 15, 13, .97); border: 1px solid var(--line-2); box-shadow: 0 .14rem .4rem rgba(0, 0, 0, .65);
animation: pop-in var(--t-med) var(--ease-out);
}
/* placement (gameLogic bondPopupPlace / BPOP — keep in sync): beside the left-docked detail card, left of the
right-docked one, against the right edge, or over the card it was opened from — never over the selected unit's
underframe when a place is free */
.bpop--beside { left: 7.32rem; z-index: 10; }
.bpop--besideR { left: auto; right: 5.92rem; z-index: 10; }
.bpop--right { left: auto; right: .9rem; z-index: 10; }
.bpop.is-over { z-index: 10; background: rgb(11, 15, 13); }
.bpop__close { position: absolute; right: .08rem; top: .08rem; width: .36rem; height: .36rem; display: grid; place-items: center; background: none; border: 1px solid var(--line-2); color: var(--text-lo); }
.bpop__close .icon { width: .18rem; height: .18rem; }
.bpop__head { display: flex; gap: .16rem; align-items: center; }
.bpop__disc { width: .86rem; height: .86rem; border-radius: 50%; display: grid; place-items: center; background: #1b2320; border: 3px solid rgba(255, 255, 255, .14); flex: none; }
.bpop__disc .bglyph { width: .56rem; height: .56rem; }
.bpop__disc .bglyph img { filter: brightness(0) invert(.8); }
.bpop__disc.is-active { background: var(--mint-500); border-color: var(--mint-400); box-shadow: 0 0 .24rem rgba(23, 249, 183, .45); }
.bpop__disc.is-active .bglyph img { filter: brightness(0) opacity(.85); }
.bpop__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.bpop__name { font-size: .32rem; font-weight: 900; letter-spacing: .08em; }
.bpop__facts { display: flex; gap: .16rem; font-size: .15rem; color: var(--text-lo); }
.bpop__facts b { color: #fff; font-size: .18rem; }
.bpop__facts small { font-size: .12rem; }
.bpop__tiers { display: flex; gap: .06rem; }
.bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: .13rem; }
.bpop__tier b { font-size: .2rem; }
.bpop__tier.is-on { background: var(--mint-500); color: var(--text-on-mint); border-color: var(--mint-400); }
.bpop__sec { display: flex; flex-direction: column; gap: .06rem; }
.bpop__sec h4 { font-size: .16rem; color: var(--mint-400); display: flex; align-items: baseline; gap: .08rem; }
.bpop__sec h4 small { font-size: .13rem; color: var(--text-lo); font-weight: 400; }
.bpop__sec--now { padding: .1rem .12rem; background: rgba(78, 216, 175, .06); border-left: 3px solid var(--mint-500); }
.bpop__desc { font-size: .15rem; line-height: 1.7; color: var(--text-md); }
.bpop__members { display: grid; grid-template-columns: repeat(5, 1fr); gap: .08rem; }
.bpop__member { position: relative; display: flex; flex-direction: column; align-items: center; gap: .03rem; padding: .04rem 0; background: none; border: 1px solid transparent; }
.bpop__member:hover { border-color: var(--line-2); background: rgba(255, 255, 255, .03); }
.bpop__member.is-on .uthumb__art { border-color: var(--mint-400); box-shadow: 0 0 .1rem rgba(23, 249, 183, .45); }
.bpop__mname { font-size: .12rem; color: var(--text-lo); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bpop__member.is-on .bpop__mname { color: var(--mint-400); }
.bpop__member.is-owned .bpop__mname { color: var(--text-hi); }
.bpop__ban { position: absolute; top: .02rem; right: .1rem; width: .2rem; height: .2rem; display: grid; place-items: center; background: var(--red); color: #fff; border-radius: 50%; }
.bpop__ban .icon { width: .14rem; height: .14rem; }
/* ---- enemy / info drawer ---- */
.edrawer {
--bk-c: var(--amber);
position: absolute; left: 50%; top: 1.08rem; transform: translateX(-50%); width: 7.4rem; max-height: calc(100% - 4.4rem); z-index: 9;
display: flex; flex-direction: column; background: rgba(11, 15, 13, .97); border: 1px solid var(--line-2); box-shadow: 0 .14rem .4rem rgba(0, 0, 0, .65);
animation: drawer-in var(--t-slow) var(--ease-out);
}
@keyframes drawer-in { from { opacity: 0; transform: translate(-50%, -.2rem); } }
.edrawer__top { display: flex; align-items: flex-end; justify-content: space-between; padding: .08rem .12rem 0 .16rem; border-bottom: 1px solid var(--line); }
.edrawer__top .tabs { border-bottom: 0; }
.edrawer__close { width: .34rem; height: .34rem; margin-bottom: .06rem; display: grid; place-items: center; background: none; border: 1px solid var(--line-2); color: var(--text-lo); }
.edrawer__close .icon { width: .18rem; height: .18rem; }
.edrawer__body { overflow: auto; padding: .14rem .18rem .18rem; display: flex; flex-direction: column; gap: .12rem; }
.edrawer__sum { display: flex; align-items: center; justify-content: space-between; gap: .12rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); }
.edrawer__sum b { font-size: .19rem; }
.edrawer__empty { display: flex; align-items: center; gap: .08rem; justify-content: center; padding: .3rem; color: var(--text-dim); font-size: .16rem; }
.edrawer__empty .icon { width: .24rem; height: .24rem; }
.fchips { display: flex; gap: .06rem; flex-wrap: wrap; }
.fchip { display: inline-flex; align-items: center; gap: .04rem; padding: 0 .1rem 0 .02rem; font-size: .13rem; color: var(--amber); border: 1px solid rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .06); }
.fchip__icon { width: .26rem; height: .26rem; object-fit: contain; }
.elist { display: grid; grid-template-columns: 1fr 1fr; gap: .08rem; }
.erow { display: flex; align-items: center; gap: .1rem; padding: .06rem .12rem .06rem .06rem; background: rgba(255, 255, 255, .025); border: 1px solid var(--line); text-align: left; color: inherit; transition: border-color var(--t-fast), background var(--t-fast); }
.erow:hover { border-color: var(--line-3); background: rgba(255, 255, 255, .05); }
.erow--boss { border-color: rgba(255, 110, 40, .6); background: rgba(232, 90, 26, .1); }
.erow--bounty { border-color: rgba(255, 198, 0, .45); }
.erow__text { display: flex; flex-direction: column; gap: .03rem; min-width: 0; flex: 1; }
.erow__name { font-size: .16rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.erow__tags { display: flex; gap: .04rem; flex-wrap: wrap; }
.erow__count { font-size: .26rem; color: #fff; }
.etag { font-size: .11rem; line-height: 1.5; padding: 0 .05rem; font-weight: 700; }
.etag--boss { background: var(--orange); color: #fff; }
.etag--bounty { background: var(--gold); color: #1d1400; }
.etag--escort { background: var(--bg-4); color: var(--text-md); }
.etag--elite { background: var(--gold-2); color: #1d1400; }
.etag--fly { background: #2c4a63; color: #cfe8ff; }
.etag--faction { border: 1px solid rgba(246, 163, 41, .5); color: var(--amber); font-weight: 500; }
.eboss { display: flex; gap: .12rem; align-items: center; padding: .1rem .12rem; border: 1px solid rgba(255, 110, 40, .45); background: linear-gradient(90deg, rgba(232, 90, 26, .14), transparent); }
.eboss > div { display: flex; flex-direction: column; gap: .02rem; }
.eboss b { font-size: .19rem; }
.eboss .t-lo { font-size: .13rem; }
.iband { display: flex; gap: .12rem; align-items: flex-start; padding: .1rem; background: rgba(78, 216, 175, .05); border: 1px solid var(--mint-800); }
.iband > div { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.iband b { font-size: .19rem; }
.iband b small { font-size: .14rem; font-weight: 400; margin-left: .06rem; }
.iband__desc { font-size: .14rem; line-height: 1.6; color: var(--text-md); }
.istage { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.istage .icon { width: .18rem; height: .18rem; color: var(--mint-500); }
.istage b { color: var(--text-hi); }
.ihead { font-size: .16rem; color: var(--mint-400); display: flex; gap: .08rem; align-items: baseline; }
.ihead small { color: var(--text-lo); font-size: .14rem; }
.ibonds { display: flex; flex-wrap: wrap; gap: .06rem; }
.ibond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .08rem .03rem .04rem; font-size: .14rem; border: 1px solid var(--line-2); color: var(--text-md); }
.ibond.is-off { color: var(--text-dim); background: rgba(255, 255, 255, .02); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
.ibond.is-off .bglyph img { filter: grayscale(1) brightness(.5); }
.ibond__ban { display: inline-flex; align-items: center; gap: .02rem; font-size: .12rem; padding: 0 .05rem; background: var(--red); color: #fff; text-decoration: none; }
.ibond__ban .icon { width: .12rem; height: .12rem; }
.ibanned { display: flex; flex-wrap: wrap; gap: .06rem; }
.ibanned__one { padding: 0; background: none; border: 0; }
/* ---- 机变 overlay ---- */
.spov { position: absolute; inset: 0 0 0 2.8rem; z-index: 20; }
.gm.has-sp .gm__bonds, .gm.has-sp .gm__effects { opacity: 0; pointer-events: none; transition: opacity var(--t-med); }
.spov__veil { position: absolute; inset: 0; background: linear-gradient(90deg, rgba(4, 7, 6, .6) 0, rgba(4, 7, 6, .9) .5rem, rgba(4, 7, 6, .92)); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); border-left: 1px solid var(--line); }
.spov__inner { position: absolute; left: .3rem; right: .5rem; top: 1.14rem; bottom: .5rem; display: flex; flex-direction: column; gap: .16rem; animation: screen-in var(--t-slow) var(--ease-out); }
.spov__head { display: flex; align-items: flex-end; justify-content: space-between; gap: .3rem; }
.spov__titles { display: flex; flex-direction: column; gap: .04rem; }
.spov__title { display: flex; align-items: baseline; gap: .14rem; font-size: .5rem; font-weight: 900; letter-spacing: .08em; color: #eafff8; text-shadow: 0 0 .2rem rgba(23, 249, 183, .35); }
.spov__title.is-special { color: var(--gold-2); text-shadow: 0 0 .2rem rgba(242, 189, 62, .35); }
.spov__bar { color: var(--mint-700); font-weight: 400; }
.spov__desc { font-size: .22rem; font-weight: 400; color: var(--text-lo); letter-spacing: .04em; text-shadow: none; }
.spov__sub { font-size: .15rem; color: var(--text-dim); }
.spov__turn { display: flex; align-items: center; gap: .24rem; }
.spov__turntxt { display: flex; align-items: center; gap: .08rem; font-size: .24rem; font-weight: 700; color: var(--text-md); }
.spov__turntxt .icon { width: .26rem; height: .26rem; }
.spov__turntxt.is-mine { color: var(--gold); text-shadow: 0 0 .16rem rgba(255, 198, 0, .45); animation: blink 1.2s ease-in-out infinite; }
.spov__turntxt.is-done { color: var(--mint-400); }
.spov__order { display: flex; gap: .1rem; }
.spov__who { display: flex; align-items: center; gap: .08rem; padding: .05rem .12rem .05rem .06rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); }
.spov__who.is-cur { border-color: var(--gold); background: rgba(255, 198, 0, .08); }
.spov__who.is-done { border-color: var(--mint-800); }
.spov__who.is-self .spov__wname { color: var(--mint-400); }
.spov__idx { font-size: .16rem; color: var(--text-dim); width: .2rem; text-align: center; }
.spov__wname { font-size: .15rem; max-width: 1.2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spov__wstate { display: grid; }
.spov__wstate .icon { width: .2rem; height: .2rem; }
.spov__who.is-done .spov__wstate { color: var(--mint-400); }
.spov__who.is-cur .spov__wstate { color: var(--gold); }
.spov__grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); gap: .2rem; }
.spov__grid--3 { grid-template-columns: repeat(3, minmax(0, 4.3rem)); grid-template-rows: minmax(0, 5rem); justify-content: center; align-content: start; gap: .36rem; margin-top: .3rem; }
.spov__grid--3 .spcard { justify-content: flex-start; padding-top: .62rem; }
.spov__grid--3 .spcard__icon { width: 1.6rem; height: 1.6rem; margin-bottom: .1rem; }
.spov__grid--3 .spcard--bounty .spcard__icon { width: 1.4rem; height: 1.4rem; }
.spcard {
position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .12rem; padding: .3rem .3rem .24rem; overflow: hidden; text-align: center;
background: linear-gradient(180deg, rgba(210, 224, 219, .12), rgba(120, 140, 132, .06)); border: 1px solid rgba(200, 220, 212, .25); color: var(--text-hi);
transition: transform var(--t-fast) var(--ease-out), border-color var(--t-fast), background var(--t-fast), opacity var(--t-med);
}
.spcard__glow { position: absolute; inset: 0; background: radial-gradient(70% 50% at 50% 0%, rgba(78, 216, 175, .16), transparent 70%); pointer-events: none; }
.spcard.is-pickable { cursor: pointer; }
.spcard.is-pickable:hover { transform: translateY(-.06rem); border-color: var(--mint-400); background: linear-gradient(180deg, rgba(78, 216, 175, .18), rgba(78, 216, 175, .05)); box-shadow: 0 0 0 1px var(--mint-400), 0 .1rem .3rem rgba(23, 249, 183, .2); }
.spcard:disabled { cursor: default; }
.spcard.is-taken { background: rgba(120, 140, 132, .05); }
.spcard.is-taken > :not(.spcard__taker) { opacity: .38; filter: grayscale(.7); }
.spcard.is-mine { border-color: var(--mint-400); box-shadow: inset 0 0 0 1px var(--mint-400); }
.spcard.is-mine > :not(.spcard__taker) { opacity: 1; filter: none; }
.spcard__tier { position: absolute; left: .12rem; top: .12rem; }
.spcard__icon { width: 1.3rem; height: 1.3rem; display: grid; place-items: center; flex: none; }
.spcard__icon img { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 .06rem .12rem rgba(0, 0, 0, .6)); }
.spcard__icon .icon { width: .7rem; height: .7rem; color: var(--mint-500); }
.spcard--bounty .spcard__icon { width: 1.1rem; height: 1.1rem; border: 2px solid rgba(255, 90, 74, .5); background: radial-gradient(circle, #3a1c18, #140d0c); }
.spcard--bounty .spcard__icon img { object-fit: cover; }
.spcard__body { display: flex; flex-direction: column; align-items: center; gap: .08rem; min-height: 0; }
.spcard__name { font-size: .26rem; font-weight: 900; letter-spacing: .06em; }
.spcard__desc { font-size: .16rem; line-height: 1.65; color: var(--text-md); max-width: 4.6rem; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.spov__grid--3 .spcard__desc { -webkit-line-clamp: 8; }
.spcard__tags { display: flex; gap: .08rem; }
.spcard__tag { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
.spcard__tag--team { background: var(--mint-500); color: var(--text-on-mint); }
.spcard__tag--coin { background: var(--gold); color: #1d1400; }
.spcard__taker { position: absolute; right: .12rem; top: .12rem; display: flex; align-items: center; gap: .06rem; padding: .03rem .1rem .03rem .03rem;
background: rgba(8, 11, 10, .92); border: 1px solid var(--mint-500); border-radius: .3rem; font-size: .13rem; color: var(--mint-400); font-weight: 700; }
.spcard__taker .pavatar { --pa: .4rem; border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
.spcard.is-taken .spcard__taker { filter: none; }
.spcard__spin { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0, 0, 0, .4); }
/* Grid image boxes: definite 100% tracks so `height: 100%` images resolve against the box (an auto track would
let non-square art overflow its frame). */
.uthumb__art, .bandicon, .dhead__icon, .spcard__icon, .effect, .pavatar__img, .brief-faction__icon, .scard__itemart, .bglyph {
grid-template-rows: 100%;
grid-template-columns: 100%;
}
.spcard__icon img, .dhead__icon img, .scard__itemart img, .effect img { max-width: 100%; max-height: 100%; }
/* ---- direction wheel (ui/facingWheel.js, research 09 §1.2): white diamond, 4 chevrons, "✕ 点击取消", orange range ---- */
.fwheel { position: fixed; inset: 0; z-index: 6; touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair; }
.fwheel__stripes { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.fwheel__dia { position: absolute; transform: translate(-50%, -50%); pointer-events: none; animation: fwheel-in 160ms var(--ease-out); }
@keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } }
.fwheel__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 0 .05rem rgba(0, 0, 0, .75)); }
.fwheel__outer { fill: rgba(8, 12, 10, .1); stroke: #fff; stroke-width: 2.4px; vector-effect: non-scaling-stroke; }
.fwheel__quad { fill: transparent; transition: fill var(--t-fast); }
/* only the chevron lights (research 09 §1.2): a tinted quadrant would wash out the units standing under the diamond */
.fwheel__quad.is-on { fill: transparent; }
.fwheel__inner { fill: rgba(255, 255, 255, .06); stroke: rgba(255, 255, 255, .9); stroke-width: 1.6px; vector-effect: non-scaling-stroke; }
.fwheel.is-pressed .fwheel__inner { fill: rgba(255, 255, 255, .1); }
.fwheel__chev path { fill: rgba(255, 255, 255, .92); transform-box: fill-box; transform-origin: center; transition: transform var(--t-fast), fill var(--t-fast); }
.fwheel__chev.is-on path { fill: #ffae3d; transform: scale(1.4); filter: drop-shadow(0 0 3px rgba(255, 156, 51, .9)); }
.fwheel__cancel {
position: absolute; left: 25%; top: 25%; transform: translate(-92%, -118%); pointer-events: auto;
display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; white-space: nowrap;
background: var(--red); border: 0; color: #fff; font-size: .15rem; font-weight: 700; letter-spacing: .06em; cursor: pointer;
clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - .08rem) 100%, 0 100%); box-shadow: 0 .03rem .1rem rgba(0, 0, 0, .5);
}
.fwheel__cancel:hover { background: #ff4a2b; }
.fwheel__x { width: .18rem; height: .18rem; filter: brightness(0) invert(1); }
.fwheel__x--txt { display: grid; place-items: center; filter: none; font-size: .14rem; }
.fwheel__tip {
position: absolute; left: 50%; top: 100%; transform: translate(-50%, .08rem); white-space: nowrap; pointer-events: none;
padding: .04rem .16rem; background: rgba(8, 11, 10, .9); border: 1px solid rgba(255, 156, 51, .6); color: #ffc27a; font-size: .15rem; letter-spacing: .08em;
}
.fwheel__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ----
It sits above every HUD panel (detail card z 9, bond popup, drawer, shop bar): only its buttons take pointer events,
so they are never covered (user playtest #2 item 8); overlays that must win (机变, modals, the drag ghost) are higher. */
.gm__hud > .uframe { position: absolute; transform: translate(-50%, -50%); pointer-events: none; z-index: 12; }
.uframe__dia { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; animation: uframe-in 180ms var(--ease-out); }
@keyframes uframe-in { from { opacity: 0; transform: scale(.85); } }
.uframe__outer { fill: rgba(78, 216, 175, .05); stroke: rgba(255, 255, 255, .88); stroke-width: 1.6px; stroke-dasharray: 7 5; vector-effect: non-scaling-stroke; }
.uframe__corner { fill: none; stroke: var(--mint-400); stroke-width: 2.4px; vector-effect: non-scaling-stroke; }
.uframe__btn {
position: absolute; pointer-events: auto; display: flex; flex-direction: column; align-items: center; gap: .02rem;
padding: 0; background: none; border: 0; color: #fff; cursor: pointer; filter: drop-shadow(0 .03rem .06rem rgba(0, 0, 0, .8));
animation: uframe-in 200ms var(--ease-out);
}
.uframe__btn:disabled { opacity: .5; cursor: wait; }
.uframe__btn--retreat { left: 25%; top: 25%; transform: translate(-80%, -90%); }
.uframe__btn--sell, .uframe__btn--destroy { left: 75%; top: 25%; transform: translate(-20%, -90%); }
/* the octagon plate every button stands on (the official util_btn plates are octagons) */
.uframe__glyph, .uframe__plate {
width: .56rem; height: .56rem; padding: .08rem; box-sizing: border-box;
clip-path: polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%);
}
.uframe__glyph { background: var(--red); fill: #fff; box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .22); }
/* coloured plates: tone colour through the official sprite's shape (mask), dark backing through its glyph cut-out */
.uframe__plate { --uf-tone: linear-gradient(180deg, #ffd06a 0%, #f6a329 55%, #d9820c 100%); --uf-ink: #1d1400;
position: relative; display: grid; place-items: center; padding: 0; background: var(--uf-ink); }
.uframe__plate--destroy { --uf-tone: linear-gradient(180deg, #ff6a4f 0%, #e73118 60%, #b8200d 100%); --uf-ink: #230703; }
.uframe__plate.has-mask::after {
content: ''; position: absolute; inset: 0; background: var(--uf-tone);
-webkit-mask: var(--uf-mask) center / 100% 100% no-repeat; mask: var(--uf-mask) center / 100% 100% no-repeat;
}
.uframe__plate.is-glyph { background: var(--uf-tone); }
.uframe__pglyph { width: 64%; height: 64%; color: var(--uf-ink); }
.uframe__btn:hover:not(:disabled) .uframe__glyph { background: #ff4a2b; }
.uframe__btn:hover:not(:disabled) .uframe__plate { filter: brightness(1.12) drop-shadow(0 0 .08rem rgba(255, 198, 90, .7)); }
.uframe__btn--destroy:hover:not(:disabled) .uframe__plate { filter: brightness(1.12) drop-shadow(0 0 .08rem rgba(255, 90, 60, .7)); }
.uframe__label { font-size: .14rem; font-weight: 700; letter-spacing: .12em; padding: 0 .06rem; background: rgba(8, 11, 10, .82); text-shadow: 0 1px 2px #000; }
.uframe__btn--sell .uframe__label { color: #ffd98a; }
.uframe__price { position: absolute; right: -.12rem; top: -.06rem; --hex-h: .24rem; }
/* ---- equip-replace dialog (js/ui/equipReplace.js; official equip_replace_* sprites via --eqr-* when installed) ---- */
.eqr-host { display: contents; }
.modal__box.eqr {
--bk-c: var(--red-premium);
background: rgba(14, 14, 18, .98);
background: var(--eqr-bg, none) center / cover no-repeat, repeating-linear-gradient(0deg, rgba(255, 255, 255, .015) 0 1px, transparent 1px 3px), linear-gradient(180deg, #17171c, #0d0d11);
border-color: rgba(255, 84, 84, .35);
}
.eqr .modal__body { display: flex; flex-direction: column; align-items: stretch; gap: .1rem; padding-bottom: .16rem; }
.eqr__lead { font-size: .17rem; line-height: 1.6; color: var(--text-md); }
.eqr__lead b { color: var(--text-hi); }
.eqr__lead .t-red { color: var(--red-premium); }
.eqr__top { display: flex; align-items: center; justify-content: center; gap: .22rem; margin-top: .06rem; }
.eqr__op { display: flex; flex-direction: column; align-items: center; gap: .05rem; flex: none; width: 1.1rem; }
.eqr__ava {
position: relative; display: grid; place-items: center; width: .96rem; height: .96rem;
background: var(--eqr-ava, linear-gradient(160deg, #26262c, #111114)) center / 100% 100% no-repeat;
}
.eqr__ava::after { content: ''; position: absolute; inset: .1rem; background: var(--eqr-avaf, none) center / 100% 100% no-repeat; pointer-events: none; }
.eqr__ava .uthumb { --ut: .72rem; }
.eqr__opname { font-size: .15rem; font-weight: 700; color: var(--text-hi); max-width: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eqr__swap { display: grid; place-items: center; width: .4rem; height: .32rem; color: var(--mint-400); flex: none; }
.eqr__swap img, .eqr__swap .icon { width: 100%; height: 100%; object-fit: contain; }
.eqr__incoming { display: flex; flex-direction: column; gap: .04rem; min-width: 0; flex: 0 1 4.2rem; }
.eqr__card { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; column-gap: .12rem; row-gap: .04rem; align-items: center; text-align: left; }
.eqr__card--new {
padding: .1rem .14rem; border: 1px solid rgba(89, 244, 202, .45);
background: linear-gradient(90deg, rgba(78, 216, 175, .14), rgba(78, 216, 175, .03));
}
.eqr__icon { grid-row: 1; grid-column: 1; position: relative; display: grid; place-items: center; }
.eqr__card--new .eqr__icon { width: .74rem; height: .74rem; background: var(--eqr-round, none) center / 100% 100% no-repeat; }
.eqr__card--new .eqr__icon .uthumb { --ut: .56rem; }
.eqr__meta { grid-row: 1; grid-column: 2; display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.eqr__chips { display: flex; align-items: center; gap: .06rem; min-height: .2rem; }
.eqr__elite { font-size: .12rem; padding: 0 .06rem; background: var(--gold); color: #1b1300; font-weight: 700; }
.eqr__name { font-size: .19rem; color: var(--text-hi); line-height: 1.2; }
.eqr__desc { grid-row: 2; grid-column: 1 / -1; margin: 0; font-size: .145rem; line-height: 1.55; color: var(--text-md); max-height: 1.12rem; overflow: auto; }
.eqr__arrow { position: relative; height: .5rem; margin: 0 auto; width: 50%; }
.eqr__aline { position: absolute; left: 0; right: 0; top: .2rem; height: .2rem; border: .045rem solid rgba(242, 242, 242, .82); border-bottom: 0; }
.eqr__aline::before { content: ''; position: absolute; left: 50%; top: -.25rem; width: .045rem; height: .21rem; margin-left: -.0225rem; background: rgba(242, 242, 242, .82); }
.eqr__ahead { position: absolute; top: .34rem; width: .3rem; height: .2rem; margin-left: -.15rem; background: var(--eqr-ahead, none) center / 100% 100% no-repeat; }
.eqr__ahead--l { left: calc(0% + .0225rem); } .eqr__ahead--r { left: calc(100% - .0225rem); }
.eqr-host:not([style*="--eqr-ahead"]) .eqr__ahead { width: .16rem; height: .16rem; margin-left: -.08rem; top: .3rem; border-right: .045rem solid rgba(242, 242, 242, .82); border-bottom: .045rem solid rgba(242, 242, 242, .82); transform: rotate(45deg); background: none; }
.eqr__opts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .3rem; }
.eqr__opt {
position: relative; display: flex; flex-direction: column; align-items: center; gap: .08rem; min-height: 2.5rem; padding: .18rem .16rem .5rem;
text-align: center; color: inherit; cursor: pointer; overflow: hidden;
background: var(--eqr-opt, none) center / 100% 100% no-repeat, linear-gradient(180deg, rgba(255, 255, 255, .07), rgba(255, 255, 255, .02));
border: 2px solid rgba(255, 255, 255, .16);
transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast);
}
.eqr__opt .eqr__icon { width: .86rem; height: .86rem; }
.eqr__opt .eqr__icon .uthumb { --ut: .8rem; }
.eqr__opt .eqr__meta { align-items: center; }
.eqr__opt .eqr__desc { text-align: left; max-height: 1rem; }
.eqr__opt:hover { border-color: rgba(255, 255, 255, .42); }
.eqr__opt:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 2px; }
.eqr__opt.is-sel { border-color: #e2262a; box-shadow: 0 0 .2rem rgba(226, 38, 42, .45); }
.eqr__opt.is-sel::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: var(--eqr-sel, linear-gradient(225deg, rgba(226, 38, 42, .55), transparent 38%)) center / 100% 100% no-repeat;
}
.eqr__flag {
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; height: .44rem; display: flex; align-items: center; justify-content: center; gap: .08rem;
font-size: .17rem; font-weight: 700; letter-spacing: .12em; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
background: var(--eqr-selbar, linear-gradient(90deg, #a3141a, #d42a2a)) center / 100% 100% no-repeat;
opacity: 0; transform: translateY(100%); transition: opacity var(--t-fast), transform var(--t-med) var(--ease-out);
}
.eqr__flag-icon { width: .2rem; height: .2rem; }
.eqr__opt.is-sel .eqr__flag { opacity: 1; transform: none; }
.eqr__hint { margin: .04rem 0 0; text-align: center; font-size: .15rem; color: var(--text-lo); min-height: .24rem; }
.eqr__hint b { color: var(--text-hi); }
.eqr__ok-icon { width: .2rem; height: .2rem; margin-right: .06rem; vertical-align: -.03rem; filter: brightness(0) invert(1); }
+224
View File
@@ -0,0 +1,224 @@
/* ==========================================================================================
screens/game-shop.css — bottom shop bar (js/ui/shopBar.js) + merge reward overlay (rewardOverlay.js).
Research 06 §11.2: LEVEL card, operator cards (tier chip, price hexagon, half-body art, bond & class
rows, icy frozen overlay), item card, funds card with ✕ 收起; 冻结 (ice) / 刷新 (amber) above.
========================================================================================== */
.shopbar {
position: absolute; right: .26rem; bottom: .2rem;
display: flex; flex-direction: column; align-items: flex-end; gap: .1rem;
animation: shop-up var(--t-slow) var(--ease-out);
}
@keyframes shop-up { from { opacity: 0; transform: translateY(.3rem); } }
/* Official layout (research 06 §11.2 / D3): "剩余可放置角色: N" on its own line, right-aligned ABOVE [冻结] [刷新]. On one
line the block reached over the bench's last pads (整备区 slot 9 at 16:9 under the official shop camera). Only the
bar's parts take the pointer: its box spans the empty space left of the tools, which used to swallow drags and taps
on bench slots 5–9. */
.gm__hud > .shopbar { pointer-events: none; }
.shopbar__row, .shopbar__tools > button { pointer-events: auto; }
.shopbar__tools { display: grid; grid-template-columns: auto auto; justify-content: end; align-items: center; gap: .06rem .12rem; }
.shopbar__remain {
grid-column: 1 / -1; justify-self: end; pointer-events: none;
font-size: .16rem; color: var(--text-md); padding: .04rem .14rem; background: rgba(8, 11, 10, .78);
border-left: 3px solid var(--mint-500);
}
.shopbar__remain b { color: var(--mint-400); font-size: .2rem; margin-left: .04rem; }
.toolbtn {
--tb: var(--ice);
display: flex; align-items: center; gap: .08rem; height: .48rem; padding: 0 .14rem 0 .08rem;
background: rgba(8, 11, 10, .9); border: 2px solid var(--tb); color: var(--tb); font-size: .19rem; font-weight: 700; letter-spacing: .1em;
box-shadow: 0 .04rem .14rem rgba(0, 0, 0, .45);
transition: background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast);
}
.toolbtn:hover:not(:disabled) { background: rgba(8, 11, 10, .9); background: color-mix(in srgb, var(--tb) 16%, rgba(8, 11, 10, .9)); box-shadow: 0 0 .18rem transparent; box-shadow: 0 0 .18rem color-mix(in srgb, var(--tb) 40%, transparent); }
.toolbtn:active:not(:disabled) { transform: translateY(1px); }
.toolbtn:disabled { --tb: var(--line-3); color: var(--text-dim); cursor: not-allowed; }
.toolbtn__img { width: .34rem; height: .34rem; object-fit: contain; }
.toolbtn .icon { width: .24rem; height: .24rem; }
.toolbtn--amber { --tb: var(--amber); }
.toolbtn--ice.is-on { background: var(--ice); color: #0b1822; }
.toolbtn--ice.is-on kbd { color: #28435a; border-color: #5d88aa; }
.toolbtn--ice.is-on:hover:not(:disabled) { background: #c2e5ff; color: #0b1822; box-shadow: 0 0 .22rem rgba(159, 212, 255, .6); }
.toolbtn--ice.is-on kbd { background: rgba(255, 255, 255, .35) !important; }
.toolbtn__free { font-size: .13rem; padding: .01rem .06rem; background: var(--mint-500); color: var(--text-on-mint); letter-spacing: 0; }
.toolbtn .hex { --hex-h: .26rem; }
.shopbar__row {
position: relative; display: flex; align-items: stretch; gap: .08rem; padding: .1rem;
background: linear-gradient(180deg, rgba(14, 19, 17, .94), rgba(8, 11, 10, .97));
border: 1px solid var(--line-2); border-top: 2px solid var(--mint-700);
box-shadow: 0 -.04rem .3rem rgba(0, 0, 0, .55);
}
.shopbar__row::before {
content: ''; position: absolute; left: 0; right: 0; top: -2px; height: 2px;
background: linear-gradient(90deg, var(--mint-400), transparent 30%, transparent 70%, var(--mint-400));
}
.shopbar__cards { display: flex; gap: .08rem; }
.shopbar__item { display: flex; gap: .08rem; padding-left: .08rem; border-left: 1px dashed var(--line-2); }
.shopbar.is-frozen .shopbar__row { border-color: rgba(159, 212, 255, .5); border-top-color: var(--ice); }
.shopbar.is-locked .scard:not(.scard--sold) { filter: saturate(.55) brightness(.8); }
/* ---- level card ---- */
.lvcard {
position: relative; width: 1.24rem; height: 2.24rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .12rem;
padding: .2rem .1rem .1rem; background: linear-gradient(180deg, #5fe0b9, #36b690); border: 0; color: var(--text-on-mint);
clip-path: polygon(0 0, 100% 0, 100% calc(100% - .14rem), calc(100% - .14rem) 100%, 0 100%);
transition: filter var(--t-fast), transform var(--t-fast);
}
.lvcard:hover:not(.is-disabled) { filter: brightness(1.08); }
.lvcard:active:not(.is-disabled) { transform: translateY(1px); }
.lvcard__price { position: absolute; top: .08rem; left: 50%; transform: translateX(-50%); }
.lvcard__frame { position: relative; display: flex; flex-direction: column; align-items: center; padding: .04rem .16rem .02rem; border: 2px solid rgba(6, 17, 13, .75); }
.lvcard__frame::before, .lvcard__frame::after { content: ''; position: absolute; width: .1rem; height: .1rem; border: 2px solid var(--text-on-mint); }
.lvcard__frame::before { left: -.06rem; top: -.06rem; border-right: 0; border-bottom: 0; }
.lvcard__frame::after { right: -.06rem; bottom: -.06rem; border-left: 0; border-top: 0; }
.lvcard__micro { font-family: var(--font-display); font-size: .12rem; letter-spacing: .24em; font-weight: 700; }
.lvcard__num { font-size: .66rem; line-height: .9; font-weight: 700; }
.lvcard__label { font-size: .2rem; font-weight: 900; letter-spacing: .3em; padding-left: .3em; }
.lvcard__key { position: absolute; right: .08rem; bottom: .06rem; color: var(--mint-900) !important; border-color: rgba(6, 17, 13, .35) !important; background: transparent !important; }
.lvcard.is-disabled { background: linear-gradient(180deg, #3a4a44, #2a3531); color: #8fa59c; cursor: not-allowed; }
.lvcard.is-disabled .lvcard__frame { border-color: rgba(143, 165, 156, .5); }
.lvcard.is-disabled .lvcard__frame::before, .lvcard.is-disabled .lvcard__frame::after { border-color: #8fa59c; }
.lvcard.is-max { background: linear-gradient(180deg, #d9b24a, #a07a1c); color: #1d1400; cursor: default; filter: none; }
/* ---- operator / item card ---- */
.scard-wrap { display: flex; }
.scard {
--tc: var(--tier-1);
position: relative; width: 1.56rem; height: 2.24rem; padding: 0; overflow: hidden; text-align: left;
display: flex; flex-direction: column; justify-content: flex-end;
background: #151a18; border: 1px solid rgba(78, 216, 175, .35);
transition: transform var(--t-fast) var(--ease-out), border-color var(--t-fast), box-shadow var(--t-fast);
}
.scard--t1 { --tc: var(--tier-1); } .scard--t2 { --tc: var(--tier-2); } .scard--t3 { --tc: var(--tier-3); }
.scard--t4 { --tc: var(--tier-4); } .scard--t5 { --tc: var(--tier-5); } .scard--t6 { --tc: var(--tier-6); }
.scard__bg { position: absolute; inset: 0;
background: linear-gradient(180deg, #1c2320, #0f1311); /* fallback: no color-mix() (Safari < 16.2) */
background:
radial-gradient(120% 70% at 80% 30%, color-mix(in srgb, var(--tc) 28%, transparent), transparent 70%),
linear-gradient(180deg, #1c2320, #0f1311); }
.scard__water { position: absolute; left: -.2rem; top: .34rem; width: 1.1rem; height: 1.1rem; opacity: .1; }
.scard__water .bglyph { width: 100%; height: 100%; }
.scard__art { position: absolute; right: -.14rem; top: 0; height: 100%; width: auto; max-width: none; object-fit: cover; pointer-events: none;
-webkit-mask-image: linear-gradient(180deg, #000 55%, transparent 88%); mask-image: linear-gradient(180deg, #000 55%, transparent 88%); }
.scard__top { position: absolute; left: .06rem; right: .06rem; top: .06rem; display: flex; align-items: flex-start; justify-content: space-between; z-index: 2; }
.scard__price { position: absolute; left: 50%; top: 0; transform: translateX(-50%); filter: drop-shadow(0 1px 3px #000); }
.scard__free { position: absolute; left: 50%; top: .02rem; transform: translateX(-50%); font-family: var(--font-num); font-weight: 700; font-size: .16rem; letter-spacing: .1em; color: var(--text-on-mint); background: var(--mint-400); padding: 0 .1rem; clip-path: polygon(.08rem 0, calc(100% - .08rem) 0, 100% 50%, calc(100% - .08rem) 100%, .08rem 100%, 0 50%); }
.scard__pips { display: flex; gap: .03rem; padding: .03rem .04rem; background: rgba(0, 0, 0, .6); }
.scard__pips i { width: .07rem; height: .07rem; border: 1px solid var(--gold); transform: rotate(45deg); }
.scard__pips i.on { background: var(--gold); }
.scard__body { position: relative; z-index: 2; display: flex; flex-direction: column; gap: .03rem; padding: .3rem .08rem .08rem; background: linear-gradient(0deg, rgba(8, 11, 10, .98) 60%, transparent); }
/* the loadout's skill (DESIGN §16) above the name; mint frame = not the default skill */
.scard__skill { position: absolute; right: .07rem; top: -.02rem; width: .3rem; height: .3rem; display: grid; place-items: center;
background: #0a0d0c; border: 1px solid var(--line-3); box-shadow: 0 1px 3px rgba(0, 0, 0, .8); }
.scard__skill img { width: 100%; height: 100%; object-fit: cover; }
.scard__skill .icon { width: .18rem; height: .18rem; color: var(--gold); }
/* a chosen skill without an icon in the asset manifest: its slot letter */
.scard__sglyph { font-family: var(--font-display); font-size: .13rem; font-weight: 700; letter-spacing: .02em; color: var(--mint-400); }
.scard__skill.is-custom { border-color: var(--mint-400); box-shadow: 0 0 0 1px var(--mint-500), 0 0 .08rem rgba(23, 249, 183, .5); }
.scard__skill.is-custom::after { content: ''; position: absolute; right: -.04rem; top: -.04rem; width: .08rem; height: .08rem; background: var(--mint-400); transform: rotate(45deg); }
.scard__mod { position: absolute; right: 100%; bottom: 0; margin-right: .03rem; display: flex; align-items: center; gap: .02rem; font-family: var(--font-display); font-size: .1rem; line-height: 1.4; padding: 0 .04rem; background: rgba(0, 0, 0, .75); color: var(--gold-2); white-space: nowrap; }
/* the official module type icon (local-client art, DESIGN §13); the type text stays beside it */
.scard__skill .scard__modicon { flex: none; width: .14rem; height: .14rem; object-fit: contain; }
.scard__name { font-size: .19rem; font-weight: 900; letter-spacing: .04em; color: #fff; text-shadow: 0 1px 3px #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.scard__bonds { display: flex; flex-wrap: wrap; gap: .02rem .08rem; }
.scard__bond { display: inline-flex; align-items: center; gap: .03rem; font-size: .13rem; color: var(--mint-400); }
.scard__bond .bglyph { width: .18rem; height: .18rem; }
.scard__class { display: flex; align-items: center; gap: .05rem; font-size: .13rem; color: var(--text-lo); }
.scard__prof { width: .18rem; height: .18rem; object-fit: contain; opacity: .85; }
.scard__detail { position: absolute; right: .04rem; bottom: .04rem; z-index: 3; width: .24rem; height: .24rem; display: grid; place-items: center; color: var(--text-dim); opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); cursor: help; }
.scard__detail .icon { width: .18rem; height: .18rem; }
.scard:hover .scard__detail { opacity: 1; }
.scard__detail:hover { color: var(--mint-400); }
.scard__mergetag { position: absolute; z-index: 3; right: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--gold); color: #1d1400; letter-spacing: .08em; }
.scard:not(.is-disabled):not(.scard--sold):hover { transform: translateY(-.08rem); border-color: var(--mint-400); box-shadow: 0 0 0 1px var(--mint-400), 0 .1rem .24rem rgba(23, 249, 183, .22); }
.scard:not(.is-disabled):not(.scard--sold):active { transform: translateY(-.04rem); }
.scard.is-disabled { cursor: not-allowed; }
.scard.is-disabled .scard__art, .scard.is-disabled .scard__itemart { filter: grayscale(.7) brightness(.6); }
.scard.is-merge { border-color: var(--gold); box-shadow: 0 0 0 1px rgba(255, 198, 0, .6), 0 0 .2rem rgba(255, 198, 0, .3); }
.scard.is-frozen::after { content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none;
background: linear-gradient(180deg, rgba(159, 212, 255, .28), rgba(159, 212, 255, .12) 50%, rgba(159, 212, 255, .34));
box-shadow: inset 0 0 0 2px rgba(159, 212, 255, .8), inset 0 0 .3rem rgba(159, 212, 255, .5); }
.scard__ice { position: absolute; left: 50%; bottom: .06rem; transform: translateX(-50%); z-index: 4; width: .3rem; height: .3rem; display: grid; place-items: center; background: #0c1a26; border: 1px solid var(--ice); color: var(--ice); }
.scard__ice .icon { width: .2rem; height: .2rem; }
.scard--item { --tc: var(--gold-2); }
.scard__itemart { position: absolute; left: 50%; top: .38rem; transform: translateX(-50%); width: 1rem; height: 1rem; display: grid; place-items: center;
background: radial-gradient(circle, rgba(255, 198, 0, .16), transparent 70%); }
.scard__itemart img { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 .04rem .08rem #000); }
.scard__itemart .icon { width: .6rem; height: .6rem; color: var(--gold-2); }
.scard__idesc { font-size: .12rem; line-height: 1.45; color: var(--text-lo); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.scard--sold { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .02) 0 .08rem, transparent .08rem .16rem), rgba(8, 11, 10, .75); border: 1px dashed var(--line-2); justify-content: center; align-items: center; }
.scard__soldtxt { display: flex; flex-direction: column; align-items: center; gap: .02rem; font-size: .16rem; color: var(--text-dim); }
/* ---- funds card ---- */
.funds { width: 1.18rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .08rem; padding: .06rem; border-left: 1px solid var(--line); }
.funds__hex {
position: relative; width: 1rem; height: .9rem; display: flex; flex-direction: column; align-items: center; justify-content: center;
background: linear-gradient(180deg, #2b2410, #16120a); color: var(--gold);
clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%);
}
.funds__hex::before { content: ''; position: absolute; inset: 2px; clip-path: inherit; border: 0; background: transparent; box-shadow: inset 0 0 0 2px var(--gold); }
.funds__coin { width: .34rem; height: .34rem; fill: var(--gold); filter: drop-shadow(0 0 .06rem rgba(255, 198, 0, .6)); }
.funds__num { font-size: .36rem; line-height: 1; font-weight: 700; color: #fff; }
.funds__label { font-size: .14rem; color: var(--text-lo); letter-spacing: .1em; }
.funds__collapse { display: flex; align-items: center; gap: .04rem; font-size: .15rem; background: none; border: 1px solid var(--line-2); color: var(--text-lo); padding: .02rem .1rem; }
.funds__collapse .icon { width: .14rem; height: .14rem; }
.funds__collapse:hover { color: var(--text-hi); border-color: var(--line-3); }
.coin-glyph { fill: var(--gold); }
/* collapsed tab */
.shopbar-tab { position: absolute; right: .26rem; bottom: .2rem; display: flex; align-items: center; gap: .1rem; padding: .08rem; background: rgba(8, 11, 10, .92); border: 1px solid var(--line-2); border-top: 2px solid var(--mint-700); }
.shopbar-tab__funds { display: flex; align-items: center; gap: .06rem; padding: 0 .1rem; }
.shopbar-tab__funds .coin-glyph { width: .26rem; height: .26rem; }
.shopbar-tab__funds b { font-size: .28rem; }
.shopbar-tab__btn { display: flex; align-items: center; gap: .04rem; height: .44rem; padding: 0 .16rem; background: var(--mint-500); color: var(--text-on-mint); border: 0; font-size: .17rem; font-weight: 700; letter-spacing: .1em; }
.shopbar-tab__btn .icon { width: .18rem; height: .18rem; transform: rotate(90deg); }
/* (no drag-to-sell zone: selling is the underframe's 出售 +N button — research 09 §5) */
/* ---- merge reward (inline: replaces the operator cards) ---- */
.shopbar__reward { display: flex; gap: .08rem; padding: 0 .08rem 0 0; position: relative; animation: pop-in var(--t-slow) var(--ease-out); }
.shopbar__reward::before { content: ''; position: absolute; inset: -.06rem -.04rem; border: 1px solid rgba(255, 198, 0, .55);
box-shadow: 0 0 .24rem rgba(255, 198, 0, .18), inset 0 0 .3rem rgba(255, 198, 0, .08); pointer-events: none; }
.rwtag {
width: .92rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .06rem; padding: .08rem .04rem;
background: linear-gradient(180deg, rgba(255, 198, 0, .22), rgba(255, 198, 0, .06)); color: var(--gold); text-align: center;
}
.rwtag__icon { width: .34rem; height: .34rem; filter: drop-shadow(0 0 .08rem rgba(255, 198, 0, .6)); }
.rwtag__title { writing-mode: vertical-rl; font-size: .22rem; font-weight: 900; letter-spacing: .2em; line-height: 1; }
.rwtag__micro { font-family: var(--font-display); font-size: .09rem; letter-spacing: .14em; opacity: .75; }
.rwtag__sub { font-size: .12rem; color: var(--text-md); line-height: 1.3; }
.rwtag__later { display: flex; align-items: center; gap: .02rem; margin-top: .04rem; padding: .02rem .08rem; font-size: .13rem; color: var(--text-lo);
background: rgba(0, 0, 0, .35); border: 1px solid var(--line-2); }
.rwtag__later .icon { width: .12rem; height: .12rem; }
.rwtag__later:hover { color: var(--text-hi); border-color: var(--line-3); }
.shopbar__rwcards { display: flex; gap: .08rem; }
.shopbar__rwcards .scard { border-color: rgba(255, 198, 0, .5); }
.shopbar.has-reward .shopbar__row { border-top-color: var(--gold); }
.rewardpill {
position: absolute; right: .26rem; bottom: 3.26rem; z-index: 6; display: flex; align-items: center; gap: .08rem; padding: .08rem .16rem;
background: rgba(24, 18, 4, .95); border: 1px solid var(--gold); color: var(--gold); font-size: .17rem; font-weight: 700;
animation: pulse-gold 1.6s ease-in-out infinite;
}
.rewardpill .icon { width: .2rem; height: .2rem; }
@keyframes pulse-gold { 50% { box-shadow: 0 0 .24rem rgba(255, 198, 0, .5); } }
/* ---- two-tap buy / upgrade (research 09 §5): the first tap lifts + enlarges the card and shows 确认购买 ---- */
.scard.is-armed {
z-index: 3; transform: translateY(-.16rem) scale(1.08) !important; border-color: var(--gold);
box-shadow: 0 0 0 2px var(--gold), 0 .14rem .32rem rgba(0, 0, 0, .65), 0 0 .3rem rgba(255, 198, 0, .35) !important;
}
.scard.is-armed.is-disabled { border-color: var(--red-premium); box-shadow: 0 0 0 2px var(--red-premium), 0 .14rem .32rem rgba(0, 0, 0, .65) !important; }
.scard__confirm {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 4; display: flex; flex-direction: column; align-items: center; gap: 0;
padding: .05rem 0 .04rem; background: linear-gradient(0deg, var(--gold), #e0a800); color: #1d1400; pointer-events: none;
animation: confirm-in var(--t-fast) var(--ease-out);
}
@keyframes confirm-in { from { transform: translateY(100%); } }
.scard__confirm b { font-size: .18rem; font-weight: 900; letter-spacing: .22em; padding-left: .22em; }
.scard__confirm small { font-size: .11rem; letter-spacing: .1em; opacity: .75; }
.scard__confirm.is-no { background: linear-gradient(0deg, #54150f, #34100c); color: var(--red-premium); }
.scard__confirm.is-no small { opacity: 1; color: #ffb0a6; max-width: 100%; padding: 0 .06rem; text-align: center; white-space: normal; }
.shopbar.has-armed .scard:not(.is-armed):not(.scard--sold) { filter: brightness(.72); }
.lvcard.is-armed { background: linear-gradient(180deg, #ffd65a, #e0a800); color: #1d1400; box-shadow: 0 0 0 2px #fff4c2, 0 0 .3rem rgba(255, 198, 0, .5); animation: pulse-gold 1.1s var(--ease-in-out) infinite; }
.lvcard.is-armed .lvcard__label { letter-spacing: .12em; padding-left: .12em; }
+560
View File
@@ -0,0 +1,560 @@
/* ==========================================================================================
screens/game.css — in-match screen layout & HUD (js/screens/game.js, js/ui/hud.js, bondStrip.js,
teamPanel.js, emotes.js, ticker.js, effectsList.js, combatHud.js, settings.js, matchChrome.js,
fallbackField.js). Shop: game-shop.css. Panels / overlays / rich text: game-panels.css.
Sizes in rem (1rem = 100 design px at 1920×1080).
========================================================================================== */
/* ---- screen shell ---------------------------------------------------------------------------- */
.gm { background: transparent; }
.gm__field { position: absolute; inset: 0; z-index: 0; overflow: hidden; }
.gm__field canvas { display: block; }
.gm__vignette {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background:
linear-gradient(180deg, rgba(6, 9, 8, .78) 0%, rgba(6, 9, 8, 0) 18%),
linear-gradient(0deg, rgba(6, 9, 8, .6) 0%, rgba(6, 9, 8, 0) 22%),
radial-gradient(120% 90% at 45% 50%, transparent 60%, rgba(0, 0, 0, .5) 100%);
}
.gm__loading { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; pointer-events: none; }
.gm__hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }
.gm__hud > * { pointer-events: auto; }
.gload { align-items: center; justify-content: center; gap: .2rem; }
/* ---- top bar -------------------------------------------------------------------------------------- */
.gtop {
position: absolute; left: 0; right: 0; top: 0;
display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: start;
padding: .2rem .3rem 0 .26rem; gap: .2rem; pointer-events: none;
}
.gtop > * { pointer-events: auto; }
.gtop__left { display: flex; align-items: center; gap: .16rem; }
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
.gtop__center {
--bk-c: var(--mint-700);
display: flex; align-items: center; gap: .18rem;
height: .78rem; padding: 0 .24rem 0 .14rem; margin-top: .02rem;
background: linear-gradient(180deg, rgba(10, 14, 12, .92), rgba(10, 14, 12, .78));
border: 1px solid var(--line);
box-shadow: 0 .08rem .28rem rgba(0, 0, 0, .45);
position: relative;
}
/* the two official 🔍 buttons (ui/hud.js CheckBtn): btn_check_player (mint, left) and btn_check_enemy (amber, right);
sprites are 35×29 (plain) / 61×29 (with chevrons) → .555rem / .967rem × .46rem */
.gtop__iconbtn {
display: flex; align-items: center; justify-content: center; gap: .04rem; min-width: .46rem; height: .46rem; padding: 0 .1rem;
background: rgba(78, 216, 175, .06); border: 2px solid var(--mint-600); border-radius: .06rem; color: #fff;
font-family: var(--font-num); font-weight: 700;
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast), filter var(--t-fast), opacity var(--t-fast);
}
.gtop__iconbtn .icon { width: .24rem; height: .24rem; }
.gtop__iconbtn:hover, .gtop__iconbtn.is-on { background: var(--mint-a20); color: var(--mint-400); border-color: var(--mint-500); }
.gtop__iconbtn.is-on:not(.has-sprite) { opacity: .6; }
.gtop__iconbtn .enemybtn__chev { color: #fff; }
.gtop__right { display: flex; flex-direction: column; align-items: flex-end; gap: .1rem; justify-self: end; }
.gtop__right .countdown { --cd-h: .7rem; filter: drop-shadow(0 0 .12rem rgba(0, 0, 0, .8)); }
.gtop__clock { display: flex; align-items: flex-start; gap: .14rem; }
/* solo pause / resume (ui/hud.js PauseButton, official battle pause: two bars on a dark square) */
.pausebtn {
display: grid; place-items: center; width: .6rem; height: .6rem; flex: none;
background: rgba(10, 14, 12, .88); border: 2px solid rgba(242, 242, 242, .75); color: #fff;
box-shadow: 0 .04rem .16rem rgba(0, 0, 0, .55);
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
}
.pausebtn__glyph { width: .3rem; height: .3rem; fill: currentColor; }
.pausebtn:hover:not(:disabled) { background: rgba(255, 255, 255, .14); border-color: #fff; }
.pausebtn.is-on { background: var(--mint-500); border-color: var(--mint-400); color: var(--text-on-mint); box-shadow: 0 0 .22rem rgba(23, 249, 183, .45); }
.pausebtn.is-busy { opacity: .6; }
/* boss-round overtime warning (ui/hud.js OvertimeWarning; red DOT look: official icon_warn / blood_icon + bg_lose_hp) */
.otwarn {
position: relative; display: flex; align-items: center; gap: .08rem; height: .38rem; padding: 0 .14rem 0 .1rem;
background: linear-gradient(90deg, rgba(120, 10, 14, .92), rgba(40, 6, 8, .88)); border: 1px solid rgba(255, 84, 84, .7);
color: #ffdede; font-size: .16rem; white-space: nowrap; box-shadow: 0 0 .18rem rgba(255, 36, 36, .35);
animation: ot-in var(--t-slow) var(--ease-out);
}
.otwarn__icon { width: .24rem; height: .24rem; object-fit: contain; flex: none; color: var(--red-premium); }
.otwarn__tag { font-family: var(--font-num); font-size: .13rem; font-weight: 700; letter-spacing: .1em; padding: 0 .05rem; background: #e2262a; color: #fff; line-height: 1.35; }
.otwarn__txt b { color: #fff; font-size: .2rem; }
.otwarn__lost { font-size: .13rem; color: rgba(255, 222, 222, .75); }
.otwarn__lost b { color: #fff; }
.otwarn--pending { border-style: dashed; }
.otwarn--pending .otwarn__icon { animation: blink 1s steps(2, end) infinite; }
.otwarn--drain { animation: ot-in var(--t-slow) var(--ease-out), ot-pulse 1s ease-in-out infinite; }
.otwarn__blood { display: grid; place-items: center; width: .5rem; height: .28rem; flex: none; background: radial-gradient(closest-side, rgba(255, 40, 40, .9), rgba(255, 40, 40, 0)); }
.otwarn--drain .otwarn__icon { color: #fff; }
.otwarn__tick {
position: absolute; left: .18rem; top: -.04rem; font-size: .2rem; font-weight: 700; color: var(--red-premium);
text-shadow: 0 0 .06rem rgba(0, 0, 0, .9); pointer-events: none; animation: ot-tick 1s var(--ease-out) forwards;
}
@keyframes ot-in { from { opacity: 0; transform: translateY(-.06rem); } }
@keyframes ot-pulse { 0%, 100% { box-shadow: 0 0 .12rem rgba(255, 36, 36, .3); } 50% { box-shadow: 0 0 .32rem rgba(255, 36, 36, .75); border-color: #ff6a6a; } }
@keyframes ot-tick { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-.3rem); } }
@media (prefers-reduced-motion: reduce) { .otwarn, .otwarn--drain, .otwarn__tick, .otwarn--pending .otwarn__icon { animation: none; } }
.roundbox { display: flex; align-items: center; gap: .08rem; }
.roundbox__label { font-size: .16rem; color: var(--text-lo); letter-spacing: .08em; }
.roundbox__num {
min-width: .54rem; height: .44rem; display: grid; place-items: center; padding: 0 .08rem;
background: #eef3f1; color: #0b0f0d; font-size: .32rem; font-weight: 700; line-height: 1;
clip-path: polygon(.06rem 0, 100% 0, 100% calc(100% - .06rem), calc(100% - .06rem) 100%, 0 100%, 0 .06rem);
}
/* phase capsule */
.capsule {
--cap: var(--mint-500);
display: flex; align-items: center; gap: .1rem; height: .5rem; min-width: 2.2rem; padding: 0 .22rem 0 .12rem;
border: 2px solid var(--cap); border-radius: .25rem;
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, .25)); background: linear-gradient(90deg, color-mix(in srgb, var(--cap) 16%, transparent), rgba(0, 0, 0, .25));
box-shadow: inset 0 0 .14rem transparent, 0 0 .16rem transparent; box-shadow: inset 0 0 .14rem color-mix(in srgb, var(--cap) 30%, transparent), 0 0 .16rem color-mix(in srgb, var(--cap) 25%, transparent);
white-space: nowrap;
}
.capsule__icon { width: .36rem; height: .36rem; object-fit: contain; color: var(--cap); flex: none; }
.capsule__label { font-size: .21rem; font-weight: 700; letter-spacing: .12em; color: var(--text-hi); }
.capsule__kills { font-size: .26rem; color: var(--text-lo); letter-spacing: .04em; }
.capsule__kills b { color: #fff; font-size: .3rem; }
.capsule__tag { font-size: .14rem; padding: .01rem .08rem; background: var(--orange); color: #1a0800; font-weight: 700; letter-spacing: .1em; }
.capsule--combat { --cap: var(--orange-hi); }
.capsule--boss { --cap: var(--orange-hi); min-width: 4.2rem; }
.bossbar { position: relative; flex: 1; min-width: 2.2rem; height: .2rem; background: rgba(0, 0, 0, .6); border: 1px solid rgba(255, 122, 51, .5); overflow: hidden; }
.bossbar__fill { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #b8340d, var(--orange-hi)); box-shadow: 0 0 .1rem rgba(255, 110, 40, .8); transition: width 300ms var(--ease-out); }
.bossbar__txt { position: absolute; inset: 0; display: grid; place-items: center; font-size: .14rem; font-weight: 700; color: #fff; text-shadow: 0 1px 2px #000; }
/* LP tower */
.lp { display: inline-flex; align-items: center; gap: .05rem; color: var(--mint-400); white-space: nowrap; }
.lp__icon { width: 1.1em; height: 1.1em; object-fit: contain; flex: none; }
.lp b { font-weight: 700; line-height: 1; }
.lp--sm { font-size: .19rem; }
.lp--md { font-size: .24rem; }
.lp--lg { font-size: .34rem; }
.lp--lg .lp__icon { width: .42rem; height: .42rem; }
.lp--danger { color: var(--red-premium); }
.lp--danger .lp__icon { filter: hue-rotate(-140deg) saturate(2.4) brightness(.95); }
.lp--team { color: var(--mint-glow); }
/* enemy preview button: amber [🔍▶▶] → the pen (research 09 §2); grey [🔍] in the pen view and outside 休整期 */
.enemybtn {
display: flex; align-items: center; justify-content: center; gap: .06rem; min-width: .46rem; height: .46rem; padding: 0 .14rem;
background: rgba(20, 16, 10, .85); border: 2px solid var(--amber); border-radius: .06rem; color: #fff;
font-family: var(--font-num); font-size: .16rem; letter-spacing: -.1em; font-weight: 700;
transition: background var(--t-fast), box-shadow var(--t-fast), filter var(--t-fast), opacity var(--t-fast);
}
.enemybtn .icon { width: .24rem; height: .24rem; }
.enemybtn__chev { font-size: .18rem; letter-spacing: -.12em; margin-left: .02rem; }
.enemybtn:hover:not(.is-grey) { background: rgba(246, 163, 41, .2); box-shadow: 0 0 .16rem rgba(246, 163, 41, .35); }
.enemybtn.is-grey { border-color: rgba(246, 163, 41, .32); color: rgba(255, 255, 255, .35); background: rgba(20, 20, 20, .8); }
.enemybtn[aria-disabled="true"] { cursor: not-allowed; }
/* official sprites: the picture is the whole button */
.gtop__iconbtn.has-sprite, .enemybtn.has-sprite {
width: .555rem; min-width: 0; height: .46rem; padding: 0; border: 0; border-radius: 0; box-shadow: none;
background: var(--chk-sprite) center / 100% 100% no-repeat;
}
.gtop__iconbtn.has-sprite.is-wide, .enemybtn.has-sprite.is-wide { width: .967rem; }
.gtop__iconbtn.has-sprite:hover, .enemybtn.has-sprite:hover:not(.is-grey) { background: var(--chk-sprite) center / 100% 100% no-repeat; filter: brightness(1.2); box-shadow: none; }
.gtop__iconbtn.has-sprite.is-on { background: var(--chk-sprite) center / 100% 100% no-repeat; }
/* ready toggle */
.readywrap { display: flex; flex-direction: column; align-items: flex-end; gap: .05rem; }
.readywrap__count { font-size: .14rem; color: var(--text-lo); }
.readywrap__count b { color: var(--mint-400); font-size: .17rem; }
.readybtn {
display: flex; align-items: center; gap: .12rem; height: .56rem; padding: 0 .16rem 0 .12rem; min-width: 2.3rem;
background: rgba(10, 14, 12, .9); border: 2px solid var(--mint-500); color: var(--text-hi);
font-size: .22rem; font-weight: 700; letter-spacing: .14em;
box-shadow: 0 0 .18rem rgba(23, 249, 183, .15);
transition: background var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
}
.readybtn:hover:not(:disabled) { background: rgba(78, 216, 175, .14); box-shadow: 0 0 .26rem rgba(23, 249, 183, .3); }
.readybtn__box { width: .3rem; height: .3rem; border: 2px solid var(--mint-500); display: grid; place-items: center; flex: none; }
.readybtn__box .icon { width: .26rem; height: .26rem; }
.readybtn__label { flex: 1; text-align: left; }
.readybtn__key { font-family: var(--font-num); font-size: .11rem; font-weight: 500; letter-spacing: .06em; color: var(--text-dim); border: 1px solid var(--line-2); padding: 0 .04rem; }
.readybtn.is-on { background: var(--mint-500); color: var(--text-on-mint); }
.readybtn.is-on .readybtn__box { border-color: var(--text-on-mint); background: var(--text-on-mint); color: var(--mint-400); }
.readybtn.is-on .readybtn__key { color: var(--mint-900); border-color: var(--mint-700); }
.readybtn.is-on:hover:not(:disabled) { background: var(--mint-400); color: var(--text-on-mint); box-shadow: 0 0 .3rem rgba(23, 249, 183, .45); }
.readybtn:disabled { border-color: var(--line-2); color: var(--text-dim); box-shadow: none; cursor: not-allowed; }
.readybtn:disabled .readybtn__box { border-color: var(--line-2); }
.readybtn.is-busy { opacity: .7; }
/* ---- bond strip ---------------------------------------------------------------------------------------- */
.gm__bonds { position: absolute; left: 1.56rem; top: 1.36rem; right: 5.2rem; pointer-events: none; }
/* Only the bond discs (and the +N chip) take the pointer: the strip's box spans the width under the top bar, where the
top board row's units stand (row 12 at 16:9 / 21:9 / 720p / phones, the boss field's row 5 in a boss prep) — it used
to swallow the taps and drags on them (review of user playtest #2 item 8: every own unit reachable). */
.gm__bonds > *, .gm__bonds .bstrip { pointer-events: none; }
.gm__bonds .bslot, .gm__bonds .bstrip__more { pointer-events: auto; }
.bstrip { display: flex; align-items: flex-start; gap: .1rem; flex-wrap: nowrap; }
.bstrip.is-frozen .bslot { opacity: .5; filter: saturate(.4); }
.bstrip--empty { display: inline-flex; align-items: center; gap: .12rem; padding: .08rem .16rem; font-size: .15rem; color: var(--text-dim); background: rgba(8, 11, 10, .6); border: 1px dashed var(--line-2); }
.bstrip__owner { align-self: center; writing-mode: vertical-rl; padding: .06rem .02rem; border: 1px solid rgba(246, 163, 41, .5); background: rgba(40, 26, 6, .7); }
.bstrip__owner .micro { color: var(--amber); letter-spacing: .1em; font-size: .12rem; }
.bstrip.is-other .bond__core { box-shadow: 0 0 0 1px rgba(246, 163, 41, .45); }
.bstrip__more { align-self: center; font-size: .16rem; color: var(--text-lo); padding: .04rem .08rem; border: 1px solid var(--line-2); background: rgba(8, 11, 10, .7); }
.bslot { position: relative; display: flex; flex-direction: column; align-items: center; width: .74rem; }
.bslot .bond { --disc: .52rem; }
.bslot .bond__name { font-size: .14rem; font-weight: 500; text-shadow: 0 1px 3px #000, 0 0 6px #000; }
.bslot__count {
position: absolute; top: -.04rem; right: .02rem; font-size: .13rem; font-weight: 700; color: var(--text-hi);
background: rgba(8, 11, 10, .92); border: 1px solid var(--line-2); padding: 0 .04rem; line-height: 1.35;
}
.bslot__count small { color: var(--text-dim); font-size: .11rem; }
.bslot__count.is-max { border-color: var(--mint-700); color: var(--mint-400); }
.bslot.is-active .bslot__count { border-color: var(--mint-700); }
.bslot.is-open .bond__core { box-shadow: 0 0 0 2px var(--mint-400), 0 0 .2rem rgba(23, 249, 183, .6); }
/* ---- team panel ------------------------------------------------------------------------------------------- */
.team { position: absolute; left: .22rem; top: 2.36rem; display: flex; flex-direction: column; gap: .14rem; pointer-events: none; }
.team__row { position: relative; display: flex; align-items: center; gap: .1rem; pointer-events: auto; }
.team__btn {
position: relative; padding: 0; background: none; border: 0;
transition: transform var(--t-fast);
}
.team__btn:hover { transform: translateX(.03rem); }
.team__seat {
position: absolute; left: -.02rem; top: -.04rem; font-size: .11rem; padding: 0 .04rem; line-height: 1.3;
background: rgba(8, 11, 10, .9); color: var(--text-lo); border: 1px solid var(--line-2);
}
.team__ai { position: absolute; right: -.04rem; bottom: -.04rem; font-family: var(--font-num); font-size: .11rem; font-weight: 700; padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
.team__you { position: absolute; right: -.08rem; top: -.08rem; width: .22rem; height: .22rem; display: grid; place-items: center; background: var(--mint-500); color: var(--text-on-mint); clip-path: polygon(25% 3%, 75% 3%, 100% 50%, 75% 97%, 25% 97%, 0 50%); }
.team__you .icon { width: .14rem; height: .14rem; }
.team__info { display: flex; flex-direction: column; gap: .02rem; min-width: 0; padding: .04rem .12rem .04rem .06rem; background: linear-gradient(90deg, rgba(8, 11, 10, .82), rgba(8, 11, 10, 0)); }
.team__name { font-size: .15rem; font-weight: 500; color: var(--text-md); max-width: 1.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-shadow: 0 1px 2px #000; }
.team__line { display: flex; align-items: center; gap: .1rem; }
.team__status { width: .26rem; height: .26rem; display: grid; place-items: center; border: 1px solid currentColor; background: rgba(0, 0, 0, .5); }
.team__status .icon { width: .18rem; height: .18rem; }
.team__status .lsprite { width: .2rem; height: .2rem; object-fit: contain; }
.team__status.has-sprite { border-color: transparent; background: none; animation: none; }
.team__status.has-sprite .lsprite { width: .26rem; height: .26rem; }
.team__status.is-lo { color: var(--text-lo); }
.team__status.is-mint { color: var(--mint-400); }
.team__status.is-gold { color: var(--gold); animation: blink 1.2s steps(2) infinite; }
.team__status.is-orange { color: var(--orange-hi); }
.team__status.is-red, .team__status.is-offline { color: var(--red-premium); }
.team__eye { color: var(--amber); display: grid; }
.team__eye .icon { width: .2rem; height: .2rem; }
.team__row.is-self .pavatar { box-shadow: 0 0 0 2px var(--mint-500), 0 0 .18rem rgba(23, 249, 183, .35); }
.team__row.is-watched .pavatar { box-shadow: 0 0 0 2px var(--amber), 0 0 .18rem rgba(246, 163, 41, .45); }
.team__row.is-dead .team__name { color: var(--text-dim); text-decoration: line-through; }
.team__bubble { position: absolute; left: calc(100% + .06rem); top: 50%; transform: translateY(-50%); width: max-content; max-width: 3.2rem; }
/* 前往查看 / 返回战场 (research 09 §3.1: tapping a teammate's avatar expands a mint button under it) */
.team__row.is-open .pavatar { box-shadow: 0 0 0 2px var(--mint-400), 0 0 .2rem rgba(23, 249, 183, .45); }
.team__row.is-open .team__info { background: linear-gradient(90deg, rgba(13, 44, 36, .92), rgba(8, 11, 10, 0)); }
.team__ob, .team__back { align-self: flex-start; margin-top: .04rem; min-width: 1.1rem; animation: pop-in var(--t-fast) var(--ease-out); }
.team__ob .btn__label, .team__back .btn__label { letter-spacing: .12em; }
.team__back { border-color: rgba(255, 255, 255, .7); color: #fff; }
/* player avatar (band icon / glyph) */
.pavatar {
--pa: .8rem;
position: relative; display: block; width: var(--pa); height: var(--pa); flex: none;
background: linear-gradient(135deg, hsl(var(--seat-hue) 40% 22%), hsl(var(--seat-hue) 30% 10%));
border: 1px solid var(--line-2);
}
.pavatar--sm { --pa: .42rem; }
.pavatar__img { position: absolute; inset: 0; overflow: hidden; display: grid; place-items: center; }
.pavatar__img img { width: 100%; height: 100%; object-fit: cover; }
.pavatar__glyph { font-weight: 900; font-size: calc(var(--pa) * .5); color: hsl(var(--seat-hue) 70% 72%); }
.pavatar__bot { width: 56%; height: 56%; color: #a9c3d8; }
.pavatar.is-dead .pavatar__img, .pavatar.is-left .pavatar__img, .pavatar.is-offline .pavatar__img { filter: grayscale(1) brightness(.4); }
.pavatar__x, .pavatar__door { position: absolute; inset: 0; display: grid; place-items: center; color: var(--red-premium); }
.pavatar__x .icon, .pavatar__door .icon { width: 60%; height: 60%; filter: drop-shadow(0 0 4px #000); }
/* ---- effects ---------------------------------------------------------------------------------------------- */
.gm__effects { position: absolute; right: .3rem; top: 2.4rem; }
.effects { display: flex; flex-direction: column; align-items: flex-end; gap: .08rem; }
.effect {
position: relative; width: .5rem; height: .5rem; display: grid; place-items: center;
background: rgba(8, 11, 10, .85); border: 1px solid var(--line-2);
}
.effect img { width: 86%; height: 86%; object-fit: contain; }
.effect .icon { width: .28rem; height: .28rem; color: var(--mint-500); }
.effect--band { border-color: var(--mint-800); }
.effect--team { border-color: rgba(78, 216, 175, .6); }
.effect__n { position: absolute; right: -.05rem; bottom: -.05rem; min-width: .2rem; font-size: .12rem; line-height: 1.4; text-align: center; background: var(--gold); color: #1d1400; padding: 0 .03rem; }
.efftip { display: flex; flex-direction: column; gap: .04rem; max-width: 3.6rem; }
.efftip b { font-size: .17rem; color: var(--mint-400); }
.efftip__kind { font-size: .12rem; color: var(--text-dim); }
.efftip p { font-size: .15rem; line-height: 1.55; color: var(--text-md); }
/* ---- corner: emote + settings --------------------------------------------------------------------------------- */
.gm__corner { position: absolute; left: .24rem; bottom: .24rem; display: flex; align-items: flex-end; gap: .1rem; }
.gm__gear {
width: .56rem; height: .56rem; display: grid; place-items: center; 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);
}
.gm__gear .icon { width: .28rem; height: .28rem; }
.gm__gear:hover { color: var(--mint-400); border-color: var(--mint-700); }
.ewheel { position: relative; }
.ewheel__btn {
display: flex; align-items: center; gap: .08rem; height: .56rem; padding: 0 .2rem 0 .14rem;
background: rgba(8, 11, 10, .85); border: 1px solid var(--line-2); color: var(--text-md); font-size: .18rem; font-weight: 700; letter-spacing: .1em;
transition: color var(--t-fast), border-color var(--t-fast);
}
.ewheel__btn .icon { width: .3rem; height: .3rem; color: var(--mint-500); }
.ewheel__btn:hover, .ewheel__btn.is-on { color: var(--mint-400); border-color: var(--mint-700); }
.ewheel__btn:disabled { opacity: .5; cursor: not-allowed; }
.ewheel__panel {
position: absolute; left: 0; bottom: calc(100% + .12rem); width: 4.1rem; padding: .14rem .16rem .16rem;
background: linear-gradient(180deg, rgba(20, 26, 24, .97), rgba(10, 14, 12, .97)); border: 1px solid var(--line-2);
box-shadow: 0 .1rem .3rem rgba(0, 0, 0, .6);
animation: pop-in var(--t-med) var(--ease-out);
}
.ewheel__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: .08rem; margin-top: .12rem; }
.ewheel__item {
position: relative; display: flex; flex-direction: column; align-items: center; gap: .02rem; padding: .06rem .02rem .05rem;
background: rgba(255, 255, 255, .025); border: 1px solid var(--line); font-size: .12rem; color: var(--text-lo);
transition: background var(--t-fast), border-color var(--t-fast), transform var(--t-fast), color var(--t-fast);
}
.ewheel__item:hover:not(:disabled) { background: var(--mint-a10); border-color: var(--mint-700); color: var(--mint-400); transform: translateY(-.02rem); }
.ewheel__item:disabled { opacity: .45; cursor: wait; }
.ewheel__art { width: .6rem; height: .6rem; display: grid; place-items: center; }
.ewheel__art:empty { display: none; }
.ewheel__art .eart { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 0 .06rem rgba(23, 249, 183, .25)); }
.ewheel__txt { white-space: nowrap; }
.ewheel__panel.is-text .ewheel__grid { grid-template-columns: repeat(3, 1fr); }
.ewheel__panel.is-text .ewheel__item { padding: .12rem .04rem; font-size: .15rem; color: var(--text-md); }
@keyframes pop-in { from { opacity: 0; transform: translateY(.08rem) scale(.97); } }
/* speech bubble next to a team row: official emote art (+ phrase), or the phrase alone */
.ebubble {
display: flex; align-items: center; gap: .06rem; padding: .05rem .14rem .05rem .2rem; white-space: nowrap;
background: rgba(22, 28, 26, .96); color: var(--text-hi); font-size: .15rem; font-weight: 700;
border: 1px solid rgba(78, 216, 175, .45);
clip-path: polygon(.1rem 0, 100% 0, 100% 100%, .1rem 100%, .1rem 62%, 0 50%, .1rem 38%);
animation: bubble-in 150ms var(--ease-out), bubble-out 300ms var(--ease-in-out) 2.7s forwards;
filter: drop-shadow(0 .04rem .1rem rgba(0, 0, 0, .55));
z-index: 2;
}
.ebubble--art { padding: .02rem .14rem .02rem .16rem; }
.ebubble .eart { width: .62rem; height: .62rem; object-fit: contain; }
.ebubble__txt { color: var(--mint-400); }
.ebubble--text { background: rgba(236, 244, 241, .96); border: 0; }
.ebubble--text .ebubble__txt { color: #0b1310; }
@keyframes bubble-in { from { opacity: 0; transform: translateY(-50%) scale(.6); } }
@keyframes bubble-out { to { opacity: 0; } }
/* ---- ticker -------------------------------------------------------------------------------------------- */
/* a slim strip hugging the bottom of the top bar; the bond strip starts right below it (.gm__bonds top) */
.ticker { position: absolute; top: 1.0rem; left: 50%; transform: translateX(-50%); width: 7.6rem; height: .3rem; overflow: hidden; pointer-events: none; z-index: 4; }
.ticker__line {
position: absolute; right: 0; top: 0; height: 100%; display: flex; align-items: center; gap: .1rem; white-space: nowrap;
padding: 0 .24rem; font-size: .16rem; color: var(--text-hi);
background: linear-gradient(90deg, transparent, rgba(10, 14, 12, .97) 10%, rgba(10, 14, 12, .97) 90%, transparent);
border-top: 1px solid rgba(255, 198, 0, .35); border-bottom: 1px solid rgba(255, 198, 0, .35);
animation: ticker-in 420ms var(--ease-out), ticker-out 380ms var(--ease-in-out) 4.8s forwards;
left: 0; justify-content: center;
}
.ticker__icon { width: .22rem; height: .22rem; color: var(--gold); flex: none; }
@keyframes ticker-in { from { transform: translateX(80%); opacity: 0; } }
@keyframes ticker-out { to { transform: translateX(-40%); opacity: 0; } }
/* ---- watching / dead / combat hud ------------------------------------------------------------------------------ */
/* scouting a teammate's board in prep: the shop bar is hidden, the pill takes its place */
.gm__watching {
position: absolute; left: 50%; bottom: .4rem; transform: translateX(-50%);
display: flex; align-items: center; gap: .14rem; padding: .06rem .08rem .06rem .18rem;
background: rgba(24, 16, 4, .92); border: 1px solid rgba(246, 163, 41, .6); color: var(--text-hi); font-size: .17rem;
}
.gm__watching > .icon { width: .24rem; height: .24rem; color: var(--amber); }
.gm__watching b { color: var(--amber); }
.gm__dead {
position: absolute; left: 50%; bottom: 3.1rem; transform: translateX(-50%); display: flex; align-items: center; gap: .1rem;
padding: .1rem .26rem; background: rgba(40, 8, 6, .9); border: 1px solid rgba(255, 84, 84, .5); color: var(--red-premium); font-size: .18rem; font-weight: 700;
}
.gm__dead .icon { width: .22rem; height: .22rem; }
.chud { position: absolute; inset: 0; pointer-events: none; }
.chud > * { pointer-events: auto; }
.dpbox {
position: absolute; right: .3rem; top: 50%; transform: translateY(-50%);
display: flex; flex-direction: column; align-items: center; gap: .02rem; padding: .1rem .12rem;
background: rgba(8, 11, 10, .85); border: 1px solid var(--line-2); border-right: 3px solid var(--mint-500);
}
.dpbox__icon { width: .26rem; height: .26rem; color: var(--mint-500); }
.dpbox b { font-size: .34rem; line-height: 1; color: #fff; }
.chud__bottom { position: absolute; left: 50%; bottom: .3rem; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: .12rem; pointer-events: none; }
.chud__bottom > * { pointer-events: auto; }
.chud__msg {
display: flex; align-items: center; gap: .1rem; padding: .1rem .3rem; font-size: .19rem; color: var(--text-hi);
background: rgba(8, 11, 10, .9); border: 1px solid var(--line-2); border-radius: .3rem; white-space: nowrap;
animation: pop-in var(--t-slow) var(--ease-out);
}
.chud__msg .icon { width: .22rem; height: .22rem; color: var(--gold); animation: hg-flip 2.4s var(--ease-in-out) infinite; }
@keyframes hg-flip { 0%, 40% { transform: rotate(0); } 50%, 90% { transform: rotate(180deg); } 100% { transform: rotate(360deg); } }
.chud__msg--dead { border-color: rgba(255, 84, 84, .5); color: var(--red-premium); }
.chud__msg--dead .icon { color: var(--red-premium); animation: none; }
.vswitch { display: flex; align-items: center; gap: .1rem; height: .5rem; padding: 0 .06rem; background: rgba(8, 11, 10, .9); border: 1px solid var(--line-2); }
.vswitch__arrow { width: .4rem; height: .4rem; display: grid; place-items: center; background: none; border: 0; color: var(--mint-500); }
.vswitch__arrow .icon { width: .3rem; height: .3rem; }
.vswitch__arrow:hover:not(:disabled) { color: var(--mint-400); background: var(--mint-a10); }
.vswitch__arrow:disabled { color: var(--text-dim); cursor: default; }
.vswitch__label { min-width: 1.6rem; text-align: center; font-size: .2rem; font-weight: 700; letter-spacing: .12em; }
.vswitch.is-single .vswitch__arrow { visibility: hidden; }
/* client-side combat (ui/combatHud.js, research 09 §3.1): waiting pill + teammates' progress, observing pill, ‹ › halves */
.chud__wait { border-color: rgba(255, 198, 0, .45); }
.chud__progress { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .18rem; max-width: 9rem; font-size: .15rem; color: var(--text-md); }
.chud__prog {
display: inline-flex; align-items: center; gap: .08rem; padding: .03rem .12rem; background: rgba(8, 11, 10, .82);
border: 1px solid rgba(78, 216, 175, .25); border-left: 3px solid var(--gold);
}
.chud__prog b { font-weight: 500; max-width: 1.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chud__prog .num { color: var(--gold); font-size: .16rem; }
.chud__prog.is-done { border-left-color: var(--mint-500); }
.chud__prog__ok { color: var(--mint-400); font-weight: 900; }
.chud__observe { gap: .14rem; padding: 0 .08rem 0 .16rem; border-color: rgba(246, 163, 41, .55); }
.chud__observe .vswitch__label, .chud__layers .vswitch__label { display: inline-flex; align-items: center; justify-content: center; gap: .08rem; }
.chud__observe .vswitch__label .icon { width: .24rem; height: .24rem; color: var(--amber); }
.chud__layers .vswitch__label .icon { width: .22rem; height: .22rem; color: var(--amber); }
.chud__back { flex: none; }
.chud__layers { border-color: var(--mint-700); }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- settings modal ------------------------------------------------------------------------------------------------ */
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
.set-row { display: grid; grid-template-columns: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; }
.set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; }
.set-row__label .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.set-row__label .micro { margin-left: .04rem; }
.set-row__val { width: .5rem; text-align: right; font-size: .2rem; color: var(--mint-400); }
.set-range {
-webkit-appearance: none; appearance: none; width: 100%; height: .08rem; background: linear-gradient(90deg, var(--mint-500) var(--pct), #0a0d0c var(--pct));
border: 1px solid var(--line-2); outline: none; cursor: pointer;
}
.set-range::-webkit-slider-thumb { -webkit-appearance: none; width: .2rem; height: .26rem; background: var(--mint-400); clip-path: polygon(50% 0, 100% 30%, 100% 100%, 0 100%, 0 30%); cursor: grab; }
.set-range::-moz-range-thumb { width: .2rem; height: .26rem; background: var(--mint-400); border: 0; }
.set-toggle { grid-column: 2 / 4; justify-self: start; display: flex; align-items: center; gap: .1rem; height: .38rem; padding: 0 .14rem 0 .06rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--text-lo); font-size: .15rem; }
.set-toggle i { width: .46rem; height: .24rem; background: var(--bg-3); position: relative; transition: background var(--t-fast); }
.set-toggle i::after { content: ''; position: absolute; left: .03rem; top: .03rem; width: .18rem; height: .18rem; background: var(--text-lo); transition: transform var(--t-fast), background var(--t-fast); }
.set-toggle.is-on { color: var(--mint-400); border-color: var(--mint-700); }
.set-toggle.is-on i { background: var(--mint-800); }
.set-toggle.is-on i::after { transform: translateX(.22rem); background: var(--mint-400); }
.set-seg { grid-column: 2 / 4; justify-self: start; display: flex; border: 1px solid var(--line-2); }
.set-seg button { min-width: .8rem; height: .38rem; background: #0a0d0c; border: 0; border-right: 1px solid var(--line-2); color: var(--text-lo); font-size: .16rem; }
.set-seg button:last-child { border-right: 0; }
.set-seg button.is-on { background: var(--mint-500); color: var(--text-on-mint); font-weight: 700; }
.set-hint { margin-top: .06rem; font-size: .14rem; color: var(--text-dim); line-height: 1.8; }
.set-hint kbd, .lvcard__key, .toolbtn kbd { font-family: var(--font-num); font-size: .11rem; border: 1px solid var(--line-2); padding: 0 .04rem; color: var(--text-lo); background: rgba(0, 0, 0, .3); }
/* ---- exit / away ------------------------------------------------------------------------------------------------------ */
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: .17rem; line-height: 1.7; }
.awayov { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: rgba(4, 7, 6, .78); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); animation: screen-in var(--t-slow) var(--ease-out); }
.awayov__box { display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .44rem .7rem; background: rgba(12, 16, 14, .96); border: 1px solid var(--line-2); }
.awayov__icon { width: .8rem; height: .8rem; color: var(--ice); filter: drop-shadow(0 0 .2rem rgba(159, 212, 255, .5)); }
.awayov__box h2 { font-size: .34rem; letter-spacing: .12em; }
/* ---- simplified (DOM) field view ---------------------------------------------------------------------------------------- */
.ff { position: absolute; inset: 0; overflow: hidden; user-select: none; }
.ff::before { content: ''; position: absolute; inset: 0; background: radial-gradient(70% 60% at 45% 48%, rgba(40, 60, 52, .35), transparent 70%); }
.ff-board { position: absolute; }
.ff-tile, .ff-slot, .ff-piece, .ff-unit { position: absolute; left: 0; top: 0; }
.ff-tile { box-sizing: border-box; border: 1px solid rgba(0, 0, 0, .35); }
.ff-tile--floor { background: linear-gradient(180deg, #3d4843, #333d38); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .06); }
.ff-tile--high { background: linear-gradient(180deg, #5d6a64, #4a5651); box-shadow: inset 0 2px 0 rgba(255, 255, 255, .12), inset 0 -4px 0 rgba(0, 0, 0, .25); }
.ff-tile--lane { background: linear-gradient(180deg, #2b322f, #252c29); }
.ff-tile--void { background: transparent; border-color: transparent; }
.ff-tile--gate { background: radial-gradient(circle, rgba(255, 60, 40, .6), rgba(120, 20, 10, .5)); border: 2px solid #ff4a2e; animation: gate-pulse 1.4s ease-in-out infinite; }
.ff-tile--goal { background: radial-gradient(circle, rgba(60, 160, 255, .55), rgba(10, 50, 120, .5)); border: 2px solid #4ab0ff; }
.ff-tile--mire { background: linear-gradient(180deg, #4a4030, #3c3326); }
.ff-tile--smog { background: linear-gradient(180deg, #4a4a52, #3a3a42); }
.ff-tile--deep { background: linear-gradient(180deg, #1e3a52, #173045); }
.ff-tile--infect { background: linear-gradient(180deg, #4a2a3a, #3a2030); }
.ff-tile.is-legal { box-shadow: inset 0 0 0 2px rgba(89, 244, 202, .8), inset 0 0 calc(var(--tile) * .4) rgba(23, 249, 183, .3); }
.ff-tile.is-illegal { box-shadow: inset 0 0 0 2px rgba(255, 84, 84, .5); }
.ff-tile.is-range { box-shadow: inset 0 0 0 2px rgba(246, 163, 41, .7); }
.ff-tile.is-hover-legal { background: rgba(78, 216, 175, .45); }
.ff-tile.is-hover-illegal { background: rgba(255, 60, 60, .4); }
@keyframes gate-pulse { 50% { box-shadow: 0 0 calc(var(--tile) * .4) rgba(255, 60, 40, .8); } }
.ff-hand-label { position: absolute; left: 0; right: 0; display: flex; align-items: center; gap: .1rem; font-size: .13rem; color: var(--text-dim); letter-spacing: .2em; }
.ff-hand-label i { flex: 1; height: 1px; background: linear-gradient(90deg, var(--line-2), transparent); }
.ff-slot { box-sizing: border-box; border: 2px dashed rgba(170, 140, 90, .45); background: rgba(120, 90, 50, .16); display: grid; place-items: end start; }
.ff-slot span { font-size: .11rem; color: rgba(210, 180, 130, .5); padding: 0 .03rem; }
.ff-slot--temp { border-color: rgba(255, 122, 51, .6); background: rgba(232, 90, 26, .12); }
.ff-slot.is-hover-legal { background: rgba(78, 216, 175, .35); border-color: var(--mint-400); }
.ff-slot.is-hover-illegal { background: rgba(255, 60, 60, .3); }
.ff-piece { transition: transform 160ms var(--ease-out); z-index: 2; }
.ff-piece.is-draggable { cursor: grab; }
.ff-piece.is-lifted { opacity: .25; }
.ff-piece__art {
position: absolute; inset: 7%; overflow: hidden; display: grid; place-items: center; background: #1a201d;
border: 2px solid rgba(255, 255, 255, .22); clip-path: polygon(12% 0, 100% 0, 100% 88%, 88% 100%, 0 100%, 0 12%);
}
.ff-piece__art img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.ff-piece__art span { font-weight: 900; font-size: calc(var(--tile) * .4); color: var(--text-md); }
.ff-piece--item .ff-piece__art { background: radial-gradient(circle, #2a3430, #141917); }
.ff-piece--item .ff-piece__art img { object-fit: contain; width: 80%; height: 80%; }
.ff-piece.is-golden .ff-piece__art { border-color: var(--gold); box-shadow: 0 0 calc(var(--tile) * .18) rgba(255, 198, 0, .6); }
.ff-piece.is-temp .ff-piece__art { border-color: var(--orange-hi); }
.ff-piece__tier { position: absolute; left: 0; top: 0; transform: scale(.9); transform-origin: top left; }
.ff-piece__items { position: absolute; right: 8%; bottom: 8%; display: flex; gap: 2px; }
.ff-piece__items i { width: calc(var(--tile) * .12); height: calc(var(--tile) * .12); background: var(--gold); transform: rotate(45deg); box-shadow: 0 0 3px #000; }
.ff-piece__count { position: absolute; right: 6%; bottom: 4%; font-family: var(--font-num); font-size: calc(var(--tile) * .2); color: #fff; text-shadow: 0 1px 2px #000; }
/* facing wedge (research 09 §1.2): an orange › on the piece's edge along its direction */
.ff-piece__dir {
position: absolute; left: 50%; top: 50%; width: calc(var(--tile) * .2); height: calc(var(--tile) * .26); pointer-events: none;
background: #ff9c33; clip-path: polygon(0 0, 45% 0, 100% 50%, 45% 100%, 0 100%, 55% 50%); filter: drop-shadow(0 0 2px #000);
transform: translate(-50%, -50%) rotate(var(--dir-rot, 0deg)) translateX(calc(var(--tile) * .46));
}
.ff-dir--up { --dir-rot: -90deg; } .ff-dir--right { --dir-rot: 0deg; } .ff-dir--down { --dir-rot: 90deg; } .ff-dir--left { --dir-rot: 180deg; }
.ff-ghost { position: absolute; z-index: 20; pointer-events: none; opacity: .92; transform: scale(1.08); filter: drop-shadow(0 .1rem .16rem rgba(0, 0, 0, .7)); }
.ff-ghost img { width: 100%; height: 100%; object-fit: cover; border: 2px solid var(--mint-400); clip-path: polygon(12% 0, 100% 0, 100% 88%, 88% 100%, 0 100%, 0 12%); }
.ff-ghost.is-golden img { border-color: var(--gold); }
.ff-unit { z-index: 3; transition: transform 110ms linear, opacity 400ms; }
.ff-unit__art { width: 100%; height: 100%; overflow: hidden; border-radius: 50%; border: 2px solid var(--mint-500); background: #111; display: grid; place-items: center; }
.ff-unit__art img { width: 100%; height: 100%; object-fit: cover; }
.ff-unit__art span { font-weight: 900; font-size: calc(var(--tile) * .34); }
.ff-unit.is-enemy .ff-unit__art { border-color: #ff5a4a; border-radius: 12%; transform: rotate(45deg) scale(.82); }
.ff-unit.is-enemy .ff-unit__art img { transform: rotate(-45deg) scale(1.3); }
.ff-unit.is-golden .ff-unit__art { border-color: var(--gold); box-shadow: 0 0 8px rgba(255, 198, 0, .6); }
.ff-unit.is-boss .ff-unit__art { border-width: 3px; box-shadow: 0 0 14px rgba(255, 60, 40, .7); }
.ff-unit.is-skill .ff-unit__art { box-shadow: 0 0 10px rgba(255, 198, 0, .9); }
.ff-unit.is-stunned { filter: grayscale(.6) brightness(.8); }
.ff-unit.is-dead { opacity: 0; }
.ff-unit.is-fly { margin-top: -6%; }
.ff-unit__bars { position: absolute; left: 8%; right: 8%; bottom: -12%; display: flex; flex-direction: column; gap: 1px; }
.ff-unit__bars i { display: block; height: 4px; background: #3fdc6a; box-shadow: 0 0 0 1px #000; }
.ff-unit.is-enemy .ff-unit__bars i.hp { background: #ff4a3a; }
.ff-unit__bars i.sp { height: 3px; background: #ffd23a; }
.ff-float { position: absolute; z-index: 5; transform: translate(-50%, 0); font-family: var(--font-num); font-weight: 700; font-size: .2rem; pointer-events: none; text-shadow: 0 1px 2px #000, 0 0 4px #000; animation: float-up 900ms var(--ease-out) forwards; }
.ff-float--phys { color: #ffd7a8; }
.ff-float--arts { color: #d59cff; }
.ff-float--true { color: #fff; }
.ff-float--heal { color: #6dff9c; }
@keyframes float-up { to { transform: translate(-50%, -.4rem); opacity: 0; } }
.ff-badge { position: absolute; left: 50%; bottom: .08rem; transform: translateX(-50%); font-family: var(--font-display); font-size: .1rem; letter-spacing: .3em; color: var(--text-dim); opacity: .5; pointer-events: none; }
.gm--combat .ff-badge { display: none; }
/* ---- small screens (1280×720 → root ≈ 66px) ------------------------------------------------------------------------ */
@media (max-aspect-ratio: 16/10) {
.gm__bonds { right: 4.6rem; }
}
/* fallback view: enemy preview pen (setCamera('pen')) */
.ff-board--pen .ff-pen__enemy { padding: 0; border: 0; background: none; cursor: pointer; border-radius: 50%; }
.ff-board--pen .ff-pen__enemy:hover { filter: brightness(1.2); }
.ff-board--pen .ff-pen__enemy .ff-unit__art { border-color: rgba(255, 90, 60, .7); }
.ff-pen__empty { position: absolute; left: 0; right: 0; top: 45%; text-align: center; color: var(--text-lo); font-size: .16rem; }
/* enemy preview pen view (research 09 §2): the bond strip and the effects column step aside for the pen */
.gm__bonds, .gm__effects { transition: opacity var(--t-med) var(--ease-out); }
.gm.is-pen .gm__bonds, .gm.is-pen .gm__effects { opacity: 0; pointer-events: none; }
.gm.is-pen .gm__bonds > *, .gm.is-pen .gm__effects > *, .gm.is-pen .gm__bonds .bslot, .gm.is-pen .gm__bonds .bstrip__more { pointer-events: none; }
/* solo pause overlay (screens/game.js PausedOverlay; m.public.paused): the field dims under an official matte plate */
.pauseov {
position: absolute; inset: 0; z-index: 65; display: grid; place-items: center; pointer-events: auto;
background: rgba(4, 7, 6, .62); -webkit-backdrop-filter: grayscale(.6) blur(2px); backdrop-filter: grayscale(.6) blur(2px);
animation: screen-in var(--t-med) var(--ease-out);
}
.pauseov__box { display: flex; flex-direction: column; align-items: center; gap: .16rem; }
.pauseov__plate {
display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 3.6rem; padding: .16rem .6rem .2rem;
background: rgba(20, 22, 22, .9); background: var(--pause-plate, linear-gradient(90deg, transparent, rgba(12, 14, 14, .95) 18%, rgba(12, 14, 14, .95) 82%, transparent)) center / 100% 100% no-repeat;
}
.pauseov__micro { font-family: var(--font-num); font-size: .16rem; letter-spacing: .5em; color: var(--mint-400); }
.pauseov__plate h2 { font-size: .5rem; font-weight: 900; letter-spacing: .3em; color: #fff; text-shadow: 0 0 .2rem rgba(0, 0, 0, .8); }
.pauseov__note { font-size: .17rem; color: var(--text-md); text-shadow: 0 1px 2px #000; }
.pauseov__btns { display: flex; gap: .2rem; }
+110
View File
@@ -0,0 +1,110 @@
/* ==========================================================================================
guide.css — 玩法说明 viewer (js/ui/guide.js): the 19 official tutorial pages at 16:9, chapter tabs,
‹ › edge buttons, page dots. Sits above modals (it can be opened from the settings dialog).
========================================================================================== */
.guide {
position: fixed; inset: 0; z-index: calc(var(--z-modal) + 4);
display: grid; place-items: center;
background: radial-gradient(ellipse at 50% 40%, rgba(8, 14, 12, .82), rgba(2, 4, 3, .94));
-webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
animation: guide-fade 180ms var(--ease-out);
}
@keyframes guide-fade { from { opacity: 0; } }
.guide__box {
--bk-c: var(--mint-500);
position: relative; display: flex; flex-direction: column; gap: .12rem;
padding: .16rem .22rem .16rem;
background: linear-gradient(180deg, rgba(22, 28, 26, .98), rgba(10, 13, 12, .98));
border: 1px solid var(--line-2);
box-shadow: 0 .2rem .6rem rgba(0, 0, 0, .7), 0 0 0 1px rgba(78, 216, 175, .06) inset;
outline: none;
animation: guide-pop 220ms var(--ease-out);
}
@keyframes guide-pop { from { opacity: 0; transform: translateY(.12rem) scale(.985); } }
/* header: title | chapter tabs | close */
.guide__head { display: flex; align-items: center; gap: .3rem; min-height: .62rem; }
.guide__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 2.4rem; }
.guide__title { margin: 0; font-size: .3rem; font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
.guide__chapters { display: flex; gap: .06rem; margin: 0 auto; }
.guide__chapter {
position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .01rem;
min-width: 1.5rem; height: .56rem; padding: 0 .22rem;
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); color: var(--text-lo);
clip-path: polygon(.12rem 0, 100% 0, calc(100% - .12rem) 100%, 0 100%);
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.guide__chapter:hover { color: var(--text-hi); background: rgba(78, 216, 175, .06); }
.guide__chapter.is-on { color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-500); }
.guide__chname { font-size: .19rem; font-weight: 700; letter-spacing: .1em; }
.guide__chmicro { font-family: var(--font-display); font-size: .1rem; letter-spacing: .24em; opacity: .75; }
.guide__close {
width: .56rem; height: .56rem; display: grid; place-items: center; flex: none;
background: rgba(0, 0, 0, .35); border: 1px solid var(--line-2); color: var(--text-md);
transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast);
}
.guide__close .icon { width: .3rem; height: .3rem; }
.guide__close:hover { color: var(--text-hi); border-color: var(--red-premium); background: var(--red-a20); }
/* page stage (16:9) with edge buttons */
.guide__stage { position: relative; }
.guide__page {
position: relative; overflow: hidden;
width: min(calc(96vw - .5rem), calc((100vh - 2.3rem) * 16 / 9));
aspect-ratio: 16 / 9;
background: #050807 repeating-linear-gradient(135deg, rgba(78, 216, 175, .04) 0 .02rem, transparent .02rem .14rem);
border: 1px solid var(--line);
}
/* the pages are stored squashed to 1024²: stretch back to 16:9 */
.guide__page img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; user-select: none; opacity: 0; transition: opacity 160ms ease; }
.guide__page.is-loaded img { opacity: 1; }
.guide__loading { position: absolute; inset: 0; display: grid; place-items: center; }
.guide__missing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: .1rem; color: var(--text-lo); font-size: .2rem; }
.guide__missing .icon { width: .3rem; height: .3rem; }
.guide__nav {
position: absolute; top: 0; bottom: 0; z-index: 2; width: 1rem; display: grid; place-items: center;
color: rgba(242, 242, 242, .7); background: transparent; border: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.guide__nav .icon { width: .52rem; height: .52rem; filter: drop-shadow(0 0 .06rem rgba(0, 0, 0, .9)); }
.guide__prev { left: 0; background: linear-gradient(90deg, rgba(0, 0, 0, .38), transparent); }
.guide__next { right: 0; background: linear-gradient(270deg, rgba(0, 0, 0, .38), transparent); }
.guide__nav:hover { color: var(--mint-400); }
.guide__prev:hover { background: linear-gradient(90deg, rgba(0, 0, 0, .6), transparent); }
.guide__next:hover { background: linear-gradient(270deg, rgba(0, 0, 0, .6), transparent); }
/* footer: chapter + title | dots | count */
.guide__foot { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: .3rem; min-height: .44rem; }
.guide__label { display: flex; align-items: center; gap: .14rem; min-width: 0; }
.guide__chtag {
flex: none; padding: .02rem .12rem; font-size: .14rem; font-weight: 700; letter-spacing: .1em;
color: var(--mint-400); border: 1px solid var(--mint-800); background: var(--mint-a10);
}
.guide__ptitle { font-size: .22rem; font-weight: 700; letter-spacing: .06em; color: var(--text-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.guide__dots { display: flex; align-items: center; gap: .05rem; }
.guide__dot {
width: .2rem; height: .08rem; padding: 0; border: 0; background: var(--line-2);
transition: background var(--t-fast), width var(--t-fast);
}
.guide__dot.is-first { margin-left: .14rem; }
.guide__dot:hover { background: var(--line-3); }
.guide__dot.is-on { width: .42rem; background: var(--mint-500); box-shadow: 0 0 .08rem rgba(23, 249, 183, .5); }
.guide__count { justify-self: end; font-size: .24rem; color: var(--text-md); letter-spacing: .06em; }
/* text fallback (no local tutorial art) */
.guide__stage--text { width: min(92vw, 10rem); max-height: calc(100vh - 2rem); overflow: auto; padding: .1rem .06rem; }
.guide__tips ol { margin: .14rem 0 0; padding-left: .34rem; display: flex; flex-direction: column; gap: .08rem; }
.guide__tips li { font-size: .18rem; line-height: 1.6; color: var(--text-md); }
.guide__tips li::marker { color: var(--mint-500); font-family: var(--font-num); }
/* standard trigger */
.guide-btn .btn__icon { color: var(--mint-500); }
@media (max-height: 560px) {
.guide__box { padding: .08rem .12rem; gap: .06rem; }
.guide__head { min-height: .5rem; }
.guide__titles { display: none; }
.guide__page { width: min(calc(96vw - .3rem), calc((100vh - 1.5rem) * 16 / 9)); }
}
+362
View File
@@ -0,0 +1,362 @@
/* ==========================================================================================
screens/loadout.css — 干员调配 (Operator loadout, DESIGN §16; js/screens/loadout.js): full-screen overlay with a
filterable roster of the visible chess (left) and the selected chess's skills and elite modules (right).
Official preset sprites (ui/outer: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non)
are used when the local-client art is present; every piece has a CSS fallback.
Sizes are design px ÷ 100 (theme.css root scale); text that must stay readable on phones uses max(.xxrem, N px).
========================================================================================== */
.lo {
position: fixed; inset: 0; z-index: 55; /* above every screen (1) and the in-match banners (40), below the connection
banner (60), modals / confirm dialogs (80), toasts and tooltips */
display: flex; flex-direction: column;
padding: var(--sa-t, 0px) var(--sa-r, 0px) var(--sa-b, 0px) var(--sa-l, 0px);
background: var(--bg-0);
color: var(--text-hi);
animation: lo-in 200ms var(--ease-out) backwards;
overscroll-behavior: contain;
}
@keyframes lo-in { from { opacity: 0; } }
.lo__bg {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(70% 90% at 18% 10%, rgba(78, 216, 175, .07), transparent 60%),
radial-gradient(60% 70% at 100% 100%, rgba(52, 184, 216, .06), transparent 60%),
linear-gradient(rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
linear-gradient(90deg, rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
linear-gradient(180deg, #0f1312, #090b0a);
}
.lo > * { position: relative; }
/* ---- top bar ---------------------------------------------------------------------------------------------------- */
.lo-top {
flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: .2rem;
height: max(1rem, 48px); padding: 0 .36rem;
background: linear-gradient(180deg, rgba(8, 10, 9, .95), rgba(10, 13, 12, .6));
border-bottom: 1px solid var(--line);
}
.lo-top::after {
content: ''; position: absolute; left: .36rem; right: .36rem; bottom: -1px; height: 1px;
background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700));
}
.lo-top__left { justify-self: start; display: flex; align-items: center; gap: .14rem; }
.lo-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; }
.lo-top__title { display: flex; align-items: center; gap: .12rem; margin: 0; font-size: max(.32rem, 16px); font-weight: 900; letter-spacing: .14em; line-height: 1.15; }
.lo-top__icon { width: max(.3rem, 16px); height: max(.3rem, 16px); object-fit: contain; color: var(--mint-500); }
.lo-top__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
.lo-count { font-size: max(.16rem, 11px); color: var(--text-lo); white-space: nowrap; }
.lo-count b { color: var(--mint-400); font-size: max(.22rem, 13px); margin: 0 .02rem 0 .06rem; }
/* co-op briefing: time left before the match locks the loadout (the overlay covers the briefing's own countdown) */
.lo-deadline { --cd-h: max(.44rem, 20px); flex: none; }
.lo-deadline .countdown__label { font-size: max(.11rem, 8px); }
.lo-sync { font-size: max(.14rem, 10px); padding: .03rem .1rem; border: 1px solid var(--line-2); color: var(--text-lo); white-space: nowrap; letter-spacing: .04em; }
.lo-sync.is-ok { color: var(--mint-400); border-color: var(--mint-800); }
.lo-sync.is-busy { color: var(--text-md); }
.lo-sync.is-warn { color: var(--amber); border-color: rgba(246, 163, 41, .45); }
.lo-sync.is-bad { color: var(--red-premium); border-color: rgba(255, 84, 84, .45); }
.lo-note {
flex: none; display: flex; align-items: center; gap: .08rem; margin: 0; padding: .07rem .36rem;
font-size: max(.15rem, 10px); color: var(--text-lo);
background: linear-gradient(90deg, rgba(78, 216, 175, .08), transparent 70%);
border-bottom: 1px solid rgba(47, 58, 53, .6);
}
.lo-note .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); color: var(--mint-500); flex: none; }
.lo-note.is-locked { color: var(--amber); background: linear-gradient(90deg, rgba(246, 163, 41, .12), transparent 70%); }
.lo-note.is-locked .icon { color: var(--amber); }
.lo-loading { flex: 1; display: flex; align-items: center; justify-content: center; gap: .14rem; color: var(--text-lo); font-size: max(.18rem, 12px); }
/* ---- body layout -------------------------------------------------------------------------------------------------- */
.lo-body {
flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 7rem);
gap: .22rem; padding: .18rem .3rem .22rem;
}
.lo-roster { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: .12rem; }
.lo-detail-wrap { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.lo-detail-back { display: none; }
/* ---- filters ----------------------------------------------------------------------------------------------------------- */
.lo-filters { flex: none; display: flex; flex-direction: column; gap: .08rem; }
.lo-frow { display: flex; align-items: center; gap: .12rem; flex-wrap: wrap; min-width: 0; }
.lo-chips { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; }
.lo-chip {
display: inline-flex; align-items: center; justify-content: center; gap: .05rem;
min-width: max(.46rem, 26px); height: max(.4rem, 24px); padding: 0 .12rem;
font: inherit; font-size: max(.15rem, 11px); font-weight: 700; color: var(--text-lo);
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer;
clip-path: polygon(.07rem 0, 100% 0, calc(100% - .07rem) 100%, 0 100%);
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
touch-action: manipulation;
}
.lo-chip:hover { color: var(--text-hi); border-color: var(--line-3); }
.lo-chip.is-on { color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-400); }
.lo-chip--tier .num { font-family: var(--font-num); font-size: max(.17rem, 12px); letter-spacing: .04em; }
.lo-chip--t1 { --tc: var(--tier-1); } .lo-chip--t2 { --tc: var(--tier-2); } .lo-chip--t3 { --tc: var(--tier-3); }
.lo-chip--t4 { --tc: var(--tier-4); } .lo-chip--t5 { --tc: var(--tier-5); } .lo-chip--t6 { --tc: var(--tier-6); }
.lo-chip--tier { color: var(--tc); box-shadow: inset 0 -2px 0 var(--tc); }
.lo-chip--tier.is-on { color: #0b0d0c; background: var(--tc); border-color: var(--tc); }
.lo-chip__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); object-fit: contain; }
/* prof.large art is a black glyph on a white square: inverted + screen-blended it becomes a white glyph on any dark
surface; on the mint "on" chip it is multiplied instead (dark glyph, the square takes the chip colour) */
.lo-profglyph { filter: invert(1); mix-blend-mode: screen; opacity: .85; }
.lo-chip:hover .lo-profglyph { opacity: 1; }
.lo-chip.is-on .lo-profglyph { filter: none; mix-blend-mode: multiply; opacity: 1; }
.lo-chip__lbl { font-size: max(.14rem, 10px); }
.lo-search { flex: 1 1 2.6rem; min-width: 2rem; max-width: 4.2rem; margin-left: auto; }
.lo-select { display: inline-flex; align-items: center; gap: .06rem; height: max(.4rem, 24px); padding-left: .1rem; border: 1px solid var(--line); background: rgba(0, 0, 0, .35); }
.lo-select__k { font-size: max(.13rem, 10px); color: var(--text-dim); letter-spacing: .06em; }
.lo-select select {
height: 100%; min-width: 1.6rem; padding: 0 .26rem 0 .06rem; border: 0; outline: none; cursor: pointer;
font: inherit; font-size: max(.15rem, 11px); color: var(--text-hi);
background: transparent url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%234ed8af' d='M7 9l5 6 5-6z'/></svg>") no-repeat right .04rem center / .2rem .2rem;
-webkit-appearance: none; appearance: none;
}
.lo-select select option { background: var(--bg-2); color: var(--text-hi); }
.lo-select:focus-within { border-color: var(--mint-700); }
.lo-toggle {
display: inline-flex; align-items: center; gap: .07rem; height: max(.4rem, 24px); padding: 0 .12rem;
font: inherit; font-size: max(.15rem, 11px); color: var(--text-lo); background: transparent; border: 1px solid transparent; cursor: pointer;
touch-action: manipulation;
}
.lo-toggle__box { display: grid; place-items: center; width: max(.2rem, 14px); height: max(.2rem, 14px); border: 1px solid var(--line-3); }
.lo-toggle__box .icon { width: 86%; height: 86%; opacity: 0; color: var(--text-on-mint); }
.lo-toggle.is-on { color: var(--mint-400); }
.lo-toggle.is-on .lo-toggle__box { background: var(--mint-500); border-color: var(--mint-400); }
.lo-toggle.is-on .lo-toggle__box .icon { opacity: 1; }
/* ---- roster grid ------------------------------------------------------------------------------------------------------------ */
.lo-grid {
flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.16rem, 64px), 1fr)); grid-auto-rows: max-content;
align-content: start; gap: .1rem; padding: .06rem .08rem .2rem .02rem;
scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
}
.lo-empty { grid-column: 1 / -1; padding: .4rem; text-align: center; font-size: max(.16rem, 11px); }
.lo-card {
--tc: var(--tier-1);
position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0;
padding: 0; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer;
background: linear-gradient(180deg, rgba(34, 41, 38, .95), rgba(14, 17, 16, .98));
border: 1px solid var(--line); box-shadow: inset 0 -2px 0 var(--tc);
transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast);
touch-action: manipulation;
}
.lo-card--t1 { --tc: var(--tier-1); } .lo-card--t2 { --tc: var(--tier-2); } .lo-card--t3 { --tc: var(--tier-3); }
.lo-card--t4 { --tc: var(--tier-4); } .lo-card--t5 { --tc: var(--tier-5); } .lo-card--t6 { --tc: var(--tier-6); }
.lo-card:hover { border-color: var(--line-3); transform: translateY(-1px); }
.lo-card.is-sel { border-color: var(--mint-400); box-shadow: inset 0 -2px 0 var(--tc), 0 0 0 1px var(--mint-500), 0 0 .16rem rgba(23, 249, 183, .35); }
.lo-card:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; }
.lo-card__art { position: relative; display: block; aspect-ratio: 1; overflow: hidden; background: #1b201e; }
.lo-card__art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lo-card__art::after { content: ''; position: absolute; inset: 55% 0 0; background: linear-gradient(180deg, transparent, rgba(8, 10, 9, .85)); }
.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: .44rem; font-weight: 900; color: var(--text-dim); }
.lo-card__tier { position: absolute; left: .03rem; top: .03rem; z-index: 1; }
.lo-card__flag {
position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem;
background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%);
}
.lo-card__name {
position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1;
font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-shadow: 0 1px 2px #000;
}
.lo-card__kit { display: flex; align-items: center; justify-content: space-between; gap: .04rem; height: max(.3rem, 18px); padding: 0 .04rem; background: rgba(0, 0, 0, .4); }
.lo-card__sk { display: inline-flex; align-items: center; gap: .04rem; font-size: max(.13rem, 9px); color: var(--text-lo); }
.lo-card__sk b { font-family: var(--font-num); letter-spacing: .04em; }
.lo-card__sk.is-alt { color: var(--mint-400); }
.lo-card__mod {
display: inline-grid; place-items: center; min-width: max(.24rem, 15px); height: max(.2rem, 13px); padding: 0 .03rem;
font-family: var(--font-num); font-size: max(.13rem, 9px); font-weight: 700; color: var(--text-lo);
border: 1px solid var(--line-2); background: rgba(255, 255, 255, .04);
}
.lo-card__mod.is-alt { color: var(--text-on-mint); background: var(--gold-2); border-color: var(--gold); }
/* ---- skill icon / module glyph ---------------------------------------------------------------------------------------------- */
.lo-sicon { position: relative; flex: none; display: inline-grid; place-items: center; width: .72rem; height: .72rem; background: #151a18; border: 1px solid var(--line-2); }
.lo-sicon img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lo-sicon--xs { width: max(.2rem, 13px); height: max(.2rem, 13px); border: 0; background: transparent; }
.lo-sicon--xs .lo-sicon__glyph { display: none; }
.lo-sicon--md { width: max(.72rem, 38px); height: max(.72rem, 38px); }
.lo-sicon__glyph { font-family: var(--font-num); font-size: max(.2rem, 12px); font-weight: 700; color: var(--text-lo); letter-spacing: .04em; }
.lo-sicon.is-on { border-color: #1ea0ff; }
.lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; object-fit: fill; pointer-events: none; }
.lo-sicon__ring { position: absolute; inset: -.05rem; border: 2px solid #1ea0ff; pointer-events: none; }
.lo-mglyph { position: relative; flex: none; display: inline-grid; place-items: center; width: max(.56rem, 30px); height: max(.56rem, 30px);
background: linear-gradient(180deg, #2a322f, #151a18); border: 1px solid var(--line-3); }
.lo-mglyph img { width: 78%; height: 78%; object-fit: contain; }
.lo-mglyph__t { font-family: var(--font-num); font-size: max(.26rem, 15px); font-weight: 700; color: var(--gold-2); line-height: 1; }
.lo-mglyph--none { background: #121615; border-style: dashed; }
.lo-mglyph__slash { position: absolute; inset: 22%; border-radius: 50%; border: 2px solid var(--text-dim); }
.lo-mglyph__slash::after { content: ''; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 2px; background: var(--text-dim); transform: translateX(-50%) rotate(45deg); }
/* ---- detail panel -------------------------------------------------------------------------------------------------------- */
.lo-detail {
--bk-c: var(--mint-500);
flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
background: linear-gradient(180deg, rgba(24, 30, 28, .97), rgba(10, 13, 12, .98));
border: 1px solid var(--line-2); box-shadow: 0 .14rem .4rem rgba(0, 0, 0, .45);
}
.lo-detail--empty { align-items: center; justify-content: center; font-size: max(.16rem, 11px); }
.lo-dhead { position: relative; flex: none; display: grid; grid-template-columns: max(1.3rem, 60px) minmax(0, 1fr); gap: .16rem; padding: .16rem .18rem .14rem; border-bottom: 1px solid var(--line); }
.lo-dhead__art { position: relative; overflow: hidden; aspect-ratio: 1 / 1.25; background: #1b201e; border: 1px solid var(--line-2); box-shadow: inset 0 -3px 0 var(--tc, var(--tier-1)); }
.lo-dhead__art--t1 { --tc: var(--tier-1); } .lo-dhead__art--t2 { --tc: var(--tier-2); } .lo-dhead__art--t3 { --tc: var(--tier-3); }
.lo-dhead__art--t4 { --tc: var(--tier-4); } .lo-dhead__art--t5 { --tc: var(--tier-5); } .lo-dhead__art--t6 { --tc: var(--tier-6); }
.lo-dhead__art img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 12%; display: block; }
.lo-dhead__art .uthumb { width: 100%; height: 100%; }
.lo-dhead__info { min-width: 0; display: flex; flex-direction: column; gap: .04rem; }
.lo-dhead__chips { display: flex; align-items: center; gap: .08rem; }
.lo-dhead__name { margin: .02rem 0 0; font-size: max(.34rem, 17px); font-weight: 900; letter-spacing: .06em; line-height: 1.1; }
.lo-dhead__en { font-family: var(--font-display); font-size: max(.13rem, 9px); letter-spacing: .14em; color: var(--text-dim); text-transform: uppercase; }
.lo-dhead__class { display: flex; align-items: center; gap: .05rem; font-size: max(.15rem, 10px); color: var(--text-md); }
.lo-dhead__prof { width: max(.2rem, 13px); height: max(.2rem, 13px); object-fit: contain; }
.lo-sep { width: 1px; height: .16rem; background: var(--line-3); margin: 0 .04rem; }
.lo-dhead__bonds { display: flex; flex-wrap: wrap; gap: .05rem; margin-top: .02rem; }
.lo-bond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .08rem .01rem .04rem; font-size: max(.13rem, 9px); color: var(--text-md); background: rgba(255, 255, 255, .04); border: 1px solid var(--line); }
.lo-bond__icon { width: max(.18rem, 12px); height: max(.18rem, 12px); object-fit: contain; }
.lo-bond__dot { width: .1rem; height: .1rem; border-radius: 50%; background: var(--mint-700); }
.lo-dhead__reset { position: absolute; right: .12rem; top: .12rem; }
.lo-detail__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: .12rem .18rem .2rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
.lo-sec { display: flex; flex-direction: column; gap: .08rem; }
.lo-sec + .lo-sec { margin-top: .18rem; }
.lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); }
.lo-sec__head h3 { display: flex; align-items: baseline; gap: .08rem; margin: 0; font-size: max(.2rem, 13px); font-weight: 900; letter-spacing: .1em; }
.lo-sec__head h3::before { content: ''; align-self: center; width: .05rem; height: .2rem; background: var(--mint-500); }
.lo-sec__note { margin-left: auto; font-size: max(.13rem, 9px); color: var(--text-dim); }
.lo-sec__note b { color: var(--text-md); }
.lo-seg { margin-left: auto; display: inline-flex; border: 1px solid var(--line-2); }
.lo-seg button {
height: max(.32rem, 22px); padding: 0 .12rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-lo);
background: transparent; border: 0; cursor: pointer; touch-action: manipulation;
}
.lo-seg button + button { border-left: 1px solid var(--line-2); }
.lo-seg button.is-on { color: var(--text-on-mint); background: var(--mint-500); }
.lo-seg button:disabled { opacity: .4; cursor: default; }
/* skills */
.lo-skills { display: flex; flex-direction: column; gap: .07rem; }
.lo-skill {
position: relative; display: flex; align-items: flex-start; gap: .14rem; width: 100%; padding: .1rem .12rem .1rem .16rem;
font: inherit; color: var(--text-md); text-align: left; cursor: pointer;
background: rgba(255, 255, 255, .025); border: 1px solid var(--line);
transition: background var(--t-fast), border-color var(--t-fast);
touch-action: manipulation;
}
.lo-skill:hover { border-color: var(--line-3); background: rgba(255, 255, 255, .04); }
.lo-skill.is-on { color: var(--text-hi); background: linear-gradient(90deg, rgba(30, 160, 255, .13), rgba(30, 160, 255, .02) 70%); border-color: rgba(30, 160, 255, .55); }
.lo-skill__deco { position: absolute; left: -1px; top: 50%; transform: translate(-40%, -50%); width: .14rem; height: .17rem; }
.lo-skill__deco img, .lo-skill__deco i { display: block; width: 100%; height: 100%; }
.lo-skill__deco i { background: #1ea0ff; clip-path: polygon(0 0, 100% 50%, 0 100%); }
.lo-skill__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .05rem; }
.lo-skill__head { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; }
.lo-skill__slot { font-family: var(--font-num); font-size: max(.13rem, 9px); font-weight: 700; color: var(--text-dim); padding: 0 .05rem; border: 1px solid var(--line-2); letter-spacing: .06em; }
.lo-skill.is-on .lo-skill__slot { color: #7cc8ff; border-color: rgba(30, 160, 255, .55); }
.lo-skill__name { font-size: max(.19rem, 12px); font-weight: 700; color: var(--text-hi); }
.lo-skill__tags { display: flex; flex-wrap: wrap; align-items: center; gap: .06rem; }
.lo-skill__desc { display: block; font-size: max(.15rem, 10px); line-height: 1.55; color: var(--text-md); }
.lo-sp { padding: .01rem .07rem; font-size: max(.12rem, 9px); font-weight: 700; color: #0b0d0c; background: #9bd26b; }
.lo-sp--atk { background: #ff9a3c; }
.lo-sp--def { background: #f5c542; }
.lo-sp--passive { background: #9aa4a0; }
.lo-tag { font-size: max(.13rem, 9px); color: var(--text-lo); }
.lo-tag b { color: var(--text-hi); font-family: var(--font-num); margin-left: .02rem; }
.lo-badge { display: inline-flex; align-items: center; gap: .03rem; padding: .01rem .07rem; font-size: max(.12rem, 9px); font-weight: 700; line-height: 1.4; letter-spacing: .04em; white-space: nowrap; }
.lo-badge .icon { width: .13rem; height: .13rem; }
.lo-badge--def { color: var(--gold-2); border: 1px solid rgba(242, 189, 62, .55); }
.lo-badge--on { color: #0b1a26; background: #1ea0ff; }
.lo-badge--changed { color: var(--text-on-mint); background: var(--mint-500); }
.lo-badge--plain { color: var(--text-dim); border: 1px solid var(--line-2); }
/* modules */
.lo-mods { display: flex; flex-wrap: wrap; gap: .08rem; }
.lo-mod {
position: relative; flex: 1 1 1.9rem; min-width: 1.7rem; max-width: 3.2rem; display: flex; align-items: center; gap: .1rem; padding: .08rem .12rem .08rem .08rem;
font: inherit; color: var(--text-md); text-align: left; cursor: pointer;
background: rgba(255, 255, 255, .025); border: 1px solid var(--line);
transition: background var(--t-fast), border-color var(--t-fast); touch-action: manipulation;
}
.lo-mod:hover { border-color: var(--line-3); }
.lo-mod.is-on { color: var(--text-hi); background: linear-gradient(90deg, rgba(242, 189, 62, .14), rgba(242, 189, 62, .02) 75%); border-color: rgba(242, 189, 62, .7); }
.lo-mod.is-on .lo-mglyph { border-color: var(--gold-2); box-shadow: 0 0 .1rem rgba(255, 198, 0, .35); }
.lo-mod__text { min-width: 0; display: flex; flex-direction: column; gap: .01rem; }
.lo-mod__type { font-family: var(--font-num); font-size: max(.12rem, 9px); letter-spacing: .1em; color: var(--text-dim); }
.lo-mod__name { font-size: max(.16rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lo-mod__def { position: absolute; right: .04rem; top: .04rem; }
.lo-minfo { margin-top: .04rem; padding: .1rem .12rem; display: flex; flex-direction: column; gap: .07rem; background: rgba(0, 0, 0, .28); border: 1px solid var(--line); border-left: 3px solid var(--gold-2); }
.lo-minfo--none { border-left-color: var(--line-3); }
.lo-minfo__lead { margin: 0; font-size: max(.15rem, 10px); color: var(--text-lo); }
.lo-minfo__title { display: flex; align-items: center; gap: .08rem; font-size: max(.17rem, 11px); }
.lo-minfo__type { font-family: var(--font-num); font-size: max(.13rem, 9px); color: var(--gold-2); letter-spacing: .1em; }
.lo-minfo__icon { flex: none; width: max(.26rem, 16px); height: max(.26rem, 16px); object-fit: contain; }
.lo-minfo__row { display: grid; grid-template-columns: max(.5rem, 28px) minmax(0, 1fr); gap: .08rem; align-items: baseline; }
.lo-minfo__k { font-size: max(.13rem, 9px); color: var(--text-dim); letter-spacing: .08em; }
.lo-minfo__v { font-size: max(.15rem, 10px); line-height: 1.55; color: var(--text-md); }
.lo-minfo__tname { color: var(--text-hi); margin-right: .06rem; }
.lo-attrs { display: flex; flex-wrap: wrap; gap: .06rem; }
.lo-attr { display: inline-flex; align-items: baseline; gap: .04rem; padding: .01rem .08rem; background: rgba(255, 255, 255, .04); border: 1px solid var(--line); font-size: max(.13rem, 9px); color: var(--text-lo); }
.lo-attr b { font-family: var(--font-num); font-size: max(.15rem, 10px); }
.lo-attr.is-up b { color: var(--mint-400); }
.lo-attr.is-down b { color: var(--red-premium); }
.lo-locknote { display: flex; align-items: center; gap: .06rem; margin: .16rem 0 0; font-size: max(.14rem, 10px); color: var(--amber); }
.lo-locknote .icon { width: .16rem; height: .16rem; }
/* ---- entry button (lobby / room / briefing) --------------------------------------------------------------------------- */
.lo-entry { position: relative; gap: .08rem; }
.lo-entry__icon { width: 1.25em; height: 1.25em; object-fit: contain; flex: none; }
.lo-entry__n {
display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .3em; margin-left: .02rem;
font-size: .8em; font-weight: 700; color: var(--text-on-mint); background: var(--mint-500); border-radius: 1em;
}
/* Untimed phases (user playtest #11: the official 独立模拟 shows no countdown outside combat — the server sends
deadline 0 for them, Match.soloUntimed): ui/components.js Countdown renders its "无倒计时" placeholder ("--" digits +
COUNTDOWN label), which reads as a timer — it is not shown at all. (Owner of components.js: returning null there
for a missing deadline makes this rule a no-op.) */
.countdown[aria-label="无倒计时"] { display: none; }
/* the app screens behind the overlay do not need to animate */
.sp-loadout-open .app-root > .screen { visibility: hidden; }
/* ---- narrow / short viewports ------------------------------------------------------------------------------------------- */
@media (max-width: 1100px) {
.lo-body { grid-template-columns: minmax(0, 1fr) minmax(0, 6.4rem); gap: .16rem; padding: .12rem .2rem .16rem; }
.lo-chip__lbl { display: none; }
}
/* phones (the root scale is clamped at 40 px: less than ~14 rem of width): the detail slides over the roster */
@media (max-width: 760px) {
.lo-top { padding: 0 .16rem; }
.lo-count, .lo-sync { display: none; }
.lo-body { grid-template-columns: minmax(0, 1fr); }
.lo-detail-wrap { display: none; }
.lo-body.is-detail .lo-roster { display: none; }
.lo-body.is-detail .lo-detail-wrap { display: flex; }
.lo-detail-back {
display: inline-flex; align-self: flex-start; align-items: center; gap: .04rem; margin-bottom: .08rem; padding: .04rem .12rem .04rem .04rem;
font: inherit; font-size: max(.15rem, 11px); color: var(--mint-400); background: transparent; border: 1px solid var(--mint-800); cursor: pointer;
}
.lo-detail-back .icon { width: .2rem; height: .2rem; }
}
@media (max-height: 520px) {
.lo-note { display: none; }
.lo-dhead { grid-template-columns: max(1rem, 48px) minmax(0, 1fr); padding: .1rem .14rem; }
.lo-dhead__en { display: none; }
.lo-grid { grid-template-columns: repeat(auto-fill, minmax(max(1rem, 58px), 1fr)); }
}
/* the shared Button / TextField scale with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's
own controls (返回, 全部恢复默认, 恢复默认, the search field) keep a readable size and a tappable height (review fix) */
.lo .lo-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(.18rem, 12px); }
.lo .lo-top__right .btn, .lo .lo-dhead__reset { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(.15rem, 11px); }
.lo .lo-search .field__box { height: max(.56rem, 28px); padding: 0 max(.16rem, 6px); }
.lo .lo-search .field__input { font-size: max(.2rem, 12px); }
.lo .lo-search .field__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); }
.sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; }
.sp-coarse .lo-seg button { min-height: 28px; }
@media (prefers-reduced-motion: reduce) { .lo, .lo-card { animation: none; transition: none; } }
+240
View File
@@ -0,0 +1,240 @@
/* ==========================================================================================
screens/lobby.css — mode cards (独立/同盟), difficulty cards, create button, join panel.
========================================================================================== */
.lobby-body {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: .5rem;
width: 100%;
max-width: 19.2rem;
margin: 0 auto;
padding: .36rem .6rem .4rem;
align-content: start;
}
.lobby-left, .lobby-right { display: flex; flex-direction: column; min-width: 0; }
/* ---- mode cards ------------------------------------------------------------------------------------ */
.mode-cards { display: grid; grid-template-columns: 1fr 1fr; gap: .24rem; margin-bottom: .34rem; }
.mode-card {
--bk-c: transparent;
--bk-l: .2rem;
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: .22rem;
min-height: 4.3rem;
padding: .34rem .32rem .3rem;
overflow: hidden;
text-align: left;
background: linear-gradient(180deg, rgba(28, 34, 31, .92), rgba(13, 16, 15, .94));
border: 1px solid var(--line);
color: var(--text-hi);
transition: border-color var(--t-med) var(--ease-out), box-shadow var(--t-med) var(--ease-out), transform var(--t-fast) var(--ease-out);
animation: card-in var(--t-slow) var(--ease-out) backwards;
}
.mode-card:nth-child(2) { animation-delay: 60ms; }
.mode-card__bg {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(70% 55% at 100% 0%, rgba(78, 216, 175, .10), transparent 70%),
repeating-linear-gradient(-45deg, rgba(255, 255, 255, .018) 0 2px, transparent 2px 12px),
linear-gradient(transparent 70%, rgba(0, 0, 0, .35));
transition: opacity var(--t-med);
}
.mode-card::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: linear-gradient(90deg, var(--mint-500), transparent);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--t-slow) var(--ease-out);
}
.mode-card__icon {
position: relative;
width: .88rem;
height: .88rem;
display: grid;
place-items: center;
background: #0c0f0e;
border: 1px solid var(--line-2);
color: var(--text-md);
clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%);
transition: background var(--t-med), color var(--t-med);
}
.mode-card__icon .icon { width: .44rem; height: .44rem; }
.mode-card__text { position: relative; display: flex; flex-direction: column; gap: .1rem; }
.mode-card__name { font-size: .46rem; font-weight: 900; letter-spacing: .08em; line-height: 1.1; }
.mode-card__desc { font-size: .17rem; color: var(--text-lo); line-height: 1.65; max-width: 3.6rem; }
.mode-card__points { display: flex; flex-direction: column; gap: .05rem; margin-top: .06rem; font-size: .15rem; color: var(--text-md); }
.mode-card__points span::before {
content: '';
display: inline-block;
width: .06rem;
height: .06rem;
margin: 0 .1rem .02rem 0;
background: var(--mint-700);
transform: rotate(45deg);
vertical-align: middle;
}
.mode-card__check {
position: absolute;
top: .2rem;
right: .2rem;
display: flex;
align-items: center;
gap: .05rem;
padding: .04rem .12rem .04rem .08rem;
background: var(--mint-500);
color: var(--text-on-mint);
font-size: .14rem;
font-weight: 700;
opacity: 0;
transform: translateY(-.06rem);
transition: opacity var(--t-med), transform var(--t-med) var(--ease-out);
}
.mode-card__check .icon { width: .16rem; height: .16rem; }
.mode-card:hover { border-color: var(--line-3); --bk-c: var(--line-3); }
.mode-card:active { transform: translateY(1px); }
.mode-card.is-selected {
--bk-c: var(--mint-400);
border-color: var(--mint-700);
box-shadow: 0 0 0 1px rgba(78, 216, 175, .22), 0 0 .44rem rgba(23, 249, 183, .13), inset 0 0 .7rem rgba(23, 249, 183, .05);
}
.mode-card.is-selected::before { transform: scaleX(1); }
.mode-card.is-selected .mode-card__icon { background: var(--mint-500); border-color: var(--mint-400); color: var(--text-on-mint); }
.mode-card.is-selected .mode-card__check { opacity: 1; transform: none; }
@keyframes card-in { from { opacity: 0; transform: translateY(.14rem); } }
/* ---- join panel ------------------------------------------------------------------------------------ */
.join-panel .panel__body { display: flex; flex-direction: column; gap: .14rem; padding: .22rem .26rem .18rem; }
.join-row { display: flex; gap: .16rem; align-items: stretch; }
.join-row .field { flex: 1; }
.join-row .btn { height: .64rem; }
.join-foot { display: flex; align-items: center; flex-wrap: wrap; gap: .1rem; min-height: .32rem; font-size: .14rem; }
.code-chip {
padding: .03rem .12rem .03rem .16rem;
border: 1px solid var(--line-2);
background: rgba(0, 0, 0, .3);
color: var(--amber);
font-size: .17rem;
font-weight: 700;
letter-spacing: .2em;
transition: border-color var(--t-fast), background-color var(--t-fast);
}
.code-chip:hover { border-color: var(--amber); background: rgba(246, 163, 41, .08); }
/* ---- tips -------------------------------------------------------------------------------------------- */
.tips {
--bk-c: var(--line-3);
margin-top: .3rem;
padding: .16rem .22rem .2rem;
background: rgba(8, 10, 9, .55);
border: 1px solid var(--line);
}
.tips__head { display: flex; align-items: center; gap: .1rem; font-size: .16rem; font-weight: 700; color: var(--text-md); }
.tips__head > .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.tips__idx { margin-left: auto; font-size: .16rem; color: var(--text-md); }
.tips__nav {
display: grid;
place-items: center;
width: .3rem;
height: .3rem;
padding: 0;
border: 1px solid var(--line-2);
background: transparent;
color: var(--text-lo);
transition: color var(--t-fast), border-color var(--t-fast);
}
.tips__nav:hover { color: var(--mint-400); border-color: var(--mint-700); }
.tips__nav .icon { width: .18rem; height: .18rem; }
.tips__text { margin-top: .1rem; font-size: .17rem; color: var(--text-lo); line-height: 1.6; animation: tip-in var(--t-slow) var(--ease-out); }
@keyframes tip-in { from { opacity: 0; transform: translateX(.1rem); } }
/* ---- difficulty cards -------------------------------------------------------------------------------- */
.diff-list { display: flex; flex-direction: column; gap: .14rem; }
.diff-card {
--d-color: var(--amber);
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto auto;
gap: .06rem .2rem;
padding: .18rem .24rem .18rem .36rem;
overflow: hidden;
text-align: left;
background: linear-gradient(90deg, rgba(28, 34, 31, .93), rgba(15, 19, 17, .93));
border: 1px solid var(--line);
color: var(--text-hi);
transition: border-color var(--t-med), box-shadow var(--t-med), background var(--t-med);
animation: card-in var(--t-slow) var(--ease-out) backwards;
}
.diff-card:nth-child(2) { animation-delay: 40ms; }
.diff-card:nth-child(3) { animation-delay: 80ms; }
.diff-card:nth-child(4) { animation-delay: 120ms; }
.diff-card__bar { position: absolute; left: 0; top: 0; bottom: 0; width: .06rem; background: var(--d-color); opacity: .45; transition: opacity var(--t-med), box-shadow var(--t-med); }
.diff-card__head { grid-column: 1 / -1; display: flex; align-items: center; gap: .1rem; min-width: 0; }
.diff-card__glyph { width: .28rem; height: .28rem; color: var(--d-color); filter: drop-shadow(0 0 5px var(--d-color)); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--d-color) 55%, transparent)); }
.diff-card__name { font-size: .27rem; font-weight: 900; letter-spacing: .06em; color: var(--d-color); }
.diff-card__code { font-size: .14rem; color: var(--text-dim); letter-spacing: .1em; }
.diff-card__meta { margin-left: auto; display: flex; align-items: center; gap: .04rem; font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
.diff-card__meta .num { color: var(--text-hi); font-weight: 700; font-size: .17rem; }
.diff-card__hidden {
margin-left: .08rem;
padding: 0 .07rem;
border: 1px solid transparent; border: 1px solid color-mix(in srgb, var(--d-color) 45%, transparent);
color: var(--d-color); color: color-mix(in srgb, var(--d-color) 80%, #fff);
font-size: .12rem;
line-height: 1.6;
}
.diff-card__desc { grid-column: 1; font-size: .16rem; color: var(--text-md); }
.diff-card__effects { grid-column: 1; display: flex; flex-wrap: wrap; gap: .04rem .2rem; font-size: .14rem; color: var(--text-lo); }
.diff-card__effects span::before { content: '·'; margin-right: .05rem; color: var(--d-color); font-weight: 900; }
/* battlefield pool note (config.json modes[].stages): 战场固定为 战场#01 / 战场随机(共N张) — the last item of the
effects row (no extra card height: the lobby must not scroll at 1280×720 / 1920×1080) */
.diff-card__effects .diff-card__stage { display: inline-flex; align-self: flex-start; align-items: center; gap: .05rem; }
.diff-card__effects .diff-card__stage::before { content: none; }
.diff-card__stage .icon { flex: none; width: .15rem; height: .15rem; color: var(--d-color); opacity: .85; }
.diff-card__check {
grid-column: 2;
grid-row: 2 / span 2;
align-self: center;
display: flex;
flex-direction: column;
align-items: center;
gap: .03rem;
font-size: .12rem;
color: var(--text-dim);
}
.diff-card__check .icon {
width: .46rem;
height: .46rem;
padding: .09rem;
border-radius: 50%;
border: 2px solid var(--line-2);
color: transparent;
transition: background var(--t-med), border-color var(--t-med), color var(--t-med), transform var(--t-med) var(--ease-out);
}
.diff-card__check span { opacity: 0; transition: opacity var(--t-med); }
.diff-card:hover { border-color: var(--line-3); }
.diff-card.is-selected {
border-color: var(--d-color); border-color: color-mix(in srgb, var(--d-color) 65%, transparent);
background: linear-gradient(90deg, #151a18, rgba(15, 19, 17, .95) 70%); background: linear-gradient(90deg, color-mix(in srgb, var(--d-color) 15%, #151a18), rgba(15, 19, 17, .95) 70%);
box-shadow: 0 0 .3rem transparent; box-shadow: 0 0 .3rem color-mix(in srgb, var(--d-color) 16%, transparent);
}
.diff-card.is-selected .diff-card__bar { opacity: 1; box-shadow: 0 0 .14rem var(--d-color); }
.diff-card.is-selected .diff-card__check { color: var(--d-color); }
.diff-card.is-selected .diff-card__check .icon { background: var(--d-color); border-color: var(--d-color); color: #150a04; transform: rotate(-8deg); }
.diff-card.is-selected .diff-card__check span { opacity: 1; }
/* ---- create ------------------------------------------------------------------------------------------ */
.create-box { margin-top: .3rem; display: flex; flex-direction: column; gap: .12rem; }
.create-box .btn--xl { height: .92rem; font-size: .32rem; letter-spacing: .3em; }
.create-box__hint { display: flex; align-items: center; justify-content: center; min-height: .3rem; font-size: .14rem; color: var(--text-lo); }
+78
View File
@@ -0,0 +1,78 @@
/* ==========================================================================================
screens/result.css — settlement (js/screens/result.js). Research 06 §10.6: mint 模拟完成 headline,
huge "通过回合" numerals, boss medallions, per-player cards with lineup, bonds, stats and 评语.
========================================================================================== */
.result__bg { position: absolute; inset: 0; z-index: 0; background: var(--result-bg, none) center / cover no-repeat; opacity: .55; filter: saturate(.9); }
.result__bg::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(8, 11, 10, .72), rgba(8, 11, 10, .5) 40%, rgba(8, 11, 10, .88) 62%); }
.result__grid { position: absolute; inset: 0; z-index: 0; pointer-events: none;
background: linear-gradient(rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .6rem .6rem, linear-gradient(90deg, rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .6rem .6rem;
-webkit-mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); }
.result__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 5.6rem 1fr; gap: .5rem; padding: .36rem .5rem .3rem .6rem; }
.result__hero { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; padding-top: .3rem; min-height: 0; }
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
.result__headline { font-size: .96rem; font-weight: 900; letter-spacing: .12em; line-height: 1.05; color: var(--mint-400); text-shadow: 0 0 .4rem rgba(23, 249, 183, .45); animation: head-in 600ms var(--ease-out); }
.result.is-lose .result__headline { color: var(--red-premium); text-shadow: 0 0 .4rem rgba(255, 60, 60, .4); }
@keyframes head-in { from { opacity: 0; letter-spacing: .5em; } }
.result__sub { font-size: .2rem; color: var(--text-lo); letter-spacing: .1em; }
.result__rounds { display: flex; align-items: baseline; gap: .12rem; margin-top: .1rem; }
.result__rlabel { font-size: .22rem; color: var(--text-md); letter-spacing: .14em; }
.result__rnum { font-size: 1.5rem; line-height: .9; font-weight: 700; color: #fff; }
.result__rof { font-size: .4rem; color: var(--text-dim); }
.result__medals { display: flex; gap: .24rem; margin-top: .12rem; }
.medal { position: relative; width: 1.1rem; display: flex; flex-direction: column; align-items: center; gap: .08rem; }
.medal img { width: .96rem; height: .96rem; object-fit: cover; border-radius: 50%; border: 3px solid var(--line-3); background: #151a18; filter: grayscale(.7) brightness(.7); }
.medal.is-done img { border-color: var(--gold); filter: none; box-shadow: 0 0 .24rem rgba(255, 198, 0, .4); }
.medal--hidden.is-done img { border-color: var(--red-premium); box-shadow: 0 0 .24rem rgba(255, 60, 60, .4); }
.medal__check { position: absolute; right: .04rem; top: .64rem; width: .32rem; height: .32rem; display: grid; place-items: center; background: var(--text-dim); color: #fff; border-radius: 50%; }
.medal.is-done .medal__check { background: var(--gold); color: #1d1400; }
.medal__check .icon { width: .2rem; height: .2rem; }
.medal__label { font-size: .14rem; color: var(--text-lo); }
.result__time { font-size: .16rem; }
.result__time b { color: var(--text-hi); }
.result__players { display: flex; flex-direction: column; gap: .1rem; min-height: 0; overflow: auto; padding-right: .06rem; }
.result__players > :first-child { margin-top: auto; }
.result__players > :last-child { margin-bottom: auto; }
.rcard {
display: flex; flex-direction: column; gap: .09rem; padding: .12rem .18rem .14rem; background: rgba(11, 15, 13, .9); border: 1px solid var(--line);
border-left: 3px solid var(--line-3); animation: card-in var(--t-slow) var(--ease-out) backwards;
}
.rcard:nth-child(3) { animation-delay: 80ms; } .rcard:nth-child(4) { animation-delay: 160ms; } .rcard:nth-child(5) { animation-delay: 240ms; }
@keyframes card-in { from { opacity: 0; transform: translateX(.3rem); } }
.rcard.is-self { border-left-color: var(--mint-500); background: linear-gradient(90deg, rgba(78, 216, 175, .08), rgba(11, 15, 13, .9) 40%); }
.rcard.is-dead { opacity: .8; }
.rcard__head { display: grid; grid-template-columns: auto minmax(1.5rem, 2.1rem) minmax(0, 1fr) auto 2.3rem; gap: .18rem; align-items: center; }
.rcard__head .pavatar { --pa: .6rem; }
.rcard__who { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.rcard__name { font-size: .22rem; display: flex; align-items: center; gap: .08rem; }
.rcard__ai, .rcard__you { font-size: .11rem; padding: 0 .05rem; font-weight: 700; }
.rcard__ai { background: #a9c3d8; color: #0b1520; }
.rcard__you { background: var(--mint-500); color: var(--text-on-mint); }
.rcard__band { display: inline-flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.rcard__round { display: flex; flex-direction: column; align-items: center; }
.rcard__round b { font-size: .34rem; line-height: 1; color: #fff; }
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: .13rem; color: var(--gold); }
.rcard__gain span { display: inline-flex; align-items: center; gap: .02rem; }
.rcard__gain .icon { width: .14rem; height: .14rem; }
.rcard__gain b { font-size: .14rem; color: var(--gold); }
.rcard__title { display: flex; align-items: center; gap: .08rem; padding: .06rem .14rem .06rem .06rem; border: 1px solid rgba(255, 198, 0, .4); background: rgba(255, 198, 0, .06); min-width: 0; }
.rcard__title--none { border-style: dashed; border-color: var(--line); background: none; height: .64rem; }
.rcard__title > span { display: flex; flex-direction: column; }
.rcard__title b { font-size: .19rem; color: var(--gold); }
.rcard__ticon { width: .5rem; height: .5rem; object-fit: contain; }
.rcard__title .icon { width: .3rem; height: .3rem; color: var(--gold); }
.rcard__mid { display: flex; flex-direction: column; justify-content: center; gap: .06rem; min-width: 0; }
.rcard__lineup .uthumb { --ut: .46rem; }
.rcard__lineup { display: flex; flex-wrap: wrap; gap: .05rem; }
.rcard__noinfo { font-family: var(--font-display); font-size: .14rem; letter-spacing: .3em; color: var(--text-dim); padding: .14rem .2rem; border: 1px dashed var(--line-2); }
.rcard__bonds { display: flex; gap: .05rem; flex-wrap: wrap; }
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: .13rem; color: var(--text-lo); }
.rbond .bglyph { width: .2rem; height: .2rem; }
.rbond.is-on { border-color: var(--mint-800); color: var(--mint-400); }
.rcard__stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; }
.rstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0e1210; box-shadow: 0 0 0 1px var(--line); font-size: .13rem; color: var(--text-lo); white-space: nowrap; min-width: 0; }
.rstat b { font-size: .18rem; color: #fff; }
.rstat.is-best b { color: var(--gold); }
.result__foot { margin-top: auto; padding-top: .2rem; display: flex; }
.result__foot .btn { min-width: 3.4rem; }
+277
View File
@@ -0,0 +1,277 @@
/* ==========================================================================================
screens/room.css — room/waiting screen: invite key, 4 seat cards, bottom action bar.
========================================================================================== */
.room-screen .topbar { height: 1.36rem; }
.room-ping { display: flex; flex-direction: column; align-items: flex-start; gap: .04rem; }
/* ---- invite key ---------------------------------------------------------------------------------------- */
.invite {
--bk-c: var(--mint-500);
display: grid;
grid-template-columns: auto auto;
column-gap: .2rem;
row-gap: .06rem;
align-items: center;
padding: .1rem .16rem .12rem;
background: rgba(0, 0, 0, .38);
border: 1px solid var(--line);
}
.invite__label { grid-column: 1 / -1; display: flex; align-items: center; gap: .07rem; font-size: .14rem; color: var(--text-lo); }
.invite__label .icon { width: .16rem; height: .16rem; color: var(--mint-500); }
.invite__code { display: flex; gap: .06rem; }
.invite__code span {
display: grid;
place-items: center;
width: .46rem;
height: .58rem;
background: linear-gradient(180deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02));
border-bottom: 2px solid var(--mint-600);
color: var(--mint-400);
font-size: .44rem;
font-weight: 700;
line-height: 1;
text-shadow: 0 0 .16rem rgba(23, 249, 183, .45);
}
.invite__btns { display: flex; flex-direction: column; gap: .06rem; }
.solo-note { display: flex; flex-direction: column; align-items: flex-end; gap: .03rem; font-size: .16rem; color: var(--text-lo); }
/* ---- seats ------------------------------------------------------------------------------------------------ */
.seats {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 3.8rem));
grid-template-rows: minmax(0, 6rem);
justify-content: center;
align-content: center;
gap: .3rem;
padding: .3rem .6rem .24rem;
}
.seats--solo { grid-template-columns: 3.8rem minmax(0, 6.4rem); }
.seat {
--bk-c: var(--line-3);
--bk-l: .18rem;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: .07rem;
height: 100%;
min-height: 0;
padding: .16rem .2rem .18rem;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .014) 0 1px, transparent 1px 3px),
linear-gradient(180deg, rgba(29, 35, 32, .95), rgba(11, 14, 13, .97));
border: 1px solid var(--line);
box-shadow: 0 .14rem .34rem rgba(0, 0, 0, .4);
transition: border-color var(--t-med), box-shadow var(--t-med);
animation: seat-in var(--t-slow) var(--ease-out) backwards;
animation-delay: calc(var(--seat-i, 0) * 60ms);
}
@keyframes seat-in { from { opacity: 0; transform: translateY(.18rem); } }
.seat::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 3px;
background: var(--line-2);
transition: background var(--t-med), box-shadow var(--t-med);
}
.seat__head { align-self: stretch; display: flex; align-items: center; gap: .1rem; }
.seat__no { font-size: .22rem; font-weight: 700; color: var(--text-md); padding-left: .08rem; border-left: 3px solid var(--line-3); line-height: 1.1; }
.seat__host {
margin-left: auto;
display: flex;
align-items: center;
gap: .04rem;
padding: .02rem .09rem;
background: var(--gold);
color: #1d1400;
font-size: .13rem;
font-weight: 700;
}
.seat__host .icon { width: .15rem; height: .15rem; }
.seat__art {
position: relative;
flex: 1;
min-height: 0;
align-self: stretch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .08rem;
margin: .06rem 0 .04rem;
overflow: hidden;
background:
radial-gradient(circle at 50% 42%, rgba(78, 216, 175, .08), transparent 62%),
linear-gradient(rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .3rem .3rem,
linear-gradient(90deg, rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .3rem .3rem,
#0b0e0d;
border: 1px solid rgba(255, 255, 255, .04);
}
.seat__stripes {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: .14rem;
background: repeating-linear-gradient(-45deg, rgba(255, 255, 255, .06) 0 .05rem, transparent .05rem .1rem);
}
.seat__art .avatar { --av: 2.1rem; }
.seat__bot-label {
display: flex;
align-items: center;
gap: .05rem;
padding: .02rem .1rem;
background: rgba(159, 212, 255, .1);
border: 1px solid rgba(159, 212, 255, .35);
color: var(--ice);
font-size: .14rem;
font-weight: 700;
}
.seat__bot-label .icon { width: .16rem; height: .16rem; }
.seat__who { display: flex; align-items: center; gap: .08rem; max-width: 100%; }
.seat__name { font-size: .26rem; font-weight: 700; max-width: 3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seat__you { flex: none; padding: .01rem .07rem; background: var(--mint-500); color: var(--text-on-mint); font-size: .13rem; font-weight: 700; }
.seat__foot {
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
gap: .1rem;
min-height: .46rem;
margin-top: .06rem;
padding-top: .1rem;
border-top: 1px solid var(--line);
}
.seat__foot .tt-anchor { position: absolute; right: .14rem; bottom: .14rem; }
.seat__state { display: flex; align-items: center; gap: .06rem; font-size: .18rem; font-weight: 700; letter-spacing: .06em; }
.seat__state .icon { width: .22rem; height: .22rem; }
.seat__state--ready { color: var(--mint-400); }
.seat__state--waiting { color: var(--text-lo); }
.seat__state--offline { color: var(--orange-hi); }
.seat__state--host { color: var(--gold-2); }
.seat.is-ready { --bk-c: var(--mint-500); border-color: var(--mint-800); }
.seat.is-ready::before { background: var(--mint-500); box-shadow: 0 0 .12rem rgba(23, 249, 183, .6); }
.seat.is-host .seat__no { border-left-color: var(--gold); }
.seat.is-me { box-shadow: 0 0 0 1px rgba(78, 216, 175, .28), 0 0 .44rem rgba(23, 249, 183, .12), 0 .14rem .34rem rgba(0, 0, 0, .4); }
.seat.is-me .seat__no { color: var(--mint-400); }
.seat.is-offline::before { background: var(--orange); }
.seat.is-bot .seat__art { background:
radial-gradient(circle at 50% 42%, rgba(159, 212, 255, .08), transparent 62%),
linear-gradient(rgba(159, 212, 255, .04) 1px, transparent 1px) 0 0 / .3rem .3rem,
linear-gradient(90deg, rgba(159, 212, 255, .04) 1px, transparent 1px) 0 0 / .3rem .3rem,
#0b0e10; }
.seat--empty { background: rgba(10, 13, 12, .5); border-style: dashed; border-color: var(--line-2); box-shadow: none; }
.seat--empty .seat__art { background: transparent; border: 0; }
.seat__radar {
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
background:
repeating-radial-gradient(circle, transparent 0 .2rem, rgba(78, 216, 175, .14) .2rem .21rem),
conic-gradient(from 0deg, rgba(23, 249, 183, .2), transparent 25%);
-webkit-mask-image: radial-gradient(circle, #000 60%, transparent 71%);
mask-image: radial-gradient(circle, #000 60%, transparent 71%);
animation: radar-sweep 4s linear infinite;
}
@keyframes radar-sweep { to { transform: rotate(360deg); } }
.seat__wait { font-size: .2rem; color: var(--text-lo); letter-spacing: .08em; }
.seat--empty .seat__foot { border-top-style: dashed; }
.solo-brief {
--bk-c: var(--mint-500);
align-self: stretch;
display: flex;
flex-direction: column;
gap: .14rem;
height: 100%;
padding: .34rem .4rem;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .014) 0 1px, transparent 1px 3px),
linear-gradient(180deg, rgba(24, 29, 27, .92), rgba(11, 14, 13, .95));
border: 1px solid var(--line);
animation: seat-in var(--t-slow) 80ms var(--ease-out) backwards;
}
.solo-brief h2 { font-size: .36rem; font-weight: 900; letter-spacing: .06em; }
.solo-brief p { font-size: .18rem; color: var(--text-md); }
.solo-brief ul { margin: .06rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: .08rem; font-size: .16rem; color: var(--text-lo); }
.solo-brief li::before { content: ''; display: inline-block; width: .06rem; height: .06rem; margin: 0 .12rem .02rem 0; background: var(--mint-600); transform: rotate(45deg); vertical-align: middle; }
.solo-brief b { color: var(--text-hi); }
/* ---- bottom bar ----------------------------------------------------------------------------------------- */
.room-bar {
position: relative;
flex: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: .3rem;
height: 1.5rem;
padding: 0 .6rem;
background: linear-gradient(0deg, rgba(7, 9, 8, .96), rgba(10, 13, 12, .7));
border-top: 1px solid var(--line);
}
.room-bar::before {
content: '';
position: absolute;
left: .6rem;
right: .6rem;
top: -1px;
height: 1px;
background: linear-gradient(90deg, var(--mint-700), transparent 30%, transparent 70%, var(--mint-700));
}
.room-bar__left { display: flex; align-items: center; gap: .22rem; min-width: 0; }
.room-bar__label { display: flex; flex-direction: column; font-size: .17rem; font-weight: 700; color: var(--text-md); line-height: 1.35; }
.room-bar__center { display: flex; flex-direction: column; align-items: center; gap: .06rem; }
.room-bar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; }
.room-bar__status { font-size: .15rem; }
.room-bar__status .icon { width: .18rem; height: .18rem; margin-right: .05rem; vertical-align: -.03rem; }
.dpick { display: flex; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); }
.dpick__opt {
--d-color: var(--amber);
position: relative;
display: flex;
align-items: center;
gap: .06rem;
height: .54rem;
padding: 0 .2rem;
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--text-lo);
font-size: .18rem;
font-weight: 700;
letter-spacing: .08em;
transition: color var(--t-fast), background-color var(--t-med), box-shadow var(--t-med);
}
.dpick__opt:last-child { border-right: 0; }
.dpick__opt .icon { width: .2rem; height: .2rem; color: var(--d-color); opacity: .55; transition: opacity var(--t-fast); }
.dpick__opt:hover:not(:disabled) { color: var(--text-hi); background: rgba(255, 255, 255, .03); }
.dpick__opt:hover:not(:disabled) .icon { opacity: 1; }
.dpick__opt:disabled { cursor: progress; }
.dpick__opt.is-active {
color: var(--d-color);
background: transparent; background: color-mix(in srgb, var(--d-color) 17%, transparent);
box-shadow: inset 0 -3px 0 var(--d-color);
}
.dpick__opt.is-active .icon { opacity: 1; filter: drop-shadow(0 0 5px var(--d-color)); }
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: .14rem; }
.ready-count[hidden] { display: none; }
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: .16rem; }
.ready-count b { font-size: .36rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .14rem rgba(23, 249, 183, .45); }
.ready-count .t-dim { font-size: .22rem; }
.ready-count__icons { display: flex; gap: .02rem; margin-left: .12rem; align-self: center; }
.ready-count__icons .icon { width: .28rem; height: .28rem; color: var(--line-3); transition: color var(--t-med); }
.ready-count__icons .icon.is-on { color: var(--mint-400); filter: drop-shadow(0 0 4px var(--mint-glow)); }
.room-bar .btn--xl { min-width: 3.2rem; letter-spacing: .2em; }
+253
View File
@@ -0,0 +1,253 @@
/* ==========================================================================================
screens/title.css — title screen: radar + ridgeline backdrop, dot-matrix emblem, big title,
nickname console. Pure CSS/SVG art; an optional backdrop image fades in underneath.
========================================================================================== */
.title-screen { align-items: center; justify-content: center; }
/* ---- backdrop ----------------------------------------------------------------------------------- */
.title-bg { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.title-bg__art {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
object-position: center 30%;
filter: saturate(.8) brightness(.9);
transition: opacity 600ms ease;
}
.title-bg.has-art .title-bg__art { opacity: .8; }
/* The real backdrop already carries a radar + target point; keep only the sweep over it. */
.title-bg.has-art .title-bg__target { display: none; }
.title-bg.has-art .title-bg__radar { opacity: .35; }
.title-bg__ridge-art {
position: absolute;
left: 0;
right: 0;
bottom: -.1rem;
height: 3.3rem;
background-repeat: repeat-x;
background-position: center bottom;
background-size: auto 100%;
opacity: 0;
transition: opacity 600ms ease;
}
.title-bg.has-ridges .title-bg__ridge-art { opacity: 1; }
.title-bg__ridges { animation: fade-in 600ms ease backwards; }
@keyframes fade-in { from { opacity: 0; } }
.title-bg__glow {
position: absolute;
left: -10%;
right: -10%;
bottom: 0;
height: 6rem;
background:
radial-gradient(48% 42% at 50% 78%, rgba(23, 249, 183, .40), rgba(23, 249, 183, .08) 62%, transparent 76%),
radial-gradient(26% 26% at 50% 80%, rgba(120, 255, 215, .30), transparent 70%);
filter: blur(6px);
animation: glow-breathe 7s ease-in-out infinite;
}
@keyframes glow-breathe { 50% { opacity: .78; } }
.title-bg__radar {
position: absolute;
left: 17%;
top: 40%;
width: 5.6rem;
height: 5.6rem;
transform: translate(-50%, -50%);
border-radius: 50%;
background:
radial-gradient(circle, transparent 0 .05rem, rgba(78, 216, 175, .5) .05rem .065rem, transparent .065rem),
repeating-radial-gradient(circle, transparent 0 .69rem, rgba(78, 216, 175, .16) .69rem .7rem),
linear-gradient(rgba(78, 216, 175, .12), rgba(78, 216, 175, .12)) center / 100% 1px no-repeat,
linear-gradient(rgba(78, 216, 175, .12), rgba(78, 216, 175, .12)) center / 1px 100% no-repeat;
-webkit-mask-image: radial-gradient(circle, #000 55%, transparent 71%);
mask-image: radial-gradient(circle, #000 55%, transparent 71%);
opacity: .8;
}
.title-bg__sweep {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from 0deg, rgba(23, 249, 183, .28), rgba(23, 249, 183, 0) 22%, transparent 100%);
animation: radar-sweep 6s linear infinite;
}
@keyframes radar-sweep { to { transform: rotate(360deg); } }
.title-bg__target {
position: absolute;
right: 12%;
top: 22%;
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
background:
radial-gradient(circle, rgba(246, 163, 41, .9) 0 .06rem, transparent .065rem),
radial-gradient(circle, transparent 0 .2rem, rgba(246, 163, 41, .5) .2rem .215rem, transparent .215rem),
radial-gradient(circle, transparent 0 .5rem, rgba(246, 163, 41, .22) .5rem .51rem, transparent .51rem),
radial-gradient(circle, rgba(246, 163, 41, .18), transparent 70%);
animation: target-pulse 3.2s ease-in-out infinite;
}
.title-bg__target::after {
content: 'TARGET POINT';
position: absolute;
left: 110%;
top: 50%;
transform: translateY(-50%);
padding: .02rem .08rem;
border: 1px solid rgba(246, 163, 41, .35);
font-family: var(--font-display);
font-size: .1rem;
letter-spacing: .2em;
color: rgba(246, 163, 41, .7);
white-space: nowrap;
}
@keyframes target-pulse { 50% { transform: scale(1.06); opacity: .75; } }
.title-bg__ridges { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 40%; }
.title-bg__ridges .ridge { stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.title-bg__ridges .ridge--far { opacity: .95; }
.title-bg__haze {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(12, 15, 14, .6) 0%, transparent 25%, transparent 70%, rgba(6, 8, 7, .7) 100%),
radial-gradient(46% 42% at 50% 44%, rgba(10, 13, 12, .82), rgba(10, 13, 12, .4) 60%, transparent 80%);
}
/* ---- corners ------------------------------------------------------------------------------------- */
.title-corner { position: absolute; z-index: 2; display: flex; align-items: flex-start; gap: .12rem; line-height: 1.7; }
.title-corner--tl { left: .44rem; top: .38rem; }
.title-corner--tr { right: .44rem; top: .38rem; text-align: right; }
.title-corner__mark {
width: .32rem;
height: .32rem;
margin-top: .02rem;
border: 1px solid var(--mint-700);
background:
linear-gradient(var(--mint-500), var(--mint-500)) center / 40% 2px no-repeat,
linear-gradient(var(--mint-500), var(--mint-500)) center / 2px 40% no-repeat;
clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%);
}
/* ---- centre column ------------------------------------------------------------------------------- */
.title-main {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
margin-top: -.3rem;
}
.emblem { position: relative; display: flex; align-items: center; gap: .26rem; margin-bottom: .26rem; animation: rise-in 600ms var(--ease-out) backwards; }
.emblem__svg { width: 1.3rem; height: 1.4rem; overflow: visible; filter: drop-shadow(0 0 .12rem rgba(210, 255, 240, .25)); }
.emblem__svg circle { fill: #dfe6e2; animation: dot-twinkle 4.8s ease-in-out infinite; }
.emblem__svg circle.d1 { animation-delay: -.7s; }
.emblem__svg circle.d2 { animation-delay: -1.4s; }
.emblem__svg circle.d3 { animation-delay: -2.1s; }
.emblem__svg circle.d4 { animation-delay: -2.8s; }
.emblem__svg circle.d5 { animation-delay: -3.5s; }
.emblem__svg circle.d6 { animation-delay: -4.2s; }
.emblem__svg circle.is-accent { fill: var(--mint-glow); filter: drop-shadow(0 0 .04rem var(--mint-glow)); animation: none; }
@keyframes dot-twinkle { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
.emblem__bracket { width: .16rem; height: 1.7rem; border: 2px solid rgba(223, 230, 226, .45); }
.emblem__bracket--l { border-right: 0; }
.emblem__bracket--r { border-left: 0; }
.title-en {
display: flex;
flex-direction: column;
align-items: center;
gap: .02rem;
font-family: var(--font-display);
text-transform: uppercase;
line-height: 1;
animation: rise-in 600ms 80ms var(--ease-out) backwards;
}
.title-en__a { font-size: .34rem; font-weight: 500; letter-spacing: .3em; padding-left: .3em; color: #cdd6d1; }
.title-en__b {
font-size: .38rem;
font-weight: 700;
letter-spacing: .34em;
padding-left: .34em;
color: var(--mint-500);
text-shadow: 0 0 .18rem rgba(23, 249, 183, .45);
}
.title-cn {
margin-top: .14rem;
font-size: 1.02rem;
font-weight: 900;
line-height: 1.12;
letter-spacing: .06em;
color: #f4f6f5;
text-shadow: 0 .04rem .2rem rgba(0, 0, 0, .7);
animation: rise-in 600ms 140ms var(--ease-out) backwards;
}
.title-cn__colon { color: #f4f6f5; }
.title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); }
.title-tag {
margin-top: .12rem;
font-size: .19rem;
color: var(--text-lo);
letter-spacing: .1em;
animation: rise-in 600ms 200ms var(--ease-out) backwards;
}
@keyframes rise-in { from { opacity: 0; transform: translateY(.14rem); } }
.title-login {
--bk-c: var(--mint-500);
position: relative;
width: 4.9rem;
margin-top: .42rem;
display: flex;
flex-direction: column;
gap: .16rem;
padding: .24rem .28rem .2rem;
background: linear-gradient(180deg, rgba(20, 25, 23, .82), rgba(10, 13, 12, .88));
border: 1px solid var(--line-2);
box-shadow: 0 .2rem .5rem rgba(0, 0, 0, .5), 0 0 .5rem rgba(23, 249, 183, .06);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
animation: rise-in 600ms 260ms var(--ease-out) backwards;
}
.title-login::before {
content: '';
position: absolute;
left: -1px;
right: -1px;
top: -1px;
height: 2px;
background: linear-gradient(90deg, var(--mint-500), transparent 60%);
}
.title-login .btn--xl { letter-spacing: .6em; padding-left: calc(.48rem + .3em); }
.title-invite {
display: flex;
align-items: center;
gap: .08rem;
padding: .08rem .12rem;
background: rgba(246, 163, 41, .08);
border: 1px solid rgba(246, 163, 41, .35);
color: var(--amber);
font-size: .16rem;
}
.title-invite .icon { width: .2rem; height: .2rem; }
.title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); }
.title-conn .ping { height: .26rem; font-size: .15rem; }
.title-foot {
position: absolute;
z-index: 2;
left: .44rem;
right: .44rem;
bottom: .3rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: .13rem;
color: var(--text-dim);
}
+369
View File
@@ -0,0 +1,369 @@
/* ==========================================================================================
theme.css — design tokens, fonts, root scale, base reset, shared backdrops & utilities.
Look: research 06 §12 (near-black green-grey, mint accents, gold prices, bracket frames).
Root scale: 1rem = 100 design px at 1920×1080 and scales with min(vw/1920, vh/1080), so the
whole UI is authored in design pixels ÷ 100 (e.g. 24px → .24rem). The root never drops below
40px, which keeps it clear of CJK browsers' minimum-font-size clamping. 1px hairlines stay px.
========================================================================================== */
:root {
color-scheme: dark;
/* surfaces */
--bg-0: #0c0f0e;
--bg-1: #141816;
--bg-2: #1e2421;
--bg-3: #252c29;
--bg-4: #2d3632;
--bg-glass: rgba(16, 20, 18, .82);
--line: #2f3a35;
--line-2: #3e4b45;
--line-3: #58675f;
/* accents (official hex values where research marks [DATA]) */
--mint-500: #4ed8af;
--mint-400: #59f4ca;
--mint-600: #35d8b4;
--mint-glow: #17f9b7;
--mint-700: #2a9e7f;
--mint-800: #145443;
--mint-900: #0d2c24;
--mint-a10: rgba(78, 216, 175, .10);
--mint-a20: rgba(78, 216, 175, .20);
--mint-a35: rgba(78, 216, 175, .35);
--gold: #ffc600;
--gold-2: #f2bd3e;
--gold-a20: rgba(255, 198, 0, .2);
--amber: #f6a329;
--amber-a20: rgba(246, 163, 41, .2);
--orange: #e85a1a;
--orange-hi: #ff7a33;
--orange-a20: rgba(232, 90, 26, .22);
--red: #e73118;
--red-abyss: #ff0024;
--red-premium: #ff5454;
--red-a20: rgba(231, 49, 24, .22);
--ice: #9fd4ff;
--ice-a20: rgba(159, 212, 255, .2);
--ice-900: #14283a;
/* text */
--text-hi: #f2f2f2;
--text-md: #c3cbc7;
--text-lo: #8a948f;
--text-dim: #5d6863;
--text-on-mint: #06110d;
/* difficulty (shared/constants.js DIFFICULTY_COLORS) */
--diff-funny: #f6a329;
--diff-normal: #e85a1a;
--diff-hard: #e73118;
--diff-abyss: #ff0024;
/* chess tier accents (I–VI), sampled from the official tier chips (img_chess_level_1..6) */
--tier-1: #a8a8a8;
--tier-2: #f2f2f2;
--tier-3: #34e6b2;
--tier-4: #34b8d8;
--tier-5: #f8bf00;
--tier-6: #f88000;
/* shop level tag colours (shopLevelDisplayDataDict) */
--lv-1: #434343;
--lv-2: #626654;
--lv-3: #445760;
--lv-4: #615b74;
--lv-5: #6c5e41;
--lv-6: #5d341e;
/* type */
--font-cjk: 'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans SC', 'Noto Sans CJK SC', sans-serif;
--font-num: 'Bender', 'Rajdhani', 'Oxanium', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-display: 'Novecento', 'Novecento Wide', 'Oxanium', 'Rajdhani', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'Bender', 'Oxanium', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
/* motion */
--ease-out: cubic-bezier(.2, .8, .2, 1);
--ease-in-out: cubic-bezier(.6, 0, .3, 1);
--t-fast: 150ms;
--t-med: 200ms;
--t-slow: 250ms;
/* layers */
--z-screen: 1;
--z-banner: 40;
--z-conn: 60;
--z-modal: 80;
--z-toast: 90;
--z-tooltip: 95;
--z-rotate: 100;
/* bracket corners (see .brackets) */
--bk-c: var(--mint-500);
--bk-l: .14rem;
--bk-w: 2px;
}
/* ---- root scale ----------------------------------------------------------------------------- */
html {
font-size: clamp(40px, min(calc(100vw / 19.2), calc(100vh / 10.8)), 240px);
/* mobile browsers: 100vh is the height with the toolbars hidden — the small viewport (svh; Safari 15.4+, Chrome 108+,
Firefox 101+) is what is really visible while they show; older browsers keep the line above */
font-size: clamp(40px, min(calc(100vw / 19.2), calc(100svh / 10.8)), 240px);
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
height: 100%;
background: var(--bg-0);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg-0);
color: var(--text-hi);
font-family: var(--font-cjk);
font-size: .18rem;
line-height: 1.5;
font-weight: 400;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
input, textarea, [contenteditable], .selectable { -webkit-user-select: text; user-select: text; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
img { max-width: 100%; }
h1, h2, h3, h4, p { margin: 0; }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 2px; }
::selection { background: var(--mint-500); color: var(--text-on-mint); }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-2); }
::-webkit-scrollbar-thumb:hover { background: var(--mint-700); }
.sr-only {
position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* ---- typography helpers ---------------------------------------------------------------------- */
.num { font-family: var(--font-num); font-variant-numeric: tabular-nums; letter-spacing: .02em; }
.display { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .12em; }
.micro {
font-family: var(--font-display);
font-size: .11rem;
line-height: 1.2;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--text-dim);
white-space: nowrap;
}
.micro--mint { color: var(--mint-700); }
.micro--gold { color: #9c7c16; }
.micro--hi { color: var(--text-lo); }
.t-mint { color: var(--mint-500); }
.t-gold { color: var(--gold); }
.t-amber { color: var(--amber); }
.t-orange { color: var(--orange-hi); }
.t-red { color: var(--red-premium); }
.t-ice { color: var(--ice); }
.t-lo { color: var(--text-lo); }
.t-dim { color: var(--text-dim); }
/* ---- bracket corners: 4 L-shaped corners drawn with gradients on ::after ---------------------- */
.brackets { position: relative; }
.brackets::after {
content: '';
position: absolute;
inset: -1px;
pointer-events: none;
background:
linear-gradient(var(--bk-c), var(--bk-c)) top left / var(--bk-l) var(--bk-w) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) top left / var(--bk-w) var(--bk-l) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) top right / var(--bk-l) var(--bk-w) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) top right / var(--bk-w) var(--bk-l) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom left / var(--bk-l) var(--bk-w) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom left / var(--bk-w) var(--bk-l) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom right / var(--bk-l) var(--bk-w) no-repeat,
linear-gradient(var(--bk-c), var(--bk-c)) bottom right / var(--bk-w) var(--bk-l) no-repeat;
transition: opacity var(--t-med) var(--ease-out);
}
/* ---- texture overlays -------------------------------------------------------------------------- */
.fx-scan { position: relative; }
.fx-scan::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(255, 255, 255, .028) 0 1px, transparent 1px 3px);
mix-blend-mode: screen;
z-index: 0;
}
.fx-noise {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .8 0 0 0 0 .9 0 0 0 0 .85 0 0 0 .55 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/></svg>");
}
/* ---- global app backdrop (behind every screen) ------------------------------------------------- */
.app-root {
position: fixed;
inset: 0;
overflow: hidden;
isolation: isolate;
}
.app-bg {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(120% 80% at 50% 110%, rgba(23, 249, 183, .10), transparent 55%),
radial-gradient(80% 60% at 50% 40%, rgba(40, 60, 52, .35), transparent 70%),
linear-gradient(180deg, #0b0e0d 0%, #0e1311 55%, #0a0d0c 100%);
}
.app-bg::before {
/* faint survey grid */
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(rgba(78, 216, 175, .045) 1px, transparent 1px) 0 0 / .8rem .8rem,
linear-gradient(90deg, rgba(78, 216, 175, .045) 1px, transparent 1px) 0 0 / .8rem .8rem;
-webkit-mask-image: radial-gradient(90% 80% at 50% 50%, #000 30%, transparent 85%);
mask-image: radial-gradient(90% 80% at 50% 50%, #000 30%, transparent 85%);
}
.app-bg::after {
/* grain + scanlines + vignette */
content: '';
position: absolute;
inset: 0;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .02) 0 1px, transparent 1px 3px),
radial-gradient(130% 100% at 50% 50%, transparent 55%, rgba(0, 0, 0, .65) 100%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .75 0 0 0 0 .85 0 0 0 0 .8 0 0 0 .6 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='.35'/></svg>");
opacity: .9;
}
/* ---- screens ------------------------------------------------------------------------------------ */
.screen {
position: absolute;
inset: 0;
z-index: var(--z-screen);
display: flex;
flex-direction: column;
overflow: hidden;
animation: screen-in var(--t-slow) var(--ease-out) backwards;
}
/* Opacity only: a transform here would become the containing block of fixed overlays. */
@keyframes screen-in { from { opacity: 0; } }
.screen__scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
/* crosshair "+" marks used as decoration */
.cross {
position: absolute;
width: .22rem;
height: .22rem;
pointer-events: none;
background:
linear-gradient(var(--mint-700), var(--mint-700)) center / 100% 1px no-repeat,
linear-gradient(var(--mint-700), var(--mint-700)) center / 1px 100% no-repeat;
opacity: .7;
}
/* ---- boot splash (index.html, removed by main.js) ------------------------------------------- */
.boot {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: var(--bg-0);
z-index: 200;
transition: opacity 250ms ease;
}
.boot.is-done { opacity: 0; pointer-events: none; }
.boot__inner { display: grid; justify-items: center; gap: .18rem; }
.boot__hex {
width: .64rem;
height: .64rem;
border: 2px solid var(--mint-500);
clip-path: polygon(25% 3%, 75% 3%, 100% 50%, 75% 97%, 25% 97%, 0 50%);
animation: boot-spin 1.4s linear infinite;
background: linear-gradient(135deg, transparent 45%, rgba(78, 216, 175, .35) 50%, transparent 55%);
}
.boot__text { font-family: var(--font-display); font-size: .13rem; letter-spacing: .4em; color: var(--mint-700); }
.boot__err { max-width: 6rem; text-align: center; color: var(--text-lo); font-size: .16rem; }
@keyframes boot-spin { to { transform: rotate(360deg); } }
/* ---- rotate-device hint (portrait phones/tablets) ------------------------------------------- */
.rotate-hint {
position: fixed;
inset: 0;
z-index: var(--z-rotate);
display: none;
place-items: center;
align-content: center;
gap: 18px;
padding: 24px;
text-align: center;
background: var(--bg-0);
color: var(--text-md);
font-size: 16px;
}
.rotate-hint__phone {
width: 54px;
height: 90px;
border: 3px solid var(--mint-500);
border-radius: 9px;
position: relative;
animation: rotate-phone 2.4s var(--ease-in-out) infinite;
box-shadow: 0 0 24px rgba(23, 249, 183, .25);
}
.rotate-hint__phone::after {
content: '';
position: absolute;
left: 50%;
bottom: 6px;
width: 14px;
height: 3px;
margin-left: -7px;
background: var(--mint-500);
border-radius: 2px;
}
.rotate-hint__title { font-size: 20px; font-weight: 700; color: var(--text-hi); }
.rotate-hint__micro { font-family: var(--font-display); font-size: 11px; letter-spacing: .3em; color: var(--mint-700); }
@keyframes rotate-phone {
0%, 20% { transform: rotate(0deg); }
50%, 75% { transform: rotate(-90deg); }
100% { transform: rotate(0deg); }
}
/* only on a device that can be turned (js/ui/device.js sp-rotatable: a touch screen whose screen is not landscape) —
a narrow desktop window or a split-view iPad keeps the game */
@media (orientation: portrait) and (max-width: 1024px) {
.sp-rotatable .rotate-hint { display: grid; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
}
}
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<!--
In-match UI mock harness (dev only): drives the real game screens (js/screens/game.js …) with
fabricated m.public / m.private / m.field / b.snap / b.ev streams and a tiny mock server behind
net.request, so every phase can be developed and screenshot without the game server.
URL params: ?phase=PREP|INFO_CHECK|BAND_DRAFT|BATTLE_CHECK|SP_DRAFT|COMBAT|UNITE|SETTLE|FINAL_ASSAULT|HIDDEN_CORE|RESULT
&variant=reward|bounty|supply|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
Keyboard: ` toggles the switcher.
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<title>Game Mock · 卫戍协议:盟约</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/components.css" />
<link rel="stylesheet" href="/css/screens/game.css" />
<link rel="stylesheet" href="/css/screens/game-shop.css" />
<link rel="stylesheet" href="/css/screens/game-panels.css" />
<link rel="stylesheet" href="/css/screens/briefing.css" />
<link rel="stylesheet" href="/css/screens/draft.css" />
<link rel="stylesheet" href="/css/screens/result.css" />
<link rel="stylesheet" href="/css/screens/guide.css" />
<link rel="stylesheet" href="/css/emotes.css" />
<link rel="stylesheet" href="/css/devices.css" />
<script type="importmap">
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
</script>
<style>
.mockbar { position: fixed; z-index: 150; right: 8px; top: 50%; transform: translateY(-50%); width: 188px; max-height: 92vh; overflow: auto;
display: flex; flex-direction: column; gap: 4px; padding: 8px; background: rgba(6, 9, 8, .92); border: 1px solid #3e4b45; font: 12px/1.3 sans-serif; color: #c3cbc7; }
.mockbar h4 { margin: 6px 0 2px; font-size: 11px; letter-spacing: .12em; color: #4ed8af; }
.mockbar button { text-align: left; padding: 4px 6px; background: #141816; border: 1px solid #2f3a35; color: #f2f2f2; font-size: 12px; cursor: pointer; }
.mockbar button:hover { border-color: #4ed8af; }
.mockbar button.on { background: #145443; border-color: #4ed8af; }
.mockbar.hidden { display: none; }
</style>
</head>
<body>
<div id="app"></div>
<div id="mockbar" class="mockbar"></div>
<script type="module" src="/dev/game-mock.js"></script>
</body>
</html>
+726
View File
@@ -0,0 +1,726 @@
// Mock harness for the in-match UI (see game-mock.html). Fabricates a 4-player co-op match from the real
// data files and answers `g.*` intents with a small in-browser mock server (buy / sell / move / equip /
// refresh / freeze / level / ready / reward / choice / band …), so drag & drop and every button can be
// exercised. Combat phases stream b.snap / b.ev at 20 Hz through net._emit like the real socket.
import '/js/ui/compat.js';
import { render } from '/vendor/preact.module.js';
import { installDeviceSupport } from '/js/ui/device.js';
import { html, UiHosts } from '/js/ui/components.js';
import { ToastHost, toast } from '/js/ui/toasts.js';
import { ConnectionBanner } from '/js/ui/connBanner.js';
import { GuideHost } from '/js/ui/guide.js';
import { GameScreen } from '/js/screens/game.js';
import { store, emptyMatch, selectRoute } from '/js/store.js';
import { net, NetError } from '/js/net.js';
import { data } from '/js/data.js';
import { installAudio } from '/js/audio.js';
import { settingsStore } from '/js/ui/settings.js';
import { awayStore } from '/js/ui/matchChrome.js';
import { GAME_FILES } from '/js/ui/gameComponents.js';
import { PHASE, GEO } from '/shared/constants.js';
const params = new URLSearchParams(location.search);
const SHOT = params.get('shot') === '1';
const VARIANTS = new Set((params.get('variant') || '').split(',').filter(Boolean));
// ---- deterministic rng ----------------------------------------------------------------------------------
let seed = 20260927;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 2 ** 32; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const shuffle = (arr) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const ME = 'p1';
const PLAYERS = [
{ playerId: 'p1', seat: 0, name: '凯尔希', isBot: false, bandId: 'band_bldsk' },
{ playerId: 'ai_2', seat: 1, name: 'AI·华法琳', isBot: true, bandId: 'band_sarkazb' },
{ playerId: 'p3', seat: 2, name: 'Doctor·B', isBot: false, bandId: 'band_amiya' },
{ playerId: 'p4', seat: 3, name: '灰烬', isBot: false, bandId: 'band_amedic' },
];
let uidSeq = 100;
const nextUid = () => ++uidSeq;
let S = null; // mock server state
// ---- builders ------------------------------------------------------------------------------------------------
function visibleChess(tier) {
return data.list('chess').filter((c) => c.visible && !c.isGolden && (!tier || c.tier === tier));
}
function shopItems() { return data.list('items').filter((i) => !i.isGolden && i.itemType === 'EQUIP' && !i.hideInShop); }
function makeSlot(level) {
const tier = Math.max(1, Math.min(level, 1 + Math.floor(rnd() * level)));
const c = pick(visibleChess(tier));
const base = c.price;
const r = rnd();
const price = r < 0.15 ? Math.max(1, base - 1) : r > 0.9 ? base + 1 : base;
return { kind: 'chess', id: c.chessId, price, basePrice: base, sold: false };
}
function makeItemSlot() {
const it = pick(shopItems());
return { kind: 'item', id: it.id, price: it.price, basePrice: it.price, sold: false };
}
function chessPiece(c, golden = false) {
const id = golden ? (c.goldenId || c.chessId) : c.chessId;
return { uid: nextUid(), kind: 'chess', id, golden, tier: c.tier, items: [] };
}
function itemPiece(it) { return { uid: nextUid(), kind: 'item', id: it.id, golden: !!it.isGolden, tier: it.tier }; }
function computeBonds(priv) {
const counts = new Map();
const seen = new Set();
for (const p of priv.board) {
if (p.kind !== 'chess') continue;
const c = data.lookup('chess', p.id);
const base = c?.baseId || p.id;
if (seen.has(base)) continue;
seen.add(base);
for (const b of c?.bonds || []) counts.set(b, (counts.get(b) || 0) + 1);
}
const out = [];
for (const [bondId, count] of counts) {
const b = data.lookup('bonds', bondId);
const thresholds = b?.thresholds || [2];
let tier = 0;
for (const t of thresholds) if (count >= t) tier++;
const layers = S?.layers?.[bondId] ?? (tier ? Math.floor(rnd() * 40) + 4 : Math.floor(rnd() * 6));
if (S) S.layers[bondId] = layers;
out.push({ bondId, count, active: tier > 0, tier, layers, thresholds, countsHand: !!b?.countsHand });
}
return out.sort((a, b) => (b.active - a.active) || (b.layers - a.layers));
}
function refreshPrivate() {
const p = S.priv;
p.deployCount = p.board.filter((x) => x.kind === 'chess').length;
p.bonds = computeBonds(p);
p.canReady = !p.temp.some(Boolean);
const upg = [5, 8, 11, 12, 13];
p.shop.maxLevel = 6;
if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0;
p.shop.refreshPrice = p.shop.freeRefreshes > 0 ? 0 : 1;
store.patch('match', { private: JSON.parse(JSON.stringify(p)) });
const me = S.pub.players.find((x) => x.playerId === ME);
if (me) { me.bonds = p.bonds; me.boardCount = p.deployCount; me.lp = p.lp; me.ready = p.ready; if (S.pub.phase === PHASE.PREP) me.status = p.ready ? 'ready' : 'acting'; }
pushPublic();
}
function pushPublic() {
S.pub.serverNow = Date.now();
store.patch('match', { public: JSON.parse(JSON.stringify(S.pub)) });
}
function buildState() {
uidSeq = 100;
seed = 20260927;
const stage = data.lookup('stages', 'act2autochess_m01');
const melee = stage.deployTiles.normal.melee.slice();
const ranged = stage.deployTiles.normal.rangedOnly.slice();
const bonds = data.list('bonds');
const core = shuffle(bonds.filter((b) => b.isCore && b.weight > 0)).slice(0, 3).map((b) => b.bondId);
const addon = shuffle(bonds.filter((b) => !b.isCore && b.weight > 0)).slice(0, 4).map((b) => b.bondId);
const disabled = new Set([...core, ...addon]);
const banned = visibleChess().filter((c) => c.bonds.length && c.bonds.every((b) => disabled.has(b))).map((c) => c.chessId);
const pool = visibleChess().filter((c) => !banned.includes(c.chessId));
// board: 7 units on legal tiles
const board = [];
const used = new Set();
const preferBond = pool.filter((c) => c.bonds.includes('yanShip') || c.bonds.includes('preciShip'));
const lineup = shuffle(preferBond).slice(0, 5).concat(shuffle(pool).slice(0, 4));
for (const c of lineup) {
if (board.length >= 7) break;
const tiles = c.position === 'MELEE' ? melee : [...ranged, ...melee];
const t = tiles.find((x) => !used.has(`${x[0]},${x[1]}`));
if (!t) continue;
used.add(`${t[0]},${t[1]}`);
const golden = board.length === 1;
const piece = { ...chessPiece(c, golden), row: t[0], col: t[1] };
if (board.length === 0) piece.items = [{ uid: nextUid(), id: shopItems()[3].id }, { uid: nextUid(), id: shopItems()[9].id }];
if (board.length === 2) piece.items = [{ uid: nextUid(), id: shopItems()[5].id }];
board.push(piece);
}
const hand = new Array(GEO.HAND_SIZE).fill(null);
const hc = shuffle(pool).slice(0, 5);
hand[9] = chessPiece(hc[0]);
hand[8] = chessPiece(hc[1]);
hand[7] = chessPiece(board[3] ? data.lookup('chess', board[3].id) : hc[2]); // a merge pair
hand[6] = chessPiece(hc[3], true);
hand[5] = itemPiece(shopItems()[12]);
hand[4] = itemPiece(shopItems()[20]);
const tokenOwner = data.list('tokens').find((t) => t.tokenId === 'token_10028_vigil_wolf');
if (tokenOwner) hand[3] = { uid: nextUid(), kind: 'token', id: tokenOwner.tokenId, golden: false, count: 2 };
const temp = new Array(GEO.TEMP_SIZE).fill(null);
if (VARIANTS.has('temp')) { temp[0] = chessPiece(hc[4]); temp[1] = itemPiece(shopItems()[2]); }
const level = 4;
const slots = [];
for (let i = 0; i < 5; i++) slots.push(makeSlot(level));
slots[2].sold = true;
// make one card complete a merge
if (board[4]) { const c4 = data.lookup('chess', board[4].id); slots[0] = { kind: 'chess', id: c4.chessId, price: c4.price, basePrice: c4.price, sold: false }; hand[2] = chessPiece(c4); }
slots.push(makeItemSlot());
const enemies = data.list('enemies');
const byRank = (r) => enemies.filter((e) => e.rank === r && !e.key.includes('template'));
// m.private.nextEnemies (server/match/waves.js previewOf): one entry per spawn action, in spawn order
const flyOf = (e) => e.stats?.motion === 'FLY' || !!e.isFlyEnemy;
const nextEnemies = [
...shuffle(byRank('NORMAL')).slice(0, 4).map((e, i) => ({ enemyKey: e.key, count: [8, 6, 4, 3][i], gate: i % 2 ? 'upper' : 'lower', t: [3, 5, 12, 15][i], fly: flyOf(e), elite: false, boss: false, source: 'wave', tag: null })),
...shuffle(byRank('ELITE')).slice(0, 2).map((e, i) => ({ enemyKey: e.key, count: 1, gate: i ? 'upper' : 'lower', t: 18 + i * 6, fly: flyOf(e), elite: true, boss: false, source: 'wave', tag: null })),
(() => { const e = pick(byRank('ELITE')); return { enemyKey: e.key, count: 1, gate: 'lower', t: 20, fly: flyOf(e), elite: true, boss: false, source: 'bounty', tag: 'bounty' }; })(),
].sort((a, b) => a.t - b.t);
const priv = {
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true,
shop: { level, maxLevel: 6, upgradePrice: 9, refreshPrice: 1, freeRefreshes: 0, frozen: VARIANTS.has('frozen'), slots, rewardOffer: null },
hand, temp, board, deployCap: 8, deployCount: 0, bonds: [],
effects: [
{ id: 'aceffect_band_21', name: '重点监护', desc: data.lookup('bands', 'band_bldsk')?.descRaw || '', iconKind: 'band', iconId: 'band_bldsk' },
{ id: 'allybuff_select_5', name: '补给', desc: '获得<@ba.vup>2</>次免费刷新机会,若存在其他队友则他们也获得', iconKind: 'team', iconId: 'icon_team_buff', counter: 2 },
{ id: 'enemyeffect_3', name: '悬赏·飞行I', desc: '为自身<@ba.vup>下场作战</>添加1只悬赏敌人,将其击倒者获得<@ba.vup>1</>资金', iconKind: 'choice', iconId: 'icon_player_buff', counter: 1 },
],
nextEnemies,
stats: { dmgDealt: 184230, kills: 96, leaks: 3, gold: 58, refreshes: 11, merges: 3 },
};
if (VARIANTS.has('loadout')) priv.loadout = mockLoadout(board, slots);
if (VARIANTS.has('reward')) {
priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) };
}
const players = PLAYERS.map((p, i) => ({
...p, connected: i !== 3 || true, alive: true, lp: [24, 31, 18, 5][i], shopLevel: [4, 5, 3, 4][i], boardCount: [7, 8, 6, 7][i],
ready: [false, true, false, false][i], bonds: [], fieldId: `n:${p.playerId}`, status: ['acting', 'ready', 'acting', 'acting'][i],
}));
players[3].connected = false;
// boss-round prep (最终攻势 prep on the own half of the boss field): `boss` = the left half; `bossR` = seats swapped so
// I am the second of the first pair → the mirrored right half (server pairPlayers: alive players by seat, in pairs)
if (VARIANTS.has('bossR')) { players[0].seat = 1; players[1].seat = 0; }
if (VARIANTS.has('dead')) { players[0].alive = false; players[0].status = 'dead'; players[0].lp = 0; priv.lp = 0; }
const pub = {
phase: PHASE.PREP, round: 6, lastRound: 14, bossRound: 14, hiddenRound: 15, deadline: Date.now() + 74000, serverNow: Date.now(), modeId: 'mode_multi_hard', difficulty: 'HARD',
stageId: 'act2autochess_m01', factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
hiddenBossId: 'boss_8', teamLp: null, bossHp: null, draft: null, sp: null, players, fields: [],
};
if (VARIANTS.has('boss') || VARIANTS.has('bossR')) pub.round = pub.bossRound;
S = { stage, pub, priv, layers: {}, battle: null, pool, requests: [] };
}
/**
* `loadout` variant (DESIGN §16): the first board operator and the first shop card fight with a non-default skill, the
* elite board operator with its module unequipped (m.private.loadout). Data without `skills[]` / `modules[]` (before the
* loadout data build) gets a stand-in second skill / module list on those records — mock only.
*/
function mockLoadout(board, slots) {
const out = {};
const withSkills = (c) => {
if (!c) return null;
const recs = [c, c.goldenId ? data.lookup('chess', c.goldenId) : null, c.baseId && c.baseId !== c.chessId ? data.lookup('chess', c.baseId) : null].filter(Boolean);
for (const r of recs) {
if (Array.isArray(r.skills) && r.skills.length > 1) continue;
const d = r.skill || {};
const alt = { ...d, index: (d.index ?? 0) + 1, skillId: `${d.skillId || 'sk'}_mockalt`, iconId: d.iconId || r.assets?.skillIcon || d.skillId, name: `${d.name || '技能'}·改`, isDefault: false,
desc: '(模拟)调配的第二技能:攻击力+60%,持续 20 秒', descRaw: '(模拟)调配的第二技能:攻击力<@ba.vup>+60%</>,持续 20 秒', spCost: 30, initSp: 10, duration: 20 };
r.skills = [{ ...d, isDefault: true }, alt];
}
const base = c.isGolden ? data.lookup('chess', c.baseId) || c : c;
const alt = (base.skills || []).find((x) => x && !x.isDefault);
return alt ? { base, alt } : null;
};
const b0 = board[0] ? data.lookup('chess', board[0].id) : null;
const s0 = slots.find((x) => x && x.kind === 'chess' && !x.sold);
for (const c of [b0, s0 ? data.lookup('chess', s0.id) : null]) {
const r = withSkills(c);
if (r) out[r.base.chessId] = { skill: r.alt.index, module: r.base.goldenId ? 'none' : null };
}
const gold = board.find((p) => p.golden);
const g = gold ? data.lookup('chess', gold.id) : null;
if (g && g.isGolden) {
const r = withSkills(g);
if (g.module && !Array.isArray(g.modules)) g.modules = [{ uniEquipId: g.module.id, name: g.module.name, typeName: g.module.type, isDefault: true }];
if (r) out[r.base.chessId] = { skill: r.alt.index, module: 'none' };
}
return out;
}
// ---- phases ---------------------------------------------------------------------------------------------------------
function setPhase(phase, variant) {
stopBattle();
buildState();
for (const v of (variant || '').split(',').filter(Boolean)) VARIANTS.add(v);
if (VARIANTS.has('reward') && !S.priv.shop.rewardOffer) S.priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) };
if (VARIANTS.has('temp') && !S.priv.temp.some(Boolean)) { S.priv.temp[0] = chessPiece(pick(S.pool)); S.priv.temp[1] = itemPiece(shopItems()[2]); }
const pub = S.pub;
pub.phase = phase;
store.set({ match: emptyMatch(), ticker: [], emotes: [] });
awayStore.set({ away: false });
const solo = VARIANTS.has('solo');
if (solo) {
pub.players = pub.players.slice(0, 1);
pub.modeId = 'mode_single_hard';
store.set({ room: { ...store.get().room, mode: 'solo' } });
} else {
store.set({ room: { ...store.get().room, mode: 'coop' } });
}
switch (phase) {
case PHASE.INFO_CHECK:
pub.round = 0; pub.deadline = Date.now() + 21000;
pub.players.forEach((p, i) => { p.ready = i === 1 || i === 2; p.status = p.ready ? 'ready' : 'deciding'; p.bandId = null; p.lp = 0; });
S.priv.bandId = null;
break;
case PHASE.BAND_DRAFT:
pub.round = 0; pub.deadline = Date.now() + 9000;
pub.players.forEach((p) => { p.bandId = null; p.status = 'deciding'; p.ready = false; });
S.priv.bandId = null;
if (!solo) {
pub.draft = { order: ['p3', 'p1', 'ai_2', 'p4'], turn: 'p1', picks: { p3: 'band_sarkazb' }, skipsLeft: { p1: 1, p3: 1, ai_2: 1, p4: 1 }, turnDeadline: Date.now() + 9000 };
pub.players.find((p) => p.playerId === 'p3').bandId = 'band_sarkazb';
} else pub.deadline = 0;
break;
case PHASE.BATTLE_CHECK:
pub.round = 0; pub.deadline = Date.now() + 3000;
break;
case PHASE.SP_DRAFT: {
pub.round = 9; pub.deadline = Date.now() + 14000;
const fam = VARIANTS.has('supply') ? 'supply' : VARIANTS.has('tactic') ? 'tactic' : 'bounty';
const ch = data.get('choices');
let cards;
if (fam === 'supply') cards = shuffle(shopItems().filter((i) => i.tier >= 3)).slice(0, 6).map((i) => ({ itemId: i.id }));
else if (fam === 'tactic') cards = shuffle(ch.cards.tactic.filter((t) => t.kind !== 'terrain')).slice(0, 6).map((t) => ({ effectId: t.effectId }));
else cards = shuffle(ch.cards.bounty.filter((b) => !b.name.includes('战术特训'))).slice(0, 6).map((b) => ({ effectId: b.effectId }));
if (solo) { cards = cards.slice(0, 3); pub.deadline = 0; }
pub.sp = { family: fam, cards, order: solo ? ['p1'] : ['p4', 'p1', 'ai_2', 'p3'], turn: solo ? 'p1' : 'p1', picks: solo ? {} : { p4: 2 }, untimed: solo };
pub.players.forEach((p) => { p.status = p.playerId === 'p4' ? 'ready' : 'deciding'; });
break;
}
case PHASE.COMBAT: case PHASE.UNITE: case PHASE.SETTLE: case PHASE.FINAL_ASSAULT: case PHASE.HIDDEN_CORE:
startCombat(phase);
break;
case PHASE.RESULT:
pub.round = 14;
store.patch('match', { result: buildResult(!VARIANTS.has('defeat')) });
break;
default:
break;
}
refreshPrivate();
applyUiVariants();
}
// m.result in the exact shape of server/match/results.js buildResult (title = config.titles record, stats.gold = spent)
function buildResult(victory) {
const titles = (data.get('config')?.titles || []).filter((t) => victory || !t.onlyOnWin);
const pickTitle = (i) => { const t = titles[[0, 3, 5, 2][i] % Math.max(1, titles.length)]; return t ? { id: t.id, name: t.name, picId: t.picId, text: t.text } : null; };
return {
victory, roundsPassed: victory ? 14 : 11, hiddenReached: false, hiddenCleared: false, reason: victory ? 'victory' : 'defeat',
bossId: 'boss_5', hiddenBossId: 'boss_8', difficulty: 'HARD', modeId: 'mode_multi_hard', stageId: S.pub.stageId, durationMs: 52 * 60000,
players: S.pub.players.map((p, i) => ({
playerId: p.playerId, seat: p.seat, name: p.name, isBot: p.isBot, left: false, alive: i !== 3, victory, lp: [12, 12, 12, 0][i], bandId: p.bandId,
roundsPassed: i === 3 ? 9 : victory ? 14 : 11, eliminatedRound: i === 3 ? 10 : null, title: pickTitle(i),
lineup: shuffle(S.pool).slice(0, 8 + (i % 2)).map((c, k) => ({ id: k < 3 ? c.goldenId : c.chessId, golden: k < 3, tier: c.tier, row: 9 + (k % 4), col: 2 + k, items: [] })),
bonds: shuffle(data.list('bonds')).slice(0, 4).map((b, k) => ({ bondId: b.bondId, count: 3 - (k % 3), layers: [359, 136, 34, 12][k], active: k < 3, tier: k < 3 ? 1 : 0 })),
stats: {
dmgDealt: [2310000, 1720000, 980000, 402000][i], kills: [412, 388, 301, 150][i], leaks: [3, 6, 9, 31][i], gold: [188, 164, 231, 90][i],
refreshes: [22, 18, 30, 8][i], merges: [7, 5, 9, 2][i], itemsEquipped: [9, 6, 11, 3][i], bossDamage: victory ? [912000, 610000, 240000, 0][i] : 0,
activatedLayers: [541, 402, 377, 120][i], lpLost: [16, 19, 12, 24][i], perfectRounds: [11, 12, 9, 4][i],
},
trophies: i === 3 ? 1 : 5, reward: i === 3 ? 170 : 425,
})),
};
}
// ---- combat simulation ------------------------------------------------------------------------------------------------
function stopBattle() { if (S?.battle?.timer) clearInterval(S.battle.timer); if (S) S.battle = null; }
function startCombat(phase) {
const pub = S.pub;
const boss = phase === PHASE.FINAL_ASSAULT || phase === PHASE.HIDDEN_CORE;
pub.round = boss ? (phase === PHASE.HIDDEN_CORE ? 15 : 14) : 6;
pub.deadline = boss ? Date.now() + 95000 : Date.now() + 40000;
// boss rounds (server Match: deadline = the level's 120 s countdown, overtimeAt = the drain start at 150 s):
// `overtime` = the level time ran out, the drain starts in 18 s; `drain` = draining for 7 s
if (boss) {
const now = Date.now();
if (VARIANTS.has('drain')) { pub.deadline = now - 37000; pub.overtimeAt = now - 7000; }
else if (VARIANTS.has('overtime')) { pub.deadline = now - 12000; pub.overtimeAt = now + 18000; }
else pub.overtimeAt = pub.deadline + 30000;
}
// solo pause (g.pause → m.public.paused)
if (VARIANTS.has('paused')) pub.paused = true;
pub.players.forEach((p, i) => { p.status = phase === PHASE.SETTLE ? 'done' : i === 1 ? 'done' : 'combat'; });
if (phase === PHASE.UNITE) { pub.players[1].status = 'helping'; pub.players[0].status = 'helping'; }
if (VARIANTS.has('done')) pub.players[0].status = 'done';
let fields;
let rect;
let kind;
if (boss) {
kind = phase === PHASE.HIDDEN_CORE ? 'hidden' : 'boss';
fields = pub.players.length === 1 ? [{ fieldId: 'b1', kind, players: ['p1'], live: true }]
: [{ fieldId: 'b1', kind, players: ['p1', 'ai_2'], live: true }, { fieldId: 'b2', kind, players: ['p3', 'p4'], live: true }];
rect = { ...GEO.BOSS_RECT };
pub.teamLp = 61;
pub.bossHp = { hp: 1_240_000, max: 1_990_000 };
} else if (phase === PHASE.UNITE) {
kind = 'unite';
fields = [{ fieldId: 'u', kind: 'unite', players: ['p1', 'ai_2'], live: true }];
rect = { ...GEO.UNITE_RECT };
} else {
kind = 'normal';
fields = pub.players.map((p) => ({ fieldId: `n:${p.playerId}`, kind: 'normal', players: [p.playerId], live: true }));
rect = { ...GEO.NORMAL_RECT };
}
pub.fields = fields;
// like the server's m.public players[].fieldId (Match.fieldOf): the field each player fights on, null without one
for (const p of pub.players) p.fieldId = fields.find((f) => f.players.includes(p.playerId))?.fieldId ?? null;
const fieldRef = { id: fields[0].fieldId };
const fieldId = fieldRef.id;
const units = [];
let id = 1;
const board = S.priv.board;
for (const p of board) {
const c = data.lookup('chess', p.id);
const colOff = 0;
const r = boss ? p.row - 7 : p.row;
units.push({ id: id++, kind: 'op', side: 'ally', ownerId: ME, defId: p.id, name: c?.name, tier: c?.tier, golden: !!p.golden, spine: c?.charId, avatar: c?.assets?.avatar, x: p.col + colOff, y: r, facing: 1, maxHp: c?.stats?.maxHp || 1000, uid: p.uid });
}
if (kind !== 'normal') {
for (const p of board.slice(0, 5)) {
const c = data.lookup('chess', pick(S.pool).chessId);
const r = boss ? p.row - 7 : p.row;
units.push({ id: id++, kind: 'op', side: 'ally', ownerId: 'ai_2', defId: c.chessId, name: c.name, tier: c.tier, golden: false, spine: c.charId, avatar: c.assets.avatar, x: 20 - p.col, y: r, facing: -1, maxHp: c.stats.maxHp });
}
}
const enemyKeys = S.priv.nextEnemies.map((e) => e.enemyKey);
const enemies = [];
const nEnemy = boss ? 10 : 12;
for (let i = 0; i < nEnemy; i++) {
const key = enemyKeys[i % enemyKeys.length];
const e = data.lookup('enemies', key);
const y = boss ? [2, 5, 3][i % 3] : [9, 12][i % 2];
const startX = boss ? (i % 2 ? 17 : 3) : 10;
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: key, name: e?.name, spine: key, avatar: key, x: startX + rnd() * 0.3, y, facing: -1, maxHp: e?.stats?.maxHp || 3000, hp: e?.stats?.maxHp || 3000, spawnAt: i * 0.9, dir: boss ? (i % 2 ? -1 : 1) : -1, dead: false });
}
if (boss) {
const bossRec = data.lookup('bosses', phase === PHASE.HIDDEN_CORE ? 'boss_8' : 'boss_5');
const e = data.lookup('enemies', bossRec.enemyKey);
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: bossRec.enemyKey, name: bossRec.name, spine: bossRec.enemyKey, avatar: bossRec.enemyKey, x: 10, y: 3, facing: -1, maxHp: e?.stats?.maxHp || 1e5, hp: (e?.stats?.maxHp || 1e5) * 0.62, spawnAt: 0, dir: 0, boss: true, dead: false });
}
const field = { fieldId, kind, rect, stageId: pub.stageId, units: units.map((u) => ({ ...u })) };
store.patch('match', { field });
const allyState = units.map((u) => ({ ...u, hp: u.maxHp * (0.55 + rnd() * 0.45), sp: rnd() * 20, spMax: 20 }));
const t0 = performance.now();
const total = enemies.length;
let killed = phase === PHASE.SETTLE ? total : 0;
if (phase === PHASE.SETTLE) enemies.forEach((e) => { e.dead = true; });
const spawned = new Set();
let pausedFor = 0;
let pausedSince = null;
const tick = () => {
// a paused solo battle stands still (the server's runner stops the clock too)
if (S.pub.paused) { if (pausedSince == null) pausedSince = performance.now(); return; }
if (pausedSince != null) { pausedFor += performance.now() - pausedSince; pausedSince = null; }
const t = (performance.now() - t0 - pausedFor) / 1000 + 4;
const ev = [];
for (const e of enemies) {
if (e.dead || t < e.spawnAt) continue;
if (!spawned.has(e.id)) { spawned.add(e.id); ev.push(['spawn', { ...e }]); }
if (e.dir) e.x = Math.max(rect.c0 + 2.2, Math.min(rect.c1 - 2, e.x + e.dir * 0.035));
}
// attacks
const liveEnemies = enemies.filter((e) => !e.dead && t >= e.spawnAt);
for (const a of allyState) {
if (!liveEnemies.length || rnd() > 0.2) continue;
const tgt = liveEnemies[Math.floor(rnd() * liveEnemies.length)];
const dmg = Math.round(200 + rnd() * 700);
tgt.hp -= e2boss(tgt) ? dmg * 3 : dmg;
ev.push(['atk', a.id, tgt.id, 'arrow'], ['dmg', tgt.id, dmg, rnd() < 0.3 ? 'arts' : 'phys']);
a.sp = (a.sp + 1) % a.spMax;
if (tgt.hp <= 0 && !tgt.boss && !tgt.dead) { tgt.dead = true; killed++; ev.push(['die', tgt.id]); }
}
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
const snapUnits = [
...allyState.map((a) => [a.id, a.x, a.y, a.hp, a.maxHp, a.sp, a.spMax, a.sp > 15 ? 16 : 0, 2]),
...enemies.filter((e) => !e.dead && t >= e.spawnAt).map((e) => [e.id, e.x, e.y, Math.max(1, e.hp), e.maxHp, 0, 0, e.y === 5 ? 512 : 0, e.dir ? 1 : 2]),
];
// wire frames exactly like server/match/fields.js: `t` is the frame type, game time travels as `gt`; b.ev first
const snap = { t: 'b.snap', fieldId: fieldRef.id, gt: t, units: snapUnits, dp: Math.min(99, 10 + Math.floor(t)), killed, total };
if (boss) snap.boss = { hp: S.pub.bossHp.hp, max: S.pub.bossHp.max };
if (ev.length) net._emit('b.ev', { t: 'b.ev', fieldId: fieldRef.id, gt: t, ev });
net._emit('b.snap', snap);
};
const e2boss = (e) => !!e.boss;
S.battle = { timer: setInterval(tick, 50), fieldRef };
setTimeout(tick, 60);
}
// ---- mock server --------------------------------------------------------------------------------------------------------
const fail = (code) => { throw new NetError(code); };
function findPiece(uid) {
const p = S.priv;
let i = p.board.findIndex((x) => x.uid === uid);
if (i >= 0) return { area: 'board', i, piece: p.board[i] };
i = p.hand.findIndex((x) => x && x.uid === uid);
if (i >= 0) return { area: 'hand', i, piece: p.hand[i] };
i = p.temp.findIndex((x) => x && x.uid === uid);
if (i >= 0) return { area: 'temp', i, piece: p.temp[i] };
return null;
}
function removeAt(loc) {
const p = S.priv;
if (loc.area === 'board') p.board.splice(loc.i, 1);
else if (loc.area === 'hand') p.hand[loc.i] = null;
else p.temp[loc.i] = null;
}
function freeHandIdx() { for (let i = GEO.HAND_SIZE - 1; i >= 0; i--) if (!S.priv.hand[i]) return i; return -1; }
async function mockRequest(t, f = {}) {
await new Promise((r) => setTimeout(r, 40 + Math.random() * 60));
if (!S) fail('WRONG_PHASE');
S.requests.push([t, JSON.parse(JSON.stringify(f ?? {}))]); // E2E: what the UI sent
const p = S.priv;
const pub = S.pub;
const prepOnly = () => { if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE'); if (p.ready && t !== 'g.ready') fail('ALREADY'); };
switch (t) {
case 'g.infoReady': { const me = pub.players.find((x) => x.playerId === ME); me.ready = true; me.status = 'ready'; pushPublic(); return {}; }
case 'g.band': {
p.bandId = f.bandId; const me = pub.players.find((x) => x.playerId === ME); me.bandId = f.bandId;
if (pub.draft) { pub.draft.picks[ME] = f.bandId; pub.draft.turn = 'ai_2'; pub.draft.turnDeadline = Date.now() + 12000; }
refreshPrivate(); return {};
}
case 'g.bandSkip': {
if (!pub.draft) fail('WRONG_PHASE');
if (pub.draft.skipsLeft?.[ME] === 0) fail('ALREADY');
pub.draft.order = pub.draft.order.filter((x) => x !== ME).concat(ME); pub.draft.skipsLeft = { ...pub.draft.skipsLeft, [ME]: 0 }; pub.draft.turn = 'ai_2'; pushPublic(); return {};
}
case 'g.buy': {
prepOnly();
const s = p.shop.slots[f.slot];
if (!s || s.sold) fail('SOLD_OUT');
if (s.price > p.funds) fail('NO_FUNDS');
const idx = freeHandIdx();
if (idx < 0) fail('HAND_FULL');
p.funds -= s.price; s.sold = true;
p.hand[idx] = s.kind === 'item' ? itemPiece(data.lookup('items', s.id)) : chessPiece(data.lookup('chess', s.id));
refreshPrivate(); return {};
}
case 'g.refresh': {
prepOnly();
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1;
const n = p.shop.slots.filter((s) => s.kind !== 'item').length;
p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()];
refreshPrivate(); return {};
}
case 'g.freeze': prepOnly(); p.shop.frozen = !p.shop.frozen; refreshPrivate(); return {};
case 'g.levelUp': {
prepOnly();
if (p.shop.level >= 6) fail('MAX_LEVEL');
if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS');
p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0;
const n = p.shop.level >= 4 ? 5 : 4;
while (p.shop.slots.filter((s) => s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, makeSlot(p.shop.level));
toast(`调度中心等级提升至 ${p.shop.level}`, 'success');
refreshPrivate(); return {};
}
case 'g.sell': {
prepOnly();
const loc = findPiece(f.uid); if (!loc) fail('BAD_TARGET');
if (loc.piece.kind === 'item') fail('BAD_TARGET');
removeAt(loc); p.funds += 1; refreshPrivate(); return {};
}
case 'g.destroy': { prepOnly(); const loc = findPiece(f.uid); if (!loc) fail('BAD_TARGET'); removeAt(loc); refreshPrivate(); return {}; }
case 'g.move': {
prepOnly();
const loc = findPiece(f.uid); if (!loc) fail('BAD_TARGET');
const to = f.to;
if (to.area === 'hand') {
const occ = p.hand[to.idx];
removeAt(loc);
if (occ) {
if (loc.area === 'board') { p.board.push({ ...occ, row: loc.piece.row, col: loc.piece.col }); }
else if (loc.area === 'hand') p.hand[loc.i] = occ;
else p.temp[loc.i] = occ;
}
const { row, col, dir, ...rest } = loc.piece; // eslint-disable-line no-unused-vars
p.hand[to.idx] = rest;
} else {
// the deploy wheel's facing, like PlayerState.move: top-level dir, else to.dir, else RIGHT (research 09 §1.2)
const d = ['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(f.dir) ? f.dir : ['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(to.dir) ? to.dir : 'RIGHT';
const oi = p.board.findIndex((x) => x.row === to.row && x.col === to.col);
const occ = oi >= 0 ? p.board[oi] : null;
if (occ && occ.uid === f.uid) { occ.dir = d; refreshPrivate(); return {}; } // re-orient in place
if (!occ && loc.area !== 'board' && loc.piece.kind === 'chess' && p.board.filter((x) => x.kind === 'chess').length >= p.deployCap) fail('BOARD_FULL');
if (occ) p.board.splice(oi, 1);
const locNow = findPiece(f.uid);
removeAt(locNow);
if (occ) {
if (loc.area === 'board') p.board.push({ ...occ, row: loc.piece.row, col: loc.piece.col });
else { const { row, col, dir, ...rest } = occ; if (loc.area === 'hand') p.hand[loc.i] = rest; else p.temp[loc.i] = rest; } // eslint-disable-line no-unused-vars
}
p.board.push({ ...loc.piece, row: to.row, col: to.col, dir: d });
}
refreshPrivate(); return {};
}
case 'g.equip': {
// like server PlayerState.equip: a third item replaces the equipped item the dialog picked (replaceUid, which
// must be one of the target's items), else the oldest; the replaced item is destroyed
prepOnly();
const it = findPiece(f.itemUid); const tg = findPiece(f.targetUid);
if (!it || !tg || tg.piece.kind !== 'chess') fail('BAD_TARGET');
if (f.replaceUid != null && !(tg.piece.items || []).some((x) => x.uid === f.replaceUid)) fail('BAD_TARGET');
removeAt(it);
const target = findPiece(f.targetUid).piece;
const items = [...(target.items || [])];
if (items.length >= 2) {
const i = f.replaceUid != null ? items.findIndex((x) => x.uid === f.replaceUid) : 0;
items.splice(i >= 0 ? i : 0, 1);
}
target.items = [...items, { uid: it.piece.uid, id: it.piece.id }];
refreshPrivate(); return {};
}
case 'g.pause': {
// solo battles only (server: g.pause {on} → m.public.paused)
if (S.pub.players.length > 1) fail('WRONG_PHASE');
if (![PHASE.COMBAT, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE].includes(pub.phase)) fail('WRONG_PHASE');
const now = Date.now();
if (f.on && !pub.paused) { pub.paused = true; pub.pausedAt = now; }
else if (!f.on && pub.paused) {
// the clocks resume where they stopped
const d = now - (pub.pausedAt || now);
if (pub.deadline) pub.deadline += d;
if (pub.overtimeAt) pub.overtimeAt += d;
pub.paused = false; delete pub.pausedAt;
}
pushPublic(); return {};
}
case 'g.art': { prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET'); removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {}; }
case 'g.reward': {
if (!p.shop.rewardOffer) fail('WRONG_PHASE');
const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET');
const idx = freeHandIdx(); if (idx < 0) fail('HAND_FULL');
p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {};
}
case 'g.choice': {
if (!pub.sp) fail('WRONG_PHASE');
const taken = Object.values(pub.sp.picks).includes(f.idx);
if (taken) fail('BAD_TARGET');
pub.sp.picks[ME] = f.idx; pub.sp.turn = 'ai_2'; pushPublic(); return {};
}
case 'g.ready': {
if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE');
if (f.ready && !p.canReady) fail('TEMP_NOT_EMPTY');
p.ready = !!f.ready; refreshPrivate(); return {};
}
case 'g.emote': {
store.set((s) => ({ emotes: [...s.emotes.slice(-19), { seq: Date.now(), playerId: ME, id: f.id, at: Date.now() }] }));
return {};
}
case 'g.watch': {
// server Match.watch rules: the other pair's boss field is hidden from a fighting player; no dead targets
const target = (pub.fields || []).find((x) => x.fieldId === f.fieldId);
const mine = (pub.fields || []).find((x) => x.players.includes(ME));
const meAlive = pub.players.find((x) => x.playerId === ME)?.alive !== false;
if (target && (target.kind === 'boss' || target.kind === 'hidden') && meAlive && mine && mine.fieldId !== target.fieldId) fail('BAD_TARGET');
if (!target && String(f.fieldId).startsWith('n:') && pub.players.find((x) => x.playerId === f.fieldId.slice(2))?.alive === false) fail('BAD_TARGET');
if ([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE, PHASE.SETTLE].includes(pub.phase)) {
const cur = store.get().match.field;
if (S.battle?.fieldRef) S.battle.fieldRef.id = f.fieldId;
if (cur) store.patch('match', { field: { ...cur, fieldId: f.fieldId } });
} else {
const who = f.fieldId.replace(/^n:/, '');
if (who !== ME) {
const units = shuffle(S.pool).slice(0, 6).map((c, i) => ({ id: 900 + i, kind: 'op', side: 'ally', ownerId: who, defId: c.chessId, name: c.name, tier: c.tier, golden: i === 0, spine: c.charId, avatar: c.assets.avatar, x: [3, 4, 5, 7, 8, 5][i], y: [9, 9, 10, 11, 12, 12][i], facing: 1, maxHp: c.stats.maxHp }));
// like server/match/Match.js prepFieldMeta: a read-only prep board with THEIR coming enemies (the pen shows them)
const theirs = S.priv.nextEnemies.filter((e) => e.source !== 'bounty').slice(0, 3).map((e) => ({ ...e, gate: 'upper', count: e.count + 1 }));
store.patch('match', { field: { fieldId: f.fieldId, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: pub.stageId, units, prep: true, nextEnemies: theirs } });
}
}
return {};
}
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
default: return {};
}
}
// ---- UI variants (open panels for screenshots) ------------------------------------------------------------------------------
function applyUiVariants() {
const clickSel = (sel, delay = 250) => setTimeout(() => document.querySelector(sel)?.click(), delay);
// the enemy list is the 敌方情报 tab of the 本局信息 dialog (left 🔍); the right 🔍▶▶ pans to the pen
if (VARIANTS.has('drawer')) { clickSel('.gtop__iconbtn', 400); clickSel('.edrawer .tabs__tab:nth-child(2)', 700); }
if (VARIANTS.has('info')) clickSel('.gtop__iconbtn', 400);
if (VARIANTS.has('pen')) {
// the pen needs the field view: wait for it to mount
const t0 = Date.now();
const tryPen = () => {
if (globalThis.__SP_VIEW__ && document.querySelector('.enemybtn')) { setTimeout(() => document.querySelector('.enemybtn')?.click(), 300); return; }
if (Date.now() - t0 < 15000) setTimeout(tryPen, 100);
};
tryPen();
}
if (VARIANTS.has('bond')) clickSel('.bslot .bond', 500);
if (VARIANTS.has('emote')) {
clickSel('.ewheel__btn', 400);
setTimeout(() => {
const now = Date.now();
store.set({ emotes: [{ seq: now, playerId: 'p3', id: 'autochess_battle_nice_cooperate', at: now }, { seq: now + 1, playerId: 'ai_2', id: 'slug_autochess_battle_thanks', at: now }] });
}, 300);
}
if (VARIANTS.has('settings')) clickSel('.gm__gear', 400);
if (VARIANTS.has('collapsed')) clickSel('.funds__collapse', 400);
if (VARIANTS.has('detail')) clickSel('.scard .scard__detail', 500);
if (VARIANTS.has('detailpiece')) setTimeout(() => {
const el = document.querySelector('.ff-piece.is-golden') || document.querySelector('.ff-piece');
el?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 }));
}, 600);
if (VARIANTS.has('ticker')) setTimeout(() => store.set((s) => ({ ticker: [...s.ticker, { id: Date.now(), text: '<@ba.vup>Doctor·B博士</>对敌方领袖造成的伤害超过20%!', at: Date.now() }] })), 900);
if (VARIANTS.has('toast')) setTimeout(() => toast('资金不足', 'error'), 500);
}
// ---- switcher --------------------------------------------------------------------------------------------------------------
const SWITCH = [
['INFO_CHECK', PHASE.INFO_CHECK, ''], ['BAND_DRAFT', PHASE.BAND_DRAFT, ''], ['BAND_DRAFT solo', PHASE.BAND_DRAFT, 'solo'],
['BATTLE_CHECK', PHASE.BATTLE_CHECK, ''], ['PREP', PHASE.PREP, ''], ['PREP + reward', PHASE.PREP, 'reward'],
['PREP + temp', PHASE.PREP, 'temp'], ['PREP frozen', PHASE.PREP, 'frozen'], ['PREP dead', PHASE.PREP, 'dead'],
['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'],
['SP bounty', PHASE.SP_DRAFT, 'bounty'], ['SP supply', PHASE.SP_DRAFT, 'supply'], ['SP tactic', PHASE.SP_DRAFT, 'tactic'], ['SP solo', PHASE.SP_DRAFT, 'solo'],
['COMBAT', PHASE.COMBAT, ''], ['COMBAT done', PHASE.COMBAT, 'done'], ['UNITE', PHASE.UNITE, ''], ['SETTLE', PHASE.SETTLE, ''],
['FINAL_ASSAULT', PHASE.FINAL_ASSAULT, ''], ['FA overtime soon', PHASE.FINAL_ASSAULT, 'overtime'], ['FA draining', PHASE.FINAL_ASSAULT, 'drain'],
['HIDDEN_CORE', PHASE.HIDDEN_CORE, ''], ['COMBAT solo (pause)', PHASE.COMBAT, 'solo'], ['FA solo paused', PHASE.FINAL_ASSAULT, 'solo,paused'],
['RESULT win', PHASE.RESULT, ''], ['RESULT lose', PHASE.RESULT, 'defeat'],
];
function Switcher() {
const cur = store.get().match.public?.phase;
return html`<h4>PHASE</h4>${SWITCH.map(([label, ph, v]) => html`<button class=${cur === ph ? 'on' : ''} onClick=${() => { VARIANTS.clear(); setPhase(ph, v); renderBar(); }}>${label}</button>`)}
<h4>EVENTS</h4>
<button onClick=${() => { const now = Date.now(); store.set((s) => ({ emotes: [...s.emotes, { seq: now, playerId: pick(['p3', 'ai_2', 'p4']), id: pick(['autochess_battle_happy', 'slug_autochess_battle_thanks', 'autochess_battle_call', 'autochess_battle_fooldoctor_05', 'autochess_battle_foolamiya_03', 'autochess_battle_foolwisdel_01']), at: now }] })); }}>teammate emote</button>
<button onClick=${() => store.set((s) => ({ ticker: [...s.ticker, { id: Date.now(), text: `<@ba.vup>${pick(['Doctor·B', '灰烬'])}博士</>将调度中心等级提升为5级`, at: Date.now() }] }))}>ticker</button>
<button onClick=${() => toast('资金不足', 'error')}>error toast</button>
<button onClick=${() => { if (S.priv) { S.priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) }; refreshPrivate(); } }}>merge reward</button>
<button onClick=${() => { S.priv.funds += 10; refreshPrivate(); }}>+10 funds</button>`;
}
const bar = document.getElementById('mockbar');
function renderBar() { render(html`<${Switcher} />`, bar); }
window.addEventListener('keydown', (e) => { if (e.key === '`') bar.classList.toggle('hidden'); });
if (SHOT) bar.classList.add('hidden');
// ---- boot ---------------------------------------------------------------------------------------------------------------------
async function boot() {
net.request = mockRequest;
store.set({
session: { entered: true },
me: { playerId: ME, name: '凯尔希', token: null },
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
clock: { offset: 0, rtt: 20, synced: true },
room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) },
});
await data.loadAll(GAME_FILES);
installDeviceSupport();
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
const phase = params.get('phase') || 'PREP';
setPhase(PHASE[phase] || PHASE.PREP, params.get('variant') || '');
render(html`<div class="app-root"><div class="app-bg" aria-hidden="true"></div><${GameScreen} /><${ConnectionBanner} /><${ToastHost} /><${UiHosts} /><${GuideHost} /></div>`, document.getElementById('app'));
renderBar();
store.subscribe(() => renderBar());
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic() };
}
boot().catch((err) => console.error('[mock] boot failed', err));
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"maxHp":3003,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"maxHp":2738,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"maxHp":2680,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"maxHp":1787,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"maxHp":1208,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"maxHp":1472,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"maxHp":1184,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"maxHp":1215,"uid":8},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"maxHp":3430,"uid":101},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"maxHp":2698,"uid":102},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"maxHp":2767,"uid":103},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"maxHp":1470,"uid":104},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"maxHp":1361,"uid":105},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"maxHp":2887,"uid":106},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"maxHp":1569,"uid":107},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"maxHp":1237,"uid":108},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":3.99,"facing":1,"maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,3003,0.1,8,0,6],[2,8,2,2738,2738,0.1,8,0,6],[3,7,2,2680,2680,0.1,8,0,6],[4,4,3,1787,1787,0.1,6,0,6],[5,5,4,1208,1208,20.1,45,0,6],[6,6,5,1472,1472,16.1,34,0,6],[7,7,5,1184,1184,43.1,70,0,6],[8,8,5,1215,1215,0.1,35,0,6],[9,11,2,3430,3430,33.1,55,0,6],[10,12,2,2698,2698,15.1,55,0,6],[11,13,2,2767,2767,15.1,33,0,6],[12,16,3,1470,1470,29.1,42,0,6],[13,15,4,1361,1361,15.1,45,0,6],[14,14,5,2887,2887,20.1,34,0,6],[15,13,5,1569,1569,0.1,61,0,6],[16,12,5,1237,1237,30.1,75,0,6],[17,9.98,3.98,195000,195000,0,0,0,1],[18,10.01,3.99,195000,195000,0,0,0,1]],"dp":10,"killed":0,"total":11,"dps":{"P1":10,"P2":10},"boss":{"hp":195000,"max":195000}},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"gLine truncated
+194
View File
@@ -0,0 +1,194 @@
[
{
"name": "normal-m01",
"title": "常规作战 · 源石流发生装置 · R5",
"kind": "normal",
"stageId": "act2autochess_m01",
"duration": 55,
"frames": 550,
"maxUnits": 30,
"file": "normal-m01.json",
"bytes": 416135,
"result": {
"reason": "timeout",
"killed": 14,
"total": 32
}
},
{
"name": "normal-m02",
"title": "常规作战 · 沼泽控制 · R8",
"kind": "normal",
"stageId": "act2autochess_m02",
"duration": 85,
"frames": 850,
"maxUnits": 24,
"file": "normal-m02.json",
"bytes": 566914,
"result": {
"reason": "timeout",
"killed": 15,
"total": 35
}
},
{
"name": "normal-m03",
"title": "常规作战 · 排气格栅 · R11",
"kind": "normal",
"stageId": "act2autochess_m03",
"duration": 90,
"frames": 900,
"maxUnits": 31,
"file": "normal-m03.json",
"bytes": 719521,
"result": {
"reason": "timeout",
"killed": 11,
"total": 46
}
},
{
"name": "normal-m04",
"title": "常规作战 · 涨潮控制 · R6",
"kind": "normal",
"stageId": "act2autochess_m04",
"duration": 70,
"frames": 700,
"maxUnits": 34,
"file": "normal-m04.json",
"bytes": 557852,
"result": {
"reason": "timeout",
"killed": 19,
"total": 39
}
},
{
"name": "late-m03-r12",
"title": "常规作战 · 排气格栅 · R12(官方波次)",
"kind": "normal",
"stageId": "act2autochess_m03",
"duration": 84.23,
"frames": 843,
"maxUnits": 30,
"file": "late-m03-r12.json",
"bytes": 570239,
"result": {
"reason": "cleared",
"killed": 12,
"total": 57
}
},
{
"name": "late-m01-r13",
"title": "常规作战 · 源石流发生装置 · R13(官方波次)",
"kind": "normal",
"stageId": "act2autochess_m01",
"duration": 110,
"frames": 1100,
"maxUnits": 33,
"file": "late-m01-r13.json",
"bytes": 849162,
"result": {
"reason": "timeout",
"killed": 6,
"total": 55
}
},
{
"name": "unite-m01",
"title": "联防 · 源石流发生装置 · R9",
"kind": "unite",
"stageId": "act2autochess_m01",
"duration": 60,
"frames": 600,
"maxUnits": 36,
"file": "unite-m01.json",
"bytes": 619719,
"result": {
"reason": "timeout",
"killed": 17,
"total": 40
}
},
{
"name": "boss-m02",
"title": "最终攻势 · 卢西恩,“猩红血钻” · R14",
"kind": "boss",
"stageId": "act2autochess_m02",
"duration": 75,
"frames": 750,
"maxUnits": 24,
"file": "boss-m02.json",
"bytes": 582523,
"result": {
"reason": "forced",
"killed": 10,
"total": 17
}
},
{
"name": "normal-a1m01",
"title": "常规作战 · 战场#01 · R4",
"kind": "normal",
"stageId": "act1autochess_m01",
"duration": 55,
"frames": 550,
"maxUnits": 20,
"file": "normal-a1m01.json",
"bytes": 326193,
"result": {
"reason": "timeout",
"killed": 11,
"total": 19
}
},
{
"name": "normal-a1m02",
"title": "常规作战 · 战场#02 · R7",
"kind": "normal",
"stageId": "act1autochess_m02",
"duration": 85,
"frames": 850,
"maxUnits": 28,
"file": "normal-a1m02.json",
"bytes": 617662,
"result": {
"reason": "timeout",
"killed": 26,
"total": 43
}
},
{
"name": "normal-a1m03",
"title": "常规作战 · 战场#03 · R10",
"kind": "normal",
"stageId": "act1autochess_m03",
"duration": 90,
"frames": 900,
"maxUnits": 33,
"file": "normal-a1m03.json",
"bytes": 713331,
"result": {
"reason": "timeout",
"killed": 20,
"total": 41
}
},
{
"name": "normal-a1m04",
"title": "常规作战 · 战场#04 活性源石 · R6",
"kind": "normal",
"stageId": "act1autochess_m04",
"duration": 70,
"frames": 700,
"maxUnits": 35,
"file": "normal-a1m04.json",
"bytes": 593839,
"result": {
"reason": "timeout",
"killed": 17,
"total": 39
}
}
]
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_farttLine truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":53,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,3513,3513,0,0,0,6],[3,5,9,2738,2738,0.1,8,0,6],[4,4,9,2680,2680,0.1,8,0,6],[5,4,12,1787,1787,0.1,6,0,6]Line truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-a1m01","title":"常规作战 · 战场#01 · R4","kind":"normal","stageId":"act1autochess_m01","modeId":"mode_multi_normal","round":4,"wave":"act1autochess_04","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act1autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_a","name":"艾丝黛尔","tier":1,"golden":false,"spine":"char_127_estell","avatar":"char_127_estell","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":1840,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1540,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":5,"y":10,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":6,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,4,9,1840,1840,10,24,0,6],[4,3,12,1123,1123,8.1,24,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,5,12,1540,1540,0.1,26,0,6],[7,6,12,1472,1472,20.1,44,0,6],[8,5,11,100,100,0,0,0,6],[9,5,10,100,100,0,0,0,6],[10,6,10,100,100,0,0,0,6]],"dp":11,"killed":0,"total":19},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":5,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":6,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_a","name":"艾丝黛尔","tier":1,"golden":false,"spine":"char_127_estell","avataLine truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-a1m02","title":"常规作战 · 战场#02 · R7","kind":"normal","stageId":"act1autochess_m02","modeId":"mode_multi_normal","round":7,"wave":"act1autochess_07","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act1autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":7}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,6,9,2022,2022,55.1,90,0,6],[3,5,9,2938,2938,55.1,90,0,6],[4,3,12,1428,1428,56.1,110,0,6],[5,4,12,1271,1271,70.1,100,0,6],[6,5,12,1246,1246,15.1,45,0,6],[7,6,12,1217,1217,6.1,27,0,6]],"dp":10,"killed":0,"total":43},"ev":[["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":7}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1}],["deploy",1],["fx","garrisonGrant",6,12,{"id":7,"src":"gar:garrison_73_a","key":"garrison_96_a","from":6}],["fx","garrisonGrant",6,12,{"id":7,"src":"gar:garrison_148_a","key":"garrison_108_a","from":7}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2849,2849,0,0,0,6],[2,6,9,2022,2022,55.2,90,0,6],[3,5,9,2938,2938,55.2,90,0,6],[4,3,12,1428,1428,56.2,110,0,6],[5,4,12,1271,1271,70.2,100,0,6],[6,5,12,1246,1246,15.2,45,0,6],[7,6,12,1217,1217,6.2,27,0,6]],"dp":10,"killed":0,"total":43},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2849,2849,0,0,0,6],[2,6,9,2022,2022,55.3,90,0,6],[3,5,9,2938,2938,55.3,90,0,6],[4,3,12,1428,1428,56.3,110,0,6],[5,4,12,1271,1271,70.3,100,0,6],[6,5,12,1246,1246,15.3,45,0,6],[7,6,12,1217,1217,6.3,27,0,6]],"dp":10,"killed":0,"total":43},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.4,"units":[[1,9,9,2849,2849,0,0,0,6],[2,6,9,2022,2022,55.5,90,0,6],[3,5,9,2Line truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-a1m03","title":"常规作战 · 战场#03 · R10","kind":"normal","stageId":"act1autochess_m03","modeId":"mode_multi_hard","round":10,"wave":"act1autochess_h03","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act1autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":3,"y":10,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":7,"y":11,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":8,"y":11,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_a","name":"号角","tier":5,"golden":false,"spine":"char_4039_horn","avatar":"char_4039_horn","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2200,"uid":8},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,8,9,2250,2250,0.1,9,0,6],[3,7,9,2216,2216,0.1,9,0,6],[4,3,10,1404,1404,0.1,7,0,6],[5,4,10,1208,1208,20.1,45,0,6],[6,7,11,1206,1206,13.1,37,0,6],[7,8,11,1184,1184,43.1,70,0,6],[8,6,9,2200,2200,15.1,50,0,6],[9,9,10,100,100,0,0,0,6],[10,5,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":41},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":7,"y":11,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":8,"y":11,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7}],["deploy",7],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":3,"y":10,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi",Line truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-a1m04","title":"常规作战 · 战场#04 活性源石 · R6","kind":"normal","stageId":"act1autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act1autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":11,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,4,9,1911,1911,18.1,54,0,6],[4,3,11,1404,1404,0,19,0,6],[5,4,11,1364,1364,15,37,32,6],[6,5,11,1232,1232,20.1,60,0,6],[7,6,11,1251,1251,13.1,40,0,6],[8,3,12,100,100,0,0,0,6],[9,4,12,100,100,0,0,0,6],[10,8,12,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,11,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":11,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_aLine truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"maxHp":2216,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1285,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"maxHp":2389,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"maxHp":1123,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"maxHp":1260,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"maxHp":1400,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"maxHp":1472,"uid":7}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"maxHp":1400,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"maxHp":1472,"uid":7}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"maxHp":1145,"uid":5}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"maxHp":1123,"uid":4}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"maxHp":2389,"uid":3}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1285,"uid":2}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"maxHp":2216,"uid":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20.3,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.4,"units":[[1,9,9,2216,2216,0.4,47,0,6],[2,8,9,1285,1285,23.4,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.4,24,0,6],[5,4,11,1260,1260,0.4,60,0,6],[6,4,12,1400,1400,0.4,26,0,6],[7,5,12,1472,1472,20.4,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.5,"units":[[1,9,9,2216,2216,0.5,47,0,0],[2,8,9,1285,1285,23.5,40,0,0],[3,7,9,2389,2389,10,23,0,0Line truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"maxHp":2849,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"maxHp":2022,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"maxHp":2938,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"maxHp":1428,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"maxHp":1271,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"maxHp":1246,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"maxHp":1288,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"maxHp":1217,"uid":8},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"maxHp":1246,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"maxHp":1288,"uid":7}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"maxHp":1217,"uid":8}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"maxHp":1271,"uid":5}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"maxHp":1428,"uid":4}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"maxHp":2938,"uid":3}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"goldLine truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"maxHp":3003,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"maxHp":2250,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"maxHp":2216,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"maxHp":1404,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"maxHp":1208,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"maxHp":1206,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"maxHp":1184,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"maxHp":2775,"uid":8},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"maxHp":1404,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"maxHp":1208,"uid":5}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"maxHp":1206,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"maxHp":1184,"uid":7}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2Line truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"maxHp":2173,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"maxHp":1481,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"maxHp":1911,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"maxHp":1404,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"maxHp":1364,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"maxHp":1232,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"maxHp":1251,"uid":7},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"maxHp":1404,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"maxHp":1364,"uid":5}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"maxHp":1232,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"maxHp":1251,"uid":7}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"maxHp":1911,"uid":3}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"maxHp":1481,"uid":2}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"maxHp":2173,"uid":1}],["deploy",1]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2173,2173,0.2,40,0,6],[2,8,9,1481,1481,6.2,21,0Line truncated
+1
View File
@@ -0,0 +1 @@
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"maxHp":2849,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1414,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"maxHp":1472,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"maxHp":1400,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"maxHp":1145,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"maxHp":2022,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"maxHp":2268,"uid":101},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"maxHp":1791,"uid":102},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"maxHp":1428,"uid":103},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"maxHp":1271,"uid":104},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"maxHp":1217,"uid":105},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"maxHp":1208,"uid":106}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"maxHp":1145,"uid":5}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"maxHp":1400,"uid":4}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"maxHp":1472,"uid":3}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"maxHp":2022,"uid":6}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1285,"uid":2}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"maxHp":2849,"uid":1}],["deploy",1],["spawn",{"id":11,"kind":"op","side":"ally","ownerId":Line truncated
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Render demo · 卫戍协议:盟约</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<script type="importmap">
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
</script>
<!-- PixiJS 7 + pixi-spine as classic scripts (global PIXI / PIXI.spine), like the game page -->
<script src="/vendor/pixi.min.js"></script>
<script src="/vendor/pixi-spine.js"></script>
<style>
html, body { margin: 0; height: 100%; background: #0a0e0d; overflow: hidden; }
#field { position: fixed; inset: 0; }
.rd-panel {
position: fixed; right: 12px; bottom: 12px; z-index: 5; display: flex; flex-direction: column; gap: 8px;
padding: 10px 12px; min-width: 320px; background: rgba(12, 16, 15, .86); border: 1px solid #2f3a35;
color: #c3cbc7; font: 13px/1.35 "Noto Sans SC", system-ui, sans-serif; backdrop-filter: blur(6px);
}
.rd-panel.is-hidden { display: none; }
.rd-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rd-panel select, .rd-panel button {
background: #1e2421; color: #f2f2f2; border: 1px solid #3e4b45; padding: 4px 8px; font: inherit; cursor: pointer;
}
.rd-panel button.is-on { border-color: #4ed8af; color: #4ed8af; }
.rd-panel input[type=range] { flex: 1; accent-color: #4ed8af; }
.rd-title { font-weight: 700; color: #f2f2f2; }
.rd-micro { font: 11px/1 Bender, Oxanium, monospace; letter-spacing: .08em; color: #4ed8af; text-transform: uppercase; }
.rd-stats { font: 12px/1.4 Bender, Oxanium, ui-monospace, monospace; color: #8a948f; white-space: pre; }
.rd-log { position: fixed; left: 12px; bottom: 12px; z-index: 5; max-width: 42vw; font: 12px/1.4 ui-monospace, monospace; color: #8a948f; pointer-events: none; white-space: pre-wrap; }
</style>
</head>
<body>
<div id="field"></div>
<div class="rd-panel" id="panel">
<div class="rd-row"><span class="rd-micro">RENDER DEMO</span><span class="rd-title" id="title">—</span></div>
<div class="rd-row">
<select id="scene" aria-label="scene"></select>
<select id="stage" aria-label="stage"></select>
<button id="play" type="button">❚❚</button>
<select id="speed" aria-label="speed">
<option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option><option value="4">4×</option>
</select>
<button id="editable" type="button" class="is-on">编辑</button>
</div>
<div class="rd-row">
<span class="rd-micro">BOARD</span>
<button id="board3d" type="button" class="is-on" title="official 3D board (three.js) / 2D atlas board">3D</button>
<select id="cam" aria-label="camera">
<option value="">scene camera</option>
<option value="prep">prep · shop</option><option value="prep-noshop">prep · shop collapsed</option>
<option value="normal">battle</option><option value="unite">联防 全景</option><option value="boss">最终攻势 全景</option>
<option value="boss-L">最终攻势 左</option><option value="boss-R">最终攻势 右</option><option value="pen">enemy pen</option>
<option value="fa-L">最终攻势 整备 左</option><option value="fa-R">最终攻势 整备 右</option>
</select>
</div>
<div class="rd-row"><input id="scrub" type="range" min="0" max="1000" value="0" aria-label="scrub" /><span id="time" class="rd-stats">0.0 s</span></div>
<div class="rd-stats" id="stats"></div>
</div>
<div class="rd-log" id="log"></div>
<script type="module" src="/dev/render-demo.js"></script>
</body>
</html>
+552
View File
@@ -0,0 +1,552 @@
// public/dev/render-demo.js — dev harness for the battlefield renderer (render/app.js):
// * prep scene: fake private state (hand / temp / board pieces, items, token stack), draggable; drops are
// applied locally as if the server accepted them (swap when occupied, equip items onto operators)
// * recordings (public/dev/recordings/*.json, tools/record-battle.mjs): real battles replayed at real speed
// through the same pushSnapshot/pushEvents path as the game, with play/pause, speed, scrub and scene select
// * stress scene: 120 units + projectiles / damage numbers every frame (FPS check)
// * fx scene: a small normal field (incl. art-less / unknown units, a crate and a turret device) cycling through
// every sim fx kind (render/fx.js FX_KINDS), every projectile kind and every status icon
// Query: ?scene=prep|<recording>|stress|numbers &stage=<stageId> &t=<seconds> &speed=<x> &paused=1 &panel=0 &quality=high|medium|low
// &pen=<recording> (prep: that recording's round preview — m.private.nextEnemies — in the enemy preview pen)
// &fa=L|R (prep on that half of the Final Assault boss field, view.setCamera('bossPrep', { side }))
// &board=2d|3d (the official 3D board scene, DESIGN §15; default 3D when the local art + WebGL2 are available)
// &cam=prep|normal|unite|boss|pen|bossPrep [&side=L|R &half=1 &shop=0] (official configBlackBoard framings)
// Puppeteer hook: window.__demo = { ready, view, seek(t), play(), pause(), setScene(name), stats(), log,
// refuseDrops (true ⇒ prep drops are silently ignored, like a server that rejects the move) }
import { createFieldView } from '../js/render/app.js';
import { data } from '../js/data.js';
import { assets } from '../js/assets.js';
import { GEO } from '../../shared/constants.js';
import { FX_KINDS } from '../js/render/fx.js';
const $ = (id) => document.getElementById(id);
const q = new URLSearchParams(location.search);
const logLines = [];
function log(...a) {
const s = a.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ');
logLines.push(s);
if (logLines.length > 12) logLines.shift();
$('log').textContent = logLines.join('\n');
}
window.addEventListener('error', (e) => log('ERROR', e.message));
window.addEventListener('unhandledrejection', (e) => log('REJECT', String(e.reason?.message || e.reason)));
const demo = { ready: false, errors: [], log: logLines };
window.__demo = demo;
let demoHover = null;
const STAGE_IDS = ['act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04', 'act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04'];
async function main() {
if (q.get('panel') === '0') $('panel').classList.add('is-hidden');
await data.loadAll('chess', 'tokens', 'items', 'enemies', 'stages', 'bonds', 'config');
await assets.ready();
const index = await fetch('/dev/recordings/index.json').then((r) => (r.ok ? r.json() : []), () => []);
const view = await createFieldView($('field'), {
data, assets, settings: { quality: q.get('quality') || 'high', damageNumbers: true },
...(q.has('aa') ? { antialias: q.get('aa') !== '0' } : {}),
});
demo.view = view;
window.__view = view;
// ---- UI --------------------------------------------------------------------------------------------------
const sceneSel = $('scene');
const scenes = [{ name: 'prep', title: '休整期 · 拖拽演示' }, ...index.map((r) => ({ name: r.name, title: r.title, rec: r })), { name: 'stress', title: '压力测试 · 120 单位' }, { name: 'fx', title: '特效图鉴 · 全部 fx' }, { name: 'numbers', title: '伤害数字 · 重叠测试' }];
for (const s of scenes) sceneSel.append(new Option(s.title, s.name));
const stageSel = $('stage');
for (const id of STAGE_IDS) { const st = data.lookup('stages', id); if (st) stageSel.append(new Option(st.name || id, id)); }
let current = null; // active scene controller
let playing = q.get('paused') !== '1';
let speed = Number(q.get('speed')) || 1;
$('speed').value = String(speed);
const setPlay = (p) => { playing = p; $('play').textContent = p ? '❚❚' : '▶'; };
setPlay(playing);
$('play').onclick = () => setPlay(!playing);
$('speed').onchange = () => { speed = Number($('speed').value) || 1; };
sceneSel.onchange = () => setScene(sceneSel.value);
stageSel.onchange = () => setScene(sceneSel.value, stageSel.value);
$('scrub').oninput = () => current?.seek?.((Number($('scrub').value) / 1000) * (current.duration || 0));
$('editable').onclick = () => { const on = !$('editable').classList.contains('is-on'); $('editable').classList.toggle('is-on', on); current?.setEditable?.(on); };
// board layer toggle (3D official scene ⇄ 2D atlas board) and the official camera framings
const syncBoardBtn = () => { const on = !!view.stats().board3d?.on; $('board3d').classList.toggle('is-on', on); $('board3d').textContent = on ? '3D' : '2D'; };
$('board3d').onclick = async () => { await view.setBoardMode?.(view.stats().board3d?.on ? '2d' : '3d'); syncBoardBtn(); };
setTimeout(syncBoardBtn, 0);
$('cam').onchange = () => {
const v = $('cam').value;
if (!v) return;
const [kind, side] = v.split('-');
if (kind === 'prep') view.setCamera('prep', { shop: side !== 'noshop' });
else if (kind === 'fa') view.setCamera('bossPrep', { side: side || 'L' });
else view.setCamera(kind, { side: side || 'L', half: !!side });
};
async function setScene(name, stageId) {
try { current?.stop?.(); } catch { /* ignore */ }
const s = scenes.find((x) => x.name === name) || scenes[0];
sceneSel.value = s.name;
$('title').textContent = s.title;
if (s.name === 'prep') current = await prepScene(view, stageId || q.get('stage') || 'act2autochess_m01', index);
else if (s.name === 'numbers') current = numbersScene(view, stageId || q.get('stage') || 'act2autochess_m02');
else if (s.name === 'stress') current = stressScene(view, stageId || q.get('stage') || 'act2autochess_m01');
else if (s.name === 'fx') current = fxScene(view, stageId || q.get('stage') || 'act2autochess_m03');
else current = await recordingScene(view, s.rec);
if (current.stageId) stageSel.value = current.stageId;
demo.scene = current;
return current;
}
demo.setScene = setScene;
demo.showRange = (uid) => (demoHover ? demoHover(uid) : false);
demo.seek = (t) => current?.seek?.(t);
demo.play = () => setPlay(true);
demo.pause = () => setPlay(false);
demo.stats = () => ({ ...view.stats(), scene: sceneSel.value, t: current?.time ?? 0, duration: current?.duration ?? 0 });
await setScene(q.get('scene') || 'prep');
const t0 = Number(q.get('t'));
if (Number.isFinite(t0) && t0 > 0) current?.seek?.(t0);
// ?cam=prep|normal|unite|boss|pen|bossPrep[&side=L|R&half=1&shop=0]: override the scene's camera (official framings)
if (q.get('cam')) {
view.setCamera(q.get('cam'), { side: q.get('side') || 'L', half: q.get('half') === '1', shop: q.get('shop') === '0' ? false : undefined, instant: true });
}
let last = performance.now();
const loop = (now) => {
const dt = Math.min(0.1, (now - last) / 1000);
last = now;
if (current?.tick) {
try { current.tick(playing ? dt * speed : 0); } catch (err) { log('tick', err.message); }
if (current.duration) {
$('scrub').value = String(Math.round((current.time / current.duration) * 1000));
$('time').textContent = `${current.time.toFixed(1)} / ${current.duration.toFixed(1)} s`;
}
}
const st = view.stats();
$('stats').textContent = `fps ${st.fps.toFixed(0)} frame ${st.frameMs.toFixed(1)} ms units ${st.units} particles ${st.particles} proj ${st.projectiles} nums ${st.numbers}\nspine ${st.spine ? `${st.spine.ready} ready / ${st.spine.loading} loading / ${st.spine.failed} failed` : '-'} rate ${st.rate?.toFixed?.(2) ?? '-'} buffered ${st.buffered}\nboard ${st.board3d?.on ? `3D · ${st.board3d.calls} calls · ${st.board3d.triangles} tris · ${st.board3d.cpuMs} ms` : '2D atlas'}`;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
demo.ready = true;
}
// =============================================================================================================
// prep scene
async function prepScene(view, stageId, index = []) {
const stage = data.lookup('stages', stageId);
view.setStage(stage);
const fa = q.get('fa');
if (fa === 'L' || fa === 'R') view.setCamera('bossPrep', { side: fa });
else view.setCamera('prep', { rect: { ...GEO.NORMAL_RECT }, side: 'L' });
// the enemy preview pen: a recording's round preview (m.private.nextEnemies shape), else a small R4-like wave
let nextEnemies = [
{ enemyKey: 'enemy_1007_slime', count: 2, gate: 'lower', t: 3, fly: false, elite: false, source: 'wave' },
{ enemyKey: 'enemy_1000_gopro_2', count: 3, gate: 'upper', t: 3, fly: false, elite: false, source: 'wave' },
{ enemyKey: 'enemy_1019_jshoot', count: 3, gate: 'lower', t: 12, fly: false, elite: false, source: 'wave' },
{ enemyKey: 'enemy_1006_shield', count: 1, gate: 'lower', t: 13, fly: false, elite: true, source: 'wave' },
{ enemyKey: 'enemy_1005_yokai', count: 2, gate: 'upper', t: 7, fly: true, elite: false, source: 'wave' },
];
const penRec = q.get('pen') ? index.find((r) => r.name === q.get('pen')) : null;
if (penRec) {
const rec = await fetch(`/dev/recordings/${penRec.file}`).then((r) => r.json(), () => null);
if (Array.isArray(rec?.preview)) nextEnemies = rec.preview;
} else if (q.get('pen') === '0') nextEnemies = [];
const chess = data.list('chess').filter((c) => c.visible && !c.isGolden);
const pick = (name) => chess.find((c) => c.name === name);
const golden = (c) => data.lookup('chess', c.goldenId) || c;
const items = data.list('items').filter((i) => i.itemType === 'EQUIP' && !i.isGolden);
let uid = 1;
const P = (c, extra = {}) => ({ uid: uid++, kind: 'chess', id: c.chessId, golden: !!c.isGolden, tier: c.tier, items: [], ...extra });
const deploy = stage?.deployTiles?.normal || { melee: [], rangedOnly: [] };
const meleeTiles = deploy.melee.slice().sort((a, b) => a[0] - b[0] || b[1] - a[1]);
const rangedTiles = [...(deploy.rangedOnly || []), ...meleeTiles.slice().reverse()];
const state = { hand: Array(10).fill(null), temp: Array(5).fill(null), board: [], nextEnemies };
const taken = new Set();
const place = (c) => {
const list = c.position === 'MELEE' ? meleeTiles : rangedTiles;
const t = list.find(([r, cc]) => !taken.has(`${r},${cc}`));
if (!t) return;
taken.add(`${t[0]},${t[1]}`);
state.board.push({ ...P(c), row: t[0], col: t[1] });
};
for (const n of ['塞雷娅', '银灰', '德克萨斯', '能天使', '莫斯提马', '白面鸮', '伊内丝']) { const c = pick(n); if (c) place(n === '银灰' || n === '能天使' ? golden(c) : c); }
const it0 = items[3];
if (state.board[0] && it0) state.board[0].items = [{ uid: uid++, id: it0.id }];
const handNames = ['斯卡蒂', '夕', '史尔特尔', '缇缇', '蕾缪安', '异客'];
handNames.forEach((n, i) => { const c = pick(n); if (c) state.hand[i] = P(c); });
if (items[0]) state.hand[6] = { uid: uid++, kind: 'item', id: items[0].id, golden: false, tier: items[0].tier };
if (items[12]) state.hand[7] = { uid: uid++, kind: 'item', id: items[12].id, golden: false, tier: items[12].tier };
const tok = data.list('tokens').find((t) => t.kind === 'summon' && t.displayType === 'DEFAULT');
if (tok) state.hand[8] = { uid: uid++, kind: 'token', id: tok.tokenId, golden: false, tier: 1, count: 3 };
const c1 = pick('角峰'), c2 = pick('格雷伊');
if (c1) state.temp[0] = P(c1);
if (c2) state.temp[1] = P(c2);
let editable = true;
const find = (u) => {
for (let i = 0; i < 10; i++) if (state.hand[i]?.uid === u) return { area: 'hand', idx: i, piece: state.hand[i] };
for (let i = 0; i < 5; i++) if (state.temp[i]?.uid === u) return { area: 'temp', idx: i, piece: state.temp[i] };
const b = state.board.find((p) => p.uid === u);
return b ? { area: 'board', piece: b } : null;
};
const at = (t) => (t.area === 'hand' ? state.hand[t.idx] : state.board.find((p) => p.row === t.row && p.col === t.col) || null);
const tileOk = (piece, r, c) => {
const rec = data.lookup('chess', piece.id);
const inMelee = deploy.melee.some(([a, b]) => a === r && b === c);
const inRanged = (deploy.rangedOnly || []).some(([a, b]) => a === r && b === c);
return rec?.position === 'MELEE' ? inMelee : inMelee || inRanged;
};
const canPlace = (u, target) => {
const src = find(u);
if (!src || !editable) return false;
const occ = at(target);
if (src.piece.kind === 'item') return !!occ && occ.kind === 'chess' && (occ.items || []).length < 2;
if (target.area === 'hand') return !occ || src.area !== 'board' || occ.kind !== 'item';
if (src.piece.kind !== 'chess' && src.piece.kind !== 'token') return false;
if (!tileOk(src.piece, target.row, target.col) && src.piece.kind === 'chess') return false;
if (occ && src.area === 'board' && !tileOk(occ, src.piece.row, src.piece.col)) return false;
return true;
};
const apply = (u, target) => {
const src = find(u);
if (!src) return;
const occ = at(target);
if (src.piece.kind === 'item') {
if (occ && occ.kind === 'chess') { occ.items = [...(occ.items || []), { uid: src.piece.uid, id: src.piece.id }]; remove(src); }
return;
}
const moved = { ...src.piece };
remove(src);
if (occ) {
const o = { ...occ };
removeAt(target);
putAt(o, src.area === 'board' ? { area: 'board', row: src.piece.row, col: src.piece.col } : { area: src.area, idx: src.idx });
}
putAt(moved, target);
};
const remove = (loc) => {
if (loc.area === 'hand') state.hand[loc.idx] = null;
else if (loc.area === 'temp') state.temp[loc.idx] = null;
else state.board = state.board.filter((p) => p.uid !== loc.piece.uid);
};
const removeAt = (t) => {
if (t.area === 'hand') state.hand[t.idx] = null;
else state.board = state.board.filter((p) => !(p.row === t.row && p.col === t.col));
};
const putAt = (p, t) => {
const { row, col, ...rest } = p; // eslint-disable-line no-unused-vars
if (t.area === 'hand') state.hand[t.idx] = rest;
else if (t.area === 'temp') state.temp[t.idx] = rest;
else state.board.push({ ...rest, row: t.row, col: t.col });
};
const push = () => view.setPrep(JSON.parse(JSON.stringify(state)), { editable, canPlace });
push();
const offs = [
view.on('pieceDrop', (e) => {
log('pieceDrop', { uid: e.uid, target: e.target });
if (e.target.area === 'outside' || demo.refuseDrops) return;
// simulate the server round trip
setTimeout(() => { apply(e.uid, e.target); push(); }, 90);
}),
view.on('pieceClick', (e) => log('pieceClick', { uid: e.uid, detail: e.detail })),
view.on('pieceDragStart', (e) => { view.highlightTiles(null); log('pieceDragStart', { uid: e.uid, from: e.from }); }),
// hover a board operator ⇒ its attack range (orange) + its tile (mint), like the original
view.on('pieceHover', (e) => {
view.highlightTiles(null);
const p = e.uid != null ? state.board.find((x) => x.uid === e.uid) : null;
const rec = p ? data.lookup('chess', p.id) : null;
if (!rec || !Array.isArray(rec.rangeGrid)) return;
const tiles = rec.rangeGrid.map(([dr, dc]) => [p.row + dr, p.col + dc]).filter(([r, c]) => !(r === p.row && c === p.col));
view.highlightTiles(tiles, { color: 0xff9c33, fill: 0.28, line: 0.85, group: 'range' });
view.highlightTiles([[p.row, p.col]], { color: 0x4ed8af, fill: 0.3, line: 0.9, group: 'rangeStand' });
}),
];
demoHover = (uid) => {
const p = state.board.find((x) => x.uid === uid);
const rec = p ? data.lookup('chess', p.id) : null;
if (!rec) return false;
view.highlightTiles(rec.rangeGrid.map(([dr, dc]) => [p.row + dr, p.col + dc]).filter(([r, c]) => !(r === p.row && c === p.col)), 'range');
view.highlightTiles([[p.row, p.col]], 'rangeStand');
return true;
};
let hl = 0;
return {
stageId,
duration: 0,
time: 0,
setEditable(on) { editable = on; push(); },
tick(dt) {
hl += dt;
// periodic range preview of the first ranged board piece (demo of highlightTiles 'range')
void hl;
},
stop() { for (const off of offs) off(); view.highlightTiles(null); },
state,
};
}
// =============================================================================================================
// recording scene
async function recordingScene(view, meta) {
const rec = await fetch(`/dev/recordings/${meta.file}`).then((r) => r.json());
const stage = data.lookup('stages', rec.stageId);
const frames = rec.frames;
const t0 = frames[0].snap.t;
const duration = frames[frames.length - 1].snap.t - t0;
const kind = rec.kind === 'hidden' ? 'boss' : rec.kind;
const fieldId = rec.field.fieldId;
let idx = 0, gameT = t0, endWait = 0;
const start = () => {
view.setStage(stage);
view.enterBattle(rec.field);
view.setCamera(kind, { rect: rec.field.rect, side: 'L' });
idx = 0; gameT = t0 - 0.001; endWait = 0;
};
start();
const feed = () => {
while (idx < frames.length && frames[idx].snap.t <= gameT) {
const f = frames[idx++];
// like the match server: b.ev first, then the b.snap of the same drain, both with the game time `gt`
// (the recorded snapshot keeps its numeric `t`, which the engine also accepts)
if (f.ev.length) view.pushEvents({ t: 'b.ev', fieldId, gt: f.snap.t, ev: f.ev });
view.pushSnapshot(f.snap);
}
};
const seek = (t) => {
const target = t0 + Math.max(0, Math.min(duration, t));
view.enterBattle(rec.field);
view.setCamera(kind, { rect: rec.field.rect, side: 'L', instant: true });
let k = 0;
while (k + 1 < frames.length && frames[k + 1].snap.t <= target) k++;
const infos = new Map();
for (let i = 0; i <= k; i++) for (const e of frames[i].ev) if (e[0] === 'spawn') infos.set(e[1].id, e[1]);
const present = new Set(frames[k].snap.units.map((u) => u[0]));
const spawns = [...infos.values()].filter((u) => present.has(u.id)).map((u) => ['spawn', u]);
if (spawns.length) view.pushEvents({ fieldId, ev: spawns });
// replay the last few snapshots so the interpolation buffer has a history
const from = Math.max(0, k - 3);
for (let i = from; i <= k; i++) view.pushSnapshot(frames[i].snap);
view.debug.interp.snapToNewest();
idx = k + 1;
gameT = frames[k].snap.t;
};
return {
stageId: rec.stageId,
duration,
get time() { return Math.max(0, gameT - t0); },
seek,
tick(dt) {
if (dt <= 0) return;
if (idx >= frames.length) {
endWait += dt;
if (endWait > 3) start();
return;
}
gameT += dt * 2; // combat runs at 2× real time
feed();
},
stop() {},
rec,
};
}
// =============================================================================================================
// stress scene: 120 units, constant projectiles / numbers
function stressScene(view, stageId) {
const stage = data.lookup('stages', stageId);
view.setStage(stage);
const rect = { r0: 9, r1: 12, c0: 0, c1: 20 };
const chess = data.list('chess').filter((c) => c.visible && !c.isGolden);
const enemies = data.list('enemies').filter((e) => assets.spineEntry(e.key) && e.rank !== 'BOSS');
let seed = 7;
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
const units = [];
let id = 1;
for (let i = 0; i < 40; i++) {
const c = chess[(i * 7) % chess.length];
units.push({ id: id++, kind: 'op', side: 'ally', defId: c.chessId, name: c.name, tier: c.tier, golden: i % 6 === 0, spine: c.assets?.spine, avatar: c.assets?.avatar, x: 2 + (i % 17), y: 9 + ((i / 17) | 0) % 4, facing: 1, maxHp: 3000 });
}
for (let i = 0; i < 80; i++) {
const e = enemies[(i * 5) % enemies.length];
units.push({ id: id++, kind: 'enemy', side: 'enemy', defId: e.key, name: e.name, tier: 1, golden: false, spine: e.spine || e.key, avatar: e.iconId || e.key, x: 20 - (i % 20), y: 9 + (i % 4) + 0.3, facing: -1, maxHp: 5000 });
}
const field = { fieldId: 'stress', kind: 'unite', rect, stageId, units };
view.enterBattle(field);
view.setCamera('unite', { rect });
let t = 0, acc = 0;
const hp = new Map(units.map((u) => [u.id, u.maxHp]));
const kinds = ['arrow', 'bolt', 'orb', 'none', 'bomb', 'chain'];
return {
stageId,
duration: 0,
time: 0,
tick(dt) {
if (dt <= 0) return;
acc += dt * 2;
while (acc >= 0.1) {
acc -= 0.1;
t += 0.1;
const tuples = units.map((u) => {
let x = u.x, y = u.y;
if (u.side === 'enemy') { x = 1 + ((u.x - t * 0.35 + 40) % 19); y = u.y + Math.sin(t + u.id) * 0.25; }
let h = hp.get(u.id);
if (h <= 0) h = u.maxHp;
hp.set(u.id, h);
const flags = (u.id % 11 === 0 ? 2 : 0) | (u.id % 13 === 0 ? 16 : 0);
return [u.id, Math.round(x * 100) / 100, Math.round(y * 100) / 100, h, u.maxHp, (t * 3 + u.id) % 20, 20, flags, u.side === 'enemy' ? 1 : 0];
});
const ev = [];
for (let k = 0; k < 14; k++) {
const a = units[(rnd() * 40) | 0], b = units[40 + ((rnd() * 80) | 0)];
ev.push(['atk', a.id, b.id, kinds[(rnd() * kinds.length) | 0]]);
const d = 100 + ((rnd() * 900) | 0);
hp.set(b.id, hp.get(b.id) - d);
ev.push(['dmg', b.id, d, rnd() < 0.6 ? 'phys' : 'arts']);
}
for (let k = 0; k < 4; k++) { const b = units[(rnd() * 40) | 0]; ev.push(['heal', b.id, 150]); }
if (rnd() < 0.2) ev.push(['skill', units[(rnd() * 40) | 0].id, 1]);
view.pushEvents({ t: 'b.ev', fieldId: 'stress', gt: t, ev });
view.pushSnapshot({ t: 'b.snap', fieldId: 'stress', gt: t, units: tuples, dp: 10, killed: 0, total: 80 });
}
},
stop() {},
};
}
// =============================================================================================================
// fx gallery: every fx kind, projectile kind and status on a small static field
function fxScene(view, stageId) {
const stage = data.lookup('stages', stageId);
view.setStage(stage);
const rect = { ...GEO.NORMAL_RECT };
const pick = (name) => data.list('chess').find((c) => c.name === name && !c.isGolden);
const op = (id, name, x, y) => { const c = pick(name); return c ? { id, kind: 'op', side: 'ally', defId: c.chessId, name, tier: c.tier, golden: false, spine: c.assets?.spine, avatar: c.assets?.avatar, x, y, facing: 1, maxHp: 3000 } : null; };
const en = (id, key, x, y, extra = {}) => { const e = data.lookup('enemies', key); return { id, kind: 'enemy', side: 'enemy', defId: key, name: e?.name || key, tier: 1, golden: false, spine: e?.spine || key, avatar: e?.iconId || key, x, y, facing: -1, maxHp: 4000, ...extra }; };
const units = [
op(1, '银灰', 4, 9), op(2, '能天使', 3, 11), op(3, '塞雷娅', 5, 10), op(4, '莫斯提马', 6, 12),
en(20, 'enemy_1007_slime', 7, 9), en(21, 'enemy_5601_entlec', 8, 10), en(22, 'enemy_nonexistent_x', 7.4, 11),
en(23, 'enemy_1042_frostd', 9, 9.2), en(24, 'enemy_2016_csphtm', 8.6, 12, { boss: true, maxHp: 90000 }),
{ id: 30, kind: 'device', side: 'ally', defId: 'trap_1105_accrate', name: 'crate', x: 6, y: 9, facing: 1, maxHp: 100 },
{ id: 31, kind: 'device', side: 'ally', defId: 'trap_1104_aclasert', name: 'turret', x: 5, y: 12, facing: 1, maxHp: 3000 },
].filter(Boolean);
view.enterBattle({ fieldId: 'fx', kind: 'normal', rect, stageId, units });
view.setCamera('normal', { rect });
const kinds = Object.keys(FX_KINDS);
const projs = ['arrow', 'bolt', 'orb', 'bomb', 'lob', 'drone', 'enemy', 'chain', 'chainHeal', 'beam', 'none'];
const statuses = ['stun', 'ab:frost', 'reed2:scorch', 'fragile', 'sleep', 'silence', 'levitate', 'skill:shotst_shred', 'lumen:resist'];
let t = 0, acc = 0, i = 0;
const hp = new Map(units.map((u) => [u.id, u.maxHp]));
const extraFor = (k) => {
const base = { id: [1, 20, 21, 3, 23][i % 5] };
switch (FX_KINDS[k].a) {
case 'beam': case 'bolt': return { ...base, from: 1, to: 20, src: 2 };
case 'volley': return { ...base, id: 2, src: 2, targets: [20, 21, 23] };
case 'blink': return { ...base, id: 21, fx: 9, fy: 11 };
case 'move': return { ...base, id: 20, fromX: 9, fromY: 9 };
case 'zone': case 'telegraph': return { ...base, r: 1.5, dur: 2, tiles: k === 'telegraph' ? 'box' : undefined };
case 'dp': return { ...base, id: 3, n: 10 };
case 'element': return { ...base, id: 21, element: ['burn', 'neural', 'apoptosis'][i % 3] };
default: return base;
}
};
return {
stageId, duration: 0, time: 0,
get kind() { return kinds[(i - 1 + kinds.length) % kinds.length]; },
tick(dt) {
if (dt <= 0) return;
acc += dt * 2;
while (acc >= 0.1) {
acc -= 0.1;
t += 0.1;
const ev = [];
if (Math.round(t * 10) % 5 === 0) {
const k = kinds[i % kinds.length];
const ex = extraFor(k);
const at = units.find((u) => u.id === ex.id) || units[0];
ev.push(['fx', k, at.x, at.y, ex]);
$('title').textContent = `特效图鉴 · ${k} (${FX_KINDS[k].a})`;
const a = units[i % 4], b = units[4 + (i % 5)];
const pk = projs[i % projs.length];
ev.push(['atk', pk === 'chain' ? 20 : a.id, b.id, pk]);
ev.push(['dmg', b.id, 120 + (i * 37) % 900, ['phys', 'arts', 'true', 'burn'][i % 4]]);
if (i % 3 === 0) ev.push(['heal', units[i % 4].id, 240]);
const st = statuses[i % statuses.length];
ev.push(['status', b.id, st, 1], ['status', units[4 + ((i + 2) % 5)].id, statuses[(i + 4) % statuses.length], 0]);
if (i % 6 === 0) ev.push(['skill', units[i % 4].id, 1]);
if (i % 6 === 3) ev.push(['skill', units[(i - 3) % 4].id, 0]);
hp.set(b.id, Math.max(1, hp.get(b.id) - 300));
i++;
}
const tuples = units.map((u) => {
const h = hp.get(u.id);
const flags = u.id === 21 ? 1 : u.id === 23 ? 128 : u.id === 2 ? 32 : 0;
return [u.id, u.x, u.y, h, u.maxHp, (t * 2 + u.id) % 20, u.kind === 'op' ? 20 : 0, flags, u.side === 'enemy' ? 2 : 0];
});
view.pushEvents({ t: 'b.ev', fieldId: 'fx', gt: t, ev });
view.pushSnapshot({ t: 'b.snap', fieldId: 'fx', gt: t, units: tuples, dp: 10, killed: 0, total: 5 });
}
},
stop() {},
};
}
// =============================================================================================================
// damage numbers: a boss and a knot of minions standing on top of each other take rapid mixed hits (the '41509' /
// '201625' overlap cases: numbers of different units side by side must never read as one)
function numbersScene(view, stageId) {
const stage = data.lookup('stages', stageId);
view.setStage(stage);
const rect = { ...GEO.BOSS_RECT };
const pick = (name) => data.list('chess').find((c) => c.name === name && !c.isGolden);
const op = (id, name, x, y) => { const c = pick(name); return c ? { id, kind: 'op', side: 'ally', defId: c.chessId, name, tier: c.tier, golden: false, spine: c.assets?.spine, avatar: c.assets?.avatar, x, y, facing: 1, dir: 'RIGHT', maxHp: 4000 } : null; };
const en = (id, key, x, y, extra = {}) => { const e = data.lookup('enemies', key); return { id, kind: 'enemy', side: 'enemy', defId: key, name: e?.name || key, tier: 1, golden: false, spine: e?.spine || key, avatar: e?.iconId || key, x, y, facing: -1, maxHp: 6000, ...extra }; };
const units = [
op(1, '史尔特尔', 6, 3), op(2, '能天使', 5, 4), op(3, '莫斯提马', 5, 2), op(4, '白面鸮', 4, 3),
en(20, 'enemy_1001_bigbo', 7.2, 3.1, { boss: true, maxHp: 400000 }),
en(21, 'enemy_1007_slime', 7.35, 3.0), en(22, 'enemy_1007_slime', 7.1, 2.9), en(23, 'enemy_1000_gopro_2', 7.5, 3.2),
en(24, 'enemy_1006_shield', 8.1, 3.05, { maxHp: 12000 }), en(25, 'enemy_1019_jshoot', 7.6, 4.1),
].filter(Boolean);
view.enterBattle({ fieldId: 'nums', kind: 'boss', rect, stageId, units });
view.setCamera('boss', { rect, side: 'L', half: true });
let seed = 11;
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
const hp = new Map(units.map((u) => [u.id, u.maxHp]));
const enemies = units.filter((u) => u.side === 'enemy');
let t = 0, acc = 0;
return {
stageId, duration: 0, time: 0,
tick(dt) {
if (dt <= 0) return;
acc += dt * 2;
while (acc >= 0.1) {
acc -= 0.1;
t += 0.1;
const ev = [];
for (let k = 0; k < 3; k++) {
const b = enemies[(rnd() * enemies.length) | 0];
const boss = !!b.boss;
const style = ['phys', 'arts', 'phys', 'true', 'burn'][(rnd() * 5) | 0];
const n = boss ? 2000 + ((rnd() * 200000) | 0) : 40 + ((rnd() * 900) | 0);
ev.push(['atk', units[(rnd() * 4) | 0].id, b.id, 'none'], ['dmg', b.id, n, style]);
hp.set(b.id, Math.max(1, hp.get(b.id) - n * 0.02));
}
if (rnd() < 0.5) ev.push(['heal', units[(rnd() * 4) | 0].id, 60 + ((rnd() * 400) | 0)]);
const tuples = units.map((u) => [u.id, u.x, u.y, hp.get(u.id), u.maxHp, 5, 20, u.side === 'enemy' && u.x < 7.4 ? 1 : 0, 0]);
view.pushEvents({ t: 'b.ev', fieldId: 'nums', gt: t, ev });
view.pushSnapshot({ t: 'b.snap', fieldId: 'nums', gt: t, units: tuples, dp: 10, killed: 0, total: 6 });
}
},
stop() {},
};
}
main().catch((err) => { log('FATAL', err.message); console.error(err); demo.error = err.message; });
+348
View File
@@ -0,0 +1,348 @@
<!doctype html>
<!--
UI kit gallery (visual QA for js/ui/components.js + css). Standalone page: /dev/uikit.html
Renders every design-system component in its variants/states. Uses real bond icons from
data/assets.json + names from data/bonds.json when those files exist.
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UI Kit · 卫戍协议:盟约</title>
<link rel="icon" href="data:," />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" media="print" onload="this.media='all'"
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Oxanium:wght@400;500;600;700&family=Rajdhani:wght@500;600;700&display=swap" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/components.css" />
<link rel="stylesheet" href="/css/emotes.css" />
<link rel="stylesheet" href="/css/devices.css" />
<script type="importmap">
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
</script>
<style>
/* Gallery page only: a scrollable document instead of the fixed game viewport. */
html { font-size: clamp(40px, calc(100vw / 19.2), 140px); }
body { overflow: auto; height: auto; min-height: 100%; }
.kit { position: relative; z-index: 1; max-width: 18rem; margin: 0 auto; padding: .5rem .5rem 1.2rem; }
.kit-bg { position: fixed; inset: 0; z-index: 0; }
.kit-head { display: flex; align-items: flex-end; justify-content: space-between; gap: .4rem; padding-bottom: .24rem; border-bottom: 1px solid var(--line); }
.kit-head h1 { font-size: .5rem; font-weight: 900; letter-spacing: .06em; }
.kit-head h1 em { font-style: normal; color: var(--mint-500); }
.kit-sec { margin-top: .5rem; }
.kit-sec > h2 { display: flex; align-items: baseline; gap: .14rem; font-size: .24rem; margin-bottom: .2rem; }
.kit-sec > h2 .num { color: var(--mint-500); font-size: .18rem; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: .24rem; margin-bottom: .22rem; }
.row--top { align-items: flex-start; }
.cap { font-size: .12rem; color: var(--text-dim); font-family: var(--font-display); letter-spacing: .14em; text-transform: uppercase; width: 100%; margin-bottom: -.1rem; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .24rem; }
.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .24rem; }
.swatch { display: flex; flex-direction: column; gap: .06rem; width: 1.3rem; font-size: .12rem; color: var(--text-lo); }
.swatch i { display: block; height: .56rem; border: 1px solid var(--line); }
.swatch b { color: var(--text-hi); font-family: var(--font-num); font-weight: 700; font-size: .14rem; }
.type-demo > * { display: block; margin-bottom: .08rem; }
.stack { display: flex; flex-direction: column; gap: .14rem; min-width: 3rem; }
/* 13 emotes */
.emo-stage { position: relative; height: 3.3rem; padding: .2rem; background: rgba(0, 0, 0, .35); border: 1px solid var(--line); }
.emo-stage .ewheel { position: absolute; left: .2rem; bottom: .2rem; }
.emo-team { position: absolute; left: 3.7rem; top: .3rem; display: flex; flex-direction: column; gap: .14rem; }
.emo-row { position: relative; display: flex; align-items: center; gap: .1rem; }
.emo-row .pav { width: .8rem; height: .8rem; display: grid; place-items: center; background: linear-gradient(135deg, #2c4a40, #10201b); border: 1px solid var(--line-2); font-size: .3rem; font-weight: 900; color: var(--mint-400); }
.emo-row .ebubble { position: absolute; left: calc(100% + .06rem); top: 50%; transform: translateY(-50%); }
.emo-bubbles { display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; }
.emo-bubbles figure { margin: 0; display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; font-size: .11rem; color: var(--text-dim); }
.emo-cat { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: .12rem .2rem; }
.emo-cat figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: .04rem; font-size: .1rem; color: var(--text-dim); font-family: var(--font-mono); text-align: center; }
.emo-cat .cell { width: .85rem; height: .85rem; padding: .07rem; box-sizing: border-box; background: rgba(255, 255, 255, .12); border-radius: .08rem; }
.emo-cat h3 { grid-column: 1 / -1; font-size: .14rem; color: var(--text-md); margin: .06rem 0 0; }
</style>
</head>
<body>
<div class="app-bg kit-bg" aria-hidden="true"></div>
<div id="kit"></div>
<script type="module">
import '/js/ui/compat.js';
import { render } from '/vendor/preact.module.js';
import { useState } from '/vendor/hooks.module.js';
import {
html, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip, BondDisc, SevenSeg, Countdown, Modal, confirmDialog,
alertDialog, Tooltip, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, PingPill, DifficultyTag, TextField,
UiHosts, Icon, ICONS,
} from '/js/ui/components.js';
import { ToastHost, toast } from '/js/ui/toasts.js';
import { data, useData, getBond } from '/js/data.js';
import { DIFFICULTIES, EMOTE_THEMES, EMOTES } from '/shared/constants.js';
import { EmoteWheel, EmoteBubble, EmoteArt } from '/js/ui/emotes.js';
const SWATCHES = [
['bg-0', '#0c0f0e'], ['bg-1', '#141816'], ['bg-2', '#1e2421'], ['line', '#2f3a35'],
['mint-500', '#4ed8af'], ['mint-400', '#59f4ca'], ['mint-600', '#35d8b4'], ['mint-glow', '#17f9b7'],
['gold', '#ffc600'], ['amber', '#f6a329'], ['orange', '#e85a1a'], ['red', '#e73118'], ['abyss', '#ff0024'],
['premium', '#ff5454'], ['ice', '#9fd4ff'], ['text-lo', '#8a948f'],
];
const BONDS = ['yanShip', 'sargonShip', 'victoriaShip', 'kjeragShip', 'preciShip', 'swiftShip', 'deputShip', 'investShip'];
function Sec({ n, title, micro, children }) {
return html`<section class="kit-sec">
<h2><span class="num">${n}</span>${title}<${MicroLabel}>${micro}<//></h2>
${children}
</section>`;
}
/** 13 · official emotes: wheel (pager per theme) → bubble beside the sender's avatar; bubbles per theme; catalog. */
function EmoteDemo() {
useData('local');
const [open, setOpen] = useState(true);
const [offline, setOffline] = useState(false);
const [bubbles, setBubbles] = useState(() => ({ me: null, p2: { id: 'slug_autochess_battle_nice_work', seq: 1 } }));
const [replay, setReplay] = useState(0);
const say = (who, id) => setBubbles((b) => ({ ...b, [who]: { id, seq: Date.now() + Math.random(), at: Date.now() } }));
const rows = [['me', '我'], ['p2', '2P']];
return html`
<div class="grid2">
<div class="emo-stage" id="emo-stage">
<div class="emo-team">
${rows.map(([who, t]) => html`<div class="emo-row" key=${who}><span class="pav">${t}</span>
${bubbles[who] ? html`<${EmoteBubble} key=${bubbles[who].seq} id=${bubbles[who].id} at=${bubbles[who].at} class="team__bubble" />` : null}</div>`)}
</div>
<${EmoteWheel} open=${open} onToggle=${setOpen} disabled=${offline} onSend=${(id) => say('me', id)} />
</div>
<div class="stack">
<div class="row">
<${Button} size="sm" onClick=${() => setOpen((o) => !o)}>${open ? '收起面板' : '展开面板'}<//>
<${Button} size="sm" variant="amber" onClick=${() => say('p2', EMOTES[Math.floor(Math.random() * EMOTES.length)])}>队友发送表情<//>
<${Button} size="sm" variant="ghost" onClick=${() => say('p2', 'not_an_emote')}>缺图回退<//>
<${Button} size="sm" variant=${offline ? 'danger' : 'ghost'} onClick=${() => setOffline((v) => !v)}>${offline ? '已断线' : '断线'}<//>
</div>
<p class="t-lo" style="font-size:.14rem">左右滑动 / 拖动 / ← → / 点击圆点切换主题;发送后按钮冷却 1 秒;气泡 3 秒后淡出,新表情替换旧表情。</p>
<div class="emo-bubbles" key=${replay}>
${EMOTE_THEMES.map((t) => html`<figure key=${t.themeId}><${EmoteBubble} id=${t.emotes[0].id} ttl=${600000} /><figcaption>${t.dir}</figcaption></figure>`)}
<figure><${EmoteBubble} id="missing_art" ttl=${600000} /><figcaption>fallback</figcaption></figure>
</div>
<div class="row"><${Button} size="sm" icon="refresh" onClick=${() => setReplay((r) => r + 1)}>重播气泡<//></div>
</div>
</div>
<div class="emo-cat" style="margin-top:.3rem">
${EMOTE_THEMES.map((t, ti) => [
html`<h3 key=${`h${t.themeId}`}>${ti + 1}. ${t.name} <span class="t-lo">(${t.themeId})</span></h3>`,
...t.emotes.map((e) => html`<figure key=${e.id}><div class="cell"><${EmoteArt} id=${e.id} /></div><figcaption>${e.sortId} · ${e.picId}</figcaption></figure>`),
])}
</div>`;
}
function Kit() {
useData('assets', 'bonds');
const assets = data.get('assets');
const [tab, setTab] = useState('shop');
const [modal, setModal] = useState(false);
const [banner, setBanner] = useState(0);
const [name, setName] = useState('凯尔希');
const [code, setCode] = useState('');
const [deadline] = useState(() => Date.now() + 25000);
const [progress, setProgress] = useState(62);
const bond = (id) => ({ id, name: getBond(id)?.name || id.replace('Ship', ''), icon: assets?.bonds?.[id] || null });
return html`<div class="kit">
<header class="kit-head">
<div>
<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL // DESIGN SYSTEM<//>
<h1>UI Kit · 卫戍协议<em>:盟约</em></h1>
</div>
<div class="row" style="margin:0">
<${PingPill} ms=${38} /><${PingPill} ms=${120} /><${PingPill} ms=${260} /><${PingPill} online=${false} />
</div>
</header>
<${Sec} n="01" title="色板" micro="PALETTE">
<div class="row">${SWATCHES.map(([n, c]) => html`<div class="swatch" key=${n}><i style=${`background:${c}`}></i><b>${n}</b>${c}</div>`)}</div>
<//>
<${Sec} n="02" title="字体" micro="TYPOGRAPHY">
<div class="grid2">
<div class="type-demo">
<span style="font-size:.56rem;font-weight:900">卫戍协议:盟约</span>
<span style="font-size:.26rem;font-weight:700">休整期 · 调度中心 · 联防阶段</span>
<span style="font-size:.18rem">正文 Noto Sans SC — 调配资金与干员,抵御多波次进攻。</span>
<span class="t-lo" style="font-size:.15rem">次要文本 text-lo · 说明与提示</span>
</div>
<div class="type-demo">
<span class="display" style="font-size:.34rem">STRONGHOLD PROTOCOL</span>
<span class="num" style="font-size:.5rem;font-weight:700">0123456789 <span class="t-gold">+12</span></span>
<${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE<//>
<${MicroLabel}>COUNTDOWN · LEVEL · STAGE 04<//>
</div>
</div>
<//>
<${Sec} n="03" title="按钮" micro="BUTTON">
${['primary', 'secondary', 'amber', 'ice', 'danger', 'ghost'].map((v) => html`<div class="row" key=${v}>
<span class="cap">${v}</span>
<${Button} variant=${v} size="sm">小按钮<//>
<${Button} variant=${v} icon="check">准备就绪<//>
<${Button} variant=${v} size="lg" iconRight="chevrons">开始模拟<//>
<${Button} variant=${v} square=${true} icon=${v === 'danger' ? 'exit' : v === 'ice' ? 'snow' : v === 'amber' ? 'refresh' : 'plus'} aria-label="icon" />
<${Button} variant=${v} disabled=${true}>禁用<//>
<${Button} variant=${v} loading=${true}>加载中<//>
</div>`)}
<div class="row"><span class="cap">xl / active / block</span>
<${Button} variant="primary" size="xl" iconRight="chevrons">开始<//>
<${Button} variant="secondary" active=${true} icon="check">已选定<//>
<div style="width:3rem"><${Button} variant="amber" block=${true} icon="users">加入同盟<//></div>
</div>
<//>
<${Sec} n="04" title="面板" micro="PANEL · BRACKET FRAME">
<div class="grid3">
<${Panel} title="调度中心" micro="SHOP · LEVEL 3" actions=${html`<${HexBadge} value=${5} size="sm" />`}>
<p class="t-lo">带括号角框的面板,标题栏含微型技术标签与操作区。</p>
<//>
<${Panel} title="联防阶段" micro="UNITE" tone="orange"><p class="t-lo">tone = orange</p><//>
<${Panel} title="模拟结束" micro="RESULT" tone="gold" glow=${true}><p class="t-lo">tone = gold, glow</p><//>
</div>
<//>
<${Sec} n="05" title="徽记" micro="HEX BADGE · TIER CHIP · DIFFICULTY">
<div class="row"><span class="cap">hex badge</span>
<${HexBadge} value=${2} /><${HexBadge} value=${4} tone="discount" /><${HexBadge} value=${5} tone="premium" />
<${HexBadge} value=${0} tone="mint" /><${HexBadge} value=${1} tone="amber" icon="refresh" /><${HexBadge} value=${'VI'} tone="dark" />
<${HexBadge} value=${13} size="lg" icon="rook" tone="gold" /><${HexBadge} value=${3} size="sm" tone="ice" />
</div>
<div class="row"><span class="cap">tier chip</span>
${[1, 2, 3, 4, 5, 6].map((t) => html`<${TierChip} key=${t} tier=${t} />`)}
${[1, 3, 6].map((t) => html`<${TierChip} key=${`g${t}`} tier=${t} golden=${true} />`)}
<${TierChip} tier=${2} size="sm" /><${TierChip} tier=${5} size="lg" /><${TierChip} tier=${6} size="lg" golden=${true} />
</div>
<div class="row"><span class="cap">difficulty</span>
${DIFFICULTIES.map((d) => html`<${DifficultyTag} key=${d} difficulty=${d} code=${`AC-${DIFFICULTIES.indexOf(d) + 1}`} />`)}
<${DifficultyTag} difficulty="ABYSS" size="lg" />
</div>
<//>
<${Sec} n="06" title="盟约" micro="BOND DISC">
<div class="row">
${BONDS.map((id, i) => {
const b = bond(id);
return html`<${BondDisc} key=${id} name=${b.name} icon=${b.icon} layers=${[358, 155, 144, 34, 14, 10, 3, 0][i]}
tier=${[3, 2, 2, 1, 1, 1, 0, 0][i]} maxTier=${3} active=${i < 6} disabled=${i === 7} onClick=${() => toast(`${b.name}:查看盟约详情`)} />`;
})}
</div>
<div class="row"><span class="cap">sizes · layers disabled · glyph fallback</span>
<${BondDisc} name="炎" size="sm" layers=${4} tier=${1} active=${true} />
<${BondDisc} name="萨尔贡" size="lg" layers=${12} tier=${2} maxTier=${4} active=${true} icon=${bond('sargonShip').icon} />
<${BondDisc} name="助力" layers=${34} tier=${1} active=${true} layersDisabled=${true} />
<${BondDisc} name="谢拉格" layers=${0} maxTier=${2} />
</div>
<//>
<${Sec} n="07" title="倒计时" micro="SEVEN SEGMENT · COUNTDOWN">
<div class="row row--top">
<${Countdown} seconds=${59} total=${65} />
<${Countdown} seconds=${8} total=${65} />
<${Countdown} deadline=${deadline} total=${25} size="lg" />
<${Countdown} seconds=${195} total=${195} size="sm" />
<${Countdown} />
</div>
<div class="row">
<${SevenSeg} text="12:45" size=".5rem" /><${SevenSeg} text="-99" tone="orange" size=".5rem" />
<${SevenSeg} text="0123456789" tone="gold" size=".4rem" /><${SevenSeg} text="HELP" tone="ice" size=".4rem" />
<${SevenSeg} text="Err" tone="red" size=".4rem" />
</div>
<//>
<${Sec} n="08" title="横幅" micro="PHASE BANNER">
<div class="stack">
<${PhaseBanner} key=${`a${banner}`} title="休整期" micro="PHASE // PREP" sub="第 3 回合" />
<${PhaseBanner} key=${`b${banner}`} title="联防阶段" micro="PHASE // UNITE" sub="层数叠加已禁用" tone="orange" />
<${PhaseBanner} key=${`c${banner}`} title="最终攻势" micro="PHASE // FINAL ASSAULT" tone="red" />
</div>
<div class="row" style="margin-top:.2rem">
<${Button} icon="refresh" onClick=${() => setBanner((b) => b + 1)}>重播动画<//>
<${Button} variant="amber" onClick=${() => setBanner((b) => -(Math.abs(b) + 1))}>全屏横幅 (2.2s)<//>
<${Chevrons} /><${Chevrons} tone="orange" count=${5} /><${Chevrons} tone="gold" dir="left" />
</div>
${banner < 0 ? html`<${PhaseBanner} key=${`o${banner}`} mode="overlay" duration=${2200} title="机变阶段" micro="PHASE // SP DRAFT"
sub="当前轮到你决策" tone="gold" onDone=${() => setBanner(0)} />` : null}
<//>
<${Sec} n="09" title="进度 · 标签页 · 加载" micro="PROGRESS · TABS · SPINNER">
<div class="grid2">
<div class="stack">
<${ProgressBar} value=${progress} label="首领生命值" showValue=${true} tone="orange" />
<${ProgressBar} value=${7} max=${9} segments=${9} label="已部署" showValue=${true} />
<${ProgressBar} value=${35} tone="ice" size="sm" />
<${ProgressBar} value=${90} tone="gold" size="lg" label="经验" />
<div class="row"><${Button} size="sm" onClick=${() => setProgress((p) => (p + 17) % 101)}>改变进度<//></div>
</div>
<div class="stack">
<${Tabs} value=${tab} onChange=${setTab} items=${[
{ id: 'shop', label: '调度中心' }, { id: 'bonds', label: '盟约', badge: 6 }, { id: 'enemy', label: '敌方情报' },
{ id: 'locked', label: '未解锁', disabled: true },
]} />
<${Tabs} size="sm" value=${tab} onChange=${setTab} items=${[{ id: 'shop', label: '自己' }, { id: 'bonds', label: '队友' }]} />
<div class="row"><${Spinner} size="sm" /><${Spinner} label="LOADING" /><${Spinner} size="lg" tone="gold" label="SYNCING" /></div>
</div>
</div>
<//>
<${Sec} n="10" title="头像框" micro="AVATAR FRAME">
<div class="row">
<${AvatarFrame} name="凯尔希" size="lg" host=${true} self=${true} ready=${true} />
<${AvatarFrame} name="Doctor" size="lg" seat=${1} />
<${AvatarFrame} name="AI·华法琳" size="lg" seat=${2} bot=${true} />
<${AvatarFrame} name="阿米娅" size="lg" seat=${3} offline=${true} />
<${AvatarFrame} size="lg" empty=${true} />
<${AvatarFrame} name="W" size="md" seat=${2} dead=${true} />
<${AvatarFrame} name="惊蛰" size="sm" seat=${1} self=${true} />
<${AvatarFrame} name="艾雅法拉" size="xl" seat=${0} host=${true} />
</div>
<//>
<${Sec} n="11" title="输入 · 提示 · 对话框" micro="FIELD · TOOLTIP · MODAL · TOAST">
<div class="grid2">
<div class="stack">
<${TextField} label="博士代号" micro="CALLSIGN" icon="user" value=${name} onInput=${setName} maxLength=${12} />
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥" onInput=${(v) => setCode(v.toUpperCase().slice(0, 4))} />
<${TextField} label="无效输入" value="!!" onInput=${() => {}} invalid=${true} hint="仅限字母与数字" />
</div>
<div class="stack">
<div class="row">
<${Tooltip} text="刷新调度中心(消耗 1 资金)"><${Button} variant="amber" icon="refresh">刷新<//><//>
<${Tooltip} text=${'冻结后,调度中心在下一回合开始时保留\n快捷键 F'} placement="bottom"><${Button} variant="ice" icon="snow">冻结<//><//>
<${Tooltip} text="只有房主可以操作"><${TierChip} tier=${4} /><//>
</div>
<div class="row">
<${Button} onClick=${() => setModal(true)}>打开模态框<//>
<${Button} variant="danger" onClick=${async () => toast((await confirmDialog({ title: '放弃模拟', text: '确定要离开吗?', danger: true, okText: '离开' })) ? '已确认' : '已取消')}>确认对话框<//>
<${Button} variant="ghost" onClick=${() => alertDialog({ title: '提示', text: '同盟人数达标,准许进入模拟。' })}>提示对话框<//>
</div>
<div class="row">
<${Button} size="sm" onClick=${() => toast('已复制同盟密钥 ABCD', 'success')}>成功<//>
<${Button} size="sm" onClick=${() => toast('新的回合即将开始')}>信息<//>
<${Button} size="sm" onClick=${() => toast('尚未连接到服务器,请稍候', 'warn')}>警告<//>
<${Button} size="sm" onClick=${() => toast('资金不足', 'error')}>错误<//>
</div>
</div>
</div>
<${Modal} open=${modal} title="装备替换" micro="EQUIPMENT" onClose=${() => setModal(false)}
actions=${html`<${Button} onClick=${() => setModal(false)}>取消<//><${Button} variant="primary" icon="check" onClick=${() => setModal(false)}>替换<//>`}>
<p class="modal__text">该干员已装备 2 件装备。继续配发将<b class="t-orange">销毁</b>较早的装备,是否继续?</p>
<//>
<//>
<${Sec} n="12" title="图标" micro="ICONS">
<div class="row">${Object.keys(ICONS).map((n) => html`<${Tooltip} key=${n} text=${n}><span style="font-size:.3rem;color:var(--text-md)"><${Icon} name=${n} /></span><//>`)}</div>
<//>
<${Sec} n="13" title="表情" micro="EMOTES · 6 THEMES × 6 · PICTURE ONLY">
<${EmoteDemo} />
<//>
</div>
<${ToastHost} />
<${UiHosts} />`;
}
render(html`<${Kit} />`, document.getElementById('kit'));
</script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<!-- A landscape game: no page zoom (iOS additionally needs the gesture handling of js/ui/device.js), full-bleed behind
notches (viewport-fit=cover; css/devices.css keeps the HUD inside the safe area). -->
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0c0f0e" />
<meta name="color-scheme" content="dark" />
<meta name="format-detection" content="telephone=no" />
<!-- "Add to Home Screen" (iOS / Android) opens full screen without browser bars -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="卫戍协议" />
<meta name="description" content="卫戍协议:盟约 · 网页联机复刻(非官方同人作品)" />
<title>卫戍协议:盟约 · STRONGHOLD PROTOCOL</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<!-- Web fonts: never render-blocking (LAN/offline play must still start); system fallbacks in theme.css. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" media="print" onload="this.media='all'"
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Oxanium:wght@400;500;600;700&family=Rajdhani:wght@500;600;700&display=swap" />
<!-- Self-hosted Bender / Novecento (provided by the assets pipeline). -->
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/components.css" />
<link rel="stylesheet" href="/css/screens/title.css" />
<link rel="stylesheet" href="/css/screens/lobby.css" />
<link rel="stylesheet" href="/css/screens/room.css" />
<link rel="stylesheet" href="/css/screens/game.css" />
<link rel="stylesheet" href="/css/screens/game-shop.css" />
<link rel="stylesheet" href="/css/screens/game-panels.css" />
<link rel="stylesheet" href="/css/screens/briefing.css" />
<link rel="stylesheet" href="/css/screens/draft.css" />
<link rel="stylesheet" href="/css/screens/result.css" />
<link rel="stylesheet" href="/css/screens/guide.css" />
<link rel="stylesheet" href="/css/screens/loadout.css" />
<link rel="stylesheet" href="/css/emotes.css" />
<link rel="stylesheet" href="/css/devices.css" />
<script type="importmap">
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
</script>
<link rel="modulepreload" href="/vendor/preact.module.js" />
<link rel="modulepreload" href="/vendor/hooks.module.js" />
<link rel="modulepreload" href="/vendor/htm.module.js" />
<link rel="modulepreload" href="/js/main.js" />
<!-- Battlefield renderer libs (classic scripts, injected by js/render/app.js when a match view mounts): warm the cache
at low priority so entering the match doesn't wait on ~800 KB of script. -->
<link rel="prefetch" href="/vendor/pixi.min.js" as="script" />
<link rel="prefetch" href="/vendor/pixi-spine.js" as="script" />
</head>
<body>
<div id="app"></div>
<div id="boot" class="boot" aria-hidden="true">
<div class="boot__inner">
<div class="boot__hex"></div>
<div class="boot__text">STRONGHOLD PROTOCOL</div>
<div id="boot-err" class="boot__err"></div>
</div>
</div>
<div class="rotate-hint" role="alert">
<div class="rotate-hint__phone"></div>
<div class="rotate-hint__title">请将设备横屏</div>
<div>卫戍协议:盟约 需要横屏显示以获得完整体验</div>
<div class="rotate-hint__micro">ROTATE DEVICE</div>
</div>
<noscript><p style="color:#c3cbc7;text-align:center;padding:40px">需要启用 JavaScript 才能运行本游戏。</p></noscript>
<script>
// Boot diagnostics (plain ES5: runs everywhere). A browser too old for the app says so instead of spinning forever:
// no ES modules (nomodule below), a module graph that cannot load (e.g. import maps: Safari / iOS < 16.4), or no
// boot after 9 s.
window.__spBootFail = function (why) {
var e = document.getElementById('boot-err');
if (!e || e.getAttribute('data-final')) return;
var im = false;
try { im = !!(window.HTMLScriptElement && HTMLScriptElement.supports && HTMLScriptElement.supports('importmap')); } catch (x) { im = false; }
e.textContent = why === 'old' || !im
? '当前浏览器版本过旧,无法运行本游戏。请升级到 iOS / iPadOS 16.4+(Safari 16.4+)、Chrome / Edge 90+ 或 Firefox 108+ 后刷新。'
: '游戏脚本加载失败,请检查网络连接后刷新页面。';
e.setAttribute('data-final', '1');
};
// a module graph that fails to link (bare specifier without import-map support) reports here, not on the <script>
window.addEventListener('error', function (ev) {
var b = document.getElementById('boot');
if (!b || b.classList.contains('is-done')) return;
if (/specifier|resolve|import|module/i.test(String(ev && ev.message))) window.__spBootFail('load');
});
</script>
<script type="module" src="/js/main.js" onerror="window.__spBootFail && window.__spBootFail('load')"></script>
<script nomodule>window.__spBootFail && window.__spBootFail('old');</script>
<script>
// If modules never boot (very old browser / blocked script), explain instead of spinning forever.
setTimeout(function () {
var b = document.getElementById('boot'), e = document.getElementById('boot-err');
if (b && !b.classList.contains('is-done') && e && !e.textContent) e.textContent = '加载时间较长…如长时间无响应,请使用最新版 Chrome / Edge / Safari / Firefox 并刷新页面。';
}, 9000);
</script>
</body>
</html>
+618
View File
@@ -0,0 +1,618 @@
// assets.js — asset manifest (/data/assets.json, docs/ASSETS.md) URL helpers, image cache/preload, and the
// Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer.
//
// Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file).
//
// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use)
// await assets.ready(); // manifest loaded (or failed → every helper returns null)
// assets.avatar('char_002_amiya', { e2: true }) // URL or null
// const data = await assets.spine.acquire(entry); // spineData (PIXI.spine) — throws on failure/timeout
// assets.spine.release(entry);
// await assets.local(); // optional local-client art manifest (data/local-assets.json,
// assets.localUrl('map/autochess', 'TX_autochessi_D') DESIGN §13) → URL or null (never required)
//
// Every URL helper is also exported as a pure function taking the manifest first (`avatarUrl(manifest, …)`),
// so it can be unit tested without a browser. Helpers never throw on unknown ids — they return null and the
// caller falls back (docs/ASSETS.md "Other fallbacks").
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const str = (v) => (typeof v === 'string' && v ? v : null);
const get = (o, k) => (isObj(o) && Object.hasOwn(o, k) ? o[k] : undefined);
// ---- pure URL helpers (manifest first) ---------------------------------------------------------------------
/** Strip a trailing `_1` / `_2` art-phase suffix (data/chess.json asset ids). */
export function baseCharId(id) {
const s = str(id);
if (!s) return null;
return s.replace(/_(1|2)$/, '');
}
/**
* Operator avatar. Accepts a charId (`char_002_amiya`) or an asset id with the E2 suffix (`char_002_amiya_2`).
* `opts.e2` prefers the E2 art. Falls back to the base avatar, then null.
*/
export function avatarUrl(m, id, opts) {
const s = str(id);
if (!s) return null;
const direct = get(get(m, 'chars'), s);
let rec = direct, e2 = !!(opts && opts.e2);
if (!rec) {
const base = baseCharId(s);
rec = get(get(m, 'chars'), base);
if (rec && /_2$/.test(s)) e2 = true;
}
if (rec) return str(e2 && rec.avatarE2) || str(rec.avatar) || null;
return null;
}
/** Operator half-body portrait (`_1` base, `_2` E2). */
export function portraitUrl(m, id, opts) {
const s = str(id);
if (!s) return null;
let rec = get(get(m, 'chars'), s), e2 = !!(opts && opts.e2);
if (!rec) {
rec = get(get(m, 'chars'), baseCharId(s));
if (rec && /_2$/.test(s)) e2 = true;
}
if (!rec) return null;
return str(e2 && rec.portraitE2) || str(rec.portrait) || null;
}
export function enemyIconUrl(m, enemyId) {
const rec = get(get(m, 'enemies'), str(enemyId) || '');
return str(rec && rec.icon);
}
/** Token avatar, falling back to its owner's avatar (docs/ASSETS.md token fallbacks). */
export function tokenAvatarUrl(m, tokenId) {
const rec = get(get(m, 'tokens'), str(tokenId) || '');
if (!rec) return null;
return str(rec.avatar) || (rec.owner ? avatarUrl(m, rec.owner) : null) || str(get(get(get(m, 'prof'), 'battlecard'), 'token'));
}
export const bondIconUrl = (m, bondId) => str(get(get(m, 'bonds'), str(bondId) || ''));
export const bandIconUrl = (m, bandId) => str(get(get(m, 'bands'), str(bandId) || ''));
/** Item icon by trapId (`trap_1041_acarm041`) or item record (`{ trapId | iconId }`). */
export function itemIconUrl(m, item) {
const id = isObj(item) ? (str(item.iconId) || str(item.trapId)) : str(item);
return id ? str(get(get(m, 'items'), id)) : null;
}
/** Skill icon by iconId, or by skillId via `skillsById`. Falls back to the empty skill icon. */
export function skillIconUrl(m, id, opts) {
const fallback = !(opts && opts.fallback === false);
const s = str(id);
const skills = get(m, 'skills');
let url = s ? str(get(skills, s)) : null;
if (!url && s) {
const icon = str(get(get(m, 'skillsById'), s));
if (icon) url = str(get(skills, icon));
}
return url || (fallback ? uiUrl(m, 'skillIcon/empty') : null);
}
/** UI sprite by `group/key` (e.g. 'battle/sprite_shadow'). */
export const uiUrl = (m, name) => str(get(get(m, 'ui'), str(name) || ''));
/** Profession icon: kind 'icon' | 'large' | 'battlecard'; profession in any case (SNIPER → sniper). */
export function profIconUrl(m, profession, kind = 'icon') {
const p = str(profession);
if (!p) return null;
return str(get(get(get(m, 'prof'), kind), p.toLowerCase()));
}
/** Sub-profession icon by subProfessionId or `sub_<id>_icon` asset id. */
export function subProfIconUrl(m, sub) {
const s = str(sub);
if (!s) return null;
const id = s.replace(/^sub_/, '').replace(/_icon$/, '');
return str(get(get(get(m, 'prof'), 'sub'), id));
}
/**
* Spine manifest entry for a unit asset id (operator charId, token id, enemy id). `opts.back` asks for the Back
* model (operators only; falls back to Front). Enemy aliases are resolved transparently. Returns the Spine
* object of docs/ASSETS.md or null.
*/
export function spineEntry(m, id, opts) {
const s = str(id);
if (!s) return null;
const ch = get(get(m, 'chars'), s) || get(get(m, 'chars'), baseCharId(s));
if (ch && isObj(ch.spine)) {
const sp = (opts && opts.back && isObj(ch.spine.back)) ? ch.spine.back : ch.spine.front;
return validSpine(sp) ? sp : null;
}
const tk = get(get(m, 'tokens'), s);
if (tk) return validSpine(tk.spine) ? tk.spine : null;
const en = get(get(m, 'enemies'), s);
if (en) {
if (validSpine(en.spine)) return en.spine;
const alias = str(en.spineAliasOf);
const al = alias ? get(get(m, 'enemies'), alias) : null;
return al && validSpine(al.spine) ? al.spine : null;
}
return null;
}
/** Whether an operator/token/enemy has a Back model. */
export function hasBackSpine(m, id) {
const ch = get(get(m, 'chars'), str(id) || '') || get(get(m, 'chars'), baseCharId(id) || '');
return !!(ch && isObj(ch.spine) && validSpine(ch.spine.back));
}
export function validSpine(sp) {
return isObj(sp) && typeof sp.skel === 'string' && /^\/[^\s]*\.skel$/.test(sp.skel) && typeof sp.atlas === 'string' && isObj(sp.anims);
}
/** Best 2D picture for a unit asset id (operator avatar, token avatar, enemy icon, item icon). */
export function unitPictureUrl(m, id) {
return avatarUrl(m, id) || tokenAvatarUrl(m, id) || enemyIconUrl(m, id) || itemIconUrl(m, id) || null;
}
// ---- audio ---------------------------------------------------------------------------------------------------
/** BGM `{ intro?, loop }` for 'lobby' | 'prep' | 'combat' | 'boss', or a boss id. */
export function bgmEntry(m, kind) {
const a = get(m, 'audio');
const b = get(get(a, 'bossBgm'), str(kind) || '') || get(get(a, 'bgm'), str(kind) || '');
return isObj(b) && str(b.loop) ? b : null;
}
/** SFX URL: group 'ui' | 'battle', key e.g. 'buy'. */
export const sfxUrl = (m, group, key) => str(get(get(get(get(m, 'audio'), 'sfx'), str(group) || ''), str(key) || ''));
/** Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` when present. */
export function unitSfxUrl(m, id, kind, skillIndex) {
const u = get(get(get(get(m, 'audio'), 'sfx'), 'units'), str(id) || '') || get(get(get(get(m, 'audio'), 'sfx'), 'units'), baseCharId(id) || '');
if (!isObj(u)) return null;
if (kind === 'skill' && Number.isInteger(skillIndex) && isObj(u.skills)) {
const s = str(u.skills[skillIndex]);
if (s) return s;
}
return str(u[kind]);
}
// ---- generic caches ----------------------------------------------------------------------------------------
/**
* LRU cache of refcounted async resources. `load(key, arg)` → Promise<value>; `unload(key, value, record)` frees it
* (`record` is the cache record the value belonged to).
* Entries with refs > 0 are never evicted; the cache may exceed `max` while everything is in use.
* Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown.
* Memory budget (optional): `weigh(key, value, arg)` → cost of a ready value; idle (refs 0) ready entries are evicted,
* least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` while nothing at all
* is referenced (no scene on screen) — once they have been idle for `idleGrace` ms (a scene switch releases and
* re-acquires its models within that window, so it never reloads them); a timer sweeps what the grace held back.
*/
export class RefLru {
constructor({ load, unload = () => {}, max = 60, timeout = 20000, concurrency = 6, failTtl = 60000, now = () => Date.now(),
weigh = null, maxIdleWeight = Infinity, quietWeight, idleGrace = 0, timers } = {}) {
if (typeof load !== 'function') throw new TypeError('RefLru: load required');
this._load = load;
this._unload = unload;
this.max = Math.max(1, max | 0);
this.timeout = timeout;
this.concurrency = Math.max(1, concurrency | 0);
this.failTtl = failTtl;
this.now = now;
this._weigh = typeof weigh === 'function' ? weigh : null;
const budget = (v, d) => (typeof v === 'number' && v >= 0 ? v : d);
this.maxIdleWeight = budget(maxIdleWeight, Infinity);
this.quietWeight = budget(quietWeight, this.maxIdleWeight);
this.idleGrace = budget(idleGrace, 0);
this._timers = timers && typeof timers.set === 'function' ? timers : {
set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; },
clear: (t) => clearTimeout(t),
};
this._sweep = null; // { timer, at } pending weight sweep
/** @type {Map<string, { key, promise, value, state: 'loading'|'ready'|'failed', refs: number, used: number, weight: number, idleSince: number|null, error?, failedAt? }>} */
this.map = new Map();
this._active = 0;
this._queue = [];
this._tick = 0;
}
/** Synchronous peek: the value if ready (does not change refs). */
peek(key) {
const e = this.map.get(key);
return e && e.state === 'ready' ? e.value : null;
}
has(key) { return this.map.has(key); }
get size() { return this.map.size; }
/** Acquire a reference; resolves to the value (or rejects). Always pair with release(key). */
acquire(key, arg) {
let e = this.map.get(key);
if (e && e.state === 'failed' && this.now() - e.failedAt > this.failTtl) { this.map.delete(key); e = null; }
if (!e) {
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null };
this.map.set(key, e);
e.promise = this._schedule(() => this._withTimeout(this._load(key, arg), key)).then(
(v) => {
if (this.map.get(key) !== e) { try { this._unload(key, v, e); } catch { /* ignore */ } return v; }
e.value = v; e.state = 'ready';
if (this._weigh) { let w = 0; try { w = Number(this._weigh(key, v, arg)); } catch { /* ignore */ } e.weight = w > 0 ? w : 0; }
this._evict();
return v;
},
(err) => {
e.state = 'failed'; e.error = err; e.failedAt = this.now();
throw err;
},
);
e.promise.catch(() => {});
}
e.refs++;
e.idleSince = null;
e.used = ++this._tick;
return e.promise;
}
/** Drop one reference (never below 0). */
release(key) {
const e = this.map.get(key);
if (!e) return;
if (e.refs > 0 && --e.refs === 0) e.idleSince = this.now();
e.used = ++this._tick;
this._evict();
}
/**
* Evict least-recently-used unreferenced ready/failed entries above `max`, then idle ready entries past their
* grace while the idle weight is over budget (`maxIdleWeight`, or `quietWeight` when nothing is referenced).
*/
_evict() {
if (this.map.size > this.max) {
const idle = [...this.map.values()].filter((e) => e.refs === 0 && e.state !== 'loading').sort((a, b) => a.used - b.used);
for (const e of idle) {
if (this.map.size <= this.max) break;
this._drop(e);
}
}
if (!this._weigh) return;
let refs = 0, idleWeight = 0;
const idle = [];
for (const e of this.map.values()) {
refs += e.refs;
if (e.refs === 0 && e.state === 'ready') { idleWeight += e.weight; idle.push(e); }
}
const budget = refs === 0 ? this.quietWeight : this.maxIdleWeight;
if (idleWeight <= budget) return;
idle.sort((a, b) => a.used - b.used);
const now = this.now();
let wait = Infinity;
for (const e of idle) {
if (idleWeight <= budget) break;
const age = now - (e.idleSince ?? now);
if (age < this.idleGrace) { wait = Math.min(wait, this.idleGrace - age); continue; }
idleWeight -= e.weight;
this._drop(e);
}
if (idleWeight > budget && wait < Infinity) this._sweepIn(wait);
}
_drop(e) {
this.map.delete(e.key);
if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
}
/** Re-run eviction in `ms` (keeps the earliest pending sweep). */
_sweepIn(ms) {
const at = this.now() + ms;
if (this._sweep && this._sweep.at <= at) return;
if (this._sweep) this._timers.clear(this._sweep.timer);
const timer = this._timers.set(() => { this._sweep = null; this._evict(); }, Math.ceil(ms) + 5);
this._sweep = { timer, at };
}
/** Unload everything (refs ignored). */
clear() {
if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; }
const all = [...this.map.values()];
this.map.clear();
for (const e of all) if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
}
stats() {
let ready = 0, loading = 0, failed = 0, refs = 0, weight = 0, idleWeight = 0;
for (const e of this.map.values()) {
if (e.state === 'ready') ready++; else if (e.state === 'loading') loading++; else failed++;
refs += e.refs;
weight += e.weight || 0;
if (e.refs === 0) idleWeight += e.weight || 0;
}
return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length };
}
_schedule(fn) {
return new Promise((resolve, reject) => {
const run = () => {
this._active++;
let p;
try { p = Promise.resolve(fn()); } catch (err) { p = Promise.reject(err); }
p.then(resolve, reject).finally(() => {
this._active--;
const next = this._queue.shift();
if (next) next();
});
};
if (this._active < this.concurrency) run(); else this._queue.push(run);
});
}
_withTimeout(p, key) {
if (!(this.timeout > 0)) return p;
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout);
p.then((v) => { clearTimeout(t); resolve(v); }, (err) => { clearTimeout(t); reject(err); });
});
}
}
/** URL of a local-client art entry from a data/local-assets.json manifest (`{ groups: { g: { name: { path } } } }`). */
export function localAssetUrl(m, group, name) {
const e = get(get(get(m, 'groups'), str(group) || ''), str(name) || '');
return isObj(e) ? str(e.path) : null;
}
/** Default browser image loader. */
export function loadImageElement(url) {
return new Promise((resolve, reject) => {
if (typeof Image === 'undefined') { reject(new Error('no Image in this environment')); return; }
const img = new Image();
img.decoding = 'async';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`image failed: ${url}`));
img.src = url;
});
}
/** Default Spine loader: PIXI.Assets.load(skel) → spineData (needs globalThis.PIXI + PIXI.spine). */
export async function loadSpineData(entry) {
const PIXI = globalThis.PIXI;
if (!PIXI || !PIXI.Assets || !PIXI.spine) throw new Error('PIXI / pixi-spine not loaded');
const res = await PIXI.Assets.load(entry.skel);
const data = res && (res.spineData || res);
if (!data || !Array.isArray(data.animations)) throw new Error(`bad spine data: ${entry.skel}`);
return data;
}
/**
* Estimated memory (bytes) held by a parsed skeleton (pixi-spine 3.8 SkeletonData): its typed/plain arrays (mesh
* vertices/UVs/triangles, timeline frames/curves, deform frames, draw orders) plus a flat cost per object. A parsed
* .skel costs ~10–15× its file size (0.1–1.4 MB files → ~1–12 MB of heap + backing store), mostly timelines, so a
* count cap alone pins anywhere from tens to hundreds of MB; the spine LRU budgets idle skeletons by this weight.
* Never throws; unknown shapes weigh `SPINE_WEIGHT_MIN`.
*/
export const SPINE_WEIGHT_MIN = 64 * 1024;
export function spineDataWeight(data) {
if (!data || typeof data !== 'object') return SPINE_WEIGHT_MIN;
const OBJ = 96, NUM = 8;
const list = (a) => (Array.isArray(a) ? a : []);
const arr = (a) => (!a || typeof a !== 'object' ? 0 : ArrayBuffer.isView(a) ? a.byteLength + OBJ
: Array.isArray(a) ? a.length * NUM + OBJ : 0);
let w = 0;
try {
for (const k of ['bones', 'slots', 'events', 'ikConstraints', 'transformConstraints', 'pathConstraints']) w += list(data[k]).length * OBJ * 2;
for (const skin of list(data.skins)) {
for (const slot of list(skin && skin.attachments)) {
if (!slot || typeof slot !== 'object') continue;
for (const at of Object.values(slot)) {
w += OBJ * 2;
if (at && typeof at === 'object') for (const f of ['vertices', 'regionUVs', 'uvs', 'triangles', 'bones', 'edges', 'lengths', 'offset']) w += arr(at[f]);
}
}
}
for (const anim of list(data.animations)) {
w += OBJ;
for (const t of list(anim && anim.timelines)) {
if (!t || typeof t !== 'object') continue;
w += OBJ + arr(t.frames) + arr(t.curves) + arr(t.attachmentNames) + arr(t.events);
for (const f of list(t.frameVertices)) w += arr(f);
for (const d of list(t.drawOrders)) w += arr(d);
}
}
} catch { /* exotic shape: whatever was counted */ }
return Math.max(SPINE_WEIGHT_MIN, w);
}
/** Atlas page image URLs of a Spine manifest entry (`textures`, else the `<skel name>.png` next to it). */
export function spinePages(entry) {
if (!isObj(entry)) return [];
const list = Array.isArray(entry.textures) ? entry.textures.filter((u) => str(u)) : [];
if (list.length) return list;
const skel = str(entry.skel);
return skel ? [skel.replace(/\.skel$/, '.png')] : [];
}
/**
* Free a skeleton evicted from the LRU: the skeleton asset, its .atlas asset (pixi-spine loads the atlas as a
* separate cached asset, whose unload disposes the page textures on the GPU) AND the atlas page images: the atlas
* parser loads each page through the loader (`loader.load({ src })`), so the decoded page (an ImageBitmap) stays in
* `PIXI.Assets.loader.promiseCache` until that URL is unloaded too. Pages listed in `keep` (still used by another
* cached skeleton) are left alone.
* @param {object} entry manifest spine entry { skel, atlas?, textures? }
* @param {any} [_value] the unloaded spineData (unused)
* @param {Set<string>} [keep] page URLs not to unload
*/
export function unloadSpineData(entry, _value, keep) {
const PIXI = globalThis.PIXI;
const skel = entry && entry.skel;
if (!skel || !PIXI?.Assets?.unload) return;
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
const loader = PIXI.Assets.loader;
const unload = (url, direct) => {
try {
const p = direct && loader && typeof loader.unload === 'function' ? loader.unload(url) : PIXI.Assets.unload(url);
Promise.resolve(p).catch(() => {});
} catch { /* ignore */ }
};
unload(skel, false);
unload(atlas, false);
// pages were loaded by the atlas parser straight through the loader (never through Assets.load / the resolver)
for (const url of spinePages(entry)) if (!(keep && keep.has(url))) unload(url, true);
}
// ---- store ---------------------------------------------------------------------------------------------------
/** Spine LRU memory budget: estimated bytes (`spineDataWeight`) of idle skeletons kept while a scene is shown. */
export const SPINE_IDLE_BYTES = 48 * 1024 * 1024;
/** How long a released skeleton is safe from the weight budget (a prep ⇄ battle switch re-acquires within it). */
export const SPINE_IDLE_GRACE_MS = 15000;
/**
* Create an asset store.
* @param {{ url?: string, fetch?: typeof fetch, manifest?: object, loadImage?: (url) => Promise<any>,
* loadSpine?: (entry) => Promise<any>, unloadSpine?: (entry, value, keepPages:Set<string>) => void, spineMax?: number,
* spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number,
* spineQuietBytes?: number, spineIdleGrace?: number, spineTimers?: { set, clear }, spineNow?: () => number }} [opts]
*/
export function createAssets(options) {
const opts = options && typeof options === 'object' ? options : {};
const url = opts.url || '/data/assets.json';
const localUrl = opts.localUrl || '/data/local-assets.json';
let localPromise = isObj(opts.localManifest) ? Promise.resolve(opts.localManifest) : null;
let localManifest = isObj(opts.localManifest) ? opts.localManifest : null;
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
let manifest = isObj(opts.manifest) ? opts.manifest : null;
let readyPromise = manifest ? Promise.resolve(manifest) : null;
const loadImage = opts.loadImage || loadImageElement;
const images = new Map(); // url → { promise, value }
function ready() {
if (!readyPromise) {
readyPromise = (async () => {
try {
const res = await doFetch(url, { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
const json = await res.json();
manifest = isObj(json) ? json : {};
} catch (err) {
console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks`);
manifest = {};
}
return manifest;
})();
}
return readyPromise;
}
const spineEntries = new Map(); // skel URL → manifest entry (for the atlas / page URLs on unload)
const spine = new RefLru({
load: (key, entry) => { spineEntries.set(key, entry); return (opts.loadSpine || loadSpineData)(entry); },
unload: (key, value, rec) => {
// a late value of an entry that was dropped while loading, with the key already loading/cached again: the
// PIXI caches are shared by URL, so unloading would pull the resources out from under the new entry
const cur = spine.map.get(key);
if (cur && rec && cur !== rec) return;
const e = spineEntries.get(key) || { skel: key };
spineEntries.delete(key);
const keep = new Set();
for (const [k, other] of spineEntries) if (k !== key && spine.map.has(k)) for (const u of spinePages(other)) keep.add(u);
(opts.unloadSpine || unloadSpineData)(e, value, keep);
},
max: opts.spineMax ?? 60,
timeout: opts.spineTimeout ?? 20000,
concurrency: opts.spineConcurrency ?? 6,
// memory budget (a parsed skeleton holds ~0.1–12 MB): idle skeletons beyond SPINE_IDLE_BYTES go, and all of
// them once no scene references any (lobby / room), each after a grace that covers a prep ⇄ battle switch
weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)),
maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES,
quietWeight: opts.spineQuietBytes ?? 0,
idleGrace: opts.spineIdleGrace ?? SPINE_IDLE_GRACE_MS,
timers: opts.spineTimers,
now: opts.spineNow,
});
/** Image element (cached; failures resolve to null). */
function image(u) {
const s = str(u);
if (!s) return Promise.resolve(null);
let e = images.get(s);
if (!e) {
e = { value: null, done: false, promise: null };
e.promise = Promise.resolve().then(() => loadImage(s)).then(
(img) => { e.value = img; e.done = true; return img; },
() => { e.done = true; return null; },
);
images.set(s, e);
}
return e.promise;
}
/** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once. */
function local() {
if (!localPromise) {
localPromise = (async () => {
try {
const res = await doFetch(localUrl, { cache: 'no-cache' });
if (!res || !res.ok) return null;
const json = await res.json();
localManifest = isObj(json) && isObj(json.groups) ? json : null;
} catch { localManifest = null; }
return localManifest;
})();
}
return localPromise;
}
const m = () => manifest || {};
return {
ready,
local,
localUrl: (group, name) => localAssetUrl(localManifest, group, name),
get manifest() { return manifest; },
get loaded() { return !!manifest; },
avatar: (id, o) => avatarUrl(m(), id, o),
portrait: (id, o) => portraitUrl(m(), id, o),
enemyIcon: (id) => enemyIconUrl(m(), id),
tokenAvatar: (id) => tokenAvatarUrl(m(), id),
bondIcon: (id) => bondIconUrl(m(), id),
bandIcon: (id) => bandIconUrl(m(), id),
itemIcon: (item) => itemIconUrl(m(), item),
skillIcon: (id, o) => skillIconUrl(m(), id, o),
profIcon: (p, kind) => profIconUrl(m(), p, kind),
subProfIcon: (s) => subProfIconUrl(m(), s),
ui: (name) => uiUrl(m(), name),
picture: (id) => unitPictureUrl(m(), id),
spineEntry: (id, o) => spineEntry(m(), id, o),
hasBack: (id) => hasBackSpine(m(), id),
audio: {
bgm: (kind) => bgmEntry(m(), kind),
sfx: (group, key) => sfxUrl(m(), group, key),
unit: (id, kind, idx) => unitSfxUrl(m(), id, kind, idx),
},
/** Cached image element promise (null on failure). */
image,
/** Already-loaded image element or null (sync). */
imageNow(u) { const e = images.get(str(u) || ''); return e && e.done ? e.value : null; },
/**
* Preload URLs; `onProgress(done, total, url)` after each. Resolves to { ok, failed } counts (never rejects).
*/
async preload(urls, onProgress) {
const list = [...new Set((Array.isArray(urls) ? urls : []).map(str).filter(Boolean))];
let done = 0, ok = 0;
await Promise.all(list.map((u) => image(u).then((img) => {
done++;
if (img) ok++;
try { onProgress?.(done, list.length, u); } catch { /* ignore */ }
})));
return { ok, failed: list.length - ok, total: list.length };
},
/** Spine data LRU: acquire(entry) → Promise<spineData>; release(entry); peek(entry). */
spine: {
acquire: (entry) => (validSpine(entry) ? spine.acquire(entry.skel, entry) : Promise.reject(new Error('no spine entry'))),
release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); },
peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null),
stats: () => spine.stats(),
clear: () => spine.clear(),
cache: spine,
},
};
}
/** Browser singleton. */
export const assets = createAssets();
+622
View File
@@ -0,0 +1,622 @@
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX. Never throws.
//
// Sources: data/assets.json → audio (docs/ASSETS.md):
// bgm { lobby, prep, combat, boss: { intro?, loop } }, bossBgm { [bossId]: { intro?, loop } },
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born? } }.
//
// - The AudioContext is created on the first user gesture (pointerdown/keydown/touchend), so browsers
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds,
// a per-unit cooldown and a per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
// 香槟炸弹) plays its impact sound instead of a death sound.
// - Buffers are fetched once and cached (LRU); failed fetch/decode ⇒ silent (logged once as a warning).
//
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
// which follows the store).
import { PHASE } from '../../shared/constants.js';
const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const BUFFER_CACHE = 180;
const XFADE_S = 1;
const FADE_S = 0.8;
// ---- pure helpers (unit-tested) -----------------------------------------------------------------------
/**
* BGM key for a route + match phase.
* @param {'title'|'lobby'|'room'|'game'|string} route
* @param {any} pub m.public (may be null)
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'boss' | 'boss:<bossId>' | null
*/
export function bgmKeyFor(route, pub) {
if (route !== 'game') return route === 'title' || route === 'lobby' || route === 'room' ? 'lobby' : null;
const phase = pub?.phase;
if (!phase) return 'lobby';
switch (phase) {
case PHASE.INFO_CHECK: case PHASE.BAND_DRAFT: case PHASE.BATTLE_CHECK: case PHASE.RESULT: case PHASE.LOBBY:
return 'lobby';
case PHASE.COMBAT: case PHASE.UNITE:
return 'combat';
case PHASE.FINAL_ASSAULT:
return pub.bossId ? `boss:${pub.bossId}` : 'boss';
case PHASE.HIDDEN_CORE:
return pub.hiddenBossId ? `boss:${pub.hiddenBossId}` : pub.bossId ? `boss:${pub.bossId}` : 'boss';
default:
return 'prep';
}
}
/**
* Resolve a BGM key to { intro?, loop } URLs from the manifest (boss:<id> falls back to the generic boss track).
* @param {any} manifest
* @param {string|null} key
* @returns {{ intro: string|null, loop: string }|null}
*/
export function resolveBgm(manifest, key) {
const a = manifest?.audio;
if (!a || !key) return null;
let t = null;
if (key.startsWith('boss:')) t = a.bossBgm?.[key.slice(5)] || a.bgm?.boss;
else t = a.bgm?.[key];
if (!t || typeof t.loop !== 'string') return null;
return { intro: typeof t.intro === 'string' ? t.intro : null, loop: t.loop };
}
/**
* Official sound class of a battle unit (audio_data `battle.ON_UNIT_DEAD|BORN.<class>` defaults):
* 'enemy' | 'char' (operators: b_char_dead “干员被击倒” / b_char_set) | 'token' (summons: b_char_tokendead /
* b_char_tokenset) | 'device' (stage devices: the act crate trap_1105 dies with b_char_tokendead, no born sound).
* Band map characters (预备干员-医疗 / Touch, `char_*` ids) are characters although the sim runs them as tokens.
* @param {{ side?: string, kind?: string, defId?: string, def?: string }|null} info tracked unit (UnitInfo subset)
*/
export function unitSoundClass(info) {
if (!info) return 'char';
if (info.side === 'enemy') return 'enemy';
const id = String(info.defId ?? info.def ?? '');
if (info.kind === 'device') return 'device';
if (info.kind === 'token') return /^char_/.test(id) ? 'char' : 'token';
return 'char';
}
/** URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie. */
function tokenDieUrl(manifest) {
const b = manifest?.audio?.sfx?.battle;
if (typeof b?.tokenDie === 'string') return b.tokenDie;
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? b.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
}
/**
* Death sound of a battle unit ('die' event): the unit's own ON_UNIT_DEAD sound, else its class default — only
* operators play the operator-knocked-down sound (charDie). A summon that fired and was used up (香槟炸弹: its
* explosion is the sound) is silent, and so is an operator leaving without being knocked out, when the event says so
* (`reason` ≠ 'killed').
* @param {any} manifest data/assets.json
* @param {{ side?: string, kind?: string, defId?: string, def?: string, boss?: boolean }|null} info
* @param {{ consumed?: boolean, reason?: string|null }} [o]
* @returns {string|null} sound URL
*/
export function deathSfxUrl(manifest, info, { consumed = false, reason = null } = {}) {
if (!info || consumed) return null;
const cls = unitSoundClass(info);
if (cls === 'char' && reason && reason !== 'killed') return null;
const own = manifest?.audio?.sfx?.units?.[info.def]?.die;
if (typeof own === 'string') return own;
const b = manifest?.audio?.sfx?.battle ?? {};
if (cls === 'enemy') return (info.boss ? b.enemyDieHeavy : null) ?? b.enemyDie ?? null;
if (cls === 'char') return typeof b.charDie === 'string' ? b.charDie : null;
return tokenDieUrl(manifest);
}
/**
* Deployment sound of an allied unit ('deploy' event): its own ON_UNIT_BORN sound, else operators b_char_set
* (sfx.battle.deploy), summons b_char_tokenset (tokenDeploy); stage devices have none.
* @returns {string|null}
*/
export function deploySfxUrl(manifest, info) {
if (!info || info.side === 'enemy') return null;
const own = manifest?.audio?.sfx?.units?.[info.def]?.born;
if (typeof own === 'string') return own;
const b = manifest?.audio?.sfx?.battle ?? {};
const cls = unitSoundClass(info);
if (cls === 'device') return null;
const url = cls === 'token' ? (b.tokenDeploy ?? b.deploy) : b.deploy;
return typeof url === 'string' ? url : null;
}
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
export class SfxLimiter {
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number }} [o] */
constructor(o = {}) {
this.maxVoices = o.maxVoices ?? MAX_VOICES;
this.unitCooldownMs = o.unitCooldownMs ?? UNIT_COOLDOWN_MS;
this.urlGapMs = o.urlGapMs ?? URL_GAP_MS;
this.active = 0;
this.lastByUnit = new Map();
this.lastByUrl = new Map();
}
/**
* Whether a sound may start now; records it when allowed (call `release()` when it ends).
* @param {number} now ms
* @param {string|number|null} unitKey e.g. `${unitId}:atk`
* @param {string} url
*/
tryAcquire(now, unitKey, url) {
if (this.active >= this.maxVoices) return false;
if (unitKey != null) {
const t = this.lastByUnit.get(unitKey);
if (t != null && now - t < this.unitCooldownMs) return false;
}
const u = this.lastByUrl.get(url);
if (u != null && now - u < this.urlGapMs) return false;
if (unitKey != null) this.lastByUnit.set(unitKey, now);
this.lastByUrl.set(url, now);
if (this.lastByUnit.size > 600) this.lastByUnit.clear();
if (this.lastByUrl.size > 400) this.lastByUrl.clear();
this.active += 1;
return true;
}
release() { this.active = Math.max(0, this.active - 1); }
}
// ---- manager -----------------------------------------------------------------------------------------------
export class AudioManager {
/**
* @param {{ getManifest?: () => any, win?: any }} [opts]
*/
constructor(opts = {}) {
this.getManifest = typeof opts.getManifest === 'function' ? opts.getManifest : () => null;
this.win = opts.win ?? (typeof window !== 'undefined' ? window : null);
this.ctx = null;
this.master = null;
this.bgmGain = null;
this.sfxGain = null;
this.volumes = { bgm: 0.6, sfx: 0.8, muted: false };
this.buffers = new Map(); // url → Promise<AudioBuffer|null> (insertion order = LRU)
this.warned = new Set();
this.limiter = new SfxLimiter();
this.uiVoices = 0;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.lastAttacker = new Map(); // target id → attacker defId
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
this._unlock = this._unlock.bind(this);
this._onVis = this._onVis.bind(this);
}
/** Attach gesture unlock + visibility handling. Idempotent. */
install() {
if (this.installed || !this.win) return;
this.installed = true;
try {
for (const ev of UNLOCK_EVENTS) this.win.addEventListener(ev, this._unlock, { capture: true, passive: true });
this.win.document?.addEventListener?.('visibilitychange', this._onVis);
// iOS / iPadOS: a phone call, Siri or another app puts the context into 'interrupted'; coming back to the page
// (pageshow / focus) resumes it (plus the next gesture, below)
this.win.addEventListener?.('pageshow', this._onVis);
this.win.addEventListener?.('focus', this._onVis);
} catch { /* ignore */ }
}
get unlocked() { return !!this.ctx; }
/**
* First user gesture: create the context. The gesture listeners stay until the context actually runs — iOS Safari
* only counts touchend / click (not pointerdown / touchstart) as activation, so a context created on pointerdown can
* stay 'suspended' until the finger lifts. A 1-sample silent buffer is played inside the gesture (older WebKit only
* unlocks output after something was started in a gesture).
*/
_unlock() {
if (this.ctx) {
const st = this.ctx.state;
if (st === 'running') { this._dropUnlock(); return; }
if (!this.win?.document?.hidden) {
this._primeOutput();
const p = this.ctx.resume?.();
if (p && typeof p.then === 'function') p.then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
return;
}
try {
const AC = this.win?.AudioContext || this.win?.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.bgmGain = this.ctx.createGain();
this.sfxGain = this.ctx.createGain();
this.bgmGain.connect(this.master);
this.sfxGain.connect(this.master);
this.master.connect(this.ctx.destination);
// iOS / iPadOS: a call, Siri or another app's audio moves a running context to 'interrupted' (or 'suspended');
// a resume without a gesture may then be refused — listen for the next gesture again (dropped once it runs)
try {
this.ctx.addEventListener?.('statechange', () => {
const s = this.ctx?.state;
if (s && s !== 'running' && s !== 'closed' && !this.win?.document?.hidden) this._armUnlock();
});
} catch { /* ignore */ }
this._applyVolumes();
this._primeOutput();
if (this.ctx.state === 'running') this._dropUnlock();
else {
const p = this.ctx.resume?.();
if (p && typeof p.then === 'function') p.then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
if (this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
} catch (err) {
this._warn('ctx', err);
this.ctx = null;
}
}
/** (Re-)attach the gesture listeners after the context stopped running while visible (see _unlock / _onVis). */
_armUnlock() {
if (!this._unlockDropped || !this.win) return;
this._unlockDropped = false;
try { for (const ev of UNLOCK_EVENTS) this.win.addEventListener(ev, this._unlock, { capture: true, passive: true }); } catch { /* ignore */ }
}
/** Remove the first-gesture listeners (the context runs). */
_dropUnlock() {
if (this._unlockDropped || !this.win) return;
this._unlockDropped = true;
try { for (const ev of UNLOCK_EVENTS) this.win.removeEventListener(ev, this._unlock, { capture: true }); } catch { /* ignore */ }
}
/** Start a silent 1-sample buffer (inside a user gesture: unlocks output on older WebKit). */
_primeOutput() {
try {
const c = this.ctx;
if (!c || typeof c.createBuffer !== 'function') return;
const src = c.createBufferSource();
src.buffer = c.createBuffer(1, 1, c.sampleRate || 44100);
src.connect(c.destination);
src.start ? src.start(0) : src.noteOn?.(0);
} catch { /* ignore */ }
}
_onVis() {
try {
if (!this.ctx) return;
if (this.win?.document?.hidden) this.ctx.suspend().catch(() => {});
else if (this.ctx.state !== 'running') {
// back on the page: resume, and keep a gesture ready in case the browser wants one first (iOS after a call)
this._armUnlock();
this.ctx.resume().then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
} catch { /* ignore */ }
}
_warn(key, err) {
if (this.warned.has(key)) return;
this.warned.add(key);
try { console.warn(`[audio] ${key} unavailable`, err?.message || err || ''); } catch { /* ignore */ }
}
/**
* Set channel volumes (0..1) and mute.
* @param {{ bgm?: number, sfx?: number, muted?: boolean }} v
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
this.volumes = { bgm: n(v?.bgm, this.volumes.bgm), sfx: n(v?.sfx, this.volumes.sfx), muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted };
this._applyVolumes();
}
_applyVolumes() {
if (!this.ctx) return;
try {
const t = this.ctx.currentTime;
this.master.gain.setTargetAtTime(this.volumes.muted ? 0 : 1, t, 0.03);
// perceptual curve
this.bgmGain.gain.setTargetAtTime(this.volumes.bgm ** 2 * 0.55, t, 0.05);
this.sfxGain.gain.setTargetAtTime(this.volumes.sfx ** 2 * 0.9, t, 0.03);
} catch { /* ignore */ }
}
/** Fetch + decode (cached, LRU). Resolves null on failure. */
_buffer(url) {
if (!this.ctx || typeof url !== 'string' || !url) return Promise.resolve(null);
const hit = this.buffers.get(url);
if (hit) {
this.buffers.delete(url);
this.buffers.set(url, hit);
return hit;
}
const p = (async () => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ab = await res.arrayBuffer();
return await new Promise((resolve) => {
try {
const r = this.ctx.decodeAudioData(ab, resolve, () => resolve(null));
if (r && typeof r.then === 'function') r.then(resolve, () => resolve(null));
} catch { resolve(null); }
});
} catch (err) {
this._warn(url, err);
return null;
}
})();
this.buffers.set(url, p);
while (this.buffers.size > BUFFER_CACHE) {
const first = this.buffers.keys().next().value;
// never evict the playing BGM
if (this.bgm && first === this.bgm.loopUrl) { const v = this.buffers.get(first); this.buffers.delete(first); this.buffers.set(first, v); break; }
this.buffers.delete(first);
}
return p;
}
/** Preload a list of URLs (e.g. UI SFX) once unlocked. */
preload(urls) {
if (!this.ctx) return;
for (const u of Array.isArray(urls) ? urls : []) this._buffer(u);
}
// ---- BGM ------------------------------------------------------------------------------------------------
/**
* Switch BGM (null stops). Same loop URL ⇒ no restart.
* @param {string|null} key see bgmKeyFor
*/
playBgm(key) {
try {
if (!this.ctx) { this.wantBgm = key; return; }
const track = resolveBgm(this.getManifest(), key);
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
if (!track && !this.bgm) return;
const token = ++this.bgmToken;
this._fadeOutBgm();
if (!track) return;
this._startBgm(key, track, token);
} catch (err) { this._warn('bgm', err); }
}
async _startBgm(key, track, token) {
const [intro, loop] = await Promise.all([track.intro ? this._buffer(track.intro) : null, this._buffer(track.loop)]);
if (token !== this.bgmToken || !this.ctx || !loop) return;
try {
const ctx = this.ctx;
const gain = ctx.createGain();
gain.connect(this.bgmGain);
const t0 = ctx.currentTime + 0.05;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(1, t0 + FADE_S);
const nodes = [];
let loopAt = t0;
if (intro) {
const s = ctx.createBufferSource();
s.buffer = intro;
const g = ctx.createGain();
s.connect(g); g.connect(gain);
s.start(t0);
const end = t0 + intro.duration;
const xf = Math.min(XFADE_S, intro.duration / 2);
g.gain.setValueAtTime(1, Math.max(t0, end - xf));
g.gain.linearRampToValueAtTime(0, end);
nodes.push({ src: s, gain: g });
loopAt = end - xf;
}
const s = ctx.createBufferSource();
s.buffer = loop;
s.loop = true;
const g = ctx.createGain();
s.connect(g); g.connect(gain);
if (intro) {
g.gain.setValueAtTime(0, loopAt);
g.gain.linearRampToValueAtTime(1, loopAt + Math.min(XFADE_S, intro.duration / 2));
}
s.start(loopAt);
nodes.push({ src: s, gain: g });
this.bgm = { key, loopUrl: track.loop, nodes, gain };
} catch (err) { this._warn('bgm-start', err); }
}
_fadeOutBgm() {
const cur = this.bgm;
this.bgm = null;
if (!cur || !this.ctx) return;
try {
const t = this.ctx.currentTime;
cur.gain.gain.cancelScheduledValues(t);
cur.gain.gain.setValueAtTime(cur.gain.gain.value, t);
cur.gain.gain.linearRampToValueAtTime(0, t + FADE_S);
for (const n of cur.nodes) { try { n.src.stop(t + FADE_S + 0.05); } catch { /* ignore */ } }
setTimeout(() => { try { cur.gain.disconnect(); } catch { /* ignore */ } }, (FADE_S + 0.3) * 1000);
} catch { /* ignore */ }
}
// ---- SFX ------------------------------------------------------------------------------------------------
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (limited) { if (!this.limiter.tryAcquire(now, unitKey, url)) return; }
else if (this.uiVoices >= 12) return;
else this.uiVoices += 1;
const release = () => { if (limited) this.limiter.release(); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
this._buffer(url).then((buf) => {
if (!buf || !this.ctx) { release(); return; }
try {
const s = this.ctx.createBufferSource();
s.buffer = buf;
s.playbackRate.value = rate;
const g = this.ctx.createGain();
g.gain.value = Math.max(0, Math.min(1.5, volume));
s.connect(g); g.connect(this.sfxGain);
let done = false;
const end = () => { if (!done) { done = true; release(); try { g.disconnect(); } catch { /* ignore */ } } };
s.onended = end;
setTimeout(end, (buf.duration / rate) * 1000 + 250); // safety if onended never fires
s.start();
} catch { release(); }
}, release);
}
/**
* UI sound by name (sfx.ui keys). Unknown names are ignored.
* @param {string} name
* @param {{ volume?: number }} [o]
*/
sfx(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
} catch { /* ignore */ }
}
/** Battle sound by name (sfx.battle keys), limited like unit sounds. */
battle(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.battle?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.7, limited: true, unitKey: o.unitKey ?? `b:${name}` });
} catch { /* ignore */ }
}
/**
* Per-unit sound (attack/hit/skill/die/born), throttled.
* @param {string} defId charId/tokenId/enemyId (or chess id — mapped via its spine/char id by the caller)
* @param {'attack'|'hit'|'skill'|'die'|'born'} kind
* @param {number|string} unitId battle unit id (cooldown key)
* @returns {boolean} whether a unit-specific sound exists
*/
unit(defId, kind, unitId, skillIndex) {
try {
const u = this.getManifest()?.audio?.sfx?.units?.[defId];
// DESIGN §16: the equipped skill's own ON_SKILL_START sound (`skills[index]`) when the manifest has it
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
const url = typeof own === 'string' ? own : u?.[kind];
if (typeof url !== 'string') return false;
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
return true;
} catch { return false; }
}
// ---- battle events ------------------------------------------------------------------------------------------
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.units.clear();
this.lastAttacker.clear();
this.consumed.clear();
for (const u of Array.isArray(units) ? units : []) this._track(u);
}
_track(u) {
if (!u || typeof u !== 'object' || u.id == null) return;
// UnitInfo.spine is the model id (charId / tokenId / enemyId) — the key of sfx.units; kind/defId pick the
// official class sounds (operator vs summon vs device)
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
}
/** Play a resolved battle sound for a unit event, limited like unit sounds. */
_playUnitUrl(url, unitKey, volume = 0.8) {
if (typeof url === 'string') this._play(url, { volume, limited: true, unitKey });
}
/**
* React to `b.ev` tuples (DESIGN §8.2).
* @param {any[]} ev
*/
handleBattleEvents(ev) {
if (!this.ctx || !Array.isArray(ev)) return;
try {
for (const e of ev) {
if (!Array.isArray(e)) continue;
const kind = e[0];
if (kind === 'spawn') { this._track(e[1]); continue; }
if (kind === 'atk') {
const src = this.units.get(e[1]);
if (!src) continue;
this.lastAttacker.set(e[2], src.def);
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
} else if (kind === 'dmg') {
const by = this.lastAttacker.get(e[1]);
if (by) this.unit(by, 'hit', `h${e[1]}`);
} else if (kind === 'heal') {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
} else if (kind === 'die') {
const u = this.units.get(e[1]);
if (!u) continue;
const consumed = this.consumed.delete(e[1]);
const m = this.getManifest();
const url = deathSfxUrl(m, u, { consumed, reason: typeof e[2] === 'string' ? e[2] : null });
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.die;
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? 0.8 : 0.7);
} else if (kind === 'deploy') {
const u = this.units.get(e[1]);
if (!u || u.side === 'enemy') continue;
const m = this.getManifest();
const url = deploySfxUrl(m, u);
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
this._playUnitUrl(url, own ? `${e[1]}:born` : 'deploy', own ? 0.8 : 0.5);
} else if (kind === 'fx') {
// a summon used up by its own effect (香槟炸弹 exploding: `consumed`): its impact sound now, no death sound
const ex = e[4];
if (!ex || typeof ex !== 'object' || !ex.consumed || ex.id == null) continue;
const u = this.units.get(ex.id);
if (!u || u.side === 'enemy') continue;
this.consumed.add(ex.id);
if (this.consumed.size > 200) this.consumed.delete(this.consumed.values().next().value);
this.unit(u.def, 'hit', `${ex.id}:boom`);
} else if (kind === 'bounty') {
this.battle('killCoin', { unitKey: 'coin' });
}
}
} catch (err) { this._warn('events', err); }
}
}
let manifestGetter = () => null;
/** App-wide audio manager. */
export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, muted:boolean } }} deps
*/
export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) audio.setVolumes(deps.settings);
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try { audio.playBgm(bgmKeyFor(deps.selectRoute(s), s.match?.public)); } catch { /* ignore */ }
};
sync(deps.getState());
return deps.subscribe((s, prev) => {
if (s.match?.public?.phase !== prev?.match?.public?.phase || s.room !== prev?.room || s.session !== prev?.session
|| s.match?.public?.bossId !== prev?.match?.public?.bossId) sync(s);
});
}
} catch (err) { console.warn('[audio] install failed', err); }
return () => {};
}
+107
View File
@@ -0,0 +1,107 @@
// Observing rules and labels of client-side combat (research 09 §3.1 / §6.3, DESIGN §14 "Spectating") — pure helpers
// for the game screen, the team panel and the combat HUD (mirror of server/match/Match.js _watchClient):
// * prep (休整期): tap a teammate → 前往查看 → their board (read-only);
// * own normal battle running: no observing ("当前无法查看");
// * own battle over: "⌛ 作战结束,等待队友完成作战" + the teammates' progress; tap a teammate → 前往查看 → a local
// replica of their battle; 返回战场 goes back;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half of the own field; the other pair's
// boss field is never shown to a fighting player;
// * eliminated: anything.
import { PHASE } from '../../../shared/constants.js';
const isObj = (v) => !!v && typeof v === 'object';
const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** The server runs client-side combat (m.public.combatMode). */
export const isClientCombat = (pub) => !!pub && pub.combatMode === 'client';
const players = (pub) => (Array.isArray(pub?.players) ? pub.players.filter(isObj) : []);
const fields = (pub) => (Array.isArray(pub?.fields) ? pub.fields.filter(isObj) : []);
/** The field listing a player, or null. */
export function fieldOf(pub, playerId) {
return fields(pub).find((f) => Array.isArray(f.players) && f.players.includes(playerId)) || null;
}
/** Display name of a player id ('队友' when unknown). */
export function nameOf(pub, playerId) {
return players(pub).find((p) => p.playerId === playerId)?.name || '队友';
}
/**
* What tapping a team row does: `{ back: true }` (own row while observing), `{ fieldId }` to observe, or `{ reason }`.
* @param {any} p m.public player row
* @param {any} pub
* @param {string} myId
* @param {{ observing?: boolean, ownDone?: boolean }} [o] ownDone: the local simulation of the own battle already ended
* (its result is on the way to the server)
*/
export function observeTarget(p, pub, myId, { observing = false, ownDone = false } = {}) {
if (!isObj(p)) return { reason: '无效的目标' };
if (p.playerId === myId) return observing ? { back: true } : { reason: null };
if (p.alive === false || p.status === 'left') return { reason: '该队友已被淘汰,无法查看' };
const phase = pub?.phase;
const me = players(pub).find((x) => x.playerId === myId) || null;
const meAlive = me ? me.alive !== false : true;
if (!COMBAT.has(phase)) return { fieldId: `n:${p.playerId}` };
const target = fieldOf(pub, p.playerId);
if (!target) return { reason: '该队友当前没有战场' };
const own = fieldOf(pub, myId);
if (!meAlive || !own) return { fieldId: target.fieldId };
if (own.fieldId === target.fieldId) return { reason: '队友与你在同一战场,使用 ‹ › 切换视角' };
if (target.kind === 'boss' || target.kind === 'hidden') return { reason: '无法查看另一组队友的战场' };
if (own.kind === 'normal' && own.live !== false && !ownDone) return { reason: '作战中无法查看队友,作战结束后可前往查看' };
return { fieldId: target.fieldId };
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
for (const f of fields(pub)) {
if (f.kind !== 'normal' || !Array.isArray(f.players)) continue;
const pid = f.players[0];
if (pid === myId) continue;
const p = players(pub).find((x) => x.playerId === pid);
const pr = isObj(f.progress) ? f.progress : null;
out.push({
playerId: pid, name: p?.name || '队友', isBot: !!p?.isBot,
killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null,
done: f.live === false || !!pr?.done,
});
}
return out;
}
/** Side of each player of a local field meta (`sides` from the runner, else seat order). */
export function sidesOf(field) {
if (isObj(field?.sides)) return field.sides;
const ps = Array.isArray(field?.players) ? field.players : [];
return Object.fromEntries(ps.map((pid, i) => [pid, i === 1 ? 'R' : 'L']));
}
/**
* The ‹ › camera layers of a 联防 / boss field: LEFT half, 全景, RIGHT half with captions "你自己" / "👁 name" /
* "全景" / "无人在家". Empty for normal fields and single-player boss fields.
*/
export function cameraLayers(field, pub, myId) {
if (!isObj(field) || (field.kind !== 'unite' && field.kind !== 'boss' && field.kind !== 'hidden')) return [];
const sides = sidesOf(field);
const at = (side) => Object.keys(sides).find((pid) => sides[pid] === side) || null;
const label = (pid) => (!pid ? '无人在家' : pid === myId ? '你自己' : nameOf(pub, pid));
const left = at('L');
const right = at('R');
if ((field.kind === 'boss' || field.kind === 'hidden') && (!left || !right)) return [];
return [
{ key: 'L', label: label(left), self: left === myId, watch: !!left && left !== myId },
{ key: 'ALL', label: '全景', self: false, watch: false },
{ key: 'R', label: label(right), self: right === myId, watch: !!right && right !== myId },
];
}
/** Camera options of a layer for view.setCamera(kind, …). */
export function layerCamera(field, layer, mySide = 'L') {
const rect = field?.rect;
if (layer === 'L' || layer === 'R') return { rect, side: layer, half: true };
return { rect, side: mySide };
}
+536
View File
@@ -0,0 +1,536 @@
// Local battle runner — client-side combat (DESIGN §14, research 09 §3.2 / §6.4).
//
// Like the official client, the browser simulates battles itself with the shared deterministic sim (server/sim,
// served read-only at /sim/). The server sends a JSON BattleSpec (`b.start`); this module builds the Battle with
// /sim/spec.js createBattleFromSpec and
// * steps it at the battle speed (fixed 1/30 s ticks, 2× → 60 ticks per real second; at most
// max(8, 4·speed) ticks per animation frame, bounded fast-forward up to CATCHUP_TICKS per frame when it is far
// behind: a reconnect, observing a running field, a tab back from the background),
// * feeds the render engine every frame through the same wire formats the server used to stream
// (`snap` = b.snap frame from battle.snapshot(), `ev` = b.ev frame from battle.drainEvents()) and publishes the
// field meta (m.field shape) into store.match.field so the game screen enters the battle,
// * when authoritative: reports b.progress (~1 Hz; boss fields 4 Hz with the shared-pool damage and the LP meter)
// and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover),
// * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse.
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
// spec fast-forwarded to the server's clock (`elapsed`) and never report.
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
// again when the session is back online ('status' → 'online') or when a b.start still names this finished battle
// authoritative (the server is waiting for it); the server takes a duplicate idempotently. A server refusal is final.
// Solo pause (g.pause, DESIGN §14): while `m.public.paused` is true every local battle clock stands still (no ticks, no
// reports); on resume the clocks move on by the paused time, like the server's field clock.
//
// The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an
// authoritative one if it is not the one on screen). stats() exposes the measured cost.
//
// import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node)
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node).
import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */
export const CATCHUP_TICKS = 240;
/** Silent catch-up slice (ticks) while a new battle is prepared before it is shown. */
const PREPARE_SLICE = 600;
const MAX_ENTRIES = 4;
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
/** Data files the simulation reads (DataSource + content/support gameData()). */
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']);
/** Request failures after which a b.result counts as never delivered (re-sent on resume / b.start). */
export const LOST_RESULT_CODES = Object.freeze(['DISCONNECTED', 'OFFLINE', 'TIMEOUT']);
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
function deepFreeze(root) {
const stack = [root];
while (stack.length) {
const o = stack.pop();
if (o === null || typeof o !== 'object' || Object.isFrozen(o)) continue;
Object.freeze(o);
for (const v of Object.values(o)) if (v !== null && typeof v === 'object') stack.push(v);
}
return root;
}
/**
* Browser sim loader: the /sim/ modules + the data files (own frozen copies — the server's data is frozen too, so a
* content bug that writes into a record fails identically on both sides).
*/
export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetchFn = (...a) => globalThis.fetch(...a) } = {}) {
const [spec, simdata, support] = await Promise.all([
import(`${base}spec.js`), import(`${base}simdata.js`), import(`${base}content/support/index.js`),
]);
const fetchOnce = async (n) => {
try {
const res = await fetchFn(`${dataBase}${n}.json`, { cache: 'no-cache' });
return res && res.ok ? await res.json() : null;
} catch { return null; }
};
const files = await Promise.all(SIM_DATA_FILES.map(async (n) => (await fetchOnce(n)) ?? fetchOnce(n)));
// a battle simulated without part of the data would still produce a plausible result the server accepts: never run
// one — the loader fails (the next b.start retries it; meanwhile the server's deadline takes the field over)
const missing = SIM_DATA_FILES.filter((n, i) => !files[i] || typeof files[i] !== 'object');
if (missing.length) throw new Error(`simulation data unavailable: ${missing.join(', ')}`);
const raw = {};
SIM_DATA_FILES.forEach((n, i) => { raw[n] = deepFreeze(files[i]); });
simdata.setSimData(raw);
if (typeof support.setGameData === 'function') support.setGameData(null); // re-read through the injected data
return { spec, ds: new simdata.DataSource(raw, null) };
}
/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */
const SIM_LOGGER = Object.freeze({
error: (...a) => console.warn('[sim]', ...a),
warn: (...a) => console.warn('[sim]', ...a),
info() {},
debug() {},
});
/**
* @param {{ net: any, store: any, loadSim?: () => Promise<{ spec: any, ds: any }>, now?: () => number,
* raf?: (fn: (t: number) => void) => any, caf?: (h: any) => void, setInterval?: Function, clearInterval?: Function,
* doc?: { hidden?: boolean, addEventListener?: Function } | null, logger?: object }} deps
*/
export function createBattleRunner(deps) {
const net = deps.net;
const store = deps.store;
const now = deps.now || (() => (globalThis.performance ? performance.now() : Date.now()));
const raf = deps.raf || ((fn) => globalThis.requestAnimationFrame(fn));
const caf = deps.caf || ((h) => globalThis.cancelAnimationFrame(h));
const setIv = deps.setInterval || ((fn, ms) => globalThis.setInterval(fn, ms));
const clearIv = deps.clearInterval || ((h) => globalThis.clearInterval(h));
const doc = deps.doc !== undefined ? deps.doc : (typeof document !== 'undefined' ? document : null);
const loadSim = deps.loadSim || (() => loadBrowserSim());
const logger = deps.logger || SIM_LOGGER;
const listeners = new Map();
const emit = (type, payload) => {
const set = listeners.get(type);
if (!set) return;
for (const fn of [...set]) {
try { fn(payload); } catch (err) { console.warn(`[runner] ${type} listener failed`, err); }
}
};
/** @type {Map<string, any>} battleId → entry */
const entries = new Map();
let cur = null; // entry on screen
let simP = null;
let startSeq = 0;
let loading = null; // b.start being prepared
let rafH = null;
let ivH = null;
let lastPool = null;
/** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */
let pausedAt = null;
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
const hidden = () => !!(doc && doc.hidden);
/** The battle clock: frozen at the pause instant while the solo battle is paused. */
const clock = () => (pausedAt != null ? pausedAt : now());
const bossLike = (e) => e.kind === 'boss' || e.kind === 'hidden';
function ensureSim() {
if (!simP) {
simP = loadSim().catch((err) => { simP = null; throw err; });
}
return simP;
}
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind } : null;
return {
battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch,
done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null,
};
}
function publishState() {
const s = state();
try { store.patch('match', { battle: s }); } catch { /* store without a match slice */ }
emit('state', s);
}
/** Target tick of an entry on its clock. */
const targetTick = (e, t) => Math.max(0, Math.floor((((t - e.t0) / 1000) * e.speed) / TICK + 1e-9));
function stepEntry(e, n) {
const b = e.battle;
const t = now();
let k = 0;
try {
for (; k < n && !b.finished; k++) b.step();
} catch (err) {
stats.errors++;
console.warn('[runner] battle step failed', err);
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
const dt = now() - t;
stats.ticks += k;
stats.stepMs += dt;
return dt;
}
function frameOf(e) {
const snap = e.battle.snapshot();
const { t: gt, ...rest } = snap || {};
return { ...rest, t: 'b.snap', fieldId: e.fieldId, gt: Number.isFinite(gt) ? gt : 0 };
}
function emitFrame(e, catchingUp) {
let ev = [];
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
const gt = Number(e.battle.time) || 0;
if (ev.length) {
const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev;
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list });
}
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
}
function progress(e, force = false) {
if (!e.authoritative || e.resultSent || !net) return;
const t = now();
const every = bossLike(e) ? 250 : 1000;
if (!force && t - e.lastProgressAt < every) return;
e.lastProgressAt = t;
const p = e.sim.spec.battleProgress(e.battle);
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
if (bossLike(e)) {
const pool = e.battle.sharedBoss;
msg.leaks = Math.min(1e6, e.meter.lp);
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) {
const by = {};
for (const pid of Object.keys(pool.byPlayer).slice(0, 4)) by[pid] = pool.byPlayer[pid];
msg.by = by;
}
} else {
msg.leaks = Math.min(1e6, p.leaks);
}
try { net.send('b.progress', msg); } catch { /* offline */ }
}
function finished(e) {
if (e.done) return;
e.done = true;
if (e.authoritative && !e.resultSent && net) {
progress(e, true);
e.resultSent = true;
let result = null;
try {
result = e.sim.spec.compactResult(e.battle.result());
// an oversized frame would close the socket at the very end of the battle (64 KB inbound limit)
if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId });
} catch (err) { console.warn('[runner] result failed', err); }
if (result) {
e.result = result;
deliver(e);
}
}
if (e === cur) publishState();
}
/**
* Send an entry's b.result (retried once on a timeout). `e.delivery`: 'pending' while a request is out, 'delivered'
* once the server answered (ok, or a refusal — final), 'undelivered' when it never got there (LOST_RESULT_CODES): kept
* for redeliver() (session back online) and for an authoritative b.start of the finished battle.
*/
function deliver(e) {
if (!net || !e.result || e.delivery === 'pending') return;
e.delivery = 'pending';
const msg = { battleId: e.battleId, result: e.result };
const send = (tries) => {
let req;
try { req = net.request('b.result', msg, { timeout: 15000 }); } catch (err) { req = Promise.reject(err); }
return Promise.resolve(req).then(() => { e.delivery = 'delivered'; }, (err) => {
const code = err && err.code;
if (code === 'TIMEOUT' && tries > 0) return send(tries - 1);
if (LOST_RESULT_CODES.includes(code)) {
e.delivery = 'undelivered';
console.warn(`[runner] b.result not delivered (${code}) — sent again when the session resumes`);
} else {
e.delivery = 'delivered';
console.warn('[runner] b.result refused', code);
}
return null;
});
};
send(1);
}
/** The session is back: every result lost with the socket goes out again. */
function redeliver() {
for (const e of entries.values()) if (e.delivery === 'undelivered') deliver(e);
}
/** Solo pause (m.public.paused): freeze / resume every local battle clock. */
function setPaused(on) {
if (on === (pausedAt != null)) return;
if (on) {
pausedAt = now();
} else {
const d = Math.max(0, now() - pausedAt);
pausedAt = null;
for (const e of entries.values()) { e.t0 += d; e.lastProgressAt += d; }
}
publishState();
schedule();
}
/** Advance one entry to its clock (bounded); render it when it is on screen. */
function advance(e, t, render) {
if (e.battle.finished) { if (!e.done) finished(e); return; }
const behind = targetTick(e, t) - (e.battle.tickCount || 0);
if (behind <= 0) return;
const cap = ticksPerFrameCap(e.speed);
const catchingUp = behind > cap * 4;
if (catchingUp) stats.catchups++;
const n = Math.min(behind, catchingUp ? CATCHUP_TICKS : cap);
const dt = stepEntry(e, n);
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
if (render) emitFrame(e, catchingUp);
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
progress(e);
if (e.battle.finished) finished(e);
}
function running(e) { return !e.battle.finished && (e === cur || (e.authoritative && !e.resultSent)); }
function frame() {
rafH = null;
stats.frames++;
const t = clock();
for (const e of [...entries.values()]) if (running(e)) advance(e, t, e === cur);
schedule();
}
function pump() {
// hidden tab: no animation frames — keep authoritative battles on their clock (no rendering)
if (!hidden()) return;
for (const e of [...entries.values()]) if (e.authoritative && running(e)) advance(e, clock(), false);
}
function schedule() {
// paused: nothing to step (the view keeps its last frame); resume reschedules
const any = pausedAt == null && [...entries.values()].some(running);
if (any && rafH == null && !hidden()) rafH = raf(frame);
const needPump = pausedAt == null && [...entries.values()].some((e) => e.authoritative && running(e));
if (needPump && ivH == null) ivH = setIv(pump, 250);
else if (!needPump && ivH != null) { clearIv(ivH); ivH = null; }
}
/** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */
function show(e) {
cur = e;
let meta = null;
try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; }
const field = {
t: 'm.field', ...meta, fieldId: e.fieldId, kind: e.kind, rect: meta.rect ?? e.spec.rect, stageId: meta.stageId ?? e.spec.stageId,
live: !e.done, battleId: e.battleId, players: e.members.slice(), local: true, speed: e.speed,
// which half each player holds (联防: the first helper takes the right half; boss pairs: L / R)
sides: Object.fromEntries((e.spec.players || []).filter((p) => p && p.playerId).map((p) => [p.playerId, p.side === 'R' || Number(p.colOffset) >= 8 ? 'R' : 'L'])),
};
emit('field', field);
try { store.patch('match', { field }); } catch { /* ignore */ }
publishState();
try { e.battle.drainEvents(); } catch { /* the view starts from the meta + this frame */ }
emit('snap', frameOf(e));
schedule();
}
function evict() {
if (entries.size <= MAX_ENTRIES) return;
for (const [id, e] of entries) {
if (entries.size <= MAX_ENTRIES) break;
if (e === cur || (e.authoritative && !e.resultSent) || e.own || e.delivery === 'pending' || e.delivery === 'undelivered') continue;
entries.delete(id);
}
}
function yieldFrame() { return new Promise((resolve) => { if (hidden()) setTimeout(resolve, 0); else raf(() => resolve()); }); }
async function onStart(msg) {
if (!msg || typeof msg !== 'object' || !msg.spec || typeof msg.battleId !== 'string') return;
const speed = Number(msg.speed) > 0 ? Number(msg.speed) : 2;
const existing = entries.get(msg.battleId);
if (existing) {
const was = existing.authoritative;
existing.authoritative = !!msg.authoritative && !existing.resultSent;
existing.watch = !!msg.watch;
// the server still waits for this finished battle's result (lost with the socket, or its answer was): again
if (msg.authoritative && existing.resultSent) deliver(existing);
if (existing.authoritative && !was) {
// handover (the partner left): continue from the field's clock and report from now on
existing.t0 = clock() - ((Number(msg.elapsed) || 0) / speed) * 1000;
existing.lastProgressAt = -Infinity;
if (existing.battle.finished) { existing.done = false; finished(existing); }
}
++startSeq;
loading = null;
// a resend of what is already on screen (reconnect / resync) only updates the state; switching back shows it
if (cur !== existing) show(existing);
else { publishState(); schedule(); }
return;
}
const seq = ++startSeq;
loading = { battleId: msg.battleId, fieldId: msg.fieldId, kind: msg.kind };
publishState();
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
if (seq === startSeq) { loading = null; publishState(); }
return;
}
if (seq !== startSeq) return; // superseded by a newer b.start
let battle;
try {
battle = sim.spec.createBattleFromSpec(msg.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
loading = null;
publishState();
return;
}
stats.battles++;
const e = {
battleId: msg.battleId, fieldId: msg.fieldId || msg.spec.fieldId, kind: msg.kind || msg.spec.kind, spec: msg.spec, sim, battle,
authoritative: !!msg.authoritative, watch: !!msg.watch, own: !msg.watch, speed,
members: (msg.spec.players || []).map((p) => p && p.playerId).filter(Boolean),
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
result: null, delivery: null,
meter: sim.spec.attachLpMeter(battle),
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
}
// silent catch-up to the field's clock before it is shown (a reconnect / observing a running field)
while (!battle.finished && targetTick(e, clock()) - battle.tickCount > ticksPerFrameCap(speed)) {
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
stepEntry(e, n);
try { battle.drainEvents(); } catch { /* ignore */ }
if (e.authoritative) progress(e);
await yieldFrame();
if (seq !== startSeq) {
// superseded while preparing: an authoritative battle must still finish (it is kept), a replica is dropped
if (e.authoritative) { entries.set(e.battleId, e); evict(); schedule(); }
return;
}
}
entries.set(e.battleId, e);
evict();
loading = null;
show(e);
if (battle.finished) finished(e);
}
function onPool(msg) {
if (!msg || typeof msg !== 'object') return;
lastPool = msg;
for (const e of entries.values()) {
const pool = e.battle.sharedBoss;
if (pool && typeof pool.sync === 'function') pool.sync(msg.hp, msg.acked ? msg.acked[e.fieldId] : undefined);
}
emit('pool', msg);
}
function onEnd(msg) {
const e = msg && entries.get(msg.battleId);
if (!e) return;
if (msg.reason === 'takeover') {
e.authoritative = false;
if (e === cur) publishState();
schedule();
return;
}
if (!e.battle.finished) {
try { e.battle.forceEnd(msg.reason === 'timeout' ? 'timeout' : 'forced'); } catch { /* ignore */ }
}
if (e === cur) emitFrame(e, false);
finished(e);
schedule();
}
/** Drop every battle (a new round's prep, the match ended, the player left). */
function clear() {
++startSeq;
loading = null;
for (const e of entries.values()) {
// never drop an unreported authoritative result (the round already moved on: the server has its own)
if (e.authoritative && !e.resultSent && !e.battle.finished) { try { e.battle.forceEnd('forced'); } catch { /* ignore */ } }
}
entries.clear();
cur = null;
lastPool = null;
if (rafH != null) { caf(rafH); rafH = null; }
if (ivH != null) { clearIv(ivH); ivH = null; }
publishState();
}
const offs = [];
if (net && typeof net.on === 'function') {
offs.push(net.on('b.start', (m) => { onStart(m).catch((err) => console.warn('[runner] b.start failed', err)); }));
offs.push(net.on('b.pool', onPool));
offs.push(net.on('b.end', onEnd));
// the session is back (reconnect / resume): results lost with the old socket go out again
offs.push(net.on('status', (st) => { if (st && st.status === 'online') redeliver(); }));
}
// phase changes: combat fields live until the next prep; leaving the match drops everything
let lastPhase = null;
if (store && typeof store.subscribe === 'function') {
offs.push(store.subscribe((s) => {
const pub = s && s.match && s.match.public ? s.match.public : null;
// solo pause (DESIGN §14): the local battle clocks follow m.public.paused
setPaused(!!(pub && pub.paused));
const phase = pub ? pub.phase : null;
if (phase === lastPhase) return;
lastPhase = phase;
if (!phase || ['PREP', 'ROUND_START', 'SP_DRAFT', 'RESULT', 'LOBBY', 'INFO_CHECK', 'BAND_DRAFT', 'BATTLE_CHECK'].includes(phase)) {
if (entries.size || loading) clear();
}
// warm the simulation up as soon as a match runs (the first b.start then starts at once)
if (phase && phase !== 'LOBBY' && !simP) ensureSim().catch(() => {});
}));
}
if (doc && typeof doc.addEventListener === 'function') {
doc.addEventListener('visibilitychange', () => { if (!hidden()) schedule(); });
}
return {
on(type, fn) {
if (typeof fn !== 'function') return () => {};
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(fn);
return () => listeners.get(type)?.delete(fn);
},
state,
stats() {
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
},
/** Re-show the current battle (the game screen remounted). */
reshow() { if (cur) show(cur); },
/** Test / debug hooks. */
_entries: entries,
_frame: frame,
_pump: pump,
onStart, onPool, onEnd, clear, ensureSim, redeliver, setPaused,
dispose() { clear(); for (const off of offs) { try { off?.(); } catch { /* ignore */ } } },
};
}
/** The browser runner, wired to the app's socket and store (null outside a browser). */
export const battleRunner = typeof window !== 'undefined' && typeof document !== 'undefined'
? createBattleRunner({ net: appNet, store: appStore })
: null;
if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection
+228
View File
@@ -0,0 +1,228 @@
// Lazy loader + cache for the generated game data served at /data/*.json.
//
// Files are fetched on first use and cached (the Promise is shared, so concurrent callers trigger a
// single request). Missing files (404, network failure, bad JSON) resolve to `null` and are
// reported once on the console — the UI must degrade gracefully while data is being generated.
//
// Each file is indexed tolerantly so the getters work whether a file is
// - an array of records carrying an id field (id / chessId / bondId / itemId / …),
// - an object map { id: record },
// - or either of those wrapped as { <name>: … } / { list: … } / { data: … }.
// Synchronous getters (getChess, getBond, …) return null until the file has loaded; use
// `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive.
import { useEffect, useReducer } from '../vendor/hooks.module.js';
/** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */
export const DATA_FILES = Object.freeze({
chess: 'chess.json',
bonds: 'bonds.json',
items: 'items.json',
bands: 'bands.json',
enemies: 'enemies.json',
bosses: 'bosses.json',
stages: 'stages.json',
tokens: 'tokens.json',
choices: 'choices.json',
config: 'config.json',
assets: 'assets.json',
// Optional art extracted from a local game client (DESIGN §13): { groups: { '<subdir>': { name: { path, w, h } } } }.
local: 'local-assets.json',
});
const ID_KEYS = ['id', 'chessId', 'bondId', 'itemId', 'bandId', 'enemyKey', 'enemyId', 'stageId', 'bossId', 'tokenId', 'choiceId', 'key'];
const WRAPPER_KEYS = ['list', 'data', 'records', 'entries'];
function idOf(rec) {
if (!rec || typeof rec !== 'object') return null;
for (const k of ID_KEYS) {
const v = rec[k];
if ((typeof v === 'string' && v) || Number.isFinite(v)) return String(v);
}
return null;
}
/**
* Build an id → record Map from a data file's JSON, tolerating the shapes listed in the header.
* @param {string} name file name (used to unwrap `{ [name]: … }`)
* @param {any} json
* @returns {Map<string, any>}
*/
export function buildIndex(name, json) {
const map = new Map();
let src = json;
if (src && typeof src === 'object' && !Array.isArray(src)) {
for (const k of [name, ...WRAPPER_KEYS]) {
const inner = src[k];
if (inner && typeof inner === 'object') { src = inner; break; }
}
}
if (Array.isArray(src)) {
for (const rec of src) {
const id = idOf(rec);
if (id != null && !map.has(id)) map.set(id, rec);
}
} else if (src && typeof src === 'object') {
for (const [k, v] of Object.entries(src)) {
if (v && typeof v === 'object') map.set(k, v);
}
}
return map;
}
/**
* Create a data store bound to a fetch implementation (injectable for tests).
* @param {{ fetch?: typeof fetch, base?: string }} [opts]
*/
export function createDataStore(opts = {}) {
const base = opts.base ?? '/data/';
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
/** @type {Map<string, { status: 'loading'|'ready'|'missing', promise: Promise<any>, value: any, index: Map<string, any>|null }>} */
const entries = new Map();
const listeners = new Set();
const warned = new Set();
const notify = (name) => {
for (const fn of [...listeners]) {
try { fn(name); } catch (err) { console.error('[data] listener failed', err); }
}
};
const urlFor = (name) => base + (DATA_FILES[name] || `${name}.json`);
function load(name) {
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name)) return Promise.resolve(null);
const cur = entries.get(name);
if (cur) return cur.promise;
const entry = { status: 'loading', promise: null, value: null, index: null };
entry.promise = (async () => {
try {
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
const json = await res.json();
entry.value = json;
entry.status = 'ready';
} catch (err) {
if (!warned.has(name)) {
warned.add(name);
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
}
entry.value = null;
entry.status = 'missing';
}
// A load superseded by invalidate() must not announce itself (its entry is no longer cached).
if (entries.get(name) === entry) notify(name);
return entry.value;
})();
entries.set(name, entry);
return entry.promise;
}
function index(name) {
const e = entries.get(name);
if (!e || e.status !== 'ready') return null;
if (!e.index) e.index = buildIndex(name, e.value);
return e.index;
}
return {
/** Fetch (once) and return a file's JSON, or null when missing. */
load,
/** Load several files; resolves when all settled. */
loadAll: (...names) => Promise.all(names.flat().map(load)),
/** Raw JSON of a loaded file (null when missing / not loaded yet). */
get: (name) => entries.get(name)?.value ?? null,
/** 'idle' | 'loading' | 'ready' | 'missing' */
status: (name) => entries.get(name)?.status ?? 'idle',
/** Record by id from a loaded file (null when unknown / not loaded). */
lookup(name, id) {
if (id == null) return null;
return index(name)?.get(String(id)) ?? null;
},
/** All records of a loaded file as an array (empty when not loaded). */
list: (name) => [...(index(name)?.values() ?? [])],
/** Drop a cached file and refetch it now (subscribers are notified when it settles). */
invalidate(name) {
if (!entries.has(name)) return Promise.resolve(null);
entries.delete(name);
warned.delete(name);
const p = load(name);
notify(name); // status is 'loading' again, never a stuck 'idle'
return p;
},
/** Called with the file name whenever a file finishes loading (or is invalidated). */
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
/** Browser data store singleton. */
export const data = createDataStore();
/** @param {...string} names @returns {Promise<any[]>} */
export const loadData = (...names) => data.loadAll(...names);
/** @param {string} id chess id (normal or elite) */
export const getChess = (id) => data.lookup('chess', id);
/** @param {string} id bond id, e.g. 'yanShip' */
export const getBond = (id) => data.lookup('bonds', id);
/** @param {string} id item (trap) id */
export const getItem = (id) => data.lookup('items', id);
/** @param {string} id band (strategy) id */
export const getBand = (id) => data.lookup('bands', id);
/** @param {string} key enemy key */
export const getEnemy = (key) => data.lookup('enemies', key);
/** @param {string} id boss id */
export const getBoss = (id) => data.lookup('bosses', id);
/** @param {string} id stage id */
export const getStage = (id) => data.lookup('stages', id);
/** @param {string} id token id */
export const getToken = (id) => data.lookup('tokens', id);
/** @returns {any|null} data/config.json */
export const getConfig = () => data.get('config');
/**
* URL of a local-client art entry (data/local-assets.json, DESIGN §13), or null when the manifest / entry is missing
* (callers draw their own fallback). Load it first with `data.load('local')` / `useData('local')`.
* @param {string} group e.g. 'ui/battle', 'emoticon/basic', 'guide'
* @param {string} name entry name without extension
*/
export function localAsset(group, name) {
const g = data.get('local')?.groups?.[group];
const e = g && typeof g === 'object' ? g[name] : null;
return e && typeof e.path === 'string' && e.path ? e.path : null;
}
/**
* Mode record from config.json (`modes[modeId]`), or null.
* @param {string} modeId e.g. 'mode_multi_hard'
*/
export function getMode(modeId) {
const cfg = getConfig();
const modes = cfg && typeof cfg === 'object' ? (cfg.modes || cfg.modeDataDict) : null;
if (!modes || typeof modes !== 'object') return null;
if (Array.isArray(modes)) return modes.find((m) => m && (m.modeId === modeId || m.id === modeId)) || null;
return modes[modeId] || null;
}
/**
* Preact hook: start loading the given files and re-render when any of them settles.
* @param {...string} names
* @returns {boolean} true once every file is settled (ready or missing)
*/
export function useData(...names) {
const [, force] = useReducer((c) => c + 1, 0);
const key = names.join('|');
useEffect(() => {
let alive = true;
const unsub = data.subscribe((n) => { if (alive && names.includes(n)) force(); });
for (const n of names) data.load(n);
return () => { alive = false; unsub(); };
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
return names.every((n) => {
const s = data.status(n);
return s === 'ready' || s === 'missing';
});
}
+336
View File
@@ -0,0 +1,336 @@
// Client entry: boot (fonts, identity, socket), net → store wiring, router, global overlays.
//
// Router (derived from the store, no URL routes):
// not entered → Title
// m.public.phase ≠ LOBBY → Game (screens/game.js: briefing / band draft / match / result by phase)
// room.inMatch → Game (match starting, m.public on its way)
// in a room → Room
// otherwise → Lobby
// Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the
// session is online (after a short grace period in case the server restores a room on resume).
// Reloading a tab that already passed the title re-enters automatically (sessionStorage flag) and
// resumes the server session with the saved token; stale room/match state is dropped if the
// server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for
// `identity.init()` (which token this tab may use without stealing another live tab's session)
// before the first connect; a page restored from the back/forward cache reloads.
// A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen:
// toast 「服务器会话已重置,上一局模拟已结束」 (store.js sessionResetNotice) and return to the lobby with nothing stale.
//
// Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser).
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
// re-layout; ui/compat.js polyfills are imported before anything else.
// 干员调配 (DESIGN §16): an overlay over any route (<LoadoutHost/>, opened from lobby / room / briefing); its loadout is
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
import './ui/compat.js';
import { render } from '../vendor/preact.module.js';
import { useErrorBoundary } from '../vendor/hooks.module.js';
import { html, UiHosts, Button, MicroLabel, closeAllDialogs } from './ui/components.js';
import { ConnectionBanner } from './ui/connBanner.js';
import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
import { net, identity, NetError } from './net.js';
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice } from './store.js';
import { data } from './data.js';
import { TitleScreen, sanitizeName } from './screens/title.js';
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
import { RoomScreen } from './screens/room.js';
import { GameScreen } from './screens/game.js';
import { installAudio } from './audio.js';
import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { installLoadoutSync } from './ui/loadoutSync.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
const TICKER_KEEP = 20;
const EMOTE_KEEP = 20;
const SCREENS = { title: TitleScreen, lobby: LobbyScreen, room: RoomScreen, game: GameScreen };
/** Copy of a server message without transport fields. */
function payload(msg) {
const { t, rid, ...rest } = msg; // eslint-disable-line no-unused-vars
return rest;
}
function clearRoomParam() {
try {
const url = new URL(location.href);
if (!url.searchParams.has('room')) return;
url.searchParams.delete('room');
history.replaceState(history.state, '', url.pathname + (url.search || '') + url.hash);
} catch { /* ignore */ }
}
// ---- net → store wiring ---------------------------------------------------------------------------
let seq = 0;
let welcomeAt = 0;
let roomStateAt = 0;
let matchAt = 0;
let restoreTimer = null;
let joinTimer = null;
let joinInFlight = false;
function clearPendingJoin() {
store.patch('ui', { pendingJoin: null });
clearRoomParam();
}
/** Auto-join the deep-linked room once entered + online (idempotent). */
function schedulePendingJoin() {
clearTimeout(joinTimer);
joinTimer = setTimeout(async () => {
const s = store.get();
const code = s.ui.pendingJoin;
if (!code || joinInFlight || !s.session.entered || net.status !== 'online') return;
if (s.room) {
if (s.room.code !== code) toast('你已在其他同盟中,请先离开当前同盟', 'warn');
clearPendingJoin();
return;
}
joinInFlight = true;
try {
await net.request('room.join', { code });
} catch (err) {
toastError(err);
} finally {
joinInFlight = false;
clearPendingJoin();
}
}, JOIN_DELAY_MS);
}
/** End the post-resume "syncing" state early once the server re-pushed what we were showing. */
function maybeFinishRestore() {
const s = store.get();
if (!s.ui.restoring) return;
const roomOk = !s.room || roomStateAt >= welcomeAt;
const matchOk = !s.match.public || matchAt >= welcomeAt || !s.room?.inMatch;
if (roomOk && matchOk) {
clearTimeout(restoreTimer);
store.patch('ui', { restoring: false });
}
}
/**
* Leave whatever room / match was on screen for the lobby with nothing stale left behind: open imperative dialogs,
* the post-resume restore timer, ticker lines and emote bubbles of the old match (the battle runner and the 暂离 flag
* follow the store themselves).
*/
function backToLobby() {
clearTimeout(restoreTimer);
const s = store.get();
if (s.room || s.match.public) closeAllDialogs();
store.set({ room: null, match: emptyMatch(), ticker: [], emotes: [] });
store.patch('ui', { restoring: false });
}
function onWelcome(msg) {
identity.saveToken(msg.token);
const prev = store.get();
const prevId = prev.me.playerId;
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
welcomeAt = Date.now();
if (prevId != null && prevId !== msg.playerId) {
// A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
const notice = sessionResetNotice(prev, msg.playerId);
backToLobby();
if (notice) toast(notice, 'warn', { ttl: 7000 });
} else if (prev.room || prev.match.public) {
// Resumed session: the server re-pushes room/match state; drop whatever it doesn't.
store.patch('ui', { restoring: true });
clearTimeout(restoreTimer);
restoreTimer = setTimeout(() => {
const s = store.get();
const patch = {};
if (s.room && roomStateAt < welcomeAt) patch.room = null;
if (s.match.public && matchAt < welcomeAt) patch.match = emptyMatch();
store.set(patch);
store.patch('ui', { restoring: false });
}, RESTORE_GRACE_MS);
}
schedulePendingJoin();
}
function onRoomState(msg) {
const room = payload(msg);
roomStateAt = Date.now();
const myId = store.get().me.playerId;
const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId)) {
// We are no longer seated (kicked / left elsewhere).
if (store.get().room) toast('你已不在该同盟中', 'warn');
store.set({ room: null, match: emptyMatch() });
return;
}
const prevRoom = store.get().room;
// A (new) match starts: forget the previous match's state so stale results never show.
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
store.set({ room });
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
maybeFinishRestore();
}
const CLOSE_REASON = {
// 'timeout' = this player was removed after staying disconnected past the lobby grace (server/lobby.js)
host_left: '创建者已离开,同盟已解散', timeout: '由于长时间断开连接,你已离开同盟', empty: '同盟已解散',
kicked: '你已被移出同盟', ended: '模拟已结束', expired: '同盟已过期', shutdown: '服务器维护中,同盟已关闭',
};
function wireNet() {
net.on('status', (snap) => {
const cur = store.get().connection;
store.set({
connection: {
status: snap.status, ping: snap.ping, attempt: snap.attempt, retryAt: snap.retryAt,
lastError: snap.lastError, everOnline: cur.everOnline || snap.status === 'online',
},
});
});
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
net.on('replaced', () => toast('该身份已在其他页面登录,本页已断开', 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
net.on('room.closed', (msg) => {
backToLobby();
toast(CLOSE_REASON[msg.reason] || (typeof msg.reason === 'string' && msg.reason.length < 60 ? `同盟已关闭:${msg.reason}` : '同盟已关闭'), 'warn');
});
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
toast(msg.text, kind);
});
net.on('m.ticker', (msg) => {
if (typeof msg.text !== 'string') return;
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now() }] }));
});
net.on('m.emote', (msg) => {
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
});
// Entering (title → lobby) while already online also needs the deep-link join.
store.subscribe((s, prev) => {
if (s.session.entered && !prev.session.entered) schedulePendingJoin();
});
}
// ---- UI chrome (the connection banner lives in ui/connBanner.js) -----------------------------------
function ScreenCrashed({ error, reset }) {
return html`<div class="screen crash">
<div class="crash__box brackets">
<${MicroLabel} tone="mint">SYSTEM FAULT<//>
<h2>界面发生错误</h2>
<p class="t-lo">${String(error?.message || error).slice(0, 200)}</p>
<${Button} variant="primary" icon="refresh" onClick=${reset}>重新加载界面<//>
</div>
</div>`;
}
function App() {
const route = useStore(selectRoute);
const [error, resetError] = useErrorBoundary((err) => console.error('[ui] screen crashed', err));
const Screen = SCREENS[route] || LobbyScreen;
return html`<div class="app-root">
<div class="app-bg" aria-hidden="true"></div>
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
<${ConnectionBanner} />
<${ToastHost} />
<${UiHosts} />
<${GuideHost} />
<${LoadoutHost} />
</div>`;
}
// ---- boot -----------------------------------------------------------------------------------------
async function waitForFonts(ms) {
const fonts = document.fonts;
if (!fonts || typeof fonts.load !== 'function') return;
const loads = [
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'),
fonts.load('700 1em "Noto Sans SC"', '开始'),
fonts.load('700 1em Bender', '0123456789'),
fonts.load('700 1em Rajdhani', '0123456789'),
].map((p) => p.catch(() => null));
await Promise.race([Promise.all(loads), new Promise((r) => setTimeout(r, ms))]);
}
function installGlobalErrorHandlers() {
window.addEventListener('unhandledrejection', (ev) => {
const err = ev.reason;
// Media autoplay/abort rejections (audio.play() before a user gesture, interrupted loads) are
// expected browser behaviour, not app errors: log quietly, never toast.
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
if (err instanceof NetError) toastError(err);
else toast(`发生意外错误:${describeError(err)}`.slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
console.error('[app] uncaught error', ev.error || ev.message);
});
}
async function boot() {
installGlobalErrorHandlers();
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
installDeviceSupport();
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
window.addEventListener('pageshow', (ev) => { if (ev.persisted) location.reload(); });
// Pick this tab's reconnect token (asks other live tabs; ≤150 ms) while fonts load.
const identityReady = identity.init();
const pendingJoin = parseRoomParam(location.search);
const savedName = sanitizeName(identity.loadName());
const entered = identity.wasEntered() && !!savedName;
store.set((s) => ({
me: { ...s.me, name: savedName },
session: { entered },
ui: { ...s.ui, pendingJoin },
}));
wireNet();
installLoadoutSync({ net });
net.attachBrowserHooks();
// Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js).
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
data.load('assets').catch(() => {});
// Warm the data cache in the background (missing files are tolerated).
data.loadAll('config').catch(() => {});
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
data.load('local').catch(() => {});
const connectWhenReady = identityReady.then(() => {
if (entered) net.setName(savedName);
else net.connect();
});
await Promise.all([waitForFonts(1200), connectWhenReady]);
const root = document.getElementById('app');
render(html`<${App} />`, root);
const splash = document.getElementById('boot');
if (splash) {
splash.classList.add('is-done');
setTimeout(() => splash.remove(), 300);
}
globalThis.__SP__ = { store, net, data, version: 1 };
}
boot().catch((err) => {
console.error('[app] boot failed', err);
const el = document.getElementById('boot-err');
if (el) el.textContent = '启动失败,请刷新页面重试';
});
+835
View File
@@ -0,0 +1,835 @@
// WebSocket client for the game server (DESIGN §8).
//
// - One socket at ws(s)://<host>/ws, JSON text frames `{ t, ...payload }`.
// - Auto-reconnect with exponential backoff + jitter; a heartbeat (`ping`) measures latency and
// detects dead sockets (a ping left unanswered — no inbound frame at all — for DEAD_AFTER_MS ⇒
// close ⇒ reconnect). Measured from the oldest unanswered ping, not from the last inbound frame,
// so a background tab whose timers the browser throttles to ~1/min is not mistaken for dead.
// - On every (re)connect, once a player name is known, sends `hello {name, token, version}`;
// the session is "online" after `welcome`.
// - `request(t, fields)` adds a `rid` and resolves on the matching `ok` (or any reply carrying the
// rid), rejects with a NetError on `error` or after REQUEST_TIMEOUT_MS. Requests made while
// reconnecting are queued and flushed after `welcome` (still bound by their timeout).
// - Every server push is emitted by its `t` (see shared/protocol.js S2C) and as '*'.
// Extra events: 'status', 'ping', 'clock', 'helloError', 'unhandledError', 'replaced'.
// - Server close code 4001 ("session replaced": the same token connected from another tab) stops
// auto-reconnect (status 'closed', lastError REPLACED) so two tabs never fight over one session.
// - Before a name is known (title screen) the socket only pings; the server closes such sockets
// with 4002 (hello timeout). That close is answered with a quiet swap: a fresh socket is opened
// while the status stays 'connected', so the title's server indicator doesn't flicker.
// - Server clock offset (serverNow ≈ Date.now() + offset) from pong samples (lowest RTT wins),
// bootstrapped from `welcome.serverNow` / `m.public.serverNow`.
//
// Identity persistence (`identity`, see the section at the end): the name lives in localStorage;
// the reconnect token lives in sessionStorage (survives reloads of this tab) plus a short list of
// recent tokens in localStorage (resume after closing/reopening the tab). `identity.init()` asks
// the other live tabs over a BroadcastChannel which tokens they hold, so a second or duplicated tab
// of the same browser becomes a separate player instead of hijacking another tab's session.
//
// Shared modules are imported relatively: in the browser '../../shared/x.js' from /js/ resolves
// to /shared/x.js (URL resolution clamps at the root); under Node it resolves to <repo>/shared.
import { PROTOCOL_VERSION, ERR_TEXT } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js';
export const REQUEST_TIMEOUT_MS = 8000;
export const HELLO_TIMEOUT_MS = 8000;
export const PING_INTERVAL_MS = 4000;
export const DEAD_AFTER_MS = 15000;
export const BACKOFF = Object.freeze({ base: 500, factor: 2, max: 10000, jitter: 0.2 });
/** Client-side error codes (in addition to shared ERR codes). */
export const CLIENT_ERR_TEXT = Object.freeze({
TIMEOUT: '请求超时,请重试',
OFFLINE: '未连接到服务器',
DISCONNECTED: '连接已断开,请重试',
CLOSED: '连接已关闭',
REPLACED: '该身份已在其他页面登录',
VERSION: '客户端版本与服务器不一致,请刷新页面',
});
/** Server close code: the session was taken over by another socket (server/net.js CLOSE.REPLACED). */
export const CLOSE_REPLACED = 4001;
/** Server close code: the socket never sent `hello` (server/net.js CLOSE.HELLO_TIMEOUT, ~30–60 s). */
export const CLOSE_HELLO_TIMEOUT = 4002;
/** A pre-hello socket must have lived this long before a 4002 close is swapped quietly (no loop). */
const QUIET_SWAP_MIN_AGE_MS = 5000;
/**
* Human readable (Chinese) text for an error code.
* @param {string} code
* @param {string} [msg] server-provided message (fallback)
* @returns {string}
*/
export function errorText(code, msg) {
return ERR_TEXT[code] || CLIENT_ERR_TEXT[code] || (typeof msg === 'string' && msg) || String(code || '未知错误');
}
/** Error thrown/rejected by requests. `code` is an ERR code or a CLIENT_ERR_TEXT key. */
export class NetError extends Error {
/** @param {string} code @param {string} [msg] server text @param {string} [detail] server developer detail */
constructor(code, msg, detail) {
const versionMismatch = typeof detail === 'string' && /version/i.test(detail);
super(versionMismatch ? CLIENT_ERR_TEXT.VERSION : errorText(code, msg));
this.name = 'NetError';
this.code = String(code || 'INTERNAL');
this.serverMsg = msg ?? null;
this.detail = typeof detail === 'string' ? detail : null;
}
}
/**
* Reconnect delay for the n-th consecutive failed attempt (0-based), with ±jitter.
* @param {number} attempt
* @param {() => number} [rand]
* @returns {number} milliseconds
*/
export function backoffDelay(attempt, rand = Math.random) {
const n = Math.max(0, Math.min(30, Math.floor(Number(attempt) || 0)));
const raw = Math.min(BACKOFF.max, BACKOFF.base * BACKOFF.factor ** n);
const jitter = raw * BACKOFF.jitter * (rand() * 2 - 1);
return Math.max(100, Math.round(raw + jitter));
}
/**
* WebSocket URL for the current page (`ws(s)://host/ws`).
* @param {{protocol: string, host: string}} [loc]
* @returns {string}
*/
export function defaultWsUrl(loc = globalThis.location) {
if (!loc || !loc.host) return 'ws://localhost:3000/ws';
return `${loc.protocol === 'https:' ? 'wss' : 'ws'}://${loc.host}/ws`;
}
const WS_OPEN = 1;
const WS_CONNECTING = 0;
/**
* Game server connection. Construct with injectable dependencies for tests.
*/
export class Net {
/**
* @param {object} [opts]
* @param {string} [opts.url] socket URL (default: derived from location at connect time)
* @param {any} [opts.WebSocket] WebSocket constructor (default: globalThis.WebSocket)
* @param {() => (string|null)} [opts.getToken] reconnect-token provider for `hello`
* @param {() => number} [opts.now]
* @param {() => number} [opts.random]
* @param {{setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function}} [opts.timers]
*/
constructor(opts = {}) {
this.url = opts.url || null;
this.WS = opts.WebSocket || null;
this.getToken = typeof opts.getToken === 'function' ? opts.getToken : () => null;
this.now = opts.now || (() => Date.now());
this.random = opts.random || Math.random;
this.timers = opts.timers || {
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeout: (id) => globalThis.clearTimeout(id),
setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
clearInterval: (id) => globalThis.clearInterval(id),
};
/** @type {'idle'|'connecting'|'connected'|'handshaking'|'online'|'reconnecting'|'closed'} */
this.status = 'idle';
this.ws = null;
this.name = null; // desired player name (hello is sent when set)
this.helloName = null; // name we sent in the hello that got the last welcome
this.serverName = null; // name as normalised by the server
this.playerId = null;
this.attempt = 0; // consecutive failed connection attempts
this.retryAt = 0; // epoch ms of the next reconnect attempt (0 = none)
this.ping = null; // last RTT in ms
this.lastError = null; // last NetError relevant to the connection (e.g. hello rejected)
this.clockOffset = 0;
this.clockSynced = false;
this._rid = 0;
this._pending = new Map(); // rid -> { msg, resolve, reject, timer, sent }
this._expired = new Set(); // rids that timed out locally (their late error was already reported)
this._listeners = new Map();
this._manualClose = false;
this._quietSwap = false; // next connect() keeps the current status (see CLOSE_HELLO_TIMEOUT)
this._openedAt = 0;
this._reconnectTimer = null;
this._pingTimer = null;
this._helloTimer = null;
this._helloRid = null;
this._helloSentName = null;
this._lastRx = 0;
this._unansweredSince = null; // time of the oldest ping sent since the last inbound frame
this._clockSamples = []; // [{ offset, rtt }]
}
// ---- events ----------------------------------------------------------------------------------
/**
* Subscribe to a message type (any S2C `t`), '*' (all messages) or a client event
* ('status' | 'ping' | 'clock' | 'helloError' | 'unhandledError').
* @param {string} type
* @param {(payload: any) => void} fn
* @returns {() => void} unsubscribe
*/
on(type, fn) {
if (typeof fn !== 'function') return () => {};
let set = this._listeners.get(type);
if (!set) this._listeners.set(type, (set = new Set()));
set.add(fn);
return () => this.off(type, fn);
}
/** @param {string} type @param {Function} fn */
off(type, fn) {
this._listeners.get(type)?.delete(fn);
}
_emit(type, payload) {
const set = this._listeners.get(type);
if (!set || set.size === 0) return;
for (const fn of [...set]) {
try { fn(payload); } catch (err) { console.error(`[net] listener for "${type}" failed`, err); }
}
}
/** Snapshot of the connection state (what the 'status' event carries). */
snapshot() {
return {
status: this.status, attempt: this.attempt, retryAt: this.retryAt, ping: this.ping,
lastError: this.lastError ? { code: this.lastError.code, text: this.lastError.message } : null,
playerId: this.playerId,
};
}
_setStatus(status) {
this.status = status;
this._emit('status', this.snapshot());
}
// ---- lifecycle -------------------------------------------------------------------------------
/** Open the socket (no-op when already open/connecting). Also resumes after close()/replacement. */
connect() {
const quiet = this._quietSwap;
this._quietSwap = false;
if (this.ws && (this.ws.readyState === WS_OPEN || this.ws.readyState === WS_CONNECTING)) return;
this._manualClose = false;
this._clearTimer('_reconnectTimer', 'clearTimeout');
this.retryAt = 0;
const WS = this.WS || globalThis.WebSocket;
const url = this.url || defaultWsUrl();
let ws;
try {
ws = new WS(url);
} catch (err) {
console.warn('[net] cannot create WebSocket', err);
this._scheduleReconnect();
return;
}
this.ws = ws;
if (!quiet) this._setStatus(this.attempt > 0 ? 'reconnecting' : 'connecting');
ws.onopen = () => { if (ws === this.ws) this._onOpen(); };
ws.onmessage = (ev) => { if (ws === this.ws) this._onMessage(ev?.data); };
ws.onerror = () => { /* followed by close */ };
ws.onclose = (ev) => { if (ws === this.ws) this._onClose(ev); };
}
/** Close for good (no reconnect). Pending requests reject with CLOSED. */
close() {
this._manualClose = true;
this._clearTimer('_reconnectTimer', 'clearTimeout');
this.retryAt = 0;
const ws = this.ws;
this._teardownSocket();
try { ws?.close(1000, 'client close'); } catch { /* ignore */ }
this._failPending('CLOSED', true);
this._setStatus('closed');
}
/** Skip the backoff wait and reconnect right now (used on `online` / tab focus / retry button). */
retryNow() {
if (this._manualClose) return;
if (this.ws && (this.ws.readyState === WS_OPEN || this.ws.readyState === WS_CONNECTING)) return;
this.connect();
}
/** Drop the current socket and reconnect immediately (fresh hello). */
reconnectNow() {
if (this._manualClose) return;
const ws = this.ws;
this._teardownSocket();
try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ }
this._failPending('DISCONNECTED', false);
this.attempt = 0;
this.connect();
}
/**
* Set the player name used in `hello`. Sends hello right away when the socket is open (the server
* accepts a repeated hello on a live socket: it renames the session and resyncs room/match
* state), otherwise as soon as it opens. No-op when that name is already accepted.
* @param {string} name
*/
setName(name) {
const n = typeof name === 'string' ? name.trim() : '';
if (!n) return;
const changed = n !== this.name;
this.name = n;
if (!this.ws || this.ws.readyState !== WS_OPEN) {
if (!this.ws && !this._reconnectTimer && !this._manualClose) this.connect();
return; // hello goes out on open
}
if (this.status === 'online' && this.helloName === n) return;
if (this.status === 'handshaking' && !changed && this._helloSentName === n) return;
// The server accepts a repeated hello on a live socket (it renames the session and resyncs).
this._sendHello();
}
_onOpen() {
this._openedAt = this.now();
this._lastRx = this._openedAt;
this._unansweredSince = null;
this.retryAt = 0;
this._startHeartbeat();
if (this.name) {
this._setStatus('handshaking');
this._sendHello();
} else {
this.attempt = 0;
this._setStatus('connected');
this._sendPing();
}
}
_onClose(ev) {
this._teardownSocket();
this._failPending('DISCONNECTED', false);
if (this._manualClose) { this._setStatus('closed'); return; }
if (ev && ev.code === CLOSE_REPLACED) {
// Another tab/socket took this session over: don't fight it; the user may reconnect manually.
this._manualClose = true;
this._failPending('REPLACED', true);
this.lastError = new NetError('REPLACED');
this._setStatus('closed');
this._emit('replaced', this.lastError);
return;
}
if (ev && ev.code === CLOSE_HELLO_TIMEOUT && !this.name && this.status === 'connected'
&& this.now() - this._openedAt >= QUIET_SWAP_MIN_AGE_MS) {
// Idle pre-hello socket dropped by the server: replace it without a visible status change.
this._quietSwap = true;
this.connect();
return;
}
this._scheduleReconnect();
}
_scheduleReconnect() {
if (this._manualClose) return;
const delay = backoffDelay(this.attempt, this.random);
this.attempt += 1;
this.retryAt = this.now() + delay;
this._clearTimer('_reconnectTimer', 'clearTimeout');
this._reconnectTimer = this.timers.setTimeout(() => {
this._reconnectTimer = null;
this.connect();
}, delay);
this._setStatus('reconnecting');
}
_teardownSocket() {
const ws = this.ws;
if (ws) {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
}
this.ws = null;
this._unansweredSince = null;
this._clearTimer('_pingTimer', 'clearInterval');
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloRid = null;
this._helloSentName = null;
}
_clearTimer(field, fn) {
if (this[field] != null) {
try { this.timers[fn](this[field]); } catch { /* ignore */ }
this[field] = null;
}
}
// ---- handshake -------------------------------------------------------------------------------
_sendHello() {
if (!this.name) return;
const rid = this._nextRid();
const msg = { t: 'hello', rid, name: this.name, version: PROTOCOL_VERSION };
let token = null;
try { token = this.getToken(); } catch { token = null; }
if (typeof token === 'string' && token.length > 0 && token.length <= 64) msg.token = token;
this._helloRid = rid;
this._helloSentName = this.name;
if (this.status !== 'handshaking') this._setStatus('handshaking');
if (!this._sendRaw(msg)) return;
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloTimer = this.timers.setTimeout(() => {
this._helloTimer = null;
if (this.status !== 'handshaking') return;
console.warn('[net] hello timed out; reconnecting');
const ws = this.ws;
this._teardownSocket();
try { ws?.close(4000, 'hello timeout'); } catch { /* ignore */ }
this._failPending('DISCONNECTED', false);
this._scheduleReconnect();
}, HELLO_TIMEOUT_MS);
}
_onWelcome(msg) {
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloRid = null;
// Compare future setName() calls against what we sent (the server may normalise the name).
this.helloName = this._helloSentName;
this.serverName = typeof msg.name === 'string' && msg.name ? msg.name : this._helloSentName;
this.playerId = msg.playerId ?? null;
this.attempt = 0;
this.lastError = null;
if (Number.isFinite(msg.serverNow)) this._addClockSample(msg.serverNow + (this.ping ?? 0) / 2 - this.now(), Infinity);
this._setStatus('online');
this._flushQueue();
this._sendPing();
}
_onHelloError(msg) {
this._clearTimer('_helloTimer', 'clearTimeout');
this._helloRid = null;
this.lastError = new NetError(msg.code, msg.msg, msg.detail);
this._setStatus('connected');
// Queued requests can't be sent without a session.
this._failPending('OFFLINE', true);
this._emit('helloError', this.lastError);
}
// ---- requests --------------------------------------------------------------------------------
_nextRid() {
this._rid = (this._rid % 2147483000) + 1;
return this._rid;
}
/**
* Send a request and wait for its `ok` (resolves with the reply message) or `error`
* (rejects with NetError). Rejects with TIMEOUT after `timeout` ms, OFFLINE when there is no
* session to send it on, BAD_MSG when it fails shared/protocol.js validation locally.
* @param {string} t message type (C2S)
* @param {object} [fields]
* @param {{timeout?: number}} [opts]
* @returns {Promise<any>}
*/
request(t, fields = {}, opts = {}) {
return new Promise((resolve, reject) => {
if (t === 'hello') { reject(new NetError('BAD_MSG', 'use setName() for hello')); return; }
const rid = this._nextRid();
const msg = { ...(fields && typeof fields === 'object' ? fields : {}), t, rid };
const bad = validateC2S(msg);
if (bad) {
console.warn(`[net] refusing invalid ${t}: ${bad}`);
reject(new NetError('BAD_MSG', bad));
return;
}
// Queue while a session is being (re)established; fail fast when none can come.
const establishing = this.status === 'connecting' || this.status === 'handshaking' || this.status === 'reconnecting';
if (this.status !== 'online' && !(establishing && this.name && !this._manualClose)) {
reject(new NetError(this._manualClose ? 'CLOSED' : 'OFFLINE'));
return;
}
const timeout = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : REQUEST_TIMEOUT_MS;
const entry = { msg, resolve, reject, sent: false, timer: null };
entry.timer = this.timers.setTimeout(() => {
if (this._pending.get(rid) !== entry) return;
this._pending.delete(rid);
if (entry.sent) {
this._expired.add(rid);
if (this._expired.size > 64) this._expired.delete(this._expired.values().next().value);
}
reject(new NetError('TIMEOUT'));
}, timeout);
this._pending.set(rid, entry);
if (this.status === 'online') entry.sent = this._sendRaw(msg);
});
}
/**
* Fire-and-forget message (no rid). Returns false when it could not be sent.
* @param {string} t
* @param {object} [fields]
* @returns {boolean}
*/
send(t, fields = {}) {
if (t === 'hello') return false;
const msg = { ...(fields && typeof fields === 'object' ? fields : {}), t };
const bad = validateC2S(msg);
if (bad) { console.warn(`[net] refusing invalid ${t}: ${bad}`); return false; }
if (this.status !== 'online') return false;
return this._sendRaw(msg);
}
_sendRaw(obj) {
const ws = this.ws;
if (!ws || ws.readyState !== WS_OPEN) return false;
try {
ws.send(JSON.stringify(obj));
return true;
} catch (err) {
console.warn('[net] send failed', err);
return false;
}
}
_flushQueue() {
for (const entry of this._pending.values()) {
if (!entry.sent) entry.sent = this._sendRaw(entry.msg);
}
}
/**
* Reject pending requests. Sent ones always fail (their reply is lost with the socket);
* unsent (queued) ones fail only when `includeQueued` is true.
*/
_failPending(code, includeQueued) {
for (const [rid, entry] of [...this._pending]) {
if (!entry.sent && !includeQueued) continue;
this._pending.delete(rid);
this._clearEntryTimer(entry);
try { entry.reject(new NetError(code)); } catch { /* ignore */ }
}
}
_clearEntryTimer(entry) {
if (entry.timer != null) {
try { this.timers.clearTimeout(entry.timer); } catch { /* ignore */ }
entry.timer = null;
}
}
/** Number of requests waiting for a reply (sent or queued). */
get pendingCount() {
return this._pending.size;
}
// ---- incoming --------------------------------------------------------------------------------
_onMessage(data) {
this._lastRx = this.now();
this._unansweredSince = null;
let msg;
try {
msg = JSON.parse(typeof data === 'string' ? data : String(data));
} catch {
console.warn('[net] dropped non-JSON frame');
return;
}
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || typeof msg.t !== 'string') return;
const { t } = msg;
const rid = msg.rid;
const isHelloError = t === 'error' && rid != null && rid === this._helloRid;
if (t === 'welcome') {
this._onWelcome(msg);
} else if (t === 'pong') {
this._onPong(msg);
} else if (isHelloError) {
this._onHelloError(msg);
} else if (t === 'm.public' && Number.isFinite(msg.serverNow) && !this.clockSynced) {
this._addClockSample(msg.serverNow + (this.ping ?? 0) / 2 - this.now(), Infinity);
}
let handled = false;
if (rid != null && t !== 'welcome' && this._pending.has(rid)) {
const entry = this._pending.get(rid);
this._pending.delete(rid);
this._clearEntryTimer(entry);
handled = true;
try {
if (t === 'error') entry.reject(new NetError(msg.code, msg.msg, msg.detail));
else entry.resolve(msg);
} catch (err) { console.error('[net] request callback failed', err); }
}
const late = rid != null && !handled && this._expired.delete(rid); // reply to a request that already timed out
if (t === 'error' && !handled && !isHelloError && !late) {
this._emit('unhandledError', new NetError(msg.code, msg.msg, msg.detail));
}
this._emit(t, msg);
this._emit('*', msg);
}
// ---- heartbeat & clock -----------------------------------------------------------------------
_startHeartbeat() {
this._clearTimer('_pingTimer', 'clearInterval');
this._pingTimer = this.timers.setInterval(() => this._heartbeat(), PING_INTERVAL_MS);
}
_heartbeat() {
const ws = this.ws;
if (!ws || ws.readyState !== WS_OPEN) return;
const live = this.status === 'online' || this.status === 'connected';
if (live && this._unansweredSince != null && this.now() - this._unansweredSince > DEAD_AFTER_MS) {
console.warn('[net] connection silent; reconnecting');
this._teardownSocket();
try { ws.close(4000, 'heartbeat timeout'); } catch { /* ignore */ }
this._failPending('DISCONNECTED', false);
this._scheduleReconnect();
return;
}
this._sendPing();
}
_sendPing() {
if (this.status !== 'online' && this.status !== 'connected') return;
const now = this.now();
if (this._sendRaw({ t: 'ping', c: now }) && this._unansweredSince == null) this._unansweredSince = now;
}
/** Force an immediate latency probe (e.g. when the tab regains focus). */
probe() {
this._heartbeat();
}
_onPong(msg) {
const now = this.now();
const c = Number(msg.c);
if (!Number.isFinite(c)) return;
const rtt = now - c;
if (!(rtt >= 0 && rtt < 60000)) return;
this.ping = Math.round(rtt);
if (Number.isFinite(msg.s)) this._addClockSample(msg.s + rtt / 2 - now, rtt);
this._emit('ping', this.ping);
this._emit('status', this.snapshot());
}
_addClockSample(offset, rtt) {
if (!Number.isFinite(offset)) return;
this._clockSamples.push({ offset, rtt });
if (this._clockSamples.length > 8) this._clockSamples.shift();
let best = this._clockSamples[0];
for (const s of this._clockSamples) if (s.rtt < best.rtt) best = s;
const prev = this.clockOffset;
this.clockOffset = Math.round(best.offset);
const wasSynced = this.clockSynced;
this.clockSynced = this.clockSynced || Number.isFinite(rtt);
if (Math.abs(prev - this.clockOffset) >= 1 || wasSynced !== this.clockSynced || this._clockSamples.length === 1) {
this._emit('clock', { offset: this.clockOffset, rtt: Number.isFinite(best.rtt) ? best.rtt : null, synced: this.clockSynced });
}
}
/** Current server time estimate (ms epoch). */
serverNow() {
return this.now() + this.clockOffset;
}
/**
* Hook browser signals: reconnect immediately when the network comes back or the tab becomes
* visible, and probe latency on focus. Safe to call in non-browser environments (no-op).
*/
attachBrowserHooks() {
if (typeof window === 'undefined' || this._hooked) return;
this._hooked = true;
window.addEventListener('online', () => this.retryNow());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
if (this.status === 'reconnecting') this.retryNow();
else if (this.status === 'online' || this.status === 'connected') this.probe();
});
}
}
// ---- identity persistence -------------------------------------------------------------------------
//
// Which reconnect token a tab uses is decided once per page load by `identity.init()`:
// 1. this tab's own token (sessionStorage: survives reloads of this tab),
// 2. else the most recent token of this browser (localStorage, so closing the tab and reopening
// the game within the server's reconnect window resumes the seat),
// skipping every token that another *live* tab of this browser is using. Liveness is asked over a
// BroadcastChannel ('who holds these token hashes?' → 'mine'), not inferred from timer heartbeats:
// browsers throttle background-tab timers to ~1/min, which would make a busy background tab look
// dead. A duplicated tab (sessionStorage is copied) therefore drops the copied token and becomes a
// new player instead of kicking the original tab off its session (server close 4001). Two tabs
// resolving at the same moment tie-break on tab id. Without BroadcastChannel a tab only ever uses
// its own token (never another tab's), so it can't steal a live session either.
const K_NAME = 'sp.name';
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first
const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen
const RECENT_MAX = 4;
const TOKEN_MAX_LEN = 64; // protocol limit for hello.token
const CHANNEL_NAME = 'sp.identity';
/** How long init() waits for other tabs to claim a candidate token (ms). */
export const CLAIM_QUERY_MS = 150;
function safeStorage(kind) {
try {
const s = globalThis[kind];
if (!s) return null;
const probe = '__sp_probe__';
s.setItem(probe, '1');
s.removeItem(probe);
return s;
} catch {
return null;
}
}
function sget(s, k) { try { return s ? s.getItem(k) : null; } catch { return null; } }
function sset(s, k, v) { try { if (s) s.setItem(k, v); } catch { /* quota / privacy mode */ } }
function sdel(s, k) { try { if (s) s.removeItem(k); } catch { /* ignore */ } }
const isToken = (t) => typeof t === 'string' && t.length > 0 && t.length <= TOKEN_MAX_LEN;
/**
* Short, stable hash of a token (FNV-1a, hex) so tabs never broadcast tokens in clear.
* @param {string} token
* @returns {string}
*/
export function tokenHash(token) {
let h = 2166136261;
const s = String(token);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
function defaultChannel() {
try {
return typeof globalThis.BroadcastChannel === 'function' ? new globalThis.BroadcastChannel(CHANNEL_NAME) : null;
} catch {
return null;
}
}
/**
* Create the identity helper (nickname, "entered" flag and reconnect-token selection; see above).
* @param {{ local?: Storage|null, session?: Storage|null, tabId?: string,
* channel?: { postMessage: Function, addEventListener?: Function, onmessage?: any } | null,
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number }} [deps]
*/
export function createIdentity(deps = {}) {
const local = deps.local !== undefined ? deps.local : safeStorage('localStorage');
const session = deps.session !== undefined ? deps.session : safeStorage('sessionStorage');
const tabId = deps.tabId || Math.random().toString(36).slice(2) + Date.now().toString(36);
const setTimer = deps.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms));
const queryMs = Number.isFinite(deps.queryMs) && deps.queryMs >= 0 ? deps.queryMs : CLAIM_QUERY_MS;
let channel = null;
let initPromise = null;
let initialized = false;
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { taken: Set<hash> } while init() waits for claims
const readRecent = () => {
try {
const v = JSON.parse(sget(local, K_RECENT) || '[]');
return Array.isArray(v) ? v.filter(isToken) : [];
} catch {
return [];
}
};
const writeRecent = (list) => sset(local, K_RECENT, JSON.stringify([...new Set(list)].slice(0, RECENT_MAX)));
const post = (msg) => {
try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ }
};
function onChannelMessage(ev) {
const m = ev && ev.data;
if (!m || typeof m !== 'object' || m.from === tabId || typeof m.from !== 'string') return;
if (m.type === 'who' && Array.isArray(m.hashes)) {
if (current) {
const h = tokenHash(current);
if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from });
}
// Another tab is choosing at the same time: the smaller tab id has precedence.
if (resolving && m.from < tabId) for (const h of m.hashes) if (typeof h === 'string') resolving.taken.add(h);
} else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') {
resolving.taken.add(m.hash);
}
}
async function resolveToken() {
channel = deps.channel !== undefined ? deps.channel : defaultChannel();
if (channel) {
if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage);
else channel.onmessage = onChannelMessage;
}
const own = sget(session, K_TOKEN);
const ownOk = isToken(own) ? own : null;
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
if (!channel || candidates.length === 0) return ownOk;
resolving = { taken: new Set() };
post({ type: 'who', hashes: candidates.map(tokenHash) });
await new Promise((r) => setTimer(r, queryMs));
const { taken } = resolving;
resolving = null;
return candidates.find((t) => !taken.has(tokenHash(t))) || null;
}
return {
tabId,
/**
* Decide which reconnect token this tab uses (once per page load; later calls return the same
* promise). Call before the first `hello`. Never rejects.
* @returns {Promise<string|null>}
*/
init() {
if (!initPromise) {
initPromise = resolveToken().catch(() => null).then((pick) => {
const own = sget(session, K_TOKEN);
if (pick) sset(session, K_TOKEN, pick);
else if (own) sdel(session, K_TOKEN); // duplicated tab: the copied token belongs to a live tab
// A welcome may already have set a token while we were waiting: that one wins.
if (!current) current = pick;
initialized = true;
return current;
});
}
return initPromise;
},
/** @returns {string} remembered player name ('' if none) */
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
/** @param {string} name */
saveName: (name) => sset(local, K_NAME, String(name)),
/** Token for `hello` (null ⇒ new session). Before init() only this tab's own token is used. */
getToken() {
if (current) return current;
if (initialized) return null;
const own = sget(session, K_TOKEN);
return isToken(own) ? own : null;
},
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
current = token;
sset(session, K_TOKEN, token);
writeRecent([token, ...readRecent().filter((t) => t !== token)]);
},
/** Forget this tab's token (e.g. the server said the session is invalid). */
clearToken() {
const t = current || sget(session, K_TOKEN);
current = null;
sdel(session, K_TOKEN);
if (t) writeRecent(readRecent().filter((x) => x !== t));
},
/** Whether this tab already passed the title screen (survives reloads, not new tabs). */
wasEntered: () => sget(session, K_ENTERED) === '1',
/** @param {boolean} on */
setEntered: (on) => (on ? sset(session, K_ENTERED, '1') : sdel(session, K_ENTERED)),
};
}
/** Browser identity singleton (main.js awaits `identity.init()` before connecting). */
export const identity = createIdentity();
/** Browser connection singleton (created lazily-safe: nothing touches the network until connect()). */
export const net = new Net({ getToken: () => identity.getToken() });
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
// render/board3d/atlas.js — which pixels of the official board textures dress each surface of the 3D board
// (DESIGN §15). Pure data + UV helpers (no three import): the geometry builder (layout.js) maps these rects straight
// onto the tiles, the three.js scene samples the textures of MT_autochess (TX_autochessi_D + _N + _M + _E, which
// share one UV layout) and MT_autochess_common (TX_autochessi_common_D/_E, device plates).
//
// Source of truth for the rects: this table. tools/crop-board-atlas.mjs validates every rect against the extracted
// images and writes it into public/assets/local/map/autochess/tiles.json → `board3d`, which the renderer prefers
// (so a re-crop needs no code change); `resolveUvTable(tiles)` merges it over these defaults.
//
// surface = { src: 'D' | 'common', rect: [x, y, w, h] (source px, y down), rot?: 0|90|180|270 (clockwise, of the
// texture on the face), flipX?: bool, tint?: '#rrggbb' (vertex colour multiply), glow?: number }
//
// Atlas pieces (TX_autochessi_D 2048², 256 px per floor cell; picked by eye, validated by the crop tool): gold-framed
// high-ground plates (row 0: strip L / M / R + single), concrete slabs (rows 1–2: rails, ✕ marker, hazard stripes,
// chevrons, red no-deploy stripes), wall panels (REST AREA / EVACUATION), REINFORCEMENTS / EQUIPMENTS bench pads
// (512² each), ribbed side panels (gold / grey), the hazard-framed glass hatch, the ▲▼ lift plate, grille slats,
// dark machinery hatch, gold ring hatch (objective), orange crate panels.
export const SOURCES = Object.freeze({
D: Object.freeze({ w: 2048, h: 2048 }),
common: Object.freeze({ w: 1024, h: 1024 }),
});
const S = (src, rect, o = {}) => Object.freeze({ src, rect: Object.freeze(rect), ...o });
/** Surface → atlas region (see header). Names are used by layout.js. */
export const SURFACES = Object.freeze({
// floors
concrete: S('D', [256, 512, 256, 256]),
concreteRailTL: S('D', [0, 256, 256, 256]),
concreteRailT: S('D', [256, 256, 256, 256]),
concreteRailL: S('D', [0, 512, 256, 256]),
concreteStripe: S('D', [512, 256, 256, 256]),
concreteArrow: S('D', [768, 256, 256, 256]),
concreteRed: S('D', [768, 512, 256, 256]),
hatch: S('D', [549, 1787, 255, 256]),
lift: S('D', [804, 1790, 234, 234]),
ringHatch: S('D', [1290, 1400, 190, 190]),
slats: S('D', [1036, 480, 404, 216]),
mech: S('D', [1088, 708, 356, 310]),
steel: S('D', [12, 776, 196, 196]),
// high ground (gold-framed plates; texture top = the tile's far edge)
plateL: S('D', [0, 0, 256, 256]),
plateM: S('D', [256, 0, 256, 256]),
plateR: S('D', [512, 0, 256, 256]),
plateS: S('D', [768, 0, 256, 256]),
// bench pads
padReinf: S('D', [0, 1024, 512, 512]),
padEquip: S('D', [512, 1024, 512, 512]),
// side panels (cropped to the face aspect at use: see sideRect)
goldSide: S('D', [0, 1664, 272, 384]),
graySide: S('D', [272, 1664, 272, 384]),
benchRail: S('D', [0, 1538, 512, 98]),
restPanel: S('D', [0, 768, 512, 208]),
evacPanel: S('D', [512, 768, 256, 208]),
pipePanel: S('D', [544, 1540, 480, 244]),
// devices
crateSide: S('D', [1087, 1938, 134, 108]),
crateTop: S('D', [1222, 1938, 134, 108], { rot: 90 }),
// common atlas plates (MT_autochess_common, alpha-tested decals)
hazardX: S('common', [2, 2, 250, 248]),
heal: S('common', [264, 2, 256, 250]),
shield: S('common', [538, 2, 256, 250]),
target: S('common', [2, 262, 250, 250]),
blast: S('common', [538, 262, 250, 250]),
enemyMark: S('common', [792, 258, 232, 262]),
fast: S('common', [2, 520, 250, 250]),
arrowUp: S('common', [508, 762, 244, 244]),
arrowDown: S('common', [776, 762, 244, 244]),
});
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** Validate one surface record (from tiles.json) → a clean record or null. */
export function cleanSurface(v) {
if (!isObj(v) || !SOURCES[v.src] || !Array.isArray(v.rect) || v.rect.length !== 4) return null;
const [x, y, w, h] = v.rect.map(Number);
const src = SOURCES[v.src];
if (![x, y, w, h].every(Number.isFinite) || w < 4 || h < 4 || x < 0 || y < 0 || x + w > src.w || y + h > src.h) return null;
const rot = [0, 90, 180, 270].includes(v.rot) ? v.rot : 0;
const out = { src: v.src, rect: [x, y, w, h], rot, flipX: !!v.flipX };
if (typeof v.tint === 'string' && /^#[0-9a-f]{6}$/i.test(v.tint)) out.tint = v.tint;
return out;
}
/** Surface table: defaults overridden by a tiles.json `board3d` section (invalid entries ignored). */
export function resolveUvTable(tiles) {
const out = {};
for (const [k, v] of Object.entries(SURFACES)) out[k] = cleanSurface(v);
const extra = isObj(tiles) && isObj(tiles.board3d) ? tiles.board3d : null;
if (extra) for (const [k, v] of Object.entries(extra)) { const c = cleanSurface(v); if (c) out[k] = c; }
return out;
}
/**
* UV corners of a surface for a quad whose corners are given as [bottom-left, bottom-right, top-right, top-left]
* of the face as seen from outside (for a top face: near-left, near-right, far-right, far-left). Optional
* `sub` = [u0, v0, u1, v1] (fractions of the rect, v down) crops the rect (side panels, partial faces).
* Returns [u,v, u,v, u,v, u,v] in three's convention (v up, textures flipped on upload). `inset` (source px)
* keeps mip filtering inside the region.
*/
export function surfaceUV(surface, sub = null, inset = 2) {
const src = SOURCES[surface.src] || SOURCES.D;
let [x, y, w, h] = surface.rect;
if (sub) {
const [a, b, c, d] = sub;
x += w * a; y += h * b; const w2 = w * (c - a), h2 = h * (d - b); w = w2; h = h2;
}
const ix = Math.min(inset, w / 4), iy = Math.min(inset, h / 4);
const u0 = (x + ix) / src.w, u1 = (x + w - ix) / src.w;
const vTop = 1 - (y + iy) / src.h, vBot = 1 - (y + h - iy) / src.h;
// texture corners in face order BL, BR, TR, TL
let c = [[u0, vBot], [u1, vBot], [u1, vTop], [u0, vTop]];
if (surface.flipX) c = [c[1], c[0], c[3], c[2]];
const turns = ((surface.rot | 0) / 90) & 3;
// rotating the texture clockwise on the face = the face corner k samples texture corner k − turns
for (let t = 0; t < turns; t++) c = [c[1], c[2], c[3], c[0]];
return [c[0][0], c[0][1], c[1][0], c[1][1], c[2][0], c[2][1], c[3][0], c[3][1]];
}
/** Sub-rect (fractions) of a side panel that keeps the texture undistorted on a face `width` × `height` tiles. */
export function sideRect(surface, width, height) {
const [, , w, h] = surface.rect;
const faceAspect = Math.max(1e-3, height / Math.max(1e-3, width));
const texAspect = h / w;
if (faceAspect >= texAspect) return [0, 0, 1, 1];
const frac = faceAspect / texAspect;
return [0, 0, 1, frac];
}
/** '#rrggbb' → [r, g, b] (0..1); null → [1, 1, 1]. */
export function tintRgb(hex) {
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return [1, 1, 1];
const n = parseInt(hex.slice(1), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
+560
View File
@@ -0,0 +1,560 @@
// render/board3d/layout.js — the official board's geometry, built from a stage grid the way the client builds the
// autochess board at runtime (DESIGN §15): pure JS, no three import (tested in Node; render/board3d/scene.js turns
// the buckets into merged BufferGeometries).
//
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh })
// → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...],
// terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats }
//
// World space = render/projection.js: x = col, y = row, z = height (up); tile (r,c) covers [c±½]×[r±½].
// Heights follow render/style.js TILE_H (the Pixi layers stand units on the same tops: tiles.js heightAt).
//
// Tiles (glyph legend DATA.md §12):
// LOW r road → concrete slab (8 rotation/flip variants) · R → hazard-striped concrete · f / p lane & preview pen →
// the hazard-framed glass hatch · b fence → concrete + an orange tubular railing around the fenced region ·
// S gate → chevron concrete (+ the official red start box, scene.js) · E objective → gold ring hatch (+ blue
// end box) · I / O → lift plate + ▼/▲ plate · m / g / d / i → concrete / grille / sunk basin / concrete with the
// animated terrain overlay of scene.js
// HIGH h → gold-framed plates chosen by connectivity (strip L / M / R, vertical strips, single) with gold ribbed
// sides · # / X → dark hatched blocks ("▨", the official boss-field / arena look; X with REST AREA /
// EVACUATION wall panels) · a / A → REINFORCEMENTS / EQUIPMENTS bench pads (own bench), concrete plates
// (Final Assault benches)
// Every top has a small bevel (tile seams catch the light), raised blocks have chamfered edges, vertex colours carry
// ambient occlusion at wall feet. The enemy preview pen's hatches (enemy_place_rect) go to their own `glass` bucket:
// the official pen reads as glossy glass panes in a hazard frame reflecting the sky (scene.js glassMaterial), not as
// the striped hatch. The Final Assault benches (rows 0–1) are dark-rimmed plates with a 2×2 grid of light panels. The island ("drawn" tiles: content + the forbidden ring around it) stands on
// panelled cliff faces; beyond it the official background plane shows through (scene.js).
import { SURFACES, surfaceUV, sideRect, tintRgb } from './atlas.js';
import { GLYPH, TILEKEY_GLYPH, TILE_H } from '../style.js';
import { parsePenRect, PEN_RECT } from '../pen.js';
export const ROWS = 19;
export const COLS = 21;
/** How far the island's cliff faces go down (tiles). */
export const CLIFF = 0.75;
/** Top bevel: inset (tiles) and drop (tiles) of low tiles and raised blocks. */
export const BEVEL = Object.freeze({ low: [0.035, 0.018], block: [0.04, 0.035] });
/** Deep-sea basins: floor depth below the ground. */
export const BASIN = 0.14;
const CONTENT_OF = (g) => g !== '#' && g !== 'X';
/**
* Areas built per phase (inclusive tile rects; configBlackBoard leftNormal ((6,0),(12,10)), rightNormal
* ((6,11),(12,20)), leftBoss ((0,0),(6,10)), rightBoss ((0,11),(6,20)); enemy_place_rect ((14,7),(18,13)) + the
* separator in front of it). The official prep / battle view shows only the player's own field and the preview pen
* (the island ends under the bench row); 联防 joins both normal halves; the Final Assault builds the boss field.
*/
export const AREAS = Object.freeze({
normal: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
unite: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 20 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
boss: Object.freeze([Object.freeze({ r0: 0, r1: 6, c0: 0, c1: 20 })]),
all: Object.freeze([Object.freeze({ r0: 0, r1: 18, c0: 0, c1: 20 })]),
});
/** Area set of a camera kind ('prep' | 'normal' | 'pen' | 'unite' | 'boss' | 'hidden' | 'bossPrep'). */
export function areaFor(kind) {
if (kind === 'unite') return AREAS.unite;
if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return AREAS.boss;
return AREAS.normal;
}
/** Union of area sets (camera transitions show both). */
export function unionAreas(...sets) {
const out = [];
const seen = new Set();
for (const s of sets) for (const a of s || []) { const k = `${a.r0},${a.r1},${a.c0},${a.c1}`; if (!seen.has(k)) { seen.add(k); out.push(a); } }
return out;
}
const hash2 = (r, c) => ((r * 73856093) ^ (c * 19349663)) >>> 0;
// ---- geometry accumulator ----------------------------------------------------------------------------------
export class Geom {
constructor() { this.pos = []; this.nrm = []; this.uv = []; this.col = []; this.idx = []; this.n = 0; }
/**
* Quad from 4 corners in counter-clockwise order seen from the front (BL, BR, TR, TL), `uv` = 8 numbers in the
* same corner order, `nrm` one [x,y,z] (flat) or 4, `col` one [r,g,b] or 4.
*/
quad(p, uv, nrm, col) {
const base = this.n;
for (let k = 0; k < 4; k++) {
const q = p[k];
this.pos.push(q[0], q[1], q[2]);
const nn = Array.isArray(nrm[0]) ? nrm[k] : nrm;
this.nrm.push(nn[0], nn[1], nn[2]);
this.uv.push(uv ? uv[k * 2] : 0, uv ? uv[k * 2 + 1] : 0);
const cc = col ? (Array.isArray(col[0]) ? col[k] : col) : [1, 1, 1];
this.col.push(cc[0], cc[1], cc[2]);
}
this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
this.n += 4;
return this;
}
/** Triangle (CCW from the front). */
tri(p, nrm, col) {
const base = this.n;
for (let k = 0; k < 3; k++) {
this.pos.push(p[k][0], p[k][1], p[k][2]);
const nn = Array.isArray(nrm[0]) ? nrm[k] : nrm;
this.nrm.push(nn[0], nn[1], nn[2]);
this.uv.push(0, 0);
const cc = col ? (Array.isArray(col[0]) ? col[k] : col) : [1, 1, 1];
this.col.push(cc[0], cc[1], cc[2]);
}
this.idx.push(base, base + 1, base + 2);
this.n += 3;
return this;
}
get vertexCount() { return this.n; }
get triangleCount() { return this.idx.length / 3; }
/** Typed arrays for a BufferGeometry. */
finish() {
return {
position: new Float32Array(this.pos), normal: new Float32Array(this.nrm), uv: new Float32Array(this.uv),
color: new Float32Array(this.col), index: this.n > 65535 ? new Uint32Array(this.idx) : new Uint16Array(this.idx),
vertexCount: this.n, triangleCount: this.idx.length / 3,
};
}
}
const norm3 = (x, y, z) => { const l = Math.hypot(x, y, z) || 1; return [x / l, y / l, z / l]; };
/** Bilinear UV at fractions (fx right, fy far) of a face whose corner UVs are [BL, BR, TR, TL] (8 numbers). */
export function uvAt(uv, fx, fy) {
const bu = uv[0] + (uv[2] - uv[0]) * fx, bv = uv[1] + (uv[3] - uv[1]) * fx;
const tu = uv[6] + (uv[4] - uv[6]) * fx, tv = uv[7] + (uv[5] - uv[7]) * fx;
return [bu + (tu - bu) * fy, bv + (tv - bv) * fy];
}
// ---- stage classification -----------------------------------------------------------------------------------
/**
* Per-tile description of a stage: `{ r, c, glyph, content, drawn, h (top height), raised, surf (top surface),
* tint, side (side panel surface) }`. Tolerant of malformed stages (missing rows → forbidden). `area` = the built
* areas (inclusive tile rects, see AREAS): content outside them is not built, like the client which only builds the
* areas of the current phase (configBlackBoard leftNormal / rightNormal / leftBoss / rightBoss + the preview pen).
*/
export function classifyStage(stage, area = null) {
const rows = Array.isArray(stage?.rows) ? stage.rows : [];
const pen = parsePenRect(stage?.config?.enemy_place_rect) || PEN_RECT;
const legend = stage && typeof stage.tiles === 'object' && stage.tiles ? stage.tiles : {};
const grid = [];
for (let r = 0; r < ROWS; r++) {
const line = typeof rows[r] === 'string' ? rows[r] : '';
const row = [];
for (let c = 0; c < COLS; c++) {
let g = line[c] || '#';
if (!GLYPH[g]) g = TILEKEY_GLYPH[legend[g]?.tileKey] || (legend[g]?.height === 'HIGH' ? '#' : 'R');
const def = GLYPH[g];
const inArea = !area || area.some((a) => r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1);
row.push({ r, c, glyph: g, content: inArea && CONTENT_OF(g), inArea, drawn: false, h: def.h ? TILE_H[def.h] : 0, raised: !!def.h });
}
grid.push(row);
}
// the island: content tiles + the forbidden / separator ring around them (Chebyshev 1)
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = grid[r][c];
if (t.content) { t.drawn = true; continue; }
if (!t.inArea) continue;
let near = false;
for (let dr = -1; dr <= 1 && !near; dr++) for (let dc = -1; dc <= 1; dc++) {
const n = grid[r + dr]?.[c + dc];
if (n && n.content) { near = true; break; }
}
t.drawn = near;
}
const at = (r, c) => grid[r]?.[c] || null;
for (const row of grid) for (const t of row) {
t.tint = [1, 1, 1];
t.surf = 'concrete';
t.rot = 0; t.flipX = false;
const h = hash2(t.r, t.c);
switch (t.glyph) {
case 'r': t.rot = (h % 4) * 90; t.flipX = ((h >> 3) & 1) === 1; break;
case 'R': t.surf = 'concreteStripe'; t.rot = (h & 1) ? 180 : 0; break;
case 'f': case 'p':
t.surf = 'hatch'; t.rot = (h % 4) * 90; if (t.glyph === 'p') t.tint = [0.9, 0.92, 0.95];
t.glass = t.r >= pen.r0 && t.r <= pen.r1 && t.c >= pen.c0 && t.c <= pen.c1;
break;
case 'b': t.rot = (h % 4) * 90; break;
case 'S': t.surf = 'concreteArrow'; break;
case 'E': t.surf = 'ringHatch'; break;
case 'I': case 'O': t.surf = 'lift'; t.rot = t.glyph === 'O' ? 180 : 0; break;
case 'g': t.surf = 'slats'; t.tint = [0.72, 0.76, 0.78]; break;
case 'm': t.rot = (h % 4) * 90; t.tint = [0.78, 0.8, 0.7]; break;
case 'i': t.rot = (h % 4) * 90; t.tint = [0.95, 0.88, 0.86]; break;
case 'd': t.surf = 'concrete'; t.tint = [0.35, 0.42, 0.46]; break;
// the own bench (rows 7–8): REINFORCEMENTS / EQUIPMENTS pads; the Final Assault benches (rows 0–1) are plain
// 2×2-slab concrete plates in the official boss field
case 'a': t.surf = t.r <= 1 ? 'concrete' : 'padReinf'; t.side = 'benchRail'; if (t.r <= 1) { t.tint = [0.3, 0.32, 0.35]; t.panels = true; } break;
case 'A': t.surf = t.r <= 1 ? 'concrete' : 'padEquip'; t.side = 'benchRail'; if (t.r <= 1) { t.tint = [0.3, 0.32, 0.35]; t.panels = true; } break;
case 'h': t.side = 'goldSide'; break;
// forbidden blocks and separators: the dark hatched ("▨") blocks of the official boss field / arena rows
case '#': t.surf = 'hatch'; t.rot = (h & 1) ? 90 : 0; t.tint = [0.34, 0.36, 0.39]; t.side = 'graySide'; t.sideTint = [0.52, 0.55, 0.58]; break;
case 'X': t.surf = 'hatch'; t.tint = [0.36, 0.38, 0.41]; t.side = (t.c % 5 === 2) ? 'evacPanel' : 'restPanel'; t.sideTint = [0.8, 0.82, 0.86]; break;
default: break;
}
}
// high-ground plates by connectivity (texture top = the tile's far edge)
const wall = (r, c) => { const n = at(r, c); return !!n && n.glyph === 'h'; };
for (const row of grid) for (const t of row) {
if (t.glyph !== 'h') continue;
const L = wall(t.r, t.c - 1), R = wall(t.r, t.c + 1);
if (L || R) { t.surf = L && R ? 'plateM' : R ? 'plateL' : 'plateR'; continue; }
const near = wall(t.r - 1, t.c), far = wall(t.r + 1, t.c);
if (near && far) { t.surf = 'plateM'; t.rot = 90; }
else if (far) { t.surf = 'plateL'; t.rot = 270; }
else if (near) { t.surf = 'plateL'; t.rot = 90; }
else t.surf = 'plateS';
}
return grid;
}
/** Top height of tile (r,c) of a classified grid (0 off-grid / not drawn), like tiles.js heightAt. */
export function heightOf(grid, r, c) {
const t = grid[r]?.[c];
return t && t.drawn ? t.h : 0;
}
// ---- builder ----------------------------------------------------------------------------------------------------
const DIRS = [['S', 0, -1], ['E', 1, 0], ['N', 0, 1], ['W', -1, 0]];
const NRM = { S: [0, -1, 0], E: [1, 0, 0], N: [0, 1, 0], W: [-1, 0, 0] };
/** Corners (BL, BR, TR, TL seen from outside) of the side face `dir` of box [x0,x1]×[y0,y1] between zb and zt. */
export function sideCorners(dir, x0, x1, y0, y1, zb, zt) {
switch (dir) {
case 'S': return [[x0, y0, zb], [x1, y0, zb], [x1, y0, zt], [x0, y0, zt]];
case 'N': return [[x1, y1, zb], [x0, y1, zb], [x0, y1, zt], [x1, y1, zt]];
case 'E': return [[x1, y0, zb], [x1, y1, zb], [x1, y1, zt], [x1, y0, zt]];
default: return [[x0, y1, zb], [x0, y0, zb], [x0, y0, zt], [x0, y1, zt]];
}
}
const mul = (a, b) => [a[0] * b[0], a[1] * b[1], a[2] * b[2]];
const scl = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
/**
* Beveled top of a tile: an inset flat top at `zt` + 4 sloped strips down to `zt − drop` at the tile border.
* `ao` = corner occlusion [NL, NR, FR, FL] (1 = open).
*/
function bevelTop(g, uvTable, t, zt, inset, drop, ao, tint) {
const x0 = t.c - 0.5, x1 = t.c + 0.5, y0 = t.r - 0.5, y1 = t.r + 0.5;
const surf = uvTable[t.surf] || uvTable.concrete;
const uv = surfaceUV({ ...surf, rot: ((surf.rot | 0) + (t.rot | 0)) % 360, flipX: !!surf.flipX !== !!t.flipX });
const b = inset, zo = zt - drop;
const f = (fx, fy) => uvAt(uv, fx, fy);
const colAt = (fx, fy) => {
// bilinear corner AO
const a = ao[0] + (ao[1] - ao[0]) * fx, d = ao[3] + (ao[2] - ao[3]) * fx;
return scl(tint, a + (d - a) * fy);
};
const ib = b, ie = 1 - b;
// inner flat top
const P = (fx, fy, z) => [x0 + fx, y0 + fy, z];
g.quad([P(ib, ib, zt), P(ie, ib, zt), P(ie, ie, zt), P(ib, ie, zt)],
[...f(ib, ib), ...f(ie, ib), ...f(ie, ie), ...f(ib, ie)], [0, 0, 1],
[colAt(ib, ib), colAt(ie, ib), colAt(ie, ie), colAt(ib, ie)]);
if (drop <= 0) return;
const ns = norm3(0, -drop, b), nn = norm3(0, drop, b), ne = norm3(drop, 0, b), nw = norm3(-drop, 0, b);
// south strip (outer edge y0)
g.quad([P(0, 0, zo), P(1, 0, zo), P(ie, ib, zt), P(ib, ib, zt)], [...f(0, 0), ...f(1, 0), ...f(ie, ib), ...f(ib, ib)], ns,
[colAt(0, 0), colAt(1, 0), colAt(ie, ib), colAt(ib, ib)]);
// north strip (outer edge y1), seen from the north: BL = (1,1)
g.quad([P(1, 1, zo), P(0, 1, zo), P(ib, ie, zt), P(ie, ie, zt)], [...f(1, 1), ...f(0, 1), ...f(ib, ie), ...f(ie, ie)], nn,
[colAt(1, 1), colAt(0, 1), colAt(ib, ie), colAt(ie, ie)]);
// east strip (outer edge x1), seen from the east: BL = (1,0)
g.quad([P(1, 0, zo), P(1, 1, zo), P(ie, ie, zt), P(ie, ib, zt)], [...f(1, 0), ...f(1, 1), ...f(ie, ie), ...f(ie, ib)], ne,
[colAt(1, 0), colAt(1, 1), colAt(ie, ie), colAt(ie, ib)]);
// west strip (outer edge x0), seen from the west: BL = (0,1)
g.quad([P(0, 1, zo), P(0, 0, zo), P(ib, ib, zt), P(ib, ie, zt)], [...f(0, 1), ...f(0, 0), ...f(ib, ib), ...f(ib, ie)], nw,
[colAt(0, 1), colAt(0, 0), colAt(ib, ib), colAt(ib, ie)]);
}
/** Final Assault bench plate: a 2×2 grid of light concrete panels on the dark-rimmed top (the official boss field). */
export const PANEL = Object.freeze({ rim: 0.085, seam: 0.05, lift: 0.004 });
function benchPanels(g, uvTable, t, zt, ao) {
const surf = uvTable.concrete || uvTable.steel;
if (!surf) return;
const x0 = t.c - 0.5, y0 = t.r - 0.5, z = zt + PANEL.lift;
const a = PANEL.rim, h = PANEL.seam / 2;
const light = [0.9, 0.92, 0.94];
const span = [[a, 0.5 - h], [0.5 + h, 1 - a]];
for (let i = 0; i < 2; i++) for (let j = 0; j < 2; j++) {
const [fx0, fx1] = span[i], [fy0, fy1] = span[j];
// each panel shows a quarter of the concrete slab (texture v runs down: the far edge is the top of the crop)
const uv = surfaceUV(surf, [i * 0.5, (1 - j) * 0.5, i * 0.5 + 0.5, (1 - j) * 0.5 + 0.5]);
const col = (fx, fy) => { const s0 = ao[0] + (ao[1] - ao[0]) * fx, s1 = ao[3] + (ao[2] - ao[3]) * fx; return scl(light, s0 + (s1 - s0) * fy); };
g.quad([[x0 + fx0, y0 + fy0, z], [x0 + fx1, y0 + fy0, z], [x0 + fx1, y0 + fy1, z], [x0 + fx0, y0 + fy1, z]], uv, [0, 0, 1],
[col(fx0, fy0), col(fx1, fy0), col(fx1, fy1), col(fx0, fy1)]);
}
}
/** A panelled side face from zb to zt; the panel texture keeps its aspect (repeats along the face if needed). */
function sideFace(g, uvTable, surfName, dir, x0, x1, y0, y1, zb, zt, colBottom, colTop, width = 1) {
if (zt - zb < 1e-4) return;
const surf = uvTable[surfName] || uvTable.graySide;
const uv = surfaceUV(surf, sideRect(surf, width, zt - zb));
const c = sideCorners(dir, x0, x1, y0, y1, zb, zt);
g.quad(c, uv, NRM[dir], [colBottom, colBottom, colTop, colTop]);
}
/**
* Build the whole board of a stage.
* @param {any} stage data/stages.json entry
* @param {{ uv?: object, devices?: boolean }} [opts] uv = resolveUvTable(tiles.json) (defaults to the built-in table)
*/
export function buildBoard(stage, opts = {}) {
const uvTable = opts.uv || null;
const UVT = uvTable || Object.fromEntries(Object.entries(SURFACES).map(([k, v]) => [k, { ...v, rect: [...v.rect] }]));
const grid = classifyStage(stage, opts.area || null);
const at = (r, c) => grid[r]?.[c] || null;
const topZ = (t) => (t.glyph === 'd' ? -BASIN : t.h);
const board = new Geom(), decal = new Geom(), pipe = new Geom(), glass = new Geom();
const gates = [], terrain = { water: [], mire: [], smog: [], infection: [] };
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = grid[r][c];
if (!t.drawn) continue;
minX = Math.min(minX, c - 0.5); maxX = Math.max(maxX, c + 0.5); minY = Math.min(minY, r - 0.5); maxY = Math.max(maxY, r + 0.5);
const zt = topZ(t);
const x0 = c - 0.5, x1 = c + 0.5, y0 = r - 0.5, y1 = r + 0.5;
const tint = t.tint;
// corner occlusion: a corner touching a taller neighbour darkens (NL, NR, FR, FL)
const occl = (dx, dy) => {
let hi = false;
for (const [ax, ay] of [[0, 0], [dx, 0], [0, dy], [dx, dy]]) {
const n = at(r + ay, c + ax);
if (n && n.drawn && topZ(n) > zt + 0.05) hi = true;
}
return hi ? 0.66 : 1;
};
const ao = [occl(-1, -1), occl(1, -1), occl(1, 1), occl(-1, 1)];
const [inset, drop] = t.raised ? BEVEL.block : BEVEL.low;
bevelTop(t.glass ? glass : board, UVT, t, zt, inset, t.glyph === 'd' ? 0 : drop, ao, tint);
if (t.panels) benchPanels(board, UVT, t, zt, ao);
const zEdge = zt - (t.glyph === 'd' ? 0 : drop);
// sides: towards lower drawn neighbours (block sides / basin walls) and the island edge (cliffs)
for (const [dir, dx, dy] of DIRS) {
const n = at(r + dy, c + dx);
const nDrawn = !!(n && n.drawn);
const nz = nDrawn ? topZ(n) - (n.glyph === 'd' ? 0 : (n.raised ? BEVEL.block[1] : BEVEL.low[1])) : -CLIFF;
if (!nDrawn) {
// island edge: the tile's own side (if raised) then the platform's panelled cliff
const sideTint = t.sideTint || [1, 1, 1];
if (zEdge > 0.001) sideFace(board, UVT, t.side || 'graySide', dir, x0, x1, y0, y1, 0, zEdge, scl(sideTint, 0.7), sideTint);
sideFace(board, UVT, 'pipePanel', dir, x0, x1, y0, y1, -CLIFF, Math.min(0, zEdge), [0.16, 0.18, 0.2], [0.62, 0.66, 0.7], 1);
continue;
}
if (zEdge > nz + 1e-4) {
const surf = t.glyph === 'd' ? 'graySide' : (t.side || 'graySide');
const st = t.sideTint || (t.glyph === 'd' ? [0.45, 0.5, 0.55] : [1, 1, 1]);
sideFace(board, UVT, surf, dir, x0, x1, y0, y1, nz, zEdge, scl(st, 0.62), st);
}
}
// per-glyph extras
switch (t.glyph) {
case 'S': gates.push({ r, c, kind: 'start', z: 0 }); break;
case 'E': gates.push({ r, c, kind: 'end', z: 0 }); break;
case 'I': case 'O': {
const surf = UVT[t.glyph === 'I' ? 'arrowDown' : 'arrowUp'];
if (surf) {
const a = 0.3, z = zt + 0.004;
decal.quad([[c - a, r - a, z], [c + a, r - a, z], [c + a, r + a, z], [c - a, r + a, z]], surfaceUV(surf), [0, 0, 1], [1, 1, 1]);
}
break;
}
case 'd': terrain.water.push([r, c]); break;
case 'm': terrain.mire.push([r, c]); break;
case 'g': terrain.smog.push([r, c]); break;
case 'i': terrain.infection.push([r, c]); break;
default: break;
}
}
// fence railings: orange pipes around every connected region of fenced tiles ('b'), posts at the corners
const fence = (r, c) => { const n = at(r, c); return !!n && n.drawn && n.glyph === 'b'; };
const rail = { inset: 0.07, h: 0.26, rad: 0.032, post: 0.038 };
const posts = new Set();
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (!fence(r, c)) continue;
const e = rail.inset;
const x0 = c - 0.5, x1 = c + 0.5, y0 = r - 0.5, y1 = r + 0.5;
const S = !fence(r - 1, c), N = !fence(r + 1, c), W = !fence(r, c - 1), E = !fence(r, c + 1);
const xa = W ? x0 + e : x0, xb = E ? x1 - e : x1, ya = S ? y0 + e : y0, yb = N ? y1 - e : y1;
if (S) tube(pipe, [xa, y0 + e, rail.h], [xb, y0 + e, rail.h], rail.rad);
if (N) tube(pipe, [xa, y1 - e, rail.h], [xb, y1 - e, rail.h], rail.rad);
if (W) tube(pipe, [x0 + e, ya, rail.h], [x0 + e, yb, rail.h], rail.rad);
if (E) tube(pipe, [x1 - e, ya, rail.h], [x1 - e, yb, rail.h], rail.rad);
const corner = (x, y, key) => { if (!posts.has(key)) { posts.add(key); tube(pipe, [x, y, 0], [x, y, rail.h + rail.rad], rail.post); } };
if (S && W) corner(x0 + e, y0 + e, `${r},${c},sw`);
if (S && E) corner(x1 - e, y0 + e, `${r},${c},se`);
if (N && W) corner(x0 + e, y1 - e, `${r},${c},nw`);
if (N && E) corner(x1 - e, y1 - e, `${r},${c},ne`);
// a mid post on long straight runs keeps the railing from floating
if (S && !W && !E && (c % 2 === 0)) corner(x0, y0 + e, `${r},${c},sm`);
if (N && !W && !E && (c % 2 === 0)) corner(x0, y1 - e, `${r},${c},nm`);
if (W && !S && !N && (r % 2 === 0)) corner(x0 + e, y0, `${r},${c},wm`);
if (E && !S && !N && (r % 2 === 0)) corner(x1 - e, y0, `${r},${c},em`);
}
// cyan field edge: emissive strips along the play areas' outer border (the official boundary glow)
const edges = [];
const zone = (t) => !!t && t.drawn && t.content && t.glyph !== 'a' && t.glyph !== 'A';
// "outside": undrawn tiles and the forbidden / separator tiles connected to them (interior holes are not)
const outside = new Set();
const stack = [];
for (let r = -1; r <= ROWS; r++) for (let c = -1; c <= COLS; c++) {
const t = at(r, c);
if (!t || !t.drawn) { outside.add(`${r},${c}`); stack.push([r, c]); }
}
while (stack.length) {
const [r, c] = stack.pop();
for (const [, dx, dy] of DIRS) {
const rr = r + dy, cc = c + dx, n = at(rr, cc);
if (!n || outside.has(`${rr},${cc}`) || !n.drawn || n.content) continue;
outside.add(`${rr},${cc}`);
stack.push([rr, cc]);
}
}
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = grid[r][c];
if (!zone(t)) continue;
for (const [dir, dx, dy] of DIRS) {
const n = at(r + dy, c + dx);
if (zone(n)) continue;
if (!outside.has(`${r + dy},${c + dx}`)) continue; // interior holes, bench pads: no boundary glow
edges.push({ r, c, dir, z: topZ(t) });
}
}
const devices = opts.devices === false ? [] : stageDevices(stage, grid);
for (const d of devices) {
if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid);
}
return {
grid, gates, devices, terrain, edges,
buckets: { board: board.finish(), decal: decal.finish(), pipe: pipe.finish(), glass: glass.finish() },
bounds: { x0: minX, x1: maxX, y0: minY, y1: maxY },
stats: { tiles: grid.flat().filter((t) => t.drawn).length, vertices: board.vertexCount + decal.vertexCount + pipe.vertexCount },
};
}
// ---- pipes -----------------------------------------------------------------------------------------------------
/** Closed-side cylinder from a to b (8 segments) with flat caps. */
export function tube(g, a, b, rad, seg = 8, col = [1, 1, 1]) {
const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const len = Math.hypot(d[0], d[1], d[2]);
if (len < 1e-6) return g;
const w = [d[0] / len, d[1] / len, d[2] / len];
const ref = Math.abs(w[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
let u = [w[1] * ref[2] - w[2] * ref[1], w[2] * ref[0] - w[0] * ref[2], w[0] * ref[1] - w[1] * ref[0]];
const ul = Math.hypot(...u); u = [u[0] / ul, u[1] / ul, u[2] / ul];
const v = [w[1] * u[2] - w[2] * u[1], w[2] * u[0] - w[0] * u[2], w[0] * u[1] - w[1] * u[0]];
const ring = (k) => { const t = (k / seg) * Math.PI * 2, cs = Math.cos(t), sn = Math.sin(t); return [u[0] * cs + v[0] * sn, u[1] * cs + v[1] * sn, u[2] * cs + v[2] * sn]; };
for (let k = 0; k < seg; k++) {
const n0 = ring(k), n1 = ring(k + 1);
const p = (base, n) => [base[0] + n[0] * rad, base[1] + n[1] * rad, base[2] + n[2] * rad];
g.quad([p(a, n0), p(a, n1), p(b, n1), p(b, n0)], null, [n0, n1, n1, n0], col);
}
for (const [end, sgn] of [[a, -1], [b, 1]]) {
const n = [w[0] * sgn, w[1] * sgn, w[2] * sgn];
for (let k = 0; k < seg; k++) {
const r0 = ring(k), r1 = ring(k + 1);
const p0 = [end[0] + r0[0] * rad, end[1] + r0[1] * rad, end[2] + r0[2] * rad];
const p1 = [end[0] + r1[0] * rad, end[1] + r1[1] * rad, end[2] + r1[2] * rad];
g.tri(sgn > 0 ? [end, p0, p1] : [end, p1, p0], n, col);
}
}
return g;
}
// ---- devices ----------------------------------------------------------------------------------------------------
const DEVICE_KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
/** Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias }`. */
export function stageDevices(stage, grid) {
const list = Array.isArray(stage?.devices) ? stage.devices : [];
const out = [];
for (const d of list) {
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
if (!DEVICE_KINDS.has(d.role)) continue;
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
if (!active) continue;
const [r, c] = d.pos;
const t = grid?.[r]?.[c];
if (!t || !t.drawn) continue;
out.push({ kind: d.role, r, c, dir: ['UP', 'DOWN', 'LEFT', 'RIGHT'].includes(d.dir) ? d.dir : 'UP', z0: t.h, key: d.key || null, alias: d.alias || null, rangeTiles: Array.isArray(d.rangeTiles) ? d.rangeTiles : null });
}
return out;
}
/** Device heights on their tile (platform tops are standing surfaces: tiles.js heightAt adds them). */
export const DEVICE_H = Object.freeze({ platform: TILE_H.platform, sealedFloor: 0.05, waterPlatform: 0.2 });
function slabDevice(board, decal, UVT, d, grid) {
const size = d.kind === 'sealedFloor' ? 0.92 : 0.94;
const h = DEVICE_H[d.kind] || 0.2;
const z0 = grid[d.r][d.c].h, zt = z0 + h;
const a = size / 2, x0 = d.c - a, x1 = d.c + a, y0 = d.r - a, y1 = d.r + a;
const topSurf = d.kind === 'sealedFloor' ? null : UVT.plateS;
if (topSurf) board.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(topSurf), [0, 0, 1], [1.02, 1.02, 1.02]);
else decal.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(UVT.hazardX), [0, 0, 1], [1, 1, 1]);
const side = UVT.graySide;
for (const dir of ['S', 'E', 'N', 'W']) {
const uv = surfaceUV(side, sideRect(side, size, h));
board.quad(sideCorners(dir, x0, x1, y0, y1, z0, zt), uv, NRM[dir], [[0.5, 0.53, 0.56], [0.5, 0.53, 0.56], [0.85, 0.88, 0.9], [0.85, 0.88, 0.9]]);
}
}
// ---- OBJ meshes → board space -------------------------------------------------------------------------------------
/**
* Convert a parsed OBJ (board3d/obj.js, Unity Y-up exported right-handed with X mirrored) into board space
* (x = col, y = row, z = up): (x', y', z') → (−x'·s, z'·s, y'·s) — a proper rotation, windings stay outward.
*/
export function objToBoard(obj, s = 1) {
const p = obj.position, n = obj.normal;
const pos = new Float32Array(p.length), nrm = n ? new Float32Array(n.length) : null;
for (let i = 0; i < p.length; i += 3) {
pos[i] = -p[i] * s; pos[i + 1] = p[i + 2] * s; pos[i + 2] = p[i + 1] * s;
if (nrm) { nrm[i] = -n[i]; nrm[i + 1] = n[i + 2]; nrm[i + 2] = n[i + 1]; }
}
return { ...obj, position: pos, normal: nrm };
}
/**
* Box-project UVs of a (board-space) mesh onto atlas surfaces: faces pointing up take `top`, the others `side`
* (mapped across the face's horizontal extent × height). Used for the official crate mesh (s_common_box_01), whose
* own material is assigned at runtime by the client.
*/
export function boxProjectUV(mesh, top, side) {
const p = mesh.position, n = mesh.normal;
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, z0 = Infinity, z1 = -Infinity;
for (let i = 0; i < p.length; i += 3) {
x0 = Math.min(x0, p[i]); x1 = Math.max(x1, p[i]); y0 = Math.min(y0, p[i + 1]); y1 = Math.max(y1, p[i + 1]); z0 = Math.min(z0, p[i + 2]); z1 = Math.max(z1, p[i + 2]);
}
const uvTop = surfaceUV(top), uvSide = surfaceUV(side);
const out = new Float32Array((p.length / 3) * 2);
for (let i = 0, j = 0; i < p.length; i += 3, j += 2) {
const nx = n ? n[i] : 0, ny = n ? n[i + 1] : 0, nz = n ? n[i + 2] : 1;
let uv;
if (nz > 0.6) uv = uvAt(uvTop, (p[i] - x0) / (x1 - x0 || 1), (p[i + 1] - y0) / (y1 - y0 || 1));
else {
const fy = (p[i + 2] - z0) / (z1 - z0 || 1);
// horizontal coordinate along the face (seen from outside)
let fx;
if (Math.abs(nx) >= Math.abs(ny)) fx = nx > 0 ? (p[i + 1] - y0) / (y1 - y0 || 1) : (y1 - p[i + 1]) / (y1 - y0 || 1);
else fx = ny < 0 ? (p[i] - x0) / (x1 - x0 || 1) : (x1 - p[i]) / (x1 - x0 || 1);
uv = uvAt(uvSide, fx, fy);
}
out[j] = uv[0]; out[j + 1] = uv[1];
}
return { ...mesh, uv: out, bounds: { x0, x1, y0, y1, z0, z1 } };
}
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more