feedback5 WW: split DESIGN.md into docs/design/ (the current rules) and docs/history/ (the per-release revisions), section numbers kept — a pure move: every ## N. block (heading, text, the --- after it) moves verbatim, each part file only gains a title line and a pointer to the index; docs/DESIGN.md keeps its header and becomes the index (a § → file table). design/overview.md §0 §1 §2 §11 §12, geometry-time.md §3 §4, engine.md §5 §7, match.md §6 §16, network.md §8 §14, client.md §9 §10 §13 §15; history/v2.2-playtest3.md §17, v2.3-playtest4.md §18, v2.4-playtest5.md §19, v2.5-playtest6.md §20, 0.1.1.md §21, 0.1.2.md §22, 0.1.3.md §23, 0.1.4.md §24, 0.2.0.md §25. Round trip: the index header plus the blocks in § order reproduce the pre-split DESIGN.md byte for byte (813 785 bytes, sha256 fe81884c…). Tests: test/helpers/designDocs.js designText() (the split script's splitBlocks / designText) reassembles the text in § order; docs-consistency and feedback1-gaps read it instead of docs/DESIGN.md (every pin unchanged); docs-paths guards the split (each § exactly once in docs/design/ or docs/history/, the index row of every file names its sections, every "DESIGN §N(.M)" cited in the repository is a heading — 779 in 327 files today); package.test keeps docs/design/ and docs/history/ out of the zips; golden unchanged

This commit is contained in:
sganggs committed 2026-10-06 19:08:44 +08:00
1 parent 0f9009954d
commit da1f5830ca
21 files changed
+3517 -3325

No files matched your search

+21 -3320
View File
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
# DESIGN §9, §10, §13, §15 — Rendering, UI, local-client art, the 3D board
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 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, shop }) // animates camera; shop: false = the
// folded shop's prep camera (official shop-collapsed framing, §21.27)
view.setPrep(privateState, { editable }) // render hand/temp/board pieces as chibis (spine or fallback); editable enables drag;
// a merge's new elite on the board plays fx.promote instead of fx.deploy (render/promote.js, §20.11),
// also after a battle (a merge between two preps: the last prep state is kept, §20.14)
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'|'pieceDragEnd'|'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: released off the grid or over DOM covering the canvas (the shop bar), mouse
// and touch alike — there is no drag-to-sell, the piece goes back
// the drop target, its highlight and tileHover while dragging = the tile under the POINTER; an item dropped on a
// tile equips the unit standing on it (§18.1)
view.pieceScreenRect(uid) // the drawn body's rect { left, top, right, bottom, width, height, x, y } (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, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
---
## 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; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/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 (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。
A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); 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's tile ⇒ 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 an item onto a unit's tile ⇒ equip that unit (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 (or a tap on a shop card while the bar is locked); `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; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4), boss music in R14/15.
---
## 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 the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) 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). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client.
- `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/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5).
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
- `projectiles/*` — battle projectile sprites.
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
- `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.
---
## 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).
---
+182
View File
@@ -0,0 +1,182 @@
# DESIGN §5, §7 — The battle engine (server/sim) and content modules
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 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 } | { sp } | { down: true } } ], // unite: an operator keeps its end-of-combat HP ratio + SP, a summon its SP (no running skill);
// { down: true } = knocked out at its end: deployed, then forced out (§19.3)
bonds: { [bondId]: { count, active, tier, layers } }, // snapshot from bondsMeta at combat start (联防: with the layers the
// helper's own combat reached, PlayerState.bondsView — §20.15)
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); what was added — capped at
// the bond's room under BOND_LAYER_CAP 999 (§6.3, §20.12)
coins: n, // bounty coins earned
damageDealt: n, bossDamage: n, healingDone: n, deaths: n,
unitsEnd: [ { uid, hpPct, sp, alive } ] } }, // operators + board summon pieces; sp = 技力 incl. stored charges
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), `liveRangeGrid` (allies: the 攻击范围 the detail card shows — the running skill's grid, else the unit's own, plus `rangeExtend`; never a kit's target-selection grid, §21.16), `form` (the model form it is in — an enemy's mode, §21.4, a 傀儡师's 替身 `'doll'`, §22.11 — UnitInfo `form`).
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) + ΣatkFinal) × Πmul` — `atkFinal` is the 最终加算 (阿戈尔's devoured base ATK, §24.7); the same shape without it for `maxHp`, `def`; the 卫戍 systems' "+X%" ATK / DEF / max HP (盟约, 策略, 装备, 机变 cards, per-layer 特质) are 直接乘算 and go into `Σpct` with the skills' "+X%" (`sim/constants.js DIRECT_BONUS_STACKING 'add'`, content/support `directMods`; PRTS 盟约记录 / 游戏数据基础, §20.10) — "提升至X%" effects (炎佑 ×1.5) and the char_attribute_mul 特质 stay in `Πmul`; `res = clamp((base + Σflat) × Πmul, 0, 100)`; `aspd = clamp(100 + Σaspd + base-100, 20, 600)` (`constants.js ASPD_MIN` 20: PRTS 数值范围 "ATTACK_SPEED 默认下限 20", §20.3).
- `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, atkFinal (最终加算: after the percentages), 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 — an operator's radius area damage skips it too, `Battle.foesInRadius`, PRTS 作战机制 §AOE伤害判定, §21.19, and so do an enemy's area effects and buff auras unless the 隐匿 ally blocks that enemy, `targeting.js areaSelectable` / `auraSelectable`, §22.12; an enemy's 隐匿 returns only 3 s after a block ends, or after its PRTS page's "(解除阻挡N秒后恢复)" — `stealthOff` buffs, `targeting.js enemyStealthed`, §22.8; an enemy's b.snap stealth bit is set only while its 隐匿 is on, §21.4), camou (an ally's 迷彩: like stealth for enemy targeting and on screen, but not 隐匿 for 隐匿 conditions — 叙拉古, 家族徽章; §20.2 — and an enemy's splash and other area effects still hit it, ba.camou "无法躲避溅射类攻击", §22.12), liftoff (an ally's 起飞, 蒂比's skills: it blocks flyers only and has 对地规避 — no ground enemy selects it, so no selected damage or status of one lands, while what selects nobody still does: `ignoreSelect` / 无来源 — 无视无法选择 abilities, direct picks, flying units' blasts, a debuff's ticks; it stays a ground unit on its tile; `targeting.js evadesGround`, §21.22), invulnerable, unblockable, levitate, fear (enemy: cannot attack, unblockable, runs to random tiles of the ±45° fan away from the source; no source or itself as source = inside its own tile — `fear.js`, §20.4), taunt (+aggro), cold, noHeal (no heals from others: 禁疗 summons and 孤立 units; an HP-regen attribute still applies — PRTS 安洁莉娜 备注), healFree (禁疗 that stops the unit's own heals too, shown as the status 'healFree': 史尔特尔's 余烬 sets it with noHeal — only an HP-regen attribute and her S3's "无视禁疗" start heal pass, §22.7), isolated (孤立: no friendly selector picks it — no heal, buff, aura or talent of an ally, `Battle.allySelectable`; §20.3)`.
- **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` (the 恐惧 walk, `fear.js`), `burnGauge`/`necrosis`/`neural` element gauges (see damage), `stealth`, `camou` (迷彩, its own keys: 忍冬 S3 `vulpis:camou` until her next cast, 寒芒克洛丝 S1), `reveal`.
- Immunities from enemy data (`stunImmune`, `silenceImmune`, `sleepImmune`, `frozenImmune`, `levitateImmune`) are honoured by `applyStatus`; a unit both `invulnerable` and `untargetable` (a 重生, a 永久无敌 leader part) takes no status from the other side at all (PRTS 无敌 "无法被不同阵营选中", §21.19). Flag `selfBound` = 自缚 (守墓石像's 转换模式, the 自缚 leaders) beside its `noMove`: 余 S2 never teleports such a unit (束缚 sets `noMove` only).
- Same-named debuffs on an enemy (奥术's arts damage taken, 灵知 坚冰, the 莱恩哈特 / 缄默德克萨斯 RES cuts): one instance per target whoever applies it, the strongest wins (`Battle.applyStrongest`; PRTS 作战机制 "同名buff…只能表现出一个" — strongest rather than latest [ASSUMED], §20.10). PRTS 盟约记录's current 奥术 note fits this under the default overlap; an equal re-hit refreshes the instance (an earlier PRTS wording forbade re-applying while one's own lasts) [ASSUMED, slightly stronger] (§21.7).
### 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) |
| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — fired by every HP loss of a unit without a boss pool (hits of any type, element bursts, 无来源 damage, `loseHp`); set `prevented`. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, once per deployment) `PRIO_REVIVE` −100 → items' 复活 (M3茧甲) `PRIO_RESPAWN` −101 → 埃芒加德 −110; the two 复活 revive in place and open a new deployment for the lock (§21.21); a running 坚固 window's hook sits at `PRIO_UNDYING_HELD` −99, before a 傀儡师's switch to its 替身 (−100, ahead of the lock — a 本体 holding 不死 does not switch, §22.11) |
| `kill` | `{ killer, victim }` | victim hp reached 0; a `killed` ability that keeps the enemy alive (a knock-out into a 重生 / form change, §21.4) hides it from every later handler, the kill count, credit and the bounty |
| `death` | `{ unit }` | unit removed (ops may redeploy later); the `die` event and the death hooks fire only on removal |
| `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, ignoreSelect (no selection — a 无视无法选择 ability, a direct pick, a debuff's tick: it reaches an airborne 起飞 ally, §21.22), 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.push(e, force, { from, dir, fixed, fixedAngle, inward, effect })` / `battle.pull(e, force, { to, center, stop })` / `battle.pullToFront(e, unit, force)` / `battle.forceLevel(e, force)` / `battle.pushDistance(e, force, { effect })` (the official 力度 − 重量 tables, §20.3; a 静态刚体 — data `staticBody` — is never moved, §21.9; `battle.displace(e, dir, distance)` is the raw mover), `battle.allySelectable(a, by)` / `battle.alliesFor(by, ownerId)` (friendly selection, skipping 孤立 units, §20.3), `battle.unitsInGrid(unit, grid)`, `battle.enemiesInRadius(x, y, r)` (every enemy: enemy-side auras / heals, collisions), `battle.foesInRadius(x, y, r)` (the enemies an operator's area effect can select — no untargetable, no unblocked unrevealed 隐匿 enemy; PRTS 作战机制 §AOE伤害判定, §21.19), `battle.alliesInRadius(x, y, r, ownerId?)`, `battle.addLayers(playerId, bondId, n, reason)` (no-op when gains disabled; at most the room left under `BOND_LAYER_CAP` 999 — `shared/constants.js layerGainRoom`, §20.12; records into result), `battle.loseHp(target, amount, { source, from, sourceless })` (流失 — it skips the damage events: no 受击回复, TAKE_DAMAGE, shield or counter, `damage.js isHpLoss`; only for what the official calls 流失 — "N 真实伤害 per second" effects such as 源石溶剂 are `periodicDamage` damage, §21.12; a 物理流失 is first reduced by the target's DEF, 阿戈尔's devour, §21.19; `sourceless` = 无来源, `source` keeps the credit — leader parts pass damage on 1:1 with it, §20.10), `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)` (on its rest tile: where a knocked-out operator lies, else its home; `battle.isReservedTile(r, c)` = a tile no automatic placement takes, `battle.downOn(r, c)` = the knocked-out operator lying there, whose tile no ally deploys or moves onto — §21.24), `battle.after(seconds, fn)` (scheduled callbacks), `battle.every(seconds, fn)`.
### 5.5 Combat rules (engine)
- **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once.
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). An enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — `unblockable` (恐惧, 诱导, enemy abilities), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡) — is never blocked and is released at once: a sleeping enemy frees its blocker's slot, stays where it is and, once awake, is blocked again only by a blocker with room (§24.9). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7).
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18; 「同时攻击阻挡的所有敌人」 — 强攻手 / 重剑手 / 推击手 and the skills worded so, `hitAllBlocked` — up to the block count, never fewer than 1, since 0.2.0: PRTS 分支特性信息 "普通攻击最大目标数等于阻挡数(不会低于1)"), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (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) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed — or within 3 s of a block's end (§22.8). Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18).
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3; 史尔特尔 during 余烬, §22.7), and never the enemies they block — also while blocking (§21.17); 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 (`dmgType 'none'` enemies never attack unless content arms them through `e.profile` — 转译基底's armed forms, §21.4); ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1), and an enemy's area effects — splash, blasts, area skills, zones, jumps — and buff auras skip it unless it blocks that enemy, while 迷彩 does not protect from splash and the other area effects (`targeting.js areaSelectable` / `auraSelectable`, §22.12); an airborne (起飞) ally is never a ground enemy's selection — picks, area damage, zones and statuses skip it, while 无视无法选择 abilities, direct picks, debuff ticks and flying units still reach it (§21.22); unblocked, they stand for each attack's clip (enemies.json `attackAnim`: its wind-up before the strike and the rest of the clip after it, shortened when the attacks come quicker than the clip; `ATTACK_PAUSE = 0.35 s` after the strike when no clip is known; a 「不停止移动」 attacker never stops; §22.14). Every enemy attack strikes at its clip's damage frame after its swing starts — the first one of a contact too —, and a stun / freeze / sleep / 浮空, hiding, 缴械, 恐惧 or 战栗 (blocked) before that frame cuts the swing: no strike, the next swing starts from its wind-up (0.2.0, `ai.js attackWindup`; PRTS 状态机 / 异常效果 STUNNED; no clip known: no wind-up). FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4).
- **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; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"). Then **限伤**: on a leader (`isBoss` — the 10 bossInfoDict enemyIds and their mirrored copies; never parts, drones or escorts) in a `'boss'` / `'hidden'` battle a hit with `ceil(final) ≥ 300000` is cancelled — no HP or pool loss, no credit, no damage number, no `damaged` / kill (`shared/constants.js BOSS_HIT_LIMIT`, `damage.js leaderHitCancelled`, after every multiplier and the hit-step blocks, before HP shields; `loseHp` too — a part's 传递, a drone's death; the client's `MAX_BATTLE_DAMAGE`, §20.12). Then shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **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.
- **Results**: `damageDealt` / a unit's `dmg` count only HP removed from the other side — a unit's own drain or 流失 and friendly damage are the target's `taken` only (§21.12).
- **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). A pool holding less than 1 HP is empty (`constants.js BOSS_POOL_MIN_HP`: the hit, or the per-player credit, that would leave less takes the rest; §20.5).
### 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.
Timed deployment skills use `kind: 'duration'`, `activateOnDeploy: true`, `spCost: 0`, `spType: 'none'`, `trigger: 'NEVER'`.
Deployment sets landing state, then `reset()` consumes one charge through `activate('deploy')`, then emits `deploy`;
`skillStart` fires before deploy hooks. In 联防 this is a fresh deployment activation; no previous active skill state is restored.
Duration end cleans the window and emits `skillEnd`; it leaves zero charges and `ready=false`, even after SP gifts.
Redeployment opens a new full window. Permanent passives retain `passive`; first-kill recasts of 缄默德克萨斯 explicitly
restart a full window. Special timed kits and their effect rules are described in SIM §7.2.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 too since 0.2.0: its DP at once, its gift kept for the returning pack [ASSUMED: S2's 备注 uses S1's words]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above.
**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',
activateOnDeploy: false, // optional boolean: consume a deployment charge synchronously in reset
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'|..., noRangeExtend, showOwnRange }, // noRangeExtend: the range ignores 攻击距离; showOwnRange: the grid only selects targets (the card keeps the unit's own, §21.16)
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 around the target; 格雷伊 1.0), `bombarder` (ground splash 0.9 + aftershock), `chain` caster (bounce ×3, −15%, 1.7-tile jumps — PRTS 溅射半径一览, §21.19), `phalanx`/`blastcaster` (`rangeAoe`: every selectable enemy in range at once, instant; §21.18), `funnel`, `physician` (single heal), `ringhealer` (aoe heal), `bard` (aura heal, no attack), `slower` (slow on hit), `loopshooter` (回环射手: a boomerang out to the target and back, attacks again only once it is caught — §17.4), `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).
---
## 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 one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). 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). 鸭爵 “神秘顾客” swaps 0–2 ground enemies (not the leader, its parts or bounties; on a pair boss field only the player's half — the boss / hidden `onBattleStart` event carries `routes` and `side`) in every teammate's normal / Final Assault / Hidden Core battle from R5 into the act2 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 (lpr 1, 1 fund to the killer, 联防 included) (§21.5).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); 圆仔 and 鸭爵 are 无法被阻挡 (PRTS 天赋, §21.5); the knock-out forms follow PRTS — 深池逐火 余烬 (5 / 10 hits, 隐匿, blockable, back after 10 s), 转译基底·α (damage cancelled until its 2 s change into 寻仇者 / 特战术师 / 幽灵), the leaders' 重生, 守墓石像's statue and flight — and every 重生 clears what operators put on the enemy and restarts its skills from their initial cooldown (§21.4, §21.19); the mode's air units are 静态刚体, never moved (§21.9); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken).
---
+32
View File
@@ -0,0 +1,32 @@
# DESIGN §3, §4 — Coordinates, fields, geometry and time
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 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 — the **effective** deploy type: the 深水区 `tile_deepsea` refuses deployment, NONE with the level's `buildableType` ALL kept, PRTS 深水区 地形信息 "拒绝部署(待补充)"; an active 特制水上平台 makes its tile deployable in the prep deploy map only, §21.3), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). The fenced tiles (围墙 `tile_fence_bound` / 围栏 `tile_fence`, legend `b`: LOW, buildable ALL, passable to flyers only) take units, which attack normally from there but block no ground enemy (PRTS 地形机制, §21.23). Melee chess may stand only on LOW tiles with buildable ALL/MELEE — except a melee chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, with any module or none), which may use the ranged tiles too (`shared/highGround.js`; the owner's decision of 2026-10-05, following PRTS — it reverses the 2026-10-04 one that allowed only elite 歌蕾蒂娅 with HOK-Y; on a 高台 it attacks and blocks nothing); ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`; a summon marked `ownerRange` (狼群, 流形: "只能部署在召唤者攻击范围内"; Mon3tr's 重构体 by her talent) goes only on a tile of its owner's attack range — the loadout grid rotated by the owner's facing around its tile (`PlayerState.summonRange`, §21.3) —, one marked `ownerRangeOutside` / `rangedTilesOnly` (凯尔希·思衡托's 战术锚点: "仅可以部署在凯尔希·思衡托攻击范围外的远程位") only on a 高台 outside it (`summonExcluded`, placement class `high`; 0.2.0).
- **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 — to a running skill's range too, unless that skill ignores 攻击距离 (`targeting.noRangeExtend`, 信仰搅拌机 S3, §21.16). 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; a huge enemy (data `hitArea`: 巨型单位, a 4.95 × 2.95 rectangle moved 1.0 up) if any tile its hit rectangle overlaps is one (PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"; `sim/body.js`, §19.4). Radius ranges (enemies, auras, AoE) use Euclidean distance in tiles — to a huge enemy's rectangle [ASSUMED] — except splash around a struck target, a 中点判定 on positions (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"). A ranged enemy's normal attack takes an ally whose collider (radius 0.25, PRTS 作战机制 §碰撞体积 "我方干员碰撞体积基本均为以0.25格为半径的圆形") touches its range circle: centre distance ≤ `rangeRadius` + 0.25 (`constants.js ALLY_COLLIDER_RADIUS`, `ai.js enemyAttack`; §20.4).
- **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 — on its own map, the escaped template's (stages.json `kind: 'unite'`, `unite.js uniteStageId`; GitHub #41), not the round's stage; 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.
---
+105
View File
@@ -0,0 +1,105 @@
# DESIGN §6, §16 — The match and meta engine (server/match), operator loadouts
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 6. Match & meta (server/match)
### 6.1 State machine (Match.js)
```
LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed (§18.2); all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, ONE countdown — `BAND_TURN_SECONDS` 30 s per turn = m.public.deadline, AI seats pick at once, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ the strategy the player highlights (`g.bandFocus`) while free, else band_bldsk, else the first free one; solo: free pick, no timer, no skip; a single human: untimed) → BATTLE_CHECK (3 s)
→ loop r = 1..lastRound:
ROUND_START (income, upgrade price −1 (floor 0), temp NOT wiped — what overflowed after the last prep's deadline is shown in this
prep (§6.2 temp overflow), 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; solo / single human untimed;
a card is picked with two taps, §18.2)
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo / single human: untimed, ends on ready)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline
the temp pieces due at this prep are resolved (chess sold back, items destroyed, summon stacks removed — they come back at the next round start, §20.1) — pieces that arrived
after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue))
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 — with
the layers their own combat reached (the round's pending gains, PRTS "以其阵地当前的状态" [ASSUMED]);
a helper's operator knocked out at the end of its own combat enters down — carryState { down: true },
PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3))
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; team LP 0 ⇒ defeat at once (PRTS 直接失败),
pool 0 ⇒ victory — the first end the server registers decides, §20.5)
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 | { tier, source:'merge'|'special', label, queued, slots:[Slot] } }, // a special offer (凯瑟琳's 定向投放 …) by its source's label; queued = offers behind the shown one (§21.2)
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: { round?: { r, n: { key: count } } } } // per-piece counters of the current round, module-prefixed keys (拉普兰德's first refresh, §21.1)
```
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.
Summons (§20.1): an operator on the board sends its manually deployable summons to the hand — deploy-limit copies in one stack, no deploy slot (tokens.json `placeable`, `gamedata.placeableTokens`); moving or swapping it sends its placed summons back to their stack, withdrawing or selling it removes them. A range-bound summon (tokens.json `ownerRange`: 狼群, 流形) goes only on a tile of its owner's attack range (`PlayerState._legal` / `summonRange`); in a swap each piece is checked from the tile it ends on; an in-place re-orientation or promotion that leaves it outside sends it back to its stack with a toast (one still inside stays [ASSUMED], user-confirmed 2026-10-03), and a re-orientation whose lifted summon would have no room is refused with `HAND_FULL`; on the other paths it leaves the board and returns at the next round start (§21.3).
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, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7).
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 onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
**Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5).
### 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 that has a real member on the board (the views mark such an entry `harmony: 1`, §21.26), etc. (research 02).
- 变形同构体: an operator wearing it together with an item that has a `giveBondId` is a member of that bond — counted like any member (BOARD: on the board only; once per operator), in the battle, and in the client's member list (tagged 同构) and bond chips (§21.11).
- `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).
- A bond holds at most `BOND_LAYER_CAP` = 999 layers (the client's `MAX_GARRISON_STACK`, §20.12): every writer — `PlayerState.addLayers`, the SETTLE merge, `Battle.addLayers`, the dev tools — adds `layerGainRoom(before, n)` = min(n, 999 − before); a gain at the cap adds 0 and dispatches nothing (no onLayers, no `layerGain` hook, no 'layer' event), so the per-N milestones stop with the count.
- Σ 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. `onBuy` = a shop purchase (`g.buy`) only: a free pick or a grant never counts as 购买 (休露丝, 玛恩纳, 升华, 整备; §21.21). Equipped items run from a snapshot — the owned chess and each holder's items as that step begins (every [holder, item] pair, taken before the first item runs) — each only while it is still equipped on its still-owned holder (handlers move and destroy pieces: 突变细胞, 博士投影; §21.25).
### 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 (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; 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). Under AI 托管 a human's own 自选 pieces score `DIY_PIECE_BONUS` (12) more in the shop / reward choice and the 调度中心 is levelled one round earlier for the step that opens a slotted tier (`levelUp`, `diyOpensAt`; 0.2.0 — bots field no 自选 piece).
---
## 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; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); 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) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). 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).
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
**自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources.
---
+103
View File
@@ -0,0 +1,103 @@
# DESIGN §8, §14 — The network protocol and client-side combat
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 8. Network protocol (shared/protocol.js is normative)
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
### 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.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§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`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
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}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); 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.
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `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.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `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,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , 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], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
`0/0`. Snapshot display capacity does not change runtime SP/cost.
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0)
- `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]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
- `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}`
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5)
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`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'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)).
### 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, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2
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, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc; an `off` entry (a bond the mode never activates, with members: count only, never active — the strip's grey 本局禁用 disc, bondsMeta.offBondCounts) comes last
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
nextEnemies: [ { enemyKey, count, tag, start? } ], // preview of the upcoming round's wave (after waves.js generation); `start` = a leader's spawn tile [row, col] on the boss field (the boss-field prep shows it standing there, community report #12)
loadout: { [baseChessId]: { skill, module } }, // §16: the effective loadout (chess not listed: defaults)
standIns: [ baseChessId ], // 0.2.0 补位: the chess this player fields as their stand-ins in this match (sorted; [] = everything owned)
diy: { [slotBaseId]: { charId, skillIndex, uniEquipId } }, // 0.2.0 自选编队: this player's picks in this match ({} = none)
diyBanned: [ slotBaseId ], // 0.2.0: slotted slots out of the shop this match (every bond of the operator 本局禁用)
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 (an ally's 隐匿 or 迷彩 — §20.2; an enemy's only while its 隐匿 is on: not blocked, not revealed — drawn solid otherwise, §21.4), 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.
---
## 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(, left)]` progress timeline — `left`: the 联防 leakers' enemies still standing, §20.6) 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?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). 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). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
- 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 ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, 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 from each player's damage to that round's pool, §21.10) 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) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); 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` — except a `'cleared'` result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s) has not caught up: it is **held** (`f.heldResult`; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (`_endFinal`) completes the field with it; a forged report gains nothing a `b.progress` could not already get. Any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds more than the report covers — 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`) and sends it again when that board changes (GitHub #87). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: 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` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), 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; the server deadline + takeover cover extreme cases). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). 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, narrowed by §21.15): route lengths and routes stay the official ones unless 0.1.0's road-over-floor preference route crosses strictly fewer non-blockable (floor / gate / 深水区) tiles — a corner touch is not a crossing; ties keep the official route. E.g. 战场#01's lower-gate enemies take the col-8 road, while 战场#04's keep the official diagonal from row 9 into row 10 (server/sim/grid.js header; test/sim/pathing-official.test.js, 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; the remake's 机变 cards too, §18.2); 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.
---
+159
View File
@@ -0,0 +1,159 @@
# DESIGN §0, §1, §2, §11, §12 — Scope, stack, repository layout, quality bar
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 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 shared pool — 0.2.0 plays them as 自选编队: the picks of the 干员调配 overlay's 自选编队 tab, each sold only in its player's shop; DATA.md §18, shared/diy.js, server/match/player/diy.js), trophies/progression persistence, reporting.
---
## 1. Tech stack
- **Node.js ≥ 22** (CI: 22 and 24), 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; puppeteer-core 25 is ESM-only and needs Node ≥ 22.12, so the suites load it with `await import`).
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 process entry: startServer() wires server/http/ (plain node:http + ws); boots when run directly
http/
config.js ROOT, served dirs, env (PORT, HOST, TRUST_PROXY, DEBUG), options handed to net.js / lobby.js
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames)
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards
media.js /media/… extension-less audio → public/assets/audio
files.js one file → response: MIME, gzip (.skel/.atlas/.json/.js/.css …), ETag / 304, cache policy, ranges
buildTag.js build tag of the served browser runtime (/healthz build, public/js/ui/buildGuard.js)
routes.js request listener: 414 / 400 / 405 guards, GET /healthz, then static files
common.js security headers, URL split, error page, JSON replies, bare 400 for unparseable requests
boot.js boot banner (Local / LAN / tunnel URLs), port-in-use hint, graceful SIGINT / SIGTERM
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
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 — the class:
the lobby⇄match interface (header), constructor + method install
match/ Match's methods by concern (platform, infra, messaging, views, watch, intents, pause, phases,
spDraft, prep, combat, clientCombat, reports, unitePhase, bossRounds, settle; shared constants
in common.js), installed on Match.prototype
PlayerState.js per-player economy/shop/hand/board/items/bonds/LP state + all prep-intent handlers — the class:
constructor + method install
player/ PlayerState's methods by concern (basics, pieces, acquire, economy, placement, items, prep,
round, views; common.js), installed on PlayerState.prototype
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 — the class: constructor + method install
battle/ Battle's methods by concern (players, lifecycle, hooks, spawns, deploy, blocking, status, combat,
queries, summons, tiles, displacement, economy, events), installed on Battle.prototype
constants.js TICK, conversions, tuning knobs
rng.js seeded PRNG (mulberry32) + helpers
grid.js field grid, tile queries, passability, official 4-direction SPFA flow field + line-of-sight smoothing, road-over-floor preference only where it crosses fewer floor tiles (§21.15); DEPLOY_REFUSED_TILES (深水区, §21.3)
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
body.js hit areas of huge enemies: the one helper behind every range test on enemies (§3, §19.4)
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
fear.js 恐惧 movement of enemies (reachable-tile fan, random checkpoints, self-fear flutter; §20.4)
snapshot.js compact serialization for clients
content/
index.js installs all content into a Battle (kits, bonds, garrisons, items, bands, enemies, devices)
kits/index.js operator kit registry keyed by base chessId (guide: kits/README.md)
kits/ops/*.js one operator kit (skills + talents) per file, <chessId>-<codename>.js
kits/shared/*.js helpers the kits share (shared/tier1.js: the general ones)
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: dispatch hooks, KITS, STATS_ONLY
enemies/ helpers.js, archetypes.js, one kit file per special type (invisible, times, element, dot,
reflection, fly, special) and leaders.js — each with its part of KITS
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 (entry; game/*.js plates, marks, early events), 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,
gameLogic.js (barrel; gameLogic/*.js placement, bonds, phases, shop, loadout)
render/ app.js (field view; app/*.js helpers), projection.js, tiles.js, units.js, spine.js, fx.js (effects; fx/*.js), interp.js, drag.js, pick.js, promote.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)
LICENSE GPL-3.0-or-later (the project's code); NOTICE.md: scope, non-commercial game assets, the Spine
Runtimes linking permission; THIRD-PARTY-NOTICES.md: libraries, fonts, data sources
```
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.
---
## 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; the heavy 2-player boss field (18 ops, ~130 enemies alive, `test/sim/robustness.test.js`) < 0.5 ms per tick on a development machine, the best of three runs of the same battle — 1.0 ms when `process.env.CI` is set (GitHub's shared windows runners measured 0.51–0.58 ms and failed the 0.5 ms bar on PR #10, #12, #14 and master's own push; a real regression still shows locally).
---
## 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.
---
+361
View File
@@ -0,0 +1,361 @@
# DESIGN §21 — 0.1.1 — player feedback after 0.1.0
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 21. Player feedback after 0.1.0 (v0.1.1)
Thirty-two reports after the public 0.1.0 release (2026-10-02), relayed by the user in six batches; most were not tested by the user. Verdicts: **real** (fixed), **partly** (the rule was right, what a player sees was wrong or a related part was wrong — fixed), **not real** (not reproduced; tests lock the rule). Where each is handled: batch 1 — #1 拉普兰德's "获得该干员后该回合的首次刷新" should stack 叙拉古 → §21.1 (real); #2 late bounty enemies offered in early bounty drafts → §21.2 (real); #3 the water pool map: operators deployable in the water, 突袭 operators land in it → §21.3 (real); #4 昆图斯's 突变细胞 gone after one use → §21.1 (real: not consumed; its new operator joins the bench, not the carrier's tile — corrected after the merge, pointed out in PR #2); #5 转译基底 does not transform → §21.4 (real); #6 凯瑟琳's three upgrade items shown wrong → §21.2 (real); #7 鸭爵's 圆仔 blockable, audit the other 鸭爵 enemies → §21.5 (real); #8 深池逐火战士's 怨恨的余烬 cannot be killed → §21.4 (real); #9 tacticians (伺夜) can place the tactical point outside their attack range → §21.3 (real); #10 bots too dumb, smarter without more CPU → §21.6 (real). Batch 2 — B1 奥术 not working → §21.7 (partly: the bond works, 标准 switches it off); B2 源石溶剂 white + white → gold, then an extra white → §21.7 (not real); B3 维娜·维多利亚 S3 summons one 黄金盟誓 → §21.8 (real); B4 玛恩纳's skill deals no damage → §21.8 (real, on flying waves); B5 薄绿 can pull flying enemies → §21.9 (real). Batch 3 — C1 the hidden leader's "造成50%伤害" broadcast before the fight → §21.10 (real); C2 变形同构体 + class item: the bond member count excludes the wearer → §21.11 (partly: display only). Batch 4 — D1 信仰搅拌机: 源石溶剂's HP drain should trigger S3 → §21.12 (real); D2 野鬃's skill sometimes pushes the wrong way → §21.13 (real); D3 special 源石虫 all drawn as the plain one → §21.14 (partly); D4 暴鸰's bombs do not drop properly → §21.14 (partly); D5 the originium-floor map's lower route should cut diagonally from row 4 to row 3 → §21.15 (real). Batch 5 — E1 烛煌 S3's attack range does not change → §21.16 (partly: the card); E2 medics that block a roaming leader stop healing and attack it → §21.17 (not real); E3 卡涅利安's attack is not a true AoE → §21.18 (real). Batch 6 — F1 坚固维式重锤's lock does not work → §21.21 (partly: the lock works; it was once per battle, now once per deployment as the user settled, and an M3茧甲 equipped first took the first lethal hit); F2 a free 谢拉格 uses up 休露丝's discount → §21.21 (not real); F3 蒂比 still hit after taking off → §21.22 (real); F4 深巡 on a fenced tile does not attack → §21.23 (not real; a fence blocking fault found on the way is fixed); F5 突袭 operators land on a knocked-out operator's body → §21.24 (real). Hardening credited to pull requests (PR #2, PR #7, PR #10, PR #14) → §21.25. Closing additions from the public GitHub issues and pull requests: the gaps found while triaging issues #1 / #8 → §21.26; the client fixes for issue #5 (the folded shop's camera) and #8 item 5 (operators left as placeholders) → §21.27; the strategy draft's match info (#8 item 1) → §21.28; six 重装 skills cast with an enemy in range, the owner's deliberate deviation (issue #4, PR #12) → §21.29.
Eighteen workstreams (WA–WR, each on its own branch from master 7f13473) were merged into `feedback1` in the order WB, WA, WE, WD, WC, WI, WM, WN, WO, WL, WG, WH, WJ, WK, WP, WR, WQ, WF, then WB's follow-up from 66 screenshots of 22 matches (bounty lists, the 机密商店 and 战术决策 with repeats) after QA, then batch 6's four in the order WS, WU, WT (on WI's branch), WV (on WC's), then the four closing branches (the gaps, the 突变细胞 bench rule, the issue #5 / #8 client fixes, the draft's match info — each adding its own subsection, numbered §21.26–§21.28 in that merge order) and GitHub's master (PR #10, PR #12); data/*.json was rebuilt by `tools/build-data.mjs --offline` after every builder merge, never hand-merged. The integration fixed a bug already in 0.1.0 (a 联防 battle timed out during 隐德来希 S3 reported its 心烛 as leaks, so the server refused an honest client result — candles carry `mem.noLeak`), the last review of the transform workstream (form fx were still dropped by the render engine), every cross-workstream note worth doing with a primary source, and reconciled the bot with the merged rules — §21.19. §21.20 lists the settled decisions and what stays [ASSUMED]; batch 6 and the pull-request fixes follow it (§21.21–§21.25), then the closing additions (§21.26–§21.29), with what QA of that integration found and the integration fixed: a lent 坚固维式重锤 and in-place revives against the once-per-deployment lock (§21.21), 卢西恩 / 锏 casting for an airborne 蒂比 alone (§21.22), rule 3 for a summon that is gone (§21.24) and when the dispatcher takes its item snapshot (§21.25). Normative lines rewritten with it: §2 (`grid.js`), §3 (the effective `buildable`, the 深水区, range-bound summons, 攻击距离 vs skill ranges), §5.2 (`liveRangeGrid`, `form`), §5.3 (stealth vs area damage, the 奥术 refresh), §5.4 (`kill` / `death`, `foesInRadius`, `loseHp` vs damage, 静态刚体), §5.5 (healers that block, `rangeAoe`, armed enemies, results), §5.6 (TAKE_DAMAGE, `noRangeExtend` / `showOwnRange`, the profession splash radii), §6.2 (rewardOffer, piece `meta.round`, summons in range, returned equipment), §6.3 (变形同构体), §6.6 (the bot), §7 (bounty drafts, 鸭爵, knock-out forms), §8.2 / §8.3 (fx `form`, `m.ticker`, `range`, UnitInfo `items` / `form`, the stealth bit, rewardOffer), §9 (forms, alias tint), §10 (offers, ticker, 本局禁用), §13 (`spine/enemy`), §14 (ground pathing, boss tickers, the runner's form fx), §16, §17.3, §18.5, §18.6 #10, §19.1, §19.7, §20.1, §20.3, §20.6, §20.9; with batch 6: §3 (fenced tiles), §5.3 (`liftoff`), §5.4 (`fatal`, `ignoreSelect`, `redeploy`), §5.5 (bodies and redeploys, blocking, enemy AI), §6.4 (`onBuy`, the item snapshot), §8.2 (`down` row, col), §18.3, §19.7; with the closing additions: §5.6 (TAKE_DAMAGE), §6.2 (the 突变细胞 gain), §6.3 (`harmony`), §6.6 (the band pick, the gained operator), §8.2 / §8.3 (`harmony?`), §9 (`setCamera` `shop`, the model fallback), §10 (the draft's match info), §11 (the boss-field speed bar), §14 / §20.12 (the layer bound), §17.1, §19.2, §19.8, §20.2, §20.9, §20.11.
### 21.1 拉普兰德's first refresh; 突变细胞 is not consumed and its new operator joins the bench (#1, #4) — `sim/content/garrisons/meta.js SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT`, `match/PlayerState.js pieceRoundCount / bumpPieceRoundCount / _mergeChess / transformChess / acquireChess`, `match/effectsMeta.js ctx.pieceCounter / incPieceCounter / transform`, `match/builtinMeta.js char_chess_transformation_equip`, `match/audit.js`, `match/bot.js cellTarget / makeHandRoom`, `tools/build-data.mjs ITEM_RULES`, `ui/detailPanel.js ItemDetail`
- **#1 seen**: with 普罗旺斯 + 德克萨斯 deployed — refresh, buy 拉普兰德, deploy her (3 叙拉古), refresh again: no 叙拉古 layers. Players: officially "获得该干员后该回合的首次刷新" also stacks. **Cause**: the handler compared the PLAYER's round refresh count with `refresh_cnt` 1.
- **#1 official**: garrison_123_a/_b = SERVER_REFRESH_SHOP / SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT, bb `refresh_cnt` 1, layer 4 / 8, "若为本回合首次主动刷新…此干员在整备区时也有效" (activity_table act2autochess). Prep effects run on the scene server (research 11 §1.2); PRTS has no timing note. Text and report agree when the count belongs to the trait instance; the "本回合每刷新过1次" multipliers (阿罗玛 122, 安洁莉娜 147) fire on other events and keep the player's count.
- **#1 now**: each copy counts the manual refreshes it sees this round, on the board or in the hand (`piece.meta.round`, module-prefixed key `garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT:refreshes`), and fires on the `refresh_cnt`-th. A new piece starts at 0; a move or an in-place promotion keeps the count; a re-triggered trait neither fires nor counts; temp pieces do not count.
- **#4 seen**: "昆图斯策略给的突变细胞用完就没了". **Cause**: research 04's [ASSUMED] "item consumed"; the built-in and a content override both destroyed the cell.
- **#4 official**: the text "战斗结束后,装备者替换为高一阶的随机干员" never says 销毁 (every consumable's text does; data EQUIP, upgradeNum 100); PRTS 下半/PRTS盟约记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)"; PRTS 卫戍协议/帮助 "佩戴的装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)或装备合并为进阶品质时自动卸除"; community guides re-inject it every round (bilibili cv47000418, cv48003106; cg.163.com "这个道具可以无限使用").
- **#4 where the new operator goes** (corrected after the WA merge; pointed out in the closed PR #2, whose playtest claim the footage confirms): official gameplay footage — bilibili BV1vzyVBuEN9 (上半, ≈ 8:24) and BV1Qkw1zMEoR (下半, ≈ 7:25) — shows the next prep with the carrier's tile **empty**, 剩余可放置角色 one higher and the 整备区 holding the returned equipment and the new tier+1 operator, which the player then deploys by hand. It reads the PRTS 备注 as written — a destroy followed by a gain ("原干员销毁,获得一名…") — and PRTS 帮助 sends whatever a player recruits or gains to the 手牌区 (= 整备区; "被发送至手牌区的物资优先从右到左填充空位"). WA had kept the new operator on the carrier's tile when legal [ASSUMED] and counted that tile for a merge the transformation completed [ASSUMED]; both are withdrawn (PR #2 itself still let the merge use the carrier's tile — not adopted).
- **#4 now**: after every battle the carrier is destroyed wherever it stands (board, hand or temp — a board tile is freed and the deploy count drops; its summons are removed, its copies return to the pool). Its equipment, the cell included, returns to the hand **first** (overflow temp; auto-merging like any gain). Then a random NORMAL operator one tier higher (max 6; an elite carrier too) is gained like any gained operator (`PlayerState.transformChess` → `acquireChess`, onGain source `'transform'`): the hand (right → left), overflow temp, and with both full the no-room rule of every gain (back to the pool, toast 整备区已满,获得的干员已返还; an item that found no slot takes one the gain freed, else stays on the gained operator up to 2 slots, else it is destroyed with a log warning, as in a merge). Like any operator on the bench it brings no summon card until it is deployed. A gain that completes a merge merges like any gained copy (§20.11): the elite on a consumed **deployed copy's** tile, else the hand — the carrier's freed tile is no copy's (`_mergeChess` lost its `fromKey` / `fromDir` options, `audit.js` its exception for them); the returned cell is stowed before the elite's summon card. Per-piece state stays with the returned items (商业包装方案's sell count, keyed by the item). One implementation (the built-in); the item card states the rule (items.json `note`). Bots inject their least valuable single normal operator below 6阶 (`cellTarget`: never an elite, a merge pair or a carrier of a cell), make a hand slot for a cell left in temp (`makeHandRoom`), and — AI 托管 too — deploy the gained operator in the next prep's lineup step like any owned unit (a real co-op round in the tests: SETTLE → the next prep). The buy loop's pre-emptive bench shed, which sells the weakest bench single by piece value while the bench is crowded, no longer sells a piece gained since the bot's last prep ended (`bot.js rememberOwned`): on the new rule it had sold 44 of 222 gained operators in 20 solo 绝境 matches on 昆图斯 before any placement step saw them (now none; a gained operator stays off the board only when the board is full). Measured (`tools/botbench.mjs`, decisions only): strategy 昆图斯 (`--band band_quintus`, seeds 201–220) 绝境 8 / 20 wins on the WA build → 7 with the bench rule alone → 11 with the shed fix (LP left 16.8 → 13.3 → 17.1), 险境 15 → 16 → 17 (16.3 → 16.2 → 17.6); the bots' own strategies, bench rule alone → with the fix: solo 绝境 (seeds 1–12) 8 → 8 / 12, solo 险境 (seeds 13–36) 19 → 19 / 24, co-op 绝境 4 AI (seeds 1–36) 9 → 8 / 36 (LP left within 0.6) — noise; the shed's cost is a set of the owned uids per prep. The client needs no special case: `render/app.js setPrep` drops the carrier's view and draws the new piece on its bench slot (checked in headless Chrome: the tile empty, the operator and the cell on the bench, no promotion cue, no console error).
- [ASSUMED]: an elite merged this round keeps its copies' highest count (no second firing that round); a copy re-bought after a sale fires on its own first refresh ("获得该干员后"; each extra +4 costs 3 + 1 − 1 = 3 funds); a bench carrier (hand or temp) transforms too (the footage shows deployed carriers); a 6阶 carrier becomes another 6阶; with the hand and temp both full the gained operator and an item without a slot follow the rules of every gain (no source shows that case).
- Tests: `test/match/feedback1-meta.test.js` (a deployed and a bench carrier, the hand full → temp, the hand and temp full, a merge with and without a deployed copy, 商业包装方案's count, bots and AI 托管 redeploying, a real solo 绝境 昆图斯 bot match: no gained operator shed in its first prep, none waiting on the bench beside a free deployment), `test/ui/feedback1-cell.test.js`; `items.test.js`, `merge.test.js`, `facing.test.js`, `playtest6-elite-to-board.test.js` updated.
### 21.2 Bounty drafts by the official round structures; special offers shown as what they hold (#2, #6) — `tools/build-data.mjs BOUNTY_INITIAL_SETS / BOUNTY_BOSS_GROUPS / BOUNTY_HUNTER_GROUPS / SHOP_DRAFT / TACTIC_DRAFT`, `match/choices.js bountyDraftCards / shopDraftCards / tacticDraftCards`, choices.json `bountyDrafts` / `shopDraft` / `tacticDraft` / `draftPool` / schedule `bountyDraft`, `test/fixtures/official-bounty-drafts.json`; `match/PlayerState.js pushRewardOffer / pushItemOffer`, `match/effectsMeta.js offerLabel`, `ui/gameLogic.js offerHeader`, `ui/shopBar.js RewardCards`, `ui/rewardOverlay.js`
- **#2 seen**: "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了打不过". **Cause**: every draft drew uniformly from 105 cards (§20.6), so R3 could offer the R9 bosses and the R11 giants.
- **#2 official** (66 screenshots of 22 co-op 绝境 / 终极 matches of this season at R3 / R9 / R11, collected by the user 2026-10-03; readings in the fixture, no player names): every card is an act2autochess (下半) effect whose enemy is the effect's `enemy_id` (the title only names category and tier: 悬赏·损伤I = 底海滑动者 e12_4 / 临时收音师 e18_1 / 心虚设计师 e16_11); each 悬赏决策 event is a fixed card list and a draft shows 6 different cards of it in varying positions.
- **R3 (22 / 22)**: six 接下来两场作战 cards, I I I II II III (10 coins), one of 10 fixed sets (enemy_initial_1..10), all six shown; 9 sets seen — series 17 / 18 / 19 whole, and hand-made mixed sets (one holds two series-20 cards, one holds 自制投石机 e17_6), so no simple rule describes them.
- **R9 (22 / 22)**: boss bounties X·悬赏 + 源石虫·特训, 下场作战 — one of 6 groups (one per bossInitial event) of 9, 9, 9, 9, 8 and 6 cards; 庞贝 and 鼠王 never share a draft; named bosses do not always come together (match 19 = 自在 + 杰斯顿 + 陷落雪祀, match 12 = 杰斯顿 alone). The 鼠王 group came in 14 of 22 matches, every dark-grey-board match among them.
- **R11**: 悬赏决策 14, 机密商店 4, 战术决策 4, 道具补给 0 of 22. A bounty draft is a list of 7 下场战斗 cards — one 特异III giant, one card per faction series — of which the draft leaves out one (match 15 leaves out the giant; matches 1 / 6 / 21 / 22 leave out four different cards of one list); 7 lists seen.
- **机密商店** (matches 2, 4, 5, 8): six free items with repeats (盟约之币 ×2, 变形同构体 ×2); each shop exactly two tier VI, at least one V and one 盟约之币, no tier I / II. **战术决策** (matches 7, 9, 18, 20): six ally cards each (no 排斥 / 责罚 / 裁决, no 模拟战场演变), 补给 twice in match 7.
- Never in a draft: the 7 multi-round cards (the 假想敌 question is answered), enemyeffect_3_*, 战术特训, the 鸭爵 set.
- **#2 now**: cards.bounty `draftPool` initial 42, boss 20, hunter 24 (86 drafted); `draftExcluded` 'unseen' (19: multi-round, e3_*, 凋零骑士 / 遗弃者 / 锏 / 扎罗 / 迷路的巨像). A draft = one group, then 6 different cards of it, each drawn with weight 1 + the official drafts of its group it showed in. R3 = one of 10 slots: a seen set, or for the unseen 10th a rule set (tiers I I I II II III, ≤ 2 per series, the III 法术大师A1 e10_6 or 鼎沸 e20_6 — the two-battle cards no draft showed); R9 = a seen group, picked by the matches it came in; R11 = one of the 7 seen lists, uniform (the 4 lists seen with 6 cards completed by the list shape, so 12_8 / 16_2 / 16_6 come in about 1 R11 bounty draft in 15); 绝境 / 终极 R11 families: bounty 14 / shop 4 / tactic 4 (the official counts; was supply 50 / shop 50). The R11 机密商店 (`shopDraft`): six slots drawn with replacement — VI, VI, V, 盟约之币 and twice V / IV / III / 盟约之币 — items weighted by the official cards they showed on; the earlier 标准 / 险境 shops keep any tier I–VI per card, also with replacement. The 战术决策 (`tacticDraft`): every card drawn on its own with replacement; at R11 only the ally cards, weighted 1 + the official cards each showed on; other rounds every card, uniform. Identical cards are separate cards (picks go by index; two picks of a team card stack). `MULTI_ROUND_BOUNTY_BATTLES = 2` stays (it now only affects 教鞭's 法术大师A2·多轮战术特训). The mode's inactive enemy list no longer removes bounty cards (PRTS 11/18 note "不影响悬赏决策出场"). Flip: add a newly seen set, group or list to the build-data constants and rebuild. On the 22 official bounty drafts the generator reaches every one (60 000 seeds per round, `mode_multi_hard` and `mode_multi_abyss`).
- **#6 seen**: 凯瑟琳's three items appeared as blank operator cards with raw ids under 晋升奖励. **Now**: a special-refresh offer is drawn as what it holds — free item cards (icon, name, tier, description, FREE) or chess cards under its source's label (凯瑟琳 定向投放, 娜仁图亚 见者有份, 寻呼模块, 信标, 松果) + SPECIAL + 免费选择 1 件 / 名; m.private `shop.rewardOffer = { tier, source: 'merge'|'special', label, queued, slots }`; offers waiting behind the shown one show as "之后还有 N 项" and a +N on the reminder pill; a special offer plays the refresh sound.
- [ASSUMED]: the card weights inside a group; the unseen 10th R3 set (rule) and 险境 R6 drafting like R3; R9 groups picked by the matches they came in (what of the match decides — map, leader or difficulty — is open) and the three single-draft groups completed only with the base cards they lack; R11 = one of the 7 seen lists (which of the 15 events R11 fires is open: by the blocks of effectChoiceInfoDict it would be bounty_hunter_8..15, and with 8 events an unseen list would come in about 1 R11 bounty draft in 8); the R11 family weights = the official counts; the 机密商店 slot split and item weights, and the earlier 标准 / 险境 shops; the 战术决策 weights, R11 without terrain cards, independent draws (open: all 4 official drafts hold a 驰援, a 盟誓 and two of 列装 / 财富 / 补给 / 整备 / 升华), twin battle passives stacking; solo 绝境 / 终极 R11 = the co-op weights (no solo screenshot; the data has 15 solo `bounty_hunter_*_s` events); solo shows 3 of 6.
- Tests: `test/match/feedback1-bounty.test.js`, `test/match/feedback1-secret-shop.test.js`, `test/match/feedback1-tactic.test.js`, `test/ui/feedback1-offers.test.js`, `test/ui/feedback1-secret-shop.test.js`, `test/ui/feedback1-tactic.test.js`, `test/match/playtest6-matchflow.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/choices.test.js`; opt-in `test/ui/feedback1-offers.e2e.test.js`, `feedback1-secret-shop.e2e.test.js`, `feedback1-tactic.e2e.test.js` (`SP_E2E=1`).
### 21.3 No deployment in the water; tactical points inside the tactician's range (#3, #9) — `sim/grid.js DEPLOY_REFUSED_TILES / canStand`, `tools/build-data.mjs effectiveBuildable / ownerRange`, `match/board.js ownerRangeKeys`, `match/PlayerState.js _legal / summonRange / _liftOutOfRange / _reorient`, `ui/gameLogic.js summonRange`, `match/bot.js`, `match/invariants.js`, `content/kits/tier3.js tacticalPoint`
- **#3 seen**: "有水池的那张图干员可以错误的被部署到水里,突袭分队干员也会自动落到水里" — the 深水区 (`tile_deepsea`) of 战场#08(下半) 涨潮控制 (act2 m04): board (10–12, 6), partner (10–12, 14), boss (3–5, 6) / (3–5, 14). **Cause**: the level gives it buildableType ALL and the legend copied it; the deploy maps (server, client, bots) and `grid.canStand` (the 突袭 landing tile, tactical points, summon tiles) treated it as road, and a tactical point even preferred it (it lies on the enemy path).
- **#3 official**: PRTS 深水区, 地形信息 tile_deepsea: 部署类型 全部位, 地形机制 "拒绝部署(待补充)"; PRTS 作战机制: the 地形标记 AdvancedBuildableMask (values include 深水) "限制玩家仅能部署匹配的单位于其上"; `trap_040_canoe` 特制水上平台 "在水上建立可以部署任意单位的平台" (season 1's 战场#05 puts one on each water tile). There is no PRTS page "地形".
- **#3 now**: `DEPLOY_REFUSED_TILES` makes the effective buildable NONE: stages.json `tiles.d.buildable` NONE (`buildableType` ALL kept), `deployTiles` without the water, `canStand` refuses it (and melee units on a hard-blocked 射击台 / mound); every automatic placement and the bots follow. An active 特制水上平台 makes its tile deployable in the prep deploy map only (act1 m05, weight 0; the sim models no canoe, its Grid keeps those tiles NONE [ASSUMED]). Gate routes are unchanged; the water counts as non-blockable in the path tie-break, so a few off-route smoothed steps of the m04 boss field (rows 4–5) go around it. 维娜·维多利亚's lions skip it too (§21.8).
- **#9 seen**: "干员伺夜这样的战术家的战术点能被错误的被放到攻击范围之外". **Official**: 狼群 / 流形 token text "只能部署在召唤者攻击范围内" (character_table; PRTS 狼群 特性); the tactician trait "可以在攻击范围内选择一次战术点来召唤援军"; PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区".
- **#9 now**: tokens.json `ownerRange`; such a piece stands only on `ownerRangeKeys(loadout attack grid, owner tile, owner dir)` — enforced by the server (`_legal` / `summonRange`), the client (drag highlights; toast "只能部署在召唤者攻击范围内"), the bot and the invariants. Moving the owner sends its summons back (unchanged); in swaps each piece is checked from the tile it ends on; an in-place re-orientation or promotion that leaves the summon outside sends it back to its stack with a toast — one still inside stays (user-confirmed, §21.20); with nowhere to go (hand and temp full) the re-orientation is refused with `HAND_FULL`, on the other paths the summon leaves the board and comes back at the next round start (`grantTokensFor`). No out-of-range placement reaches the battle.
- [ASSUMED]: the range is the loadout's grid (`attackRangeGrid`, §21.16; items and bonds do not widen it); automatic placements never put a melee unit on a 射击台; the canoe in the deploy map only.
- Tests: `test/match/feedback1-placement.test.js`, `test/sim/feedback1-water.test.js`; board, playtest5-deploy, playtest6_summons, ui/gameLogic, pathing-blockable / -crosscheck updated; opt-in `test/ui/feedback1-placement.e2e.test.js` (`SP_E2E=1`; fastServer `SP_STAGE`).
### 21.4 Enemies that change form when knocked out (#5 转译基底·α, #8 深池逐火) — `content/enemies.js` (husk, kitTranslator, reborn, statue, setForm, rebirthCleanse), `sim/ai.js enemyAttack`, `sim/snapshot.js`, `shared/protocol.js fxForm`, `battle/runner.js keepsState / hold / compactHeld`, `screens/game.js keepEarly`, `render/interp.js isCosmeticEvent`, `render/app.js renderInfo`, `render/units.js FORMS`, `render/spine.js`
- **#8 seen**: the 怨恨的余烬 could not be killed. **Cause**: the ember stood still, 隐匿 AND unblockable, so no operator could ever target it (a real R3 round on 战场#01: 0 of 7 killed, 7 leaked).
- **#8 official** (PRTS 深池逐火战士 / 护卫 天赋; `Revive[Trigger]` 5 / 10, `interval` 10; PRTS 特殊机制 §重生 / §特殊生命值机制; 作战机制 §隐匿): every knock-out → a 1 s 重生 (immobile, 无敌 + 无法阻挡 + 失衡免疫); then the ember — max HP 5 (护卫's 火灰 10), 1 per damage instance, no attack, 隐匿 + 缴械; not killed within 10 s → the warrior with full HP. A blocked enemy loses 隐匿.
- **#8 now**: the ember walks on; blocked, it is targetable and drawn solid (the stealth bit only while its 隐匿 is on). The same round: 7 / 7. An operator's radius area damage reaches it only while blocked or revealed (§21.19) — and, since 0.1.2, for 3 s after a block ends (§22.8).
- **#5 seen**: 转译基底 played a "transform" clip and vanished. **Causes**: no clip sets for its forms (it stayed on A_* and died on B_Die, the 寻仇者's death clip); its first form took damage; and the client dropped the change fx in catch-up frames, hidden tabs, the pre-entry buffer and the render engine's stale-event drop (b.snap carries no form).
- **#5 official** (PRTS 转译基底·α 天赋; talents Passive / Mode_*_Passive / Mode_Fuchou_Anger): no attack; every damage instance cancelled; 失衡免疫 + control immunities; the 4th physical hit → 寻仇者, the 4th arts hit → 特战术师 (2 targets), a block → 幽灵 (unblockable, no attack — PRTS 幽灵 "不进行普通攻击,无法被阻挡"); once, 2 s.
- **#5 now**: A_Die_B / _C / _D, then B_* / D_* / C_*; no HP lost before the change (an HP loss stops at 1); the armed forms attack through `ai.js enemyAttack` (`e.profile`).
- **Kill accounting**: a survived knock-out gives no kill, credit, bounty, die event or death hook; wave completion and 联防 counters see the enemy standing; a bounty coin is paid once, at the final death.
- **重生** (PRTS §重生 "清空自身身上除白名单外所有Buff"): `rebirthCleanse` removes ally-sourced buffs and source-less statuses, keeping `persist` talents, enemy-given buffs (锏's 抵抗) and source-less field state; its skills restart from their initial cooldown when it ends (§21.19).
- **Forms on the client**: `setForm` keeps `e.form` (UnitInfo `form`) and puts `form` on its fx (`fxForm`, the 'phase' fx too, so barrier / charge phases are no forms). No client stage drops a form fx: runner catch-up frames (`keepsState`), the hidden-tab backlog of the battle on screen (`hold`; past `HELD_MAX` superseded status / skill toggles are compacted away, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), a battle that ended while hidden (delivered when the tab is shown; every batch keeps the game time it was drained at, `EV_SLICE`), the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's queue (`render/interp.js isCosmeticEvent`: a form fx is never dropped as stale nor shed; one handed out late switches the model without its telegraph or a change clip that would already have ended, its closing clip shortened — the last three since the integration, §21.19). Late views: `renderInfo` keeps `form`. Closing clips are timed from the fx `dur` and keep the current form's death clip until the next form's fx.
- **Audit**: 锏 / 扎罗 had #5's look (fixed: their 重生 clips); 杰斯顿 → C2_*; 守墓石像 per PRTS (statue, then flight, §21.9); 再生: 1 s 重生, then shields within 1.8. No unit stays untargetable. “余音” stays unmapped (no source says which form is A).
- [ASSUMED]: the translator is immobile during its change and stays damage-cancelled; the HP-loss floor 1; form letters B / C / D; the 特战术师 form also hits flyers (the standalone PRTS 特战术师 "不会攻击飞行单位" may carry over — one line, `canTarget: (u) => !u.isFlying`); the ember's 10 s counts from the end of the 重生; 再生's 傀儡 walks; the statue does not attack (clip Sleep); the 重生 whitelist. (隐匿 returned at once after a block until 0.1.2 — now 3 s, the knock-out's own release included: §22.8.)
- Tests: `test/sim/feedback1-transform.test.js`, `test/match/feedback1-runner-forms.test.js` (stalls, hidden tabs, a battle ending while hidden, compaction, the runner feeding the render buffer), `test/render/interp.test.js`, `test/render/forms.test.js`, `test/content/enemies_bosses.test.js`; opt-in `test/render/feedback1-forms.browser.test.js` (`RENDER_E2E=1`; blocking 1 s long tasks).
### 21.5 The 鸭爵 strategy's enemies (#7) — `content/enemies.js` (runWhenHit, 圆仔 kits), `content/bands/meta.js duckReplace / duckGlobal`, `Match.startFinalAssault` (event `routes` / `side`), `tools/build-data.mjs bandSwapEnemyKeys`
- **Seen**: "鸭爵策略带来的怪物圆仔错误的能被阻挡"; the user asked for an audit of the other three. **Cause**: 圆仔's kit had only the front guard (its unblockability is in PRTS's talent text, not in the handbook ability list). The audit found more: 鸭爵's run multiplied the speed by `run.attack@move_speed` instead of adding it; the swap ran only in normal battles and only on NORMAL-rank enemies; the four kept the database's roguelike `lifePointReduce` 0; on a pair boss field a player's swaps came from both halves.
- **Official**: PRTS 圆仔 天赋 "无法攻击/被阻挡;受到来源于正面的物理和法术伤害-80%;自身始终朝向我方干员数量最多的方向"; PRTS 鸭爵 天赋 "受到伤害时开始奔跑,移动速度+400%"; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 §策略 鸭爵 备注: "每回合将有0~2名敌人被替换为上述敌人之一,且上述敌人为特殊版本,相较于原版更弱" (popup: 鸭爵 6000 HP, +300 % speed when hurt; 高普尼克 11000 / 1600 ATK; 流泪小子 4500, 3.5 s stun; 圆仔 13000), "但进入保护目标点将减少1点目标生命值,且在最终回合和隐秘核心回合中仍然生效". Data: the band's enemylist names the `_2` versions (blackboard min 0, max 2, round 5, minweight 0.6, maxweight 0.99). How the swap is drawn is in no source.
- **Now**: 圆仔 / 圆仔_2 unblockable + front guard; 鸭爵 / 鸭爵_2 run ×(1 + v) once (+400 % / +300 %). The swap runs in normal, Final Assault and Hidden Core battles (联防 adds none); candidates: any ground wave enemy, normal or elite, except the leader, its parts, bounties, earlier swaps and uncounted units — each swap takes the replaced unit's time and route; on a pair boss field each player's 0–2 come from the enemies whose route ends on that player's half (`ev.side` + `ev.routes`, the bounties' half rule), the 60–99 % share taken over that half. The four `_2` enemies have `lpr` 1 (builder): the Final Assault / Hidden Core LP and the card's 目标价值 read it.
- [ASSUMED]: the uniform 0–2 count and the uniform pick; minweight / maxweight = the 60–99 % part of the player's enemies in time order; ground only; elites eligible (about two thirds of the leader rounds' escorts in that share are elites; NORMAL-only would leave ≈ 80 % of R14 pair halves without a candidate vs ≈ 36 %); per-half swaps; decided at battle start (not in the prep preview); 圆仔's backward pose not rendered.
- Tests: `test/match/feedback1-ducklord.test.js` (a real R6 wave; solo Final Assault + LP; co-op Final Assault → Hidden Core pair fields on the real match path), `test/content/bands.test.js`, `test/content/enemies_bosses.test.js`, `test/data.test.js`.
### 21.6 AI players (#10 "人机有点太笨了") — `server/match/bot.js`, `tools/botbench.mjs`
- **Measured on the 0.1.0 bot** (`tools/botbench.mjs`, same seeds on two builds): 0–2 refreshes a prep and side-grade singles sold again for 1 (≈ 2 merges a match, ≈ 2 elites at R13); a level-up could spend the funds a third copy needed; the bounty score ignored the enemy (co-op 绝境: 155 of ≈ 310 bounty enemies leaked); 信标 (destroys its carrier) went on the best operator; DPS ignored DEF / RES; the focus bond flip-flopped.
- **Now** (decisions only, no rule or balance change): refresh vs buy by the shop odds of completing held pairs (a full board buys only merge progress or a lineup upgrade worth more than the refreshes its price would pay for; an elite's value includes the merge's free pick); ≤ 4 pairs on the bench, merges before level-ups, a freeze for an unaffordable third copy; one committed focus core bond + a second bond, a teammate's main core bond read from its public bond strip (humans too) counting against it; dealers valued by DPS against the round's DEF / RES; 坎诺特 keeps its interest capital (5) back; bounty picks by expected payout − expected leaks × (2 + 20 / LP) with the kill chance from the exposure model — an expected-value pick (co-op leaks are bound by the drafts, §21.2); items by effect (信标 on a bench single, 拟态物质 on a pair, both 博士投影 on the strongest normal operator, 突变细胞 per §21.1, bond items on a focus member, survival items on blockers); 教鞭 / “神秘顾客” used after a perfect battle and kept otherwise — a bot drops a kept one only for a hand slot, a human's seat under AI 托管 never; a tactician's 援军 inside `PlayerState.summonRange`.
- **Result** (seeds 1–40, rehearsal 3, on the WF branch): 独立 绝境 19 → 26 / 40 wins (LP left 14.3 → 16.6), 同盟 绝境 (4 AI) 5 → 12 / 40 (5.1 → 12.7), 标准 unchanged; merges per bot ≈ 2 → 3–4; forced strategies (solo 绝境, seeds 201–220): 坎诺特 9 → 14, 昆图斯 9 → 11, 杜宾 8 → 9. **Decision time unchanged**: heuristics CPU p50 / p95 31 / 74 → 28 / 70 ms solo and 17 / 39 → 17 / 38 ms co-op; the whole prep with its rehearsal 89 / 205 and 83 / 162 ms wall.
- **Reconciled with the merged rules** (§21.19): the server's legality (deploy map without the 深水区, `summonRange`), WA's cell rule (and its destroy-then-gain correction: the gained operator is deployed by the lineup step, the bench shed spares a piece gained since the last prep — §21.1), WB's draft structures (it scores whatever is offered), 本局禁用 bonds (never planned), the range a unit is deployed with, and the AoE of §21.18 in its DPS value. On the merged build, seeds 1–40: solo 绝境 27 → 27 wins, co-op 绝境 9 → 11 (within the noise); decision time stays (heuristics CPU 24 / 57 ms solo, 16 / 36 ms co-op; the whole prep 78 / 153 and 73 / 151 ms wall; BALANCE §5). After the QA: WB's 22-match bounty lists (a giant in every R11 bounty list, 14 of 22 R11s a 悬赏决策) take the same bot to 22 / 40 and 9 / 40 — a rule change; the bench-shed fix (never sell a piece of this prep, §21.19) then 24 / 40 and 10 / 40. **The 0.1.1 build against 0.1.0 (19 / 40, 5 / 40): 24 / 40 solo 绝境, 10 / 40 co-op 绝境 (4 AI)** — part of it is the rules: 0.1.0's bot on the 0.1.1 rules wins 23 / 40 and 7 / 40 (LP left 14.6 / 5.2 against 17.4 / 13.1, merges per bot 2.2 / 2.0 against 3.8 / 3.1); same-prep buy → sell in co-op 绝境 (2 AI 托管 + 2 bots, seeds 21–26) 29 → 3; decision time unchanged (one thread, seeds 1–3: the whole prep 81 / 187 → 81 / 184 ms wall solo, 71 / 153 → 71 / 129 ms co-op; heuristics CPU 36 / 88 → 35 / 86 and 16 / 40 → 16 / 40 ms).
- [ASSUMED] (tuned; the official AI is undocumented): MERGE_HIT 36, MAX_PAIRS 4, BENCH_BUDGET 6, FOCUS_MEMBER 4, ARMOR_WEIGHT 10, BOUNTY_KILL 2, the LP value 2 + 20 / LP, the teammate penalty 6, the 坎诺特 reserve = the band's interest capital, `CROWD` (×2 / ×1.3 / ×1.4).
- Tests: `test/match/bot-smarter.test.js` (bounty picks, item carriers, Arts kept / AI 托管 safe, tactician range, pair via buy or freeze, 坎诺特, the bond strip, the merged rules, a CPU cost guard < 300 ms), `test/match/feedback1-meta.test.js` (AI 托管 never destroys an item to clear temp).
### 21.7 奥术 works; 本局禁用 bonds; the 源石溶剂 merge (B1, B2) — `ui/gameLogic.js modeOffBonds`, `ui/detailPanel.js BondChips`, `ui/shopBar.js ChessCard`, `ui/bondStrip.js BondPopup`, `screens/game.js`, `match/PlayerState.js _mergeChess`, `match/effectsMeta.js destroyPiece`, `content/bonds/addon/battle.js`
- **B1 measured — the bond works** (a real 险境 battle through `Match._normalOpts`, and the same battle from its BattleSpec, the browser path — identical): each member's arts hit debuffs its target ×(1.2 + 0.01·L) arts damage taken for 3 s and later arts hits land multiplied (深靛 + 洛洛, R5, seed 7: 41 / 54 arts hits debuffed at L0, 0 without the bond); 3 different members: below 50 % HP ×1.4·(…) (seed 7: 9 hits at ×1.68 and, after 海霓's live +3 layers, 19 at ×1.722); all 10 members × S1–S3 × normal / elite apply it, summons do not (not 干员); leaders too (Final Assault 绝境 R14, 999 谢拉格: 329 819 → 426 911 leader damage in 700 s at L0).
- **What a player saw — fixed**: 标准模拟 switches 奥术 off (official modeDataDict inactiveBondIdList: 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技), while 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond; their cards showed 奥术 normally, the chip read 「0/2 未激活」 with three deployed and the popup 「在场 0/2 未激活」. **Now** such a bond is struck through on shop / reward cards, the chip reads 本局禁用, the popup reads 本局禁用 with a note and no 在场 count or 当前效果 block (its numbers would promise an effect) [ASSUMED look].
- **Official, unchanged**: 限伤 cancels a leader hit ≥ 300 000 in boss / hidden battles (§20.12), so very large multiplier stacks can make huge hits vanish (巴哈姆特 12316); our measured boards stay far below it (largest 24 813).
- **The stacking rule, reconciled — §20.10 stands**: PRTS 盟约记录's current note 「※同一单位仅可对同一目标同时施加1个该盟约法术伤害提升效果」, read with the engine default (PRTS 常见同名状态) and 巴哈姆特 12316, gives one effective instance per target (applyStrongest). Known deviation [ASSUMED]: an earlier revision (oldid 386936) said a unit cannot re-apply while its own instance lasts; an equal re-hit refreshes it here (slightly stronger). Strongest rather than earliest stays [ASSUMED].
- **B2 — not reproduced**: every PlayerState path ends with exactly one golden and no white (buy next to a white in the hand or equipped, two buys, equip on another or a full carrier, a reward pick, 道具补给, 列装, 整备 + buy, 画卷, the carrier's sale); random intents over all 50 mergeable items conserve W + 2·G and never leave a pair (upgradeNum 2 → _b; PRTS 帮助 「已拥有2件同一初始装备时…自动合并」). A latent gap closed: equipment returned by a promotion (`_mergeChess`) or by `ctx.destroyPiece` (突变细胞, the round-start promotion item) now runs `checkItemMerges` (unreachable by a player before).
- [ASSUMED]: 整备 bought next to an owned white merges the two first and keeps the effect for the next purchase (the official text may mean the bought item turns golden); the 标准 popup lists members the mode keeps out of the pool dimmed as not owned.
- Tests: `test/sim/feedback1b-arcane.test.js`, `test/ui/feedback1b-bonds.test.js`, `test/match/feedback1b-items.test.js`.
### 21.8 维娜·维多利亚's lions; 玛恩纳 S3 hits air units (B3, B4) — `kits/tier6.js siege2`, `tokens.js SKILL_SUMMON_UNCAPPED`, `kits/tier5.js` 玛恩纳
- **B3 seen**: her S3 (default, defaultSkillIndex 2) summoned one 黄金盟誓. **Real**: on the real match path (战场#05 下半, R8, (10,5)) every cast placed 1 lion beside 3 free melee tiles (the reviewer's sweep of m01–m04: 74 casts, all 1 lion). The kit picked one tile, and the per-owner deploy limit (the token's phase maxDeployCount 1, a hand count) would have withdrawn a second.
- **B3 official**: skill_table skchr_siege2_3 "立即在天赋一生效范围内可部署地面召唤“黄金盟誓”"; EN client "Summons Golden Vows on deployable tiles within Talent 1's range"; wiki.gg "only be spawned on open low ground tiles"; PRTS 备注 "于技能结束时/维娜退场时自动退场".
- **B3 now**: one lion on every free tile of her 3×3 talent area where a melee piece could deploy (LOW, ALL / MELEE, unoccupied, not a reserved home tile; `grid.canStand`, so never the 深水区), at most 8, for the skill's duration; `SKILL_SUMMON_UNCAPPED` keeps the deploy limit off them; each counts for 诸王的叹息 (546 × (1 + 1.45 + 0.30) with 6 lions). 1 → 3 lions at (10,5) on 战场#05, 6 at (11,4) on 战场#01.
- **B4 seen**: 玛恩纳 "开技能没伤害". **Real, on flying waves**: his default S3 fires (CUSTOM_RANGE 3-18) on any enemy, flyers included, but his attacks were ground-only melee — a flyer-only cast hit nothing for 26 s, then the trait ramp reset (4 of 19 real casts; seed 21 R2 dealt 0).
- **B4 official** (PRTS S3 备注): "※可对空"; "每击倒一名符合条件的敌人,特性提供的攻击力加成-10%,特性最低降低至+0%" — only "自身普通攻击(与该次攻击附带的伤害)" kills count, "于当次攻击后统一结算", not kills by 第二天赋 (reflection), by a mark another unit's attack set off, or sourceless ones; S2's "不重置" follows the same rule.
- **B4 now**: S3 `targeting.canHitFly` (flyer-only casts 0–10k → 14k–93k damage); bonus = max(0, ramp × trait_up − 0.1 × own kills), settled after the attack; an own kill = an enemy his current attack (or the mark it set off) damaged — the set is cleared after each attack, so carried damage counts and reflection / teammates' marks do not. His trait sets block 0 only while the skill is off: during S2 / S3 he blocks up to 3, and the 卡西米尔 bond's true damage rides his attacks only while he blocks nothing.
- [ASSUMED]: fence tiles (low, deployable, not walkable) count as 可部署地面; "−10 %" = absolute points of the bonus.
- Tests: `test/content/feedback1b_kits.test.js`, `kits_t6` (every lion off the reserved tile), `test/sim/feedback1-integrate.test.js` (no lion in 战场#08's 深水区).
### 21.9 Air units are 静态刚体 (B5) — `tools/build-data.mjs STATIC_BODIES`, enemies.json `staticBody`, `sim/Battle.js _displaceable`, `content/bosses.js` (胄 parts), `content/enemies.js` (statue, 喷气人)
- **Seen** (relayed: "飞机可以被薄绿的技能拉走(难道不能拉动吗?…你去复核下)"). **Real**: `_displaceable` checked only 失衡免疫 and leaders, so every flyer moved by the 力度 − 重量 tables — 薄绿 S2 dragged 妖怪 1.33 tiles per hit and an act2 m01 drone reached the goal 6.6 s late; with client combat drones were dragged 43–55 times per match; 崖心 S1 moved a 刺胄之弹 0.87 tiles; 守墓石像 moved 0.03–0.44 tiles (1.4–1.7 under 失重).
- **Official**: PRTS 特殊机制 静态刚体 "可以进入失衡状态…但物理层面上无法产生任何速度或移动…※与失衡免疫不同…※是否为静态刚体与单位的行动方式无关"; PRTS 失衡位移机制 (air units count, "但会因下述的判断而被取消位移"); every air unit of the mode except “炎佑” (weight 10, 我方单位) lists {{特殊机制|静态刚体}} in its PRTS 天赋, and so does 盐风主教昆图斯 — 28 keys (all 249 enemy pages checked). 失衡免疫 also: 刺胄之弹 / 斩胄之剑 / 破胄之锤 (their talent also lists 不可阻挡, so a grounded 剑 / 锤 stays unblockable); 守墓石像 转换模式 "无法被阻挡,自缚,失衡免疫,免疫浮空" and 飞行模式 "变为飞行单位,失衡免疫"; 喷气人 飞行模式 "近地悬浮,不可阻挡,失衡免疫,移动速度+50%", −90 % for 1.5 s after take-off and 1.333 s unblockable at −90 % after landing.
- **Now**: a static body moves 0 tiles from every push or pull; skills keep their reach and still hit it (薄绿 S2, 锏 S3, the hookmasters, 莫斯提马 S3 …; distance effects come to nothing). 守墓石像's statue is unblockable, rooted, `noDisplace` and refuses 浮空, its flight keeps a persistent `noDisplace`; 喷气人's 7 s flight is an air unit with `noDisplace`, then the brake and landing windows. Ground enemies keep §20.3's tables; 吉兆飞鳞 / 掠海漂移体 are 失衡免疫 while hovering and movable once grounded.
- [ASSUMED]: a static body's 0.1 s 失衡硬直 is not modelled; a 浮空 that 守墓石像 carries at its knock-out runs out; 见行者 S2 / 歌蕾蒂娅 S3 reach air units, 乌尔比安 S1 does not.
- Tests: `test/sim/feedback1b-displacement.test.js` (the data guard, 薄绿 vs drones, the act2 m01 flight, the 16 × 5 audit, coins / 流形 / links, the 胄 shell, 喷气人, 守墓石像).
### 21.10 Damage broadcasts in the Hidden Core; the strip by priority (C1) — `match/Match.js startFinalAssault / tickerText FLOW_TICKER_PRIORITY`, `match/finalAssault.js BOSS_HIT_STEPS / SharedBossPool.byPlayer`, `ui/ticker.js`, `main.js` (m.ticker)
- **Seen**: "隐藏boss还没打就出了造成50%伤害播报". **Reproduced** in real bot matches into the 隐秘核心: solo 绝境 seeds 3–5, the hidden leader's first hit (seed 3: 2034 of 900000) announced "超过50%"; co-op 2 seats 险境 seed 3: "超过80%" at 36 % dealt.
- **Cause 1 (server)**: share = `stats.bossDamage / pool.maxHp`, and `stats.bossDamage` (the result's 领袖伤害) sums both boss rounds. **Cause 2 (client)**: the strip played its queue first in, first out, 5.2 s a line; a Final Assault line played 2–8 s into the Hidden Core over its fresh leader.
- **Official** [DATA]: activity_table `autoChessData.broadcastList` comment_boss_hit_1..3 "{0}博士对敌方领袖造成的伤害超过20% / 50% / 80%!" (BOSS_HIT, paramList 0.2 / 0.5 / 0.8, priority 30); constData `broadcastBeginDelay` 1; client 2.7.71 keeps `bossDamage[localPlayer]` per boss battle (research 11 §2.1); research 06 §9.2 "The highest priority wins": BOSS_HIT 30 > CHAR_DAMAGE 20 > SHOP_LEVEL 11 > (bond 10) > GOLDEN_CHAR 2 > CHAR_GIFT 1.
- **Now (server)**: share = the player's damage to that round's `SharedBossPool` (`byPlayer`, fed by every credit path) ÷ its `maxHp`; each threshold once per player per round, a hit passing several announces the highest; `stats.bossDamage` unchanged (both rounds).
- **Now (client)**: lines keep `type`, `playerId`, `priority` and the round they arrived in; a BOSS_HIT line from another round leaves the queue and the strip; a player's newer BOSS_HIT line of the round replaces their older one, queued or on screen; and (integration, §21.19) the queue is ordered by priority, first in first out among equals, the line on screen never cut short, the lowest priority's oldest dropped past 4 queued — the remake's match-flow notices (隐秘核心已解锁, 联防阶段, a player out or gone) at `FLOW_TICKER_PRIORITY` 25 [ASSUMED], content `ticker()` lines 0.
- [ASSUMED]: the threshold test ≥ ("超过"); the per-battle reset of `bossDamage` (inferred); a dropped line is not replayed; the 1 s begin delay is not modelled.
- Tests: `test/match/bosshit-ticker.test.js` (solo 绝境 server-run, 2 and 4 seats client combat, SP_COMBAT=server, a takeover), `test/ui/bosshit-ticker.test.js` (the queue helpers across a 14 → 15 round change, the replacement rule, the priority order, timeline replays).
### 21.11 变形同构体 wearers in the member list and on the card (C2) — `ui/gameLogic.js grantedBonds / pieceBondIds / bondMembers`, `ui/bondStrip.js BondPopup`, `ui/detailPanel.js BondChips / ChessDetail / resolveDetail`, `ui/watchBonds.js ownerBoard`, `battle/runner.js ownerOps`, `sim/snapshot.js unitInfo`, `match/Match.js prepFieldMeta`
- **Seen**: with 变形同构体 (缪尔赛思's 特质 garrison_76 / band 变形者集群) and the matching item the wearer gets the bond's effect, but "那个盟约的人口数量没有把转职之后的干员算进去". **Partly real**: the count that activates the bond already included the wearer everywhere (`bondsMeta.pieceBonds` for the prep count, m.private / m.public, the battle input; `support.unitBonds` in the battle — checked on real matches for all 36 pairings). The client did not: the popup's member list and the wearer's card read only the records' bonds (在场 3/6 next to 成员 2/10, no chip).
- **Official**: band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; eff_acarm073 "携带者获得额外盟约(由另一件携带装备而定)"; the item's talent lists the 14 pairings; a member counts on the board only (BOARD) and once per operator.
- **Now**: `gameLogic.grantedBonds` = the server's rule (a test pins the three together); the popup lists wearers tagged 同构, the card shows the granted bond as a dashed 同构 chip; a 同构 row opens the wearer's card with its item ids; a card without an own piece lists the carried items read-only under 装备. UnitInfo `items?` (sim `unitInfo`, `Match.prepFieldMeta`) carries an ally operator's item ids, so teammates' popups and cards show wearers too.
- [ASSUMED], unchanged: operator talents that test "【拉特兰】/【卡西米尔】/【叙拉古】… 干员" read the record's bonds (kits tier1 内线 ammo pick, tier5 inFaction, tier6 hasBond, tokens.js 卡西米尔 sword) — no source says whether a converted operator counts for them; 调和's +1 raises 在场 for core bonds without a member row (explained since §21.26: 含调和 +1 and a 调和 row).
- Tests: `test/ui/morph-bonds.test.js`, `test/ui/watch-bonds.test.js`, server-side locks in the match suite.
### 21.12 源石溶剂 is damage; 信仰搅拌机 S3 counters any damage (D1) — `sim/damage.js periodicDamage / isHpLoss / applyHpLoss`, `items/battle.js periodic_damage`, kits tier1 / 2 / 4 / 5, `content/enemies.js`, `bonds/addon/battle.js`, `choices.js`
- **Seen** (community): officially the 源石溶剂 drain sets off 信仰搅拌机 S3, and next to 莫斯提马 it stacks 拉特兰 on its own; the remake never cast S3. **Reproduced** on the real match path (R3; default S3 + 源石溶剂, 莫斯提马 above her, 隐现): v0.1.0 never cast S3 (SP full at 23 s); without enemies she drained to death with 0 layers.
- **Cause**: the drain was `loseHp` — a 流失: no 受击回复 SP, no TAKE_DAMAGE, ignored by shields — and the counter needed an enemy attack and a target.
- **Official**: PRTS 盟约记录 源石溶剂 "受到60真实伤害" (修正 "并非流失"; 备注 "造成无来源真实持续环境伤害" and "携带后,全场范围内的所有敌人类我方单位也会获得此装备的…效果(该效果的付与为我方阵营索敌,可对空,无视目标可选性)"); PRTS 作战机制 (生命流失 "不会触发反伤、受击回复"; "同名buff的默认叠加策略buff只能表现出一个"); PRTS 伤害分类 (BUFF damage includes "自残类型", "弧光锋卫失衡状态下的自残伤害", "深水区/涨潮水蚀"); PRTS 帮助 重装 "受到伤害时释放技能"; PRTS 信仰搅拌机 S3 备注 "反击受到任何伤害后均可触发,无需目标,视为普通攻击"; garrison_22 (莫斯提马): every 6 bullets spent on her 4 tiles → 【拉特兰】 +1 / +2.
- **Now**: `periodicDamage(n)` = 无来源 true damage (canDodge false, tags dot / periodic) with the old credit — shields, 脆弱, `hit` effects, 受击回复 SP and TAKE_DAMAGE all see it, the kill credit is unchanged. S3 counters every damage but a 流失 or an element 损伤, spending a bullet even with no enemy in range (measured without enemies: +5 拉特兰 per cast, elite 2 casts +22; with the R3 wave +7; the browser sim gives the same digests). The drain also ticks on every 敌人类我方单位 of the field (炎佑, a partner's too), credited to nobody, once per second however many carriers, while a carrier is on the field. Real damage instead of 流失 also for 狂暴宿主组长 (悬赏·特异III), 码头水手's drowning ("每秒受到1000点无来源真实伤害"), 弧光锋卫's 失衡 bleed and the 孽罪奇美拉 aura (now radius 1.2, a tick every 0.5 s, one tick per unit — "同类效果取最高"). One rule both ways (`isHpLoss`): 雷蛇 战术防御 and 蒂比 S2 react to real damage; 乌尔比安, 伪装服, 远牙, 录武官's guard, 坚守 thorns and 机变 自愈 skip a 流失; operator 流失 (宴, 风丸, 水月, 华法琳, 烛煌, 史尔特尔, 号角 …), 传递 and the drone link stay 流失. Damage dealt (unit `dmg`, the results' 造成伤害) never counts the source's own side.
- [ASSUMED]: the drain gives 受击回复 SP; the chimera aura is 无来源; a target-less counter is one bullet with no hit; the 炎佑 tick ends with the carriers; drowning keeps its 0.25 s cadence and the 失衡 bleed stays per distance moved (the amounts are official); 心烛 and 扎罗's transfers stay 流失.
- Tests: `test/content/feedback1d-solvent.test.js`, `items.test.js`, `kits_t1t2.test.js` (蒂比).
### 21.13 野鬃's push direction (D2) — `kits/tier1.js` (野鬃), `kits/tier3.js` (琳琅诗怀雅 S3), `content/generic.js`, `sim/Battle.js push`, `constants.js`
- **Seen** (community): "干员野鬃的技能有时会把怪物往攻击方向相反方向推". **Real**: S2 夹枪冲锋 pushed radially at full force (§20.3's table), so a target behind her centre — an enemy that walked through her tile while her single block was taken, grabbed by the §19.2 hand-over — was thrown 1.7–2.14 tiles towards the objective. Measured on act2 m01–m04 (R3 / R5 / R7 official waves, every melee tile × 4 facings): 2689 of 15915 pushes (16.9 %) went more than 0.5 tile against her facing (LEFT 47 %).
- **Official** (client data): her S2 attack ability (charpack char_496_wildmn, anim Skill_2) carries buff `wildmn_s_2[force]` of template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2} (battle/buff_template_data.json): a 方向力 along her deploy direction with the PRTS 推与拉 特殊修正 (> 45° off or < 0.25 tile → radial at 受力等级 −2). Radial = `knockback[relative]`: 琳琅诗怀雅 S3 (`swire2_s_3[knockback]`; PRTS 备注 "推开效果为径向推动"), 山 S3, 莫斯提马 S3. 圣聆初雪 S1 = KnockBackWithCharacterDirection (no correction). Cross-check: 莱伊 S1 `ray_s_1` decreases by 1, matching its PRTS 备注.
- **Now**: 野鬃 S2 = `Battle.push` with `dir` = `unit.fwd` (within 45° the full 中力 table along it: w0 2.14, w1 1.7, w2 0.44, w3 0.12; behind, beside or < 0.25 tile radial at −2: w0 0.44, w1 0.12, ≥ w2 0) — 0 pushes over 0.5 tile against her facing in the same sample; 琳琅诗怀雅 S3's coins are radial from her centre at full force (they went along her facing). The generic kit's text keywords (now also 往攻击方向) are a fallback only. §20.3's table is rewritten accordingly; nothing goes into [ASSUMED].
- Tests: `test/sim/feedback1d-push.test.js` (4 facings × ahead / ≤ 45° / behind / beside / < 0.25 tile, the mirrored boss half, a real act2 m01 R5 hand-over, 琳琅 S3).
### 21.14 Special Originium slugs and 暴鸰's bomb (D3, D4) — `tools/local-extract/extract.py ENEMY_SPINES`, `tools/assets/local-enemy-spines.json`, `plan.mjs localEnemySpines`, `fetch-assets --local-spines`, `assets.js spineEntry`, `render/units.js ALIAS_TINT / FORMS`, `content/enemies.js kitBombd`, `snapshot.js form`, `style.js PROJ.droneBomb`
- **D3** ("所有特殊源石虫的模型全表现为普通源石虫"), partly real: 高能 / 冰爆 / 简饲源石虫 and “庞贝” always had their own models; 灼热 / 炽焰源石虫 (enemy_1305_mhslim / _2, the ELEMENT slugs, up to 10 a round) were the plain 源石虫 — no web dump has their models (the asset plan aliases them); the official skeletons exist only in the client (refs/arts/enm_art_12.ab). Root cause located 2026-10-04: Ark-Models *indexes* `1305_mhslim` / `1305_mhslim_2` with an **empty `assetList`** (registered, never uploaded), so the alias chain falls to `enemy_1007_slime` — a different enemy rather than a variant of it. The *mobile* build ships their own model (`enemy_spine/<enemyId>/<enemyId>.{skel,atlas,png}`, straight-alpha pages), but its only public mirror is a community wiki and not a GitHub dump, so it is deliberately **not** an asset source (docs/ASSETS.md "Enemy aliases"): the tinted alias stays the web model until a GitHub dump carries these two.
- **D3 now**: an optional overlay — the manifest keeps the alias as the web model and `enemies[id].spineLocal` names the files of the data/local-assets.json group `spine/enemy/<id>` with the metadata of the committed `tools/assets/local-enemy-spines.json` (data/assets.json is byte-identical with or without the extraction). The client draws the official model when the local manifest lists every file, else (or on a load failure) the web alias tinted toward the slug's own lava colours (`ALIAS_TINT`: 灼热 orange, 炽焰 red-orange, research 07 §5.6) [ASSUMED look]. The 0.1.1 release bundle carries the models (extracted with `node tools/setup.mjs --local`, the `spine/enemy` job). Audit (249 enemies vs the client's prefabs): no other enemy is drawn with another's model; 伊利昂的木驮兽 starts on skin `Full` in the game and is drawn with `default` (unchanged). `fetch-assets --prune` never deletes `public/assets/local` any more.
- **D4** ("暴鸰的炸弹无法正常投放"), partly real: the bomb hit in the trigger tick, with no projectile, and stayed under the drone. **Official** (prefab enemy_1040_bombd / projectile_bombd; PRTS 暴鸰): Boomb plays Attack, the bomb leaves on OnAttack (frame 8) and flies homing at 5 tiles/s; the cast ends when the bomb is gone, ≥ 0.667 s after the release; buff bomb_s: the bomb-less mode S1 (*_2 clips) + speed ×2 ("技能结束后"); `_immuneStunWhenAffecting` 0.
- **D4 now**: the drone holds still at the trigger; 8 ticks later (8/30 s) the release ('atk' droneBomb + the form 'bombed' — UnitInfo `form`); on landing the target 100 % ATK + the 8 tiles around 100 % splash (a target gone mid-flight: the bomb lands where it was; the drone's death after the release does not stop it); at max(landing, release + 0.667) ×2 speed and it flies on. A stun / freeze / sleep before the release interrupts the cast (no drop; the skill re-arms with its 1 s cooldown).
- [ASSUMED]: no drop by a dead drone; the ATK at the release; holding still through the cast; the bomb-less look from the release; an interrupted cast keeps its one trigger; a stun after the release does not stop the cast end; the trigger radius 2 (§20.4).
- Tests: `test/feedback1d-models.test.js`, `test/sim/feedback1d-bombd.test.js` (a real wave), `test/render/feedback1d-bombd.test.js`, `test/static-local-art.test.js`.
### 21.15 Enemy ground routes: 战场#04's official diagonal (D5) — `server/sim/grid.js` (`_buildField`, `_spfa`, `smoothChains`, `crossTiles` / `segmentTiles`), `data/stages.json groundPaths*`, test/sim/pathing-official / -blockable / -crosscheck / pathing
- **Seen (real)**: on 战场#04 活性源石 (act1autochess_m04, the only stage with tile_infection) the lower-gate enemies walked (9,10) → (9,8) along row 9, then straight up into (10,8).
- **Official**: every autochess route has `allowDiagonalMove` true and `visitEveryTileCenter` / `visitEveryNodeCenter` false; normal WALK routes have no checkpoints; the client SPFA flow field + Bresenham smoothing (research 08 §3.1; PRTS 作战机制 "所有敌人始终选择最短路径行动…") gives (9,10) → (10,7) → (10,4) → (9,4) → E — a diagonal that touches the floor (10,9) only at a corner.
- **Cause**: playtest #2's road-over-floor smoothing (§14 corrections) counted a diagonal step's corner tiles as floor and bent official diagonals into L shapes even where they cross no floor.
- **Now**: each flow field keeps two pointers per tile with the official distances — the official one, and 0.1.0's playtest #2 preference one restored exactly; `next` is the preference pointer only if its route crosses strictly fewer non-blockable tiles (exact geometry: a segment crosses the tiles whose interior it passes through, a corner touch is not a crossing), ties keep the official pointer (unless the preference one only skips a collinear official waypoint). Kept (playtest #2): 战场#01's lower-gate enemies take the col-8 road; on 战场#02's Final Assault both halves leave the exit via (2,9) / (2,11), so a blocker beside the exit holds them.
- **Audit** (11 stages × 14 gate / field legs = 154 routes): 140 official as merged by the workstream (0.1.0: 109); with the 深水区 refusal (§21.3) 7 routes of the inactive 战场#05(上半), whose canoe water now counts as non-blockable, differ too — 21 of 154 (pathing-official `DEVIATIONS`), each crossing strictly less floor. **Visible**: the boss exits of 战场#04, #05(下半) and #07(下半) are official diagonals — at #07(下半) the enemy passes (2,9) / (2,11) at √0.5 ≈ 0.70711 > the block radius 0.70709997 (§19.2), so an operator there no longer blocks them, as with the official route.
- Unchanged: movement tile centre to tile centre along `next[]` [ASSUMED simplification]; `findPath` / `groundPathTiles` return Bresenham tiles. One-line flip: always `use = o` in `_buildField` (pure official routes; reverses 战场#01's col-8 road).
### 21.16 The live attack range on the card; 烛煌 S3 (E1) — `sim/Battle.js _refreshRange`, `sim/skills.js changesRange`, `shared/loadoutRecord.js attackRangeGrid / traitRangeExtend / extendedGrid`, `shared/protocol.js unitStatsEntry`, `ui/detailPanel.js`, kits tier4 (信仰搅拌机) / tier5 (烛煌) / tier6 (荒芜拉普兰德)
- **Seen**: "干员烛煌开启3技能时候攻击范围不会变". **Partly real**: the sim was right (real 绝境 matches: 80 of 152 S3 hits on tiles only 4-11 reaches, none on 4-11 missed; the cast keeps the initial 3-1 per §20.2), but the battle card's 攻击范围 mini-map stayed 3-1 while the live ATK / interval beside it changed — the battle board has no range overlay.
- **Official**: skill_table skchr_blaze2_3 rangeId 4-11; PRTS 烛煌 S3 备注 "攻击溅射半径1.7;于攻击造成伤害前判定元素爆发并造成元素伤害", "补充后的弹药数量无法超过上限"; PRTS 信仰搅拌机 S3 备注 "此技能的攻击范围不受“攻击距离”属性影响".
- **Now (card)**: `unit.liveRangeGrid` (the running skill's grid, else the unit's own, plus `rangeExtend`) → `unitStatsEntry.range` (allies only) → drawn in battle 4× a second and in the prep preview; 400+ tiles read 全场, a grid beyond 6 × 7 draws smaller cells in the same box.
- **Now (record range)**: `attackRangeGrid` = the range a unit is deployed with — a selected "被动效果:攻击范围扩大" skill (引星棘刺 S3 3-9), else a module's 攻击范围扩大 grid, else its own, plus the 特性's permanent 攻击距离 (`traitRangeExtend`: 信仰搅拌机 SPT-Y +1; 空弦 ISW-A works in 集成战略 only) — used by the card without a live entry, the board overlay, the deploy wheel, the server's `summonRange` and the bot; over all 1284 loadouts it equals the battle's range at deployment.
- **Now (kits)**: 烛煌 S3 = one target on 4-11 + a 1.7 splash (中点判定; it used to hit every enemy inside 4-11); the 灼燃 burst bonus is dealt before the hit, on main and splash victims, from the attack as fired (her last shot lands after `end('ammo')` and keeps it); each landed shot emits fx 'splash'; a burst refills ammo only up to 18 / 21. 信仰搅拌机 精锐 + SPT-Y + S3 was 11 tiles, now exactly 3-13 (`targeting.noRangeExtend`). 荒芜拉普兰德 S1's target grid only selects (`targeting.showOwnRange`: the card keeps 3-1).
- [ASSUMED]: 技能范围 areas (古米, 泥岩, 山 S3, 锏) show while their skill runs; content extra tiles (蕾缪安, 维娜 S3) are not drawn; the 全场 / 6 × 7 thresholds (远牙's line is 2.1 px per cell on an 844 × 390 phone); the bonus also lands on a hit that is then dodged.
- Tests: `test/sim/feedback1e-skillrange.test.js`, `test/ui/feedback1e-range.test.js`, `test/render/feedback1e-splash.test.js`, `test/ui/playtest2.test.js`.
### 21.17 Healers that block keep healing — not reproduced (E2) — `sim/ai.js acquireTargets`, `sim/skills.js _defaultCondition`, `Battle.blockedTargets`, `test/sim/feedback1e-healers.test.js`
- **Seen** (relayed): "走炎盟约的时候,如果是那种会到处跑的BOSS,被医疗阻挡之后,医疗不奶人了,会输出BOSS而且如果是那种强力击类型的技能也会在BOSS身上打出正确数值的伤害"; the user asked to check every healer under every bond.
- **Official**: PRTS 卫戍协议/帮助 (技能策略) "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; PRTS 仇恨: a medic's heal uses HP_RATIO_NOT_FULL_ASC (full-HP units excluded, itself counted); PRTS 选择器: blocked units are added only if the selector's side can pick them; 咒愈师 "攻击造成法术伤害".
- **Verified, not reproduced** (real Battle, skills made ready while blocking): the 炎 lineup (9 炎, tier 3, 2 炎佑) — 录武官 normal / elite × S1 / S2 holds 卢西恩 / 不祥幻影, casts while holding, deals 0 damage and keeps healing; every pure healer (医师 / 群愈师 / 疗养师 / 链愈师 / 行医: 26 records, 56 loadouts, each skill cast while blocking) × 23 bonds as a member, and 112 items: 0 damage; the 20 boss / hidden fields: 0 healer damage (healers block 卢西恩 / 不祥幻影 and a 碎铳之簧 with its shield down; the other leaders and parts cannot be blocked by a block-1 unit). 古米 S1 / S2, 塞雷娅 S1, 波登可 S1 heal allies while blocking; 瑕光 S1 / S3 strike + heal by text; 咒愈师 hit what they block (§20.3); 吟游者 make no normal attack.
- **Likely what players saw** (all official): 卢西恩 evades only while unblocked (in the 炎 run 11 % of hits dodged and 1,162 dmg/s while it roams, 0 dodged and 6,751 dmg/s while the medic holds it — the numbers come from the others and 炎佑); a blocking medic heals itself first when it is the most injured; 神经 bursts stun it for 10 s; operators do not turn, so its heal animation faces the boss.
- **Now**: no behaviour change; the rule is written in §5.5, SIM.md, the ai.js header, `Battle.blockedTargets`, targeting.js and PLAYING, and locked by tests. Not checked: whether 华法琳 S2's official 流失 can kill an ally (the remake's can; PRTS gives no non-lethal note).
### 21.18 阵法术师 / 轰击术师 strike every enemy in range (E3) — `sim/professions.js` (SUB.phalanx / blastcaster, `resolveProfile` rangeAoe), `kits/tier4.js` 卡涅利安, the 流形 copy (`content/tokens.js`, `kits/tier6.js`), `render/fx.js`
- **Seen**: "干员卡涅利安的攻击不是真群攻". **Real**: the trait "技能开启时攻击造成群体法术伤害" was one bolt at the priority target + a 1.1 splash — spread enemies took 1 hit of 5 per attack; on act2 m01 R10 the elite 卡涅利安 hit 2.3 of 12.4 enemies in range per attack, 薄绿 2.0 of 12.6; on a real Match every 阵法术师 and 轰击术师 hit exactly 1 target while 2.7–4.7 stood in range.
- **Official**: a 阵法术师 / 轰击术师 attack strikes every selectable enemy on its range at once, the same damage near and far — primary for the 轰击术师: PRTS 作战机制 §AOE伤害判定, a 锁定攻击范围 AoE "在攻击前摇结束时选取范围内的全体目标,同时造成伤害" (its example, 伊芙利特's 炎爆, is her next-attack skill "下次攻击造成…"); for the 阵法术师 Terra Wiki Phalanx Caster (secondary) and PRTS 林 S3 备注 (one normal attack can kill several); supporting only: PRTS 溅射半径一览 has no radius for either (it omits 撼地者 too). 扩散术师 keep a 1.1 splash around the target (格雷伊 1.0, §21.19).
- **Now**: phalanx and blastcaster are `rangeAoe` profiles — `resolveProfile` applies the flag after every override (kit trait included): `allInRange` + instant `'beam'` hits drawn as a line to each victim (no splash ring, no shells). Blocked enemies are struck too (§20.3); a stealthed one only when revealed or blocked. Per-hit kit effects run on every enemy struck (薄绿 S2 拖拽 — a 静态刚体 is not moved, §21.9 —, 卡涅利安 S2 停顿 / 束缚, 阿罗玛 浮空, whose first-attack mark is shared between 阿罗玛 since §21.19). 卡涅利安 (PRTS 备注): a charged S1 keeps the skill-off trait — the guard AND 不攻击 for the whole skill (players will notice); S3's ATK climbs in 1 s steps to +140 % / +200 % at 20 s; the charged mark stacks before the damage, one `billro:mark` per enemy (another 卡涅利安 only adds stacks), ending with its setter's skill. A 流形 copy of a 'beam' becomes a bolt.
- [ASSUMED]: no target cap (no source states one); damage at the attack instant (no wind-up modelled); the beam look.
- Tests: `test/sim/feedback1e-aoe.test.js` (incl. a real wave counting the damaged enemies per attack id); opt-in `test/render/phalanx-aoe.browser.test.js` (`RENDER_E2E=1`).
| Branch (pool) | Official | 0.1.0 | Now |
|---|---|---|---|
| 阵法术师 (薄绿, 蜜蜡, 卡涅利安, 圣聆初雪) | every enemy in range | bolt + 1.1 splash | all in range, instant |
| 轰击术师 (协律, 阿罗玛) | every enemy on the line | bolt + 1.1 splash | all on the line, instant |
| 扩散术师 (格雷伊, 莫斯提马, 莱恩哈特, 夕, 寒檀) | 1.1 splash (格雷伊 1.0) | 1.1 | 1.1, 格雷伊 1.0 (§21.19) |
### 21.19 Integration follow-ups (`feedback1`) — `render/interp.js`, `render/app.js`, `battle/runner.js`, `screens/game.js`, `sim/Battle.js foesInRadius`, `sim/professions.js`, `sim/constants.js CHAIN_RADIUS`, kits tier1–6, `content/{enemies,tokens,generic}.js`, `content/items/battle.js`, `content/bonds/core.js`, `ui/ticker.js`, `match/Match.js`, `match/bot.js`
The last review of §21.4's client fix, the cross-workstream notes and the last review rounds were checked on the merged branch; the worthwhile items with a primary source are done here (the rest: §21.20):
- **Form fx in the render engine** (review of the final §21.4 fix, major): `render/interp.js isCosmeticEvent` counted every 'fx' as cosmetic, so `processEvents` dropped one stamped > 1.5 game s behind the render clock and a full queue shed it — after a main-thread stall over ≈ 0.78 s real at 2×, on a watched replica after a hidden tab (catch-up frames of 8 game s), after Chrome's intensive throttling, and for the game screen's replayed early buffer (stamped 0). Reproduced in headless Chrome: 转译基底·α ended `dead:null:B_Die` — report #5's look. **Now** a form fx is state for the queue too; one handed out late switches the model without its telegraph, with its closing clip shortened by the lateness and without a change clip that would already have ended. The runner compacts an overflowing hidden-tab backlog (`compactHeld`) instead of re-entering the view, flushes the backlog and a last snapshot of a battle that ended while the tab was hidden, and the game screen buffers the frames of a re-sent field meta for the field on screen until it re-enters it. The browser test stalls the main thread (blocking 1 s long tasks; the old `sleep` left the renderer's ticker running).
- **Area damage and 隐匿** (PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"; 隐匿 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标"): an operator's radius area effects — profession splash, aftershocks, skill circles and zones, counting conditions — use `Battle.foesInRadius` (no untargetable, no unblocked unrevealed 隐匿 enemy); enemy-side auras / heals and physical collisions keep `enemiesInRadius`. The 逐火 embers and every 隐匿 enemy take less splash than in 0.1.0 (officially they took none).
- **Splash radii** (PRTS 溅射半径一览, 特性 / 特殊): 投掷手 0.9 (was 1.0), 链术师 jumps 1.7 (1.8; `CHAIN_RADIUS`), 格雷伊 1.0 (1.1); 迷迭香 S2 末梢阻断 1.5 (the table's skill row; was ×1.3 [ASSUMED]).
- **阿罗玛's 非首次标记** (PRTS 阿罗玛 天赋 备注 "…自身离场时移除自身已施加的标记,不同阿罗玛之间的非首次标记通用"): one mark per enemy (`aroma:bubbled`, the setter as source) shared by every 阿罗玛, removed when its setter leaves (two used to trigger on one enemy; E3 made it show).
- **奥术法阵 on the field** (PRTS 盟约记录 备注, the same as 源石溶剂's "…敌人类我方单位也会获得此装备的“造成伤害时使目标失去特殊能力5秒”效果"): 炎佑's damage silences its target for 5 s while a carrier is on the field [ASSUMED: it ends with its carriers].
- **重生 cooldowns** (PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"): every ability with a cooldown restarts from its initial cooldown when a 重生 ends (`rebirthCooldowns`: `reborn()`, the husk's 1 s 重生, the statue's instant one).
- **高准度伦蒂尼姆城防自行炮** (悬赏 target; PRTS 技能 "以攻击范围内生命上限最高的我方单位为中心,生成9格的炮击区域,6s内每0.5s对炮击区域中生命比例最高的我方单位进行攻击…(最多进行10次攻击)", 备注 失衡 / 晕眩 / 冻结 / 浮空 / 沉睡免疫 during the animation): the lock is in its range (it was any ally), the area the 3×3 tiles, 10 shots 0.5 s apart (were 3, 1 s apart [ASSUMED]), 失衡免疫 + those immunities over the bombardment [ASSUMED: the animation's length].
- **琳琅诗怀雅 S3's coins** (PRTS 备注 "前方范围2-4内的地面敌方单位与自身阻挡的所有单位"): range_table 2-4 in front plus the units she blocks (her 1-1 attack range before).
- **阿戈尔's devour** (PRTS 盟约记录 "造成5000点物理流失", 修正 "【吞噬】的物理流失来源为被付与目标自身"; PRTS 作战机制: a 物理流失 "会受到目标当前防御力…影响而相应衰减"): 5000 less the target's DEF, still a 流失 (no shields, dodge or multipliers), the kill to the marker (DEF-free [ASSUMED] in 0.1.0).
- **The ticker by priority** (research 06 §9.2, WJ's note): §21.10.
- **AI 托管 and temp** (the promise "你开 AI 托管时,它不会销毁你的道具"): on a human seat under AI 托管 an item left in temp with a full hand gets a hand slot (a bench operator sold) instead of being destroyed; only a hand of nothing but items still drops it, as the temp deadline would.
- **The bot** (§21.6): reconciled with the merged rules as listed there.
- **0.1.0 bug found in the merge**: a 联防 battle timed out during 隐德来希 S3 reported its 心烛 as leaks, so the server refused the honest client's result (fullmatch-coop3 seed 116) — candles carry `mem.noLeak` and `Battle._timeout` skips them.
- **Checked, no change**: 斩胄之剑 / 破胄之锤 stay unblockable while grounded (their PRTS 天赋 lists 不可阻挡); the database's lpr-0 enemies (简饲源石虫, 节日气球, leader parts, tokens) still cost 0 LP in a leader round (the official `lifePointReduce`); 鸭爵 swaps skip bounty-tagged spawns; 转译基底's bounty coin is paid once.
- **After the independent QA of the merged branch** (residuals, each checked against its source first):
- **WB's 22-match follow-up merged** (QA major): on the 11-match model 16 of the 58 official bounty drafts of the 22 matches could not be generated (two series-20 cards in an R3 set, named bosses apart at R9, an R11 draft without a giant); with the follow-up every one is reachable — §21.2.
- **A status of the knocking-out hit** (§21.4): 妮芙 S2's fear (and a 叙拉古 6 proc, 铃兰's slow…) landed after the 重生's cleanse — the same hit runs `if (target.alive) applyStatus` after the kill, and the husk is still alive — so an ember walked unblockable + 隐匿 for ≈ 3 s more. A unit both 无敌 and 无法选中 now takes no status from the other side (PRTS 无敌 "无法被不同阵营选中"; `Battle.applyStatus`).
- **余 S2 and 自缚** (in 0.1.0 already): 厚礼上宾 teleported a 守墓石像 in its 转换模式 onto 余's tile. PRTS 余 S2 备注 "处于消失状态的/持有自缚的单位不视为可达目标"; the statue's PRTS 天赋 lists 自缚. Flag `selfBound` (the statue, the 自缚 leaders, the 胄 parts' anchor) beside `noMove` — not `noMove` itself, which 束缚 sets too.
- **信仰搅拌机 S2** (in 0.1.0 already, [ASSUMED] then): with fewer than 30 bullets the lethal hit went through. PRTS 备注 "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态" and "自身持有不死时此效果不会生效": it blocks, spends what is left and ends; while 坚固维式重锤's 不死 window holds her (`items/battle.js holdsUndying`: this deployment's window) it steps aside (骑士戒律's earlier 不死 already did).
- **Late form fx after a hidden tab or a catch-up** (§21.4's client half): the runner sent the hidden-tab backlog and a catch-up frame's events stamped with the frame's game time, so the render engine saw a 16 s old form fx as on time and replayed the 2 s change clip on a walking 转译基底·α (the final form was right). Such a step now drains its events every `EV_SLICE` (15) ticks and each batch keeps its own game time (`battle/runner.js stepEntry / drainSlices / heldAt`); render/app.js then skips the change clip as for a stall.
- **The bot's bench shed** (§21.6): the buy loop's pre-emptive shed (one hand slot left) sold the single it had just bought for 3–5 back for 1 — the buy picks by lineup gain, the shed by piece value (co-op 绝境, 2 AI 托管 + 2 bots, seeds 21–26: 29 same-prep buy → sell, all from that line, vs 7 in 0.1.0). It now never sells a piece that came this prep (`sellWeakestHand keepFresh`); 3 remain, from other paths (a reward pick's room, `arrange`, `sellJunk`).
- **Checked, not changed**: the QA's other notes are on the user-facing list (wording) or the release packaging (the 灼热 / 炽焰源石虫 models come from the gitignored local extraction: `public/assets/local/spine/enemy/enemy_1305_mhslim{,_2}` + `data/local-assets.json`).
- Tests: `test/sim/feedback1-followups.test.js`, `test/sim/feedback1-integrate.test.js`, `test/sim/feedback1-residuals.test.js`, `test/sim/feedback1-transform.test.js`, `test/match/feedback1-runner-forms.test.js`, `test/render/interp.test.js`, `test/ui/bosshit-ticker.test.js`, `test/match/feedback1-meta.test.js`, `test/match/bot-smarter.test.js`, `test/content/{enemies_bosses,bonds_core,kits_alt_t4,kits_alt_t6}.test.js`.
### 21.20 Settled decisions and what stays [ASSUMED]
Settled by the user:
| Decision | Settled rule | One-line flip |
|---|---|---|
| A tactician re-oriented in place (§21.3) | a range-bound summon still inside the new range stays; one outside goes back to its stack (user-confirmed 2026-10-03; PRTS only says a MOVED owner resets every summon) | `PlayerState._liftOutOfRange` lifting every range-bound summon of a re-oriented owner |
| Bounty drafts (§21.2) | the official structure of each round from the user's 66 screenshots of 22 matches (R3 fixed sets, R9 boss groups, R11 seen 7-card lists; no multi-round card in a draft) | add a newly seen set / group / list to `tools/build-data.mjs BOUNTY_INITIAL_SETS / BOUNTY_BOSS_GROUPS / BOUNTY_HUNTER_GROUPS` and rebuild |
| 机密商店 / 战术决策 repeats (§21.2) | the same card can be offered twice, as officially (the user, 2026-10-03: "机密商店按官方改成可以重复吧", "战术决策也按官方改成可以重复吧"); R11 机密商店 = two VI, ≥ 1 V, ≥ 1 盟约之币 | `choices.json shopDraft` / `tacticDraft` removed (choices.js falls back to the previous draws, still with replacement) |
| 坚固维式重锤's scope (§21.21) | one 不死 lock per DEPLOYMENT: the carrier's own redeploy (after a knock-out, a 突袭 retreat + redeploy) re-arms it and ends a window still running (the user, 2026-10-03: "每次部署一次"; the text says only 首次); the lock belongs to the deployment, so a borrowed hammer (萨尔贡 × 娜仁图亚) follows the same rule | `content/items/battle.js deploymentOf` returning one key for the whole battle (one lock per battle) |
| Six 重装 skills' trigger (§21.29) | 深巡 S2, 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 cast with an enemy in range (the basic strategy, DEFAULT) instead of the official 下半 TANK row's TAKE_DAMAGE — a deliberate, documented deviation (the owner, 2026-10-03: "反馈的人太多了"; GitHub issue #4, PR #12); every other MANUAL 重装 skill, 深巡 S1 included, waits for a hit (余 S2 excepted since 2026-10-04: the owner extended the deviation to 余 S2, which casts with an enemy on its x-1 — `SKILL_RANGE`, §22.10) | remove the entry from `tools/build-data.mjs TRIGGER_DEVIATIONS` (for 雷蛇 also PR #12's `trigger: 'DEFAULT'` kit line; 深巡 S2 reads its data's `ACTIVE_RANGE` since 0.2.0) and rebuild; for 余 S2 remove its `TRIGGER_DEVIATIONS` entry (`chess_char_6_03_a`) and rebuild |
| Multi-round bounties (§20.6, unchanged) | last two battles — now only 教鞭's card | `server/match/choices.js MULTI_ROUND_BOUNTY_BATTLES = null` |
| §20.9's decisions | unchanged (start-deploy summons, 凯瑟琳's hand card, every blocker hits, the collider reach, the elite to the board, the fixed leader pool, the watched bond strip) | as §20.9 |
Official since this release (each with its source in its section): 突变细胞 not consumed, its carrier destroyed (tile freed) and the new operator gained into the 整备区 — official footage, pointed out in PR #2 (§21.1); the 深水区 refuses deployment and a tactical point stays in range (§21.3); the knock-out forms and kill accounting (§21.4); 圆仔 / 鸭爵 unblockable, +300 % speed, swaps in leader rounds, lpr 1 (§21.5); 标准's 本局禁用 bonds (§21.7); 维娜's lions on every free tile, 玛恩纳 S3 可对空 and his per-kill rule (§21.8); 静态刚体 (§21.9); the per-round BOSS_HIT share and the priority strip (§21.10, §21.19); the 变形同构体 member (§21.11); 源石溶剂 as damage, 信仰搅拌机's counters, the BUFF-damage enemies (§21.12); 野鬃's directional push and 琳琅's radial coins (§21.13); 暴鸰's drop (§21.14); diagonal routes (§21.15); 烛煌 S3's splash, 攻击距离 vs 信仰搅拌机 S3 (§21.16); medics heal while blocking (§21.17); 阵法术师 / 轰击术师 strike all in range (§21.18); area damage vs 隐匿, the splash radii, 阿罗玛's shared mark, 奥术法阵 on 炎佑, 重生 cooldowns, the 自行炮, 琳琅's coin range, 阿戈尔's 物理流失, no status on a 无敌 + 无法选中 unit, 余 S2 vs 自缚, 信仰搅拌机 S2 with too few bullets (§21.19); the R11 机密商店 composition and repeats in the 机密商店 and the 战术决策 (§21.2); M3茧甲's revive after every 不死 (§21.21); 起飞 = 对地规避 (§21.22); no ground blocking from a fenced tile (§21.23); a knocked-out operator keeps its tile and redeploys there, rule 3 included (§21.24).
Still [ASSUMED] (each in its section):
- §21.1: an elite merged this round keeps its copies' refresh count; a 拉普兰德 re-bought after a sale fires again; a bench 突变细胞 carrier transforms too; a 6阶 carrier becomes another 6阶; with the hand and temp both full the gained operator and an item without a slot follow the rules of every gain.
- §21.2: the weights inside a group; the unseen 10th R3 set and 险境 R6 drafting like R3; R9 groups by the matches they came in; R11 one of the 7 seen lists and the family weights (solo = co-op); the 机密商店 slots / weights and the earlier shops; the 战术决策 weights, independent draws and twin passives stacking; solo shows 3 of 6. Open for the user: each match's 确认本局信息 page (what decides the R9 group), more R11 悬赏决策 / 机密商店 / 战术决策, solo 绝境 / 终极 R3 / R9 / R11.
- §21.3: the range is the loadout grid (items and bonds do not widen it); the canoe in the deploy map only.
- §21.4: the translator immobile and damage-cancelled through its change; the 特战术师 form hits flyers (open for the user); the ember's 10 s after the 重生; 隐匿 back 3 s after a block ends (§22.8; it was "at once" until 0.1.1); the 重生 whitelist; “余音” unmapped.
- §21.5: the 鸭爵 swap's 0–2 uniform count and pick, the 60–99 % window, ground only, elites eligible, per-half on pair fields, decided at battle start.
- §21.6: the bot's tuning constants and `CROWD`.
- §21.7: strongest rather than earliest for 奥术, an equal re-hit refreshing it; the 本局禁用 look; 整备 next to an owned white.
- §21.8: fence tiles as 可部署地面 for 黄金盟誓; the absolute −10 % per kill of 玛恩纳 S3.
- §21.9: a 静态刚体's 0.1 s 失衡硬直 not modelled; 守墓石像's carried 浮空 runs out; which pushes reach air units without a note.
- §21.10: ≥ for "超过"; the per-battle `bossDamage` reset; a dropped line not replayed; the flow notices at priority 25; no begin delay.
- §21.11: operator talents reading the record's bonds for "【X】干员" (a 变形同构体 wearer does not count there).
- §21.12: the drain's 受击回复 SP; the chimera aura 无来源; a target-less counter = one bullet; the 炎佑 tick ending with the carriers; the drowning / bleed cadences; 心烛 and 扎罗 as 流失.
- §21.14: the alias tint; 暴鸰's cast details.
- §21.15: tile-centre movement along `next[]`.
- §21.16: the 技能范围 areas on the card, extra content tiles not drawn, the 全场 / 6 × 7 thresholds, the bonus on a dodged hit.
- §21.18: no target cap; damage at the attack instant; the beam look.
- §21.19: the 奥术法阵 rider ending with its carriers; the 自行炮's immunity window = its bombardment.
- §21.21: a 突袭 retreat + redeploy is a deployment (it re-arms the lock); an in-place 复活 (M3茧甲, 埃芒加德) is a new deployment too (PRTS's 复活 is 退场 + a 0-time / 0-cost redeploy); the 阿戈尔 devour and the carrier's own 源石溶剂 spend the lock; kits' own savers before the item's 不死; M3茧甲 before 埃芒加德; a running window ends with its deployment and outlasts the lend that started it; a free pick or grant is no 购买; a 谢拉格 already priced ≤ 1 still counts as the round's first.
- §21.22: a ground enemy's shot in flight when 蒂比 takes off lands on nothing; ground-enemy auras still apply; 流失 DoTs keep ticking; an area skill cast because allies are near counts only those it can hurt (卢西恩's 【aoe】, 锏's CircleAttack).
- §21.23: air blocking (blockFly) on a fenced tile stays; the gate reads ground passability.
- §21.24: the body goes home once, at the knock-out, only when its home is free; 初始位置 = the homes of the board pieces of every player on the field, on the field or not (a summon that has expired or been killed included); 歌利亚头盔 ignores bodies; every 退场 leaves a body (§22.15), except the 突袭 retreat, which redeploys at once.
Not done (noted, no primary source or out of scope): whether 华法琳 S2's 流失 can kill an ally (§21.17); 伊利昂的木驮兽's `Full` skin (§21.14); the render-demo recording `public/dev/recordings/normal-a1m04.json` still shows 战场#04's old L-shaped lower route (display only); M3茧甲's "同名装备的效果不可叠加" (two copies give two revives, a + b three) and two own 坚固维式重锤 giving a 16 s lock; the M3茧甲 / 埃芒加德 revive in PRTS's form (退场, then a 0-time / 0-cost redeploy: the SP reset and the other per-deployment effects — only 坚固维式重锤's lock treats the in-place revive as a new deployment) and a status icon for the 不死 window (§21.21); 深巡 S2's nearest-first order (§21.23); 【盲信之誓】 hurting once per chain (PRTS "多条锁链的伤害不叠加") and the “帝国的甲胄” barrage's timing (§21.22's audit).
### 21.21 坚固维式重锤's lock, once per deployment; 休露丝's discount (F1, F2) — `content/items/battle.js hammerAcquire / deploymentOf / holdsUndying / revivedInPlace / PRIO_REVIVE / PRIO_RESPAWN`, `content/bands/battle.js PRIO_BAND_REVIVE`, `kits/tier4.js` (信仰搅拌机 S2), `content/bands/meta.js` (休露丝), `match/PlayerState.js buy / pickReward`
- **F1 seen**: "维多利亚坚固锤子的锁血没生效". **Official** (act2autochess eff_acarm043 / eff_acgarm043): "首次受到致命伤害时生命值不低于1,持续8秒", `undeadable_duration` 8 (the latest upstream data agrees; PRTS still prints the act1 15 s); 异常效果 不死 "重设常规生命值时不会使其低于1".
- **Measured on 0.1.0, the lock works**: every HP loss of a unit without a boss pool fires `fatal` (hits of every type, element bursts, 无来源 damage, `loseHp` 流失) — on every visible chess against scaled real enemies, in forced-hammer bot matches and in the browser's local battle.
- **Why it looked missing**: it was once per battle — the hammer state lives on the unit, which a redeploy reuses, so a carrier knocked out after its lock came back with none (forced-hammer co-op matches, 3 绝境 + 4 困难: 27 of 382 carrier knock-outs); the 阿戈尔 battle-start devour (a lethal hit on the operator in front: 10 of 179 locks on 绝境) and the carrier's own 源石溶剂 spend it; with an M3茧甲 equipped first, its revive (same priority, registered first) took the first lethal hit.
- **Now**: once per **deployment** — the user's first-hand memory of the official mode (2026-10-03, "每次部署一次"; the text says only 首次 where M3茧甲 says 一场战斗 and 拉特兰桥夹 每次部署): a new deployment of the carrier (every deploy — the redeploy after a knock-out, a 突袭 retreat + redeploy, 阿戈尔's 立刻复活 — and an in-place 复活, below) re-arms the lock and ends a window still running. The `fatal` order is kits' own savers (10 … −60) → items' 不死 `PRIO_REVIVE` −100 → items' 复活 (M3茧甲) `PRIO_RESPAWN` −101 → 埃芒加德 −110: PRTS (M3茧甲 / 埃芒加德 / 阿戈尔 备注) "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零" — a revive acts on a knock-out, which a 不死 prevents. No status icon shows the 8-s window (the client has none for 不死; a one-shot fx marks the lock).
- **QA after the integration, fixed**: (1) the lock lived in the hooks of the carrier's hammer grants — a hammer lent by 萨尔贡 × 娜仁图亚 (60 s) took its re-arm hook along when the lend ran out, so a borrower knocked out while lent came back with its lock spent when lent again (real Match, 萨尔贡 6: no lock in the second deployment), and a window started through the lend ended with it (knocked out 4 s into its 8 s). The lock now belongs to the deployment — `deploymentOf` (the deploy counter plus the in-place revives) marks the deployment whose lock is spent — and one battle-level hook holds every running window (`holdsUndying`, also what 信仰搅拌机 S2 steps aside for): a borrower is re-armed in its next deployment like an owner, a lend refreshed in the same deployment re-arms nothing, and a window outlasts the lend [ASSUMED: the 异常效果 不死 outlasts its source]. (2) M3茧甲 and 埃芒加德 revive in place, which kept the lock spent, while 阿戈尔 5's 立刻复活 — a real redeploy — re-armed it; PRTS describes all three the same way ("“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零"), so under the per-deployment rule a revive is a new deployment: both in-place revives now call `revivedInPlace` and the revived carrier locks again on its next lethal hit [ASSUMED].
- **F2 seen**: a 谢拉格 taken from the promotion reward used up 休露丝's "每回合购买的首名<谢拉格>干员消耗资金为1". **Not real**: only `g.buy` dispatches `onBuy`, where every 购买 counter lives (休露丝, 玛恩纳, 升华, 整备); the promotion reward, 寻呼模块's pick and the 拟态物质 / 紧急调度券 / 简易通讯机 grants never touch it (pinned through real intents; META `onBuy`). Buying the copy that completes a merge uses it, as it should.
- [ASSUMED]: §21.20.
- Tests: `test/content/feedback1f-hammer.test.js` (per deployment, a retreat + redeploy, the devour, the M3茧甲 order, the M3茧甲 / 埃芒加德 revives, a lent hammer), `test/sim/feedback1-residuals.test.js` (信仰搅拌机 S2 and the window of an earlier deployment), `test/match/feedback1f-sciurus.test.js`.
### 21.22 蒂比's 起飞 is 对地规避 (F3) — `sim/targeting.js evadesGround / canTargetAlly`, `sim/damage.js liftoffEvades` (DamageInfo `ignoreSelect`), `Battle.applyStatus / _blockerFor`, `buffs.js` (`liftoff`), `kits/tier2.js LIFTOFF_FLAGS`, `kits/tier5.js` (隐德来希), `content/enemies.js`, `content/bosses.js`
- **Seen**: after 蒂比 took off, enemies still hit her. **Real**: 起飞 was `unit.ground = false`, which freed her from melee enemies only; ranged ground enemies kept shooting her and their areas and statuses kept landing (46 of 241 enemy types hit her while airborne; the client replica the same).
- **Official**: ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"; PRTS 术语释义: 起飞 includes 对地规避, a 无法选择 effect only selectors whose 行动方式 is ground respect, and she keeps a ground 行动方式; PRTS 异常效果: direct picks and abilities that "无视无法选择" are not stopped (these effects act "仅在选择时"); PRTS 作战机制: an AoE judges only selectable units.
- **Now**: 起飞 is the skill flag `liftoff` (S1 and S2, normal and elite). No ground enemy (not `Unit.isFlying`) selects her — normal attacks, ability picks, 周围四格 additions, chains, bounces, shells, the “帝国的甲胄” barrage, 鼠王's 沙狱, 烹泉's blast and steam, ground zones (集团军重型火炮's 燃烧区域 is "不可对空", 迷彩 alone exempted) and their statuses; the pipeline refuses a ground enemy's damage on her before the `hit` hook, so a refused hit neither sets off her S2 nor restarts 片场工作指南. She blocks flyers only; flyers, 近地悬浮 and 浮空 enemies still attack her with no 对空 check; she stays a ground unit (污染秽蚀 at the low-ground 50/s; 隐德来希 S2's 血镰 on her cuts air).
- **Still reaches her** (no selection — DamageInfo / `applyStatus` `ignoreSelect`, or 无来源): the "无视无法选择" abilities (【污染秽蚀】, 【盲信之誓】, 萨卡兹悖谬暴虐兵长's 暴击 splash — her 片场工作指南 may dodge it); 碎铳之簧's counter and 重装侦察兵's 暴露 on their attacker; the 刺胄之弹 / 剑 / 锤 blasts (flying units); the ticks of a debuff a ground enemy put on her before take-off (出血, 沙狱, burning, 淤困, 【自然涌动】 — their mods stay); sourceless damage (毒雾, the chimera aura, 源石溶剂).
- **QA after the integration, fixed**: 卢西恩,“猩红血钻”'s 【aoe】 (radius 2) and 锏's CircleAttack (first form, radius 1.5) decided to cast by counting every ally in their radius, so an airborne 蒂比 alone in range set them off and the blast skipped her (6 casts in 60 s, 0 hits on her); they count only the allies they can hurt (`!evadesGround`) [ASSUMED: no PRTS line states the trigger]. The player text (PLAYING §10, CHANGELOG) now says that ground-enemy auras / field effects and counters on her as an attacker still reach her.
- [ASSUMED]: §21.20. Audit: 蒂比 is the mode's only 起飞 / 对地规避 operator; outside F3, ranged enemies still pick invulnerable allies (锏 S3, 泥岩 S3) and enemy splash still hits 隐匿 allies (§19.1).
- Tests: `test/sim/feedback1f-liftoff.test.js` (14), opt-in `test/render/feedback1f-liftoff.browser.test.js` (`RENDER_E2E=1`), `kits_t1t2` / `kits_alt_t2`.
### 21.23 深巡 on a fenced tile; fenced units block no ground enemy (F4) — `sim/Battle.js _blockerFor`
- **Seen**: "干员深巡放在围栏围着的地块里不会主动攻击". **Official**: the fenced tiles are 围墙 (`tile_fence_bound`, "可放置近战单位,敌方无法进入") and 围栏 (`tile_fence`, "不可以通行") — LOW, buildable ALL, passable to flyers only; PRTS 围墙 / 围栏 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人"; 深巡 is a 哨戒铁卫 ("能够阻挡三个敌人,可以进行远程攻击", range 2-2); defender skills fire "受到伤害时" (PRTS 卫戍协议/帮助).
- **Not reproduced**: from a fenced tile she attacks every targetable enemy on her range tiles — every deployable fenced tile of the 7 stages that have them, every direction facing walkable ground, normal and elite; the Final Assault halves and 联防; a real Match; the browser's battle (7 attacks, 1977 damage). Targeting reads range tiles only (no path or line-of-sight condition).
- **Likely what players saw** (official): in some directions her 3-tile line faces only fence or forbidden tiles; 阻隔工事 crates hold some fenced tiles from the start (act1 m01: 3 of each 6-tile block; act1 m04, act2 m03); nothing hits her there, so her skill rarely fired (since §21.29 her default S2 casts with an enemy in range, a deliberate deviation; S1 still waits for a hit); an own-tile melee unit never attacks from a fenced tile.
- **Fixed** (found while checking F4): a push or pull stops an enemy at the fence edge, 0.5 from the fenced unit — inside the 0.7071 ground radius — and 0.1.0 let that unit block it (薄绿 S2 on a fenced tile held the enemies she dragged). `_blockerFor` now refuses ground blocking from a tile ground units cannot pass (on the stages exactly the `b` tiles).
- [ASSUMED]: §21.20. Tests: `test/sim/feedback1f-fence.test.js`; PLAYING §10 tip.
### 21.24 Knocked-out operators keep their tile (F5) — `sim/Battle.js downOn / restTile / _layBody / isReservedTile / redeploy / _checkRedeploys / _downState / snapshot`, `sim/units.js body`, `content/bonds/addon/battle.js` (突袭, 不屈), `content/bonds/core.js` (阿戈尔), kits tier1 / 3 / 4 free-tile helpers, `render/interp.js`, `render/units.js setDown`
- **Seen**: "突袭盟约的干员会移动到濒死干员的尸体上". **Real**: a knocked-out operator left the occupancy map at once and only its home tile stayed reserved — a 突袭 member that fell after its jump lay on its landing tile, which the next jump picked; a timed redeploy came back on its home, where a teammate that had jumped there lay (96 real battles: 6 placements on a body; the shared boss field mirrored: 38).
- **Official** (PRTS 卫戍协议/帮助 §作战阶段 单位部署): "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置"; "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止"; "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置". The 突袭 text "再部署至一名地面敌人周围" adds no tile rule.
- **Now**: a knocked-out operator lies on `body` — where it fell, or its own home when it fell on another board piece's home and its home is free (x / y / tile keep where it fell for the 被击倒时 handlers). Every redeploy comes back there (timed, 不屈, 阿戈尔), also for an operator forced out by its own effects (余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3, a 商人 that cannot pay; §22.15); only the 突袭 retreat leaves no body. `downOn`: `_deploy` (redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse the body tile for every ally; `isReservedTile` reports it, so the 突袭 landing, tactical points, summon / device pickers and 维娜's lions skip it. b.snap `down` carries the tile (§8.2) and the client draws the body there; WAIT_TILE matches the `_checkRedeploys` hold-back and is a safeguard. After the fix: 0 placements on a body in 526 jumps. **Superseded**: "later redeploys use the board tile" — an operator moved off its board tile (a 突袭 jump, 乌尔比安's anchor) now redeploys where it fell; research 01 §4.3 carries the PRTS quotes.
- **QA after the integration, fixed**: rule 3 counted only the board pieces that were not removed, but a summon leaves its home free only once it has expired or been killed (a docked one keeps its tile reserved), so the 召唤物 clause never applied — a 突袭 member that landed on the home of 浊心斯卡蒂's expired 海嗣 and fell there lay on it and kept the 海嗣 (远古血亲) from coming back. Every board piece's home counts now, on the field or not. Cost: the body bookkeeping added about 2.5 % per tick on 519 real specs (8 % on boss fields; whole matches and the perf budgets unchanged); `_checkRedeploys` now checks the DP before the tile scans, so an operator waiting for DP costs no ally scan — on the same 519 specs, against the integration head, the same tick counts and boss fields 11.9 → 10.9 µs per tick (a tile → body map is left for later).
- [ASSUMED]: §21.20. Tests: `test/sim/feedback1f-downed-tile.test.js` (rule 3 for a summon that has expired or been killed), `test/render/feedback1f-down-tile.test.js`, opt-in `test/render/feedback1f-down-tile.browser.test.js`; `downelem`, `bonds_addon`, `engine-requests`, `robustness` follow the rule.
### 21.25 Hardening credited to pull requests (PR #2, PR #7, PR #10, PR #14) — `match/effectsMeta.js dispatch / stillEquipped`, `tools/fetch-assets.mjs shrinkGuard`, `tools/assets/manifest.mjs droppedEntries`, `assets.js loadImageElement`, `ui/guide.js preload`, `css/screens/loadout.css`
- **The prep dispatcher's equipped items** (pointed out in PR #2, whose 突变细胞 bench rule §21.1 now follows — official footage confirmed it —, its merge path excepted): step 5 iterated `holder.items` live while handlers move and destroy pieces — normal 博士投影 destroys itself at the round start (the splice skipped the next item on that holder), 突变细胞 transforms its holder (the next item ran with a holder that was gone). It now walks a snapshot of the owned chess and of each holder's items and runs an item only while it is still equipped on its still-owned holder; a piece gained or equipped meanwhile waits for the next dispatch (effectsMeta header, META §2.2). No shipped item has a second hook on those events, so play is unchanged; a synthetic spy handler pins the rule (`test/match/feedback1-dispatch-snapshot.test.js`). QA after the integration: the items were read per holder as the walk reached it, so an item equipped meanwhile onto a holder not yet walked ran in the same dispatch and an item moved to a later holder ran twice (latent: no shipped item equips or moves items on those hooks); every [holder, item] pair is now taken before the first item runs, and two more synthetic cases pin it.
- **fetch-assets never shrinks the committed manifest by accident** (pointed out in PR #7, whose `data/assets.json`, regenerated on a machine where some downloads failed, was 42 audio entries short — all 42 still resolve upstream): a rebuilt manifest leaves out every entry whose files are missing on that machine. When it would lose an entry of the current `data/assets.json`, the run keeps the current file, prints the entries it would drop (also the report's `droppedEntries` / `manifestWritten`) and exits 1; `--allow-shrink` (or `--prune`) writes it (ASSETS.md; `test/fetch-assets-shrink.test.js`).
- **crossOrigin on the two `new Image()` loads** (PR #10, merged on GitHub): with `/assets/` served from another origin (a CDN or object storage) an image loaded without CORS approval taints the canvas and breaks the WebGL textures (an empty map, a white 3D board). `assets.js loadImageElement` — the asset store's only image path, §21.27's seeded store included — and `ui/guide.js preload` set `crossOrigin = 'anonymous'`; a same-origin page is unaffected.
- **The loadout screen's background layer** (pointed out in PR #14): `css/screens/loadout.css` `.lo > * { position: relative }` — one class, a later rule — overrode `.lo__bg { position: absolute }`, so the mint glow and grid layer was a 0-px flex item and never drew. Now `.lo > :where(:not(.lo__bg))`: the same one-class specificity for every other layer, never the background. Checked in headless Chrome at 1920×1080 and 844×390 (touch): the layer covers the overlay and every other box of the screen is where it was (to 0.1 px). Tests: `test/ui/loadout.test.js` (the cascade), opt-in `test/ui/loadout.e2e.test.js` (`SP_E2E=1`: the layer's box and stacking at both sizes).
### 21.26 Gaps found while triaging GitHub issues #1 / #8 (v0.1.1)
Where: `ui/gameLogic.js morphPairings / HARMONY_BOND / harmonyMembers`, `ui/detailPanel.js MorphPairings / MorphGrantLine / ItemDetail / ChessDetail / BondChips`, `ui/bondStrip.js BondPopup / BondStrip`, `match/bondsMeta.js computeBonds / bondList`, `shared/bandBonds.js` (→ bands.json `bondIds`), `match/gamedata.js bandBondIds`, `match/bot.js botPickBand`, `screens/bandDraft.js BandOffTag / BandOffNote`, `ui/gameLogic.js bandOffBonds`, `tools/botbench.mjs` (`--band`), `match/fields.js layerAllowanceOf`, `public/dev/game-mock.js` (variants `funny` / `morph` / `harmony`). Three gaps the user decided to close in 0.1.1 (no rule changes), and a false rejection of honest client results found on the way.
- **变形同构体 shows its pairings** (issue #1: players read the "转职球" as doing nothing). Its text says "具体对应关系可在模拟中查看本装备天赋栏", and no screen showed that 天赋栏. **Now** the item card (shop, reward, hand / temp: `ItemDetail`) has a 天赋 section: "搭配以下装备时,携带者视为对应盟约的成员:", then one line per bond in bonds.json order — 【bond】 and the items whose `giveBondId` is it (one name per item family, by tier): the 14 pairings of the official talent (character_table trap_1073_acarm073, "“炎国短刀”→【炎】盟约 …"; a test checks the two against each other), its "“维式重锤”系列装备" as the 5 hammers — 18 items, built from the same `giveBondId` the server counts with (`morphPairings`; `pieceBonds` / `grantedBonds`). A bond the mode never activates is struck through with 本局禁用 (`modeOffBonds`, §21.7; 标准: 拉特兰 阿戈尔 卡西米尔 奥术 突袭). On a wearer's card (its 装备 rows; also a teammate's unit and the card a 同构 row opens) the pairing it wears is highlighted — 生效中, or 已搭配 when that bond is off — and a wearer without one reads 暂未生效. A bond item's card and row say "与变形同构体一同装备时,携带者视为【X】成员" (`MorphGrantLine`). The 机密商店 cards have no detail view and keep the official text. Phones: the list keeps ≥ 9 px (px floors in `game-panels.css`).
- **调和's +1 explained** (issue #1: "一些不考虑整备区的盟约计算人数时也会把整备区的干员算进去" — the +1 read as a miscount; §21.11 had left "调和's +1 raises 在场 for core bonds without a member row"). The rule is unchanged (§6.3; one +1 total, research 02 §2.1 [ASSUMED]). **Now** a bond state whose count holds the +1 carries `harmony: 1` (`computeBonds`) and both views send it — m.private `bonds[]` and m.public `players[].bonds[]` entries `{ …, harmony? }` (absent otherwise; not in the battle input, `bondSnapshot`) — so the client never re-derives the rule. The popup says 在场 n(含调和 +1) and a 调和 row heads its member list, naming the 调和 operators on that board (`harmonyMembers`: 缪尔赛思, Pith's 盟约·辅助干员; a tap opens the first one's card; with that board not on screen "调和已激活:核心盟约激活人数 +1") — for your own strip and a teammate's (§20.15: the entry from m.public, the operators from the field on screen). The strip disc's and the card chip's titles add (含调和 +1).
- **Bots skip strategies built around a switched-off bond**: in 标准 the bot took 潘格尼尼 (【定制铳械】 an elite <拉特兰>), 克莱门莎 (<阿戈尔>) or 玛恩纳 (<卡西米尔>) in 5 of 200 solo and 23 of 800 co-op picks (seeds 1–200). bands.json `bondIds` holds a band's tie, read from its own data at build time (`tools/build-data.mjs` through `shared/bandBonds.js`; `GameData.bandBondIds` and the client read the field) — the bond names in <…> of its text (the official note "在<X>部分干员缺席时体验可能不完整" among them; a bracketed item or enemy adds nothing) and the bond ids / bond pools of its blackboards (`bond`, `bond_id`; `pool` → choices.json `pools[pool].bond`, 潘格尼尼's pool_char_later) — nine bands are tied (杜遥夜 炎, 佩佩 / 娜仁图亚 萨尔贡, 哈洛德 维多利亚, 休露丝 谢拉格, 潘格尼尼 拉特兰, 克莱门莎 阿戈尔, 玛恩纳 卡西米尔, 贾维 叙拉古). `botPickBand` weighs a band tied to a bond of `gd.modeInactiveBonds` 0 — one rng draw per pick, the default band when every offered band is excluded: 0 of 200 and 0 of 800 in 标准, while the other modes keep exactly the earlier picks (绝境: 5 / 200, 23 / 800). `tools/botbench.mjs --band` still forces any band.
- **The strategy draft marks them 本局禁用** for human players (the owner's follow-up): a band whose `bondIds` meet the mode's inactive bonds (`gameLogic.bandOffBonds`, the same field and rule as the bot — a test pins the two in every mode) carries a 本局禁用 tag on its card and, in the detail pane, "本局禁用【拉特兰】盟约,此策略效果可能无法发挥" with the bond struck through (§21.7's look); it stays selectable — the official game lets you pick it.
- **Found on the way — the client-result layer bound** (`match/fields.js layerAllowanceOf / validateClientResult`): with the bots' new picks the full-match suite's coop/FUNNY/107 (4 humans on AI 托管) reached a 6-谢拉格 freeze board at R12 (初雪 + 银灰: garrison_28, 50 % +1 per freeze in range, no per-battle cap in the data; 凛御银灰 hands out a 60 % +2) whose honest +112 谢拉格 layers passed the flat 60 + 4·12 = 108, so the server rejected the client and re-simulated (SP_VERIFY=all agrees with the client on all 57 battles of that match). Uncapped traits are common (菲莱 / 百炼嘉维尔 per skill, 斯卡蒂 per 2 kills, 莫斯提马 per 6 bullets …) and capped ones reach 100–200 (史尔特尔, 荒芜拉普兰德), so the flat bound now adds, per bond, what the player's IN_BATTLE layer 特质 can add to it — each trait of its units and each one their ADD_BOND traits hand out (once per operator), on the bonds it names, up to the cap the sim applies (`max_add_count_per_battle`, the handed-out 华法琳 trait's `GRANTED_CAP_OVERRIDE`); a trait without a cap — or a 魔王, whose +extra counts toward none — leaves its bonds bounded only by the 999 room. A forged gain on any other bond meets the flat bound as before.
- Normative lines rewritten with it: §6.3 (`harmony` in the views), §6.6 (the band pick), §8.2 / §8.3 (`bonds[]` entries `harmony?`), §14 / §20.12 (the layer bound), §21.11 (its 调和 note).
- Tests: `test/ui/feedback1-gaps.test.js`, `test/match/feedback1-gaps.test.js`; opt-in `test/ui/feedback1-gaps.e2e.test.js` (`SP_E2E=1`; 1920×1080, 844×390, 756×366; `SP_E2E_OUT` moves the screenshots).
### 21.27 Client fixes for GitHub issues #5 and #8 (v0.1.1) — `ui/gameLogic.js prepCameraFor / foldCamera`, `screens/game.js`, `ui/fieldHost.js hudBands / cornerBand / seedAssets`, `render/app.js`, `css/devices.css`, `assets.js`, `render/units.js`
Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritten with them: §9 (`setCamera` `shop`, the model fallback), §17.1 (the quiet budget), §19.8 (the collapsed shop).
- **#5 seen**: "准备阶段,收起商店界面时,界面并不会进行缩放(观战及作战时无此现象)". **Real**: folding the shop (收起) was UI state only — the own prep camera was requested once on entering prep, never with `shop`, so render/projection.js's official shop-collapsed camera (`left_prepare_camera_param` / `*_boss_prepare_*`, `presetCamera` `shop: false`) was never used, and `hudBands` always reserved the bar (measured: 110 px per tile at 1920×1080, 36 at 844×390, 31 at 756×366, the same folded).
- **Now**: while the own prep board is on screen and the bar is shown folded, the game requests `prepCameraFor(pub, myId, folded)` — the prep camera with `shop: !folded` — whenever the fold changes, with the normal 0.75 s flight (`foldCamera`): never over the pen (it folds the bar itself and returns to the camera it left), on a teammate's board, in battle or for an eliminated player (no bar); deferred while a piece is dragged or its direction is chosen (the drop target and the wheel sit on tiles of the camera in use) and applied when that ends; unfolding returns to exactly the shop camera; the fold still resets at combat start. The view hands the request's flag to `hud(kind, size, { shop })` (also on resize). The folded band is the higher of the tab (`.shopbar-tab`: .8rem + 3 px, above the bottom safe-area inset) and the corner buttons' hit areas (交流 / ⚙ / 📖 / ⛶, measured by `cornerBand`; on touch screens a 34 px button takes taps 5 px above itself, `--tap-min` 44 px): with the tab alone the corner covered bench pad 0 at 756×366 and the near edge of pads 0–2 at 844×390. Below 768 CSS px the corner wraps into two rows only because the open bar would cover it; with the bar folded it stays on one row (css/devices.css). Picking reads the camera of every frame. [ASSUMED] the folded band (the official collapsed HUD is not in the extracted bundles).
- Measured (headless Chrome; px per tile at row 10; every bench pad fully pressable open and folded, also the Final Assault prep halves): 1920×1080 109.8 → 130.7 (the official camera at its own scale, panned up 46 px to clear the tab); 844×390 35.9 → 44.3; 756×366 (touch) 31.4 → 40.1. A real drag deploy and a tap on a piece work folded; a drag or the wheel defer the flight; scouting (the 'normal' camera), the battle camera and the next prep are unchanged.
- **#8 item 5 seen**: "游览器切换页面后所有干员会变成方块+水滴的图标" — the image-less placeholder (render/textures.js) on every operator. **A plain tab switch does not reproduce it; a reload does** (phone browsers and Chrome's Memory Saver discard background tabs and reload them on return; the game resumes into the match): the asset store downloaded /data/assets.json a second time beside the game data's copy, `createFieldView` waited ≤ 4 s for it, every view looked its model and avatar up once in its constructor, and a failed download kept `{}` for the page — placeholders for good. Related without a reload: a battle that began in a hidden tab built no views (they are built in animation frames), nothing referenced a skeleton, and the quiet budget (§17.1) unloaded all of them ≈ 18 s later, so back in the tab every unit was an avatar diamond until its model downloaded again; a model that failed or timed out (20 s) was never retried.
- **Now**: the field host seeds the asset store with the game data's copies (`seedAssets` → `assets.seed` / `seedLocal`; the match screen already waits for them, so no second download). Without one, `ready()` retries a transient failure after 600 ms and 2 s (like data.js), then resolves `{}` without keeping it: the next `ready()` and a bounded background backoff (5 / 15 / 30 / 60 s) fetch again, and `onChange` tells the field view when a manifest arrives late — every view re-resolves what it could not draw (`UnitView.retryAssets`; prep item icons through `setPrep` / `ItemView.setIcon`). A view whose model failed or timed out loads it again after 2 / 6 / 15 / 30 s (`SPINE_RETRY_MS`; frames only, so never while hidden), `acquire(entry, { retry: true })` dropping the remembered failure; showing the tab again retries at once (also a missing manifest). Battle mode holds the Spine cache (`spine.hold()`, from `enterBattle` to the next `setPrep` or destroy): no quiet budget meanwhile, the 48 MB idle budget still applies, and the quiet budget keeps freeing everything in the lobby, room and result screen (the policy is in the assets.js header).
- Verified (headless Chrome, no console errors): a reload with every assets.json request delayed 6 s, with only a second download delayed (the 0.1.0 race: none is made now) and with the first 4 requests failing — operators drawn as Spine models at once; the first 6 failing — placeholders until the store's first background try 5 s later, then Spine models; a battle started in a hidden tab, back after 25–30 s (still running, ended meanwhile, or paused with Spine downloads slowed to 4 s) — every unit a model within 0.3 s of the return, no diamonds (before: the cache was empty from ≈ 18 s); six solo rounds with two battles started hidden — the cache follows the roster (4 → 24 skeletons, 6.9 → 25.2 MB estimated, idle ≤ 11 MB, under the 48 MB budget), never a diamond or a placeholder.
- Tests: `test/ui/issue5-fold-camera.test.js`, `test/render/issue8-placeholders.test.js`, `test/ui/playtest5-ui.test.js` (wiring), `test/render/assets.test.js`; opt-in `SP_E2E=1 node --test test/ui/mock.e2e.test.js` (fold / unfold at 1920×1080 and 844×390).
### 21.28 Match info in the strategy draft (GitHub issue #8 item 1, v0.1.1) — `ui/matchInfo.js`, `screens/briefing.js`, `screens/bandDraft.js draftInfoStatus`, `ui/enemyDrawer.js InfoTab`, `css/screens/draft.css .minfo-dlg`
- **Reported** (public issue #8, item 1, "选策略时没法返回查看禁用的干员和盟约"): the disabled bonds and the banned operators were shown only in the INFO_CHECK briefing, but a strategy is chosen partly by them. The owner took it into 0.1.1 as a remake addition (no official screen is copied; the look is the briefing's).
- **One component**: `ui/matchInfo.js` — `matchInfoModel(pub, { bonds, chess, mode })` (pure: `gameLogic disabledBondSets` over `config.modes[modeId].inactiveBondIds` — the drawn set D "阵容不完整" and the mode's static inactive bonds "本局禁用", e.g. 标准's 10; every bond by `bondOrder`, then `identifier`, split 核心 / 附加; the banned operators of m.public `bannedChess`, known chess only, by tier; banned members per bond) and the hookless blocks `MatchBondRow` / `MatchLegend` / `BannedOperators` / `MatchInfo` (greyed disc with ✕, the red badge, the `briefingBondTip` tooltip, the legend, the 本局禁用干员 grid). The briefing's right column is `MatchInfo`; the in-game 本局信息 tab (left 🔍) reads the same model for its compact chips and avatars (its banned list is now by tier, like the briefing's).
- **The draft**: 查看禁用盟约与干员 under the order list opens `MatchInfoDialog` — `MatchInfo` in the components.js `Modal`, read-only, closed by 关闭, a tap outside or Esc (a press on the backdrop closes it on `mousedown`, so the click never reaches what lies under it — e.g. 确认选择). The draft runs on underneath: the dialog's footer repeats the turn and its seconds (`draftInfoStatus`: 轮到你决策 / X 决策中 + the step header's number, orange at ≤ 10 s; 已选择「…」after the own pick); a turn change (a pick, a skip, a turn that runs out, an AI pick) or the own pick closes it; the end of the draft unmounts it with the screen; it never changes the highlighted band (`g.bandFocus`) or the buttons.
- **Sizes**: on desktop the briefing's own sizes (1920×1080: no scrolling); every size in the dialog is max(design rem, px floor), so on phones (1rem = 40 px; 756×366, 844×390) the text stays ≥ 9–15 px, the dialog fits the safe area and its body scrolls under a fixed header and footer.
- Tests: `test/ui/match-info.test.js` (the model, greying by state, badges, tips, the legend, banned by tier, real 标准 / 绝境 matches, `draftInfoStatus`, briefing / draft / drawer reading the one module); opt-in `test/ui/match-info.e2e.test.js` (`SP_E2E=1`: solo 标准 on desktop — dialog = briefing, the three ways to close, a confirm afterwards, the in-game tab; co-op 绝境 with a desktop and a phone — the live countdown, a turn change closing it, 844×390 / 756×366 layout and scrolling, the draft's end unmounting it).
### 21.29 Deliberate deviation: six 重装 skills cast with an enemy in range (GitHub issue #4, PR #12) — `tools/build-data.mjs TRIGGER_DEVIATIONS / resolveTrigger / validateAll`, data/chess.json `skills[].trigger`, `kits/tier1.js` (深巡, 雷蛇), `kits/tier2.js` (灰毫), `kits/tier5.js` (号角)
- **Reported**: public issue #4 — 深巡 (哨戒铁卫, range 2-2) never cast her default S2 行动能力剥夺 until something hit her, in practice until she blocked; PR #12 (merged on GitHub) gave 深巡 S2 and 雷蛇 S2 `trigger: 'DEFAULT'` in their kits. Many players asked the same of other defenders; the owner (2026-10-03): "反馈的人太多了".
- **Official, unchanged**: the 下半 (act2autochess, 2026-03) `skillTriggerDataList` row `TANK | | | 0 | TAKE_DAMAGE` and PRTS 卫戍协议/帮助's 重装 strategy "不受技能范围影响,受到伤害时释放技能" (下半, 2026/3/14) — a class row, covering every MANUAL 重装 skill whatever its index (§20.2); 上半 (act1autochess, 2025-11) had no TANK row, so 重装 skills used the basic strategy then (research 03 §1.4 keeps the history). PR #12's reasons — the row as "skill 1 only" and "offensive skills activate when an enemy is in their skill range" — rested on a community summary (arknights.wiki.gg), not on the CN client or PRTS; that quote is removed from research 03 (promised on PR #12).
- **Decision** (the owner, 2026-10-03): a deliberate, documented deviation — six offensive 重装 skills take the basic strategy (DEFAULT: SP ready, about to attack, an enemy inside the initial range or one it blocks): **深巡 S2 行动能力剥夺, 雷蛇 S2 反击电弧, 号角 S2 暴风号令 and S3 终极防线, 灰毫 S1 攻击力强化·γ型 and S2 专注轰击**. Every other MANUAL 重装 skill keeps TAKE_DAMAGE — 深巡 S1 侵袭破坏应对 included.
- **Where**: in the data, so the server, the browser battle and the bots read one rule. `TRIGGER_DEVIATIONS` is keyed by the normal chess id and the skill id (the elite record follows through its `baseId`), never by the skill id alone: 灰毫 S1 is the generic `skcom_atk_up[3]`, which 惊蛰, 幽灵鲨, 耶拉, 莫斯提马 and 莱恩哈特 carry too. `resolveTrigger` applies it last; the 12 records read `trigger { rule: 'DEFAULT', rawRule: 'TAKE_DAMAGE' }` — `rawRule` keeps the official row for traceability — and `validateAll` fails the build when an entry no longer meets a TAKE_DAMAGE row. PR #12's kit lines (`trigger: 'DEFAULT'` on 深巡 / 雷蛇 S2) stay, agreeing with the data (a test pins kit = data); their comments and the contributor's tests now give this section's reason.
- **Measured** (a real battle: 战场#01(下半), one 萨卡兹枯朽前锋 walking the lane from the gate, the operator SP-full on (9,5) facing it): 深巡 / 雷蛇 S2 cast 6.2 s after the spawn, when the enemy steps onto the 2-2 line, and 号角 S2 / S3 and 灰毫 S1 / S2 within 1.3 s (their 要塞 range reaches the gate) — each before anything has hit it (the enemy reaches the defender after ≈ 24 s, if at all); each of the other 46 MANUAL 重装 records (normal + elite) casts on the first hit it takes, at 23.8 s.
- **Players notice**: these six fire sooner and more often — from range, before anything reaches the defender; 号角 S3's 过载 HP drain therefore starts sooner; 灰毫 S2 (block 0) lets an enemy pass as soon as one comes into range, not after the first hit; on a fenced tile (§21.23) 深巡's S2 fires.
- One-line flip: remove the entry from `TRIGGER_DEVIATIONS` (for 深巡 / 雷蛇 also the kit line) and rebuild.
- Normative lines rewritten with it: §5.6 (`TAKE_DAMAGE`), §19.2 (灰毫 S2), §20.2 (players notice, the table), §21.20 (a settled row), §21.23 (深巡 on a fenced tile).
- Tests: `test/sim/feedback1-tank-triggers.test.js` (the data, kit = data, the real battle for the six and for every other MANUAL 重装 record), `test/data.test.js`, `kits_t1t2` / `kits_alt_t1` (PR #12's), `kits_alt_t2` (灰毫 S2), `kits_t5` / `kits_alt_t5` (号角), `test/docs-consistency.test.js`.
- Extended 2026-10-04: 余 S2 (SKILL_RANGE on its x-1, §22.10); 14 records deviate now.
### 21.30 干员战斗语音: an operator's official lines, in battle only (user request, 2026-10-03) — `tools/assets/plan.mjs` (`voiceSlots`), `tools/assets/audio.mjs` (`indexVoice` / `VOICE_SLOTS` / `VOICE_BATTLE_SLOTS` / `VOICE_PREP_SLOTS` / `VOICE_DIRS`), `tools/assets/cache.mjs`, `tools/fetch-assets.mjs --voice-lang / --voice-all`, `public/js/audio.js` (`VOICE_PRIORITY` / `VOICE_COOLDOWN_MS` / `VoiceGate` / `resultVoiceSlot` / `resultSpeaker` / `voice()`), `server/sim/ai.js performAttack 'engage'`, `battle/runner.js emit('result')`, `screens/game.js onResult`, `ui/detailPanel.js voice`, `ui/settings.js`, `ui/gameLogic.js sanitizeSettings`
User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」.
**Where the lines come from.** Every operator the mode can field already has official battle lines in the same voice dump the BGM and SFX come from — `voice_cn/{charId}/cn_nn.mp3` (JP/EN/KR: `voice/`, `voice_en/`, `voice_kr/`, the same file names, `tools/fetch-assets.mjs --voice-lang=jp|en|kr`). `charword_table.json`'s `placeType` says when each line is used, and `indexVoice()` turns it into the manifest's `audio.voice[charId]`: 行动出发 `start` (BATTLE_START), 行动开始 `faceEnemy` (BATTLE_FACE_ENEMY), 选中干员 `select`, 部署 `place`, 作战中1-4 `skill1..skill4` (BATTLE_SKILL_N), 编入队伍 `squad`, 任命队长 `squadFirst`, 结算 `resultFour/Three/Two/Lose` (完成高难行动 / 3星结束行动 / 非3星结束行动 / 行动失败) and 干员报到 `gacha`. A slot with several lines stays an array and the client draws one. Only the base word key is planned (`wordKey === charId`): the dump has no folder for a skin variant's word key (`char_x_ita`, `char_x_epoque#28`), so those lines 404. **Only the slots a battle can play are planned** (`audio.mjs VOICE_BATTLE_SLOTS`): 干员报到 / 编入队伍 / 任命队长 are lines of the official client's own 养成 / 编队 UI, nothing in this mode ever requests them (`test/docs-consistency.test.js` checks the client's `voice(...)` call sites), and downloading them cost 360 files (19.3 MB) on every run — `--voice-all` adds them back for the complete official set. Measured: 120 of the 121 in-mode charIds have a full set — the 17 预备干员 and the mode's own 盟约·辅助干员 have no battle voice at all, so they simply get no entry — 1680 lines of the 2040 upstream, in a manifest of 5694 files / 324.2 MB (files and `stats.voiceChars` in `data/assets.json`).
**Where each line plays — battle only.** Every line comes from a running battle's own event stream or from its settlement, so no hook needs a phase check and the 休整期 stays silent: the shop does not say 干员报到 (a merge-completing buy neither says 任命队长), a retreat does not say 编入队伍 and dragging a piece onto the board in prep says nothing. In battle: 开战 — the first operator deployed says 行动出发 and the others 部署 (the sim's `deploy` events; `setFieldUnits` resets the flag per field — in this mode every piece enters at once, which is why the gate below exists, and a knocked-out operator's redeploy says 部署 again); 接敌 — the sim's new `['engage', id]` (a unit's first attack that hits an enemy, official ENCOUNTER_ENEMY); 技能 — `skillN` from the unit's `skillIndex` (作战中1-4); 选中 — the detail panel opening on an operator **while a battle runs** (`DetailPanel voice` = the game screen's `combat`); 结算 — `resultVoiceSlot()`: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动), 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败, spoken by a drawn operator **of that battle's own field** (`resultSpeaker`, from the result's `perPlayer.unitsEnd`: the operators of that field, survivors first, a summon piece never), driven by the runner's new `emit('result', …)` — never the field on screen, so watching a teammate cannot put their operator's voice on the viewer's settlement line (review on #73). `unitsEnd` names each unit by its chess id (`chess_char_*`), so `onResult` passes the chess record's `charId` as `resultSpeaker`'s `charOf`; as merged, the speaker looked for `char_*` ids only and no battle ever said its settlement line (found while porting #73 to 0.2.0; `test/ui/audio.test.js` runs a real battle result through it).
**Why a channel, not SFX.** Voices are 1–4 s long and a battle deploys eight operators, then fires dozens of skills; playing them like SFX would flood the mix. `VoiceGate` copies the official scheduling data (`audio_data.json battleVoice.voiceTypeOptions`): one line at a time, a 1.2 s global gap, a per-unit per-slot cooldown (10 s for 作战中, 3 s for 接敌, 1.5 s for 选中) and takeover when a line's priority beats the playing one by a margin (the official 0.1 s `crossfade`) — the priorities are the official ones (BATTLE_START 100, ENCOUNTER_ENEMY 90, SKILL_ACTIVE 70, PLACE_CHAR 20, FOCUS_CHAR 10) and the settlement lines get 85, above 作战中 but below 接敌. Every deferred step of a line — its decode, its `onended`, the safety timer — carries its own token and returns without touching the gate once it is stale, so a line taken over while it was still loading can never free the channel its replacement just took (review on #73). The channel has its own gain and its own slider (设置 干员语音 / VOICE; `sanitizeSettings` adds `voice`, so a saved profile without it keeps the default and players can turn voices down without losing SFX). The buffer cache also gained a decoded-PCM budget (64 MB) beside its 180-entry cap: a voice decodes to ~0.4–1.3 MB, and 180 of them would be ~100 MB.
**[ASSUMED].** 接敌 as "the unit's first attack on an enemy" (the official trigger is the first enemy it encounters, not necessarily one it hits) and the settlement mapping (漏怪 ⇒ 非3星, 完美作战 in 绝境 / 终极 ⇒ 完成高难行动) are our readings. Lines follow the field on screen: watching a teammate's battle (前往查看) plays *their* operators' lines, while the settlement line fires for the player's own battle only (`e.own`, `battle/runner.js`) and is spoken by an operator of that battle's own field (`resultSpeaker`). 部署 and 选中干员 stay in the plan although the official groups them with the prep lines: a battle *does* play them (the first operator says 行动出发 and the others 部署, and the detail panel says 选中 while a battle runs), so only the three slots no call site ever asks for are dropped — `VOICE_PRIORITY` / `VOICE_COOLDOWN_MS` keep every level, so `--voice-all` restores 编入队伍 / 任命队长 / 干员报到 for the day the 休整期 speaks again.
- Tests: `test/ui/audio.test.js` (the priority table, the settlement slot and its speaker, the gate's global gap / per-unit cooldown / takeover, the stale-callback regression, and the battle events that drive the lines against a fake Web Audio), `test/ui/gameLogic.test.js` (the `voice` setting), `test/sim/battle.test.js` (the wire format: every emitted tuple is a known `EV` — the new `engage` included); the manifest's 1680 voice paths are covered by `test/assets.test.js`.
+236
View File
@@ -0,0 +1,236 @@
# DESIGN §22 — 0.1.2 — GitHub issues after 0.1.1
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 22. GitHub issues after 0.1.1 (v0.1.2)
Public GitHub issues filed after the 0.1.1 release (2026-10-03), each triaged against 0.1.1 first; the real ones were fixed for 0.1.2 in fix branches WA–WL, each independently reviewed before it was merged into `feedback2`. Where each is handled: #25 back-facing operators keep their attack pose when knocked out → §22.1; #51 突袭's idle redeploy → §22.2; #33 items 1–2 阿戈尔's devour and 乌尔比安 S3 → §22.3; #33 item 6 活性源石, with the 沼泽 and 深水区 terrain → §22.4; #42 emotes and guide pages without a local client, and the English title (#38) → §22.5; #32 item 4 钩索师 / 推击手 on 高台 → §22.6; #52 史尔特尔's 余烬 → §22.7; #43 an enemy's 隐匿 after a block, and the 深池逐火 ember window → §22.8; #32 item 7 荒芜拉普兰德 S3's drones → §22.9; #32 item 1 余 S2 → §22.10; #44 归溟幽灵鲨's 替身 → §22.11; #32 item 6 enemy splash against a 隐匿 operator → §22.12; #35 / #16 (PR #48) 要塞 against air units and 莫斯提马's slow icon → §22.13; #58 / #60 / #64 follow in §22.14–§22.16. Decisions of the owner, 2026-10-04: 荒芜拉普兰德's drones measure the distance to an enemy's position, a huge enemy's centre (§22.9); 余 S2 casts with an enemy on its x-1 — a deliberate deviation from the official TAKE_DAMAGE row, added to §21.29's six (§22.10); 乌尔比安 keeps the devour gains through his 【移动】 — the deviation stays (§22.3).
### 22.1 Back-facing operators play their fall (GitHub #25) — `render/units.js _wantsBack / _syncModel / die / revive / _acquireSpine / _dropActor / dieClipDur`, `render/spine.js SpineActor.die / dieClip / deployElapsed`
- **Seen** (public issue #25, "部分干员被击倒后在部署状态时仍有攻击动作"; the screenshot: 跃跃 seen from behind under its redeploy ring, still in its attack pose, while 拉普兰德 beside it lies down; the reporter also saw it on 拉普兰德, 异德 (缄默德克萨斯), 斯卡蒂 and 德克萨斯, "并非稳定出现" — all five have a Back skeleton without a Die clip). **Real** in 0.1.0 and 0.1.1, drawing only (the sim had the operator down; it never attacked): an operator deployed facing UP is drawn with its Back model (§9, research 07 §5.5), and 131 of the mode's 135 Back skeletons (data/assets.json) have no Die clip — only 薄绿, 玛恩纳, 松果 and 预备干员-重装 have one, while every operator's Front skeleton has one. Not every time: only operators facing UP.
- **Official**: PRTS 卫戍协议/帮助 says a knocked-out operator stays on its tile as a "倒地干员" (§21.24); which model the official client lies it down with is in no source. The project's asset plan already had the rule — research 07 §5.4 "`die`: `Die` (if the Back model lacks it, swap to the Front model for the death animation)", docs/ASSETS.md Roles `die: null` — but the renderer never implemented it. [ASSUMED] the official client does the same (a Back skeleton without a Die clip cannot show a fall), and the four Back skeletons with a Die clip fall with it.
- **Cause**: `UnitView._wantsBack()` ignored whether the unit stands; `SpineActor.die()` found no clip, returned 0 and changed nothing, so the attack / skill / idle loop playing at the knock-out went on under the ring for the whole respawn timer.
- **Now**:
- `_wantsBack()`: facing UP with a Back model, while the unit stands; a dead or knocked-out one keeps the Back model only when that skeleton has a Die clip (`dieClipDur` of its manifest entry), so the other 131 fall and lie with the Front model (unmirrored, as facing UP).
- `die()` and `revive()` mark the view, and its next `update()` — after every event and the snapshot of that frame (render/app.js) — runs `_syncModel()`: the model the state wants is loaded when it is neither shown nor loading, and swapped in place like a direction change (no avatar diamond in between). A death and a redeploy handed out in the same frame load nothing: a 突袭 jump (retreat + redeploy in one tick), a 不屈 revive (from the death hook), a backlog delivered after a hidden tab.
- The Front model replays the state: the Die clip where it would be (`dieT`), a knocked-down one holding its end; `die(true)` and a view built for a unit already down start on the held end (the 联防 entry `FORCED_EXIT`, a field entered late, the re-entry from the field meta after an overflowed hidden-tab backlog). Its first frame gets the knocked-down grey (`_dropActor` drops the cached tint; a swapped-in model used to keep the previous model's tint state). A view that gets no `down` entry (summons and devices) fades like one facing RIGHT — a forced-out operator facing UP lies down like a knocked-out one (§22.15): the Front model's Die clip times the fade (`_fallDur`).
- The redeploy (a `deploy` event: timed, 不屈, 阿戈尔 — every `Battle._deploy` — or a revive seen only in the snapshot) brings the Back model back; a deploy clip the Front model is playing goes on on the Back model where it is (`SpineActor.deployElapsed`).
- Safety net: until the Front model is there, or when it fails to load, the Back model holds still — `SpineActor.die()` on a skeleton without a Die clip stops whatever looped and holds the first frame of its idle clip (frozen when it has none either), and nothing restarts an attack on a dead actor. The 20 summon / enemy skeletons without a Die clip (e.g. 浊心斯卡蒂's 海嗣) now fade holding still instead of looping [ASSUMED look].
- Unchanged model choice: operators facing RIGHT / DOWN / LEFT, enemies and summons (no Back models in the manifest: `_wantsBack` is false for them; only the safety net above changes how their idle-only skeletons die), the redeploy ring, the sim. Nothing changes in render/interp.js or battle/runner.js: `die` / `deploy` are state events every client stage keeps (runner `keepsState`, game.js `keepEarly`, interp `isCosmeticEvent`), and b.snap `down` / `fieldMeta` rebuild the view of an operator already down; both now reach the model swap.
- [ASSUMED]: the Front model for the fall and the knocked-down pose (above); the Back skeleton's own Die clip when it has one; the held first idle frame of a skeleton without a Die clip.
- Tests: `test/render/issue25-downback.test.js` (fake PIXI with the real manifest and asset helpers: the manifest counts; the five operators of the report lie down with their Front model's Die; an UP knock-out mid-attack — the Back model holds still, then the Front model's Die is held under the ring, greyed, every acquire paired with a release; RIGHT unchanged; the redeploy back to the Back model with the deploy clip carried over; the snapshot's DIE anim and a snapshot revive; a death and a redeploy in one frame load nothing; a view built for an operator already down and the 联防 entry lie on the held end; a withdrawn one fades like a RIGHT one; 薄绿 keeps its Back Die clip; the SpineActor safety net; enemies and summons unchanged); `downring`, `feedback1f-down-tile`, `unitview`, `forms`, `test/ui/unitFacing.test.js` unchanged.
### 22.2 突袭's idle redeploy lands where it can attack (#51) — `content/bonds/addon/battle.js raidPoll / raidTile / raidReach / raidTargets`, research 02 §3.18
- **Seen** (GitHub issue #51, on 0.1.0): a 突袭 member idle for 10 s was redeployed even when no landing tile let it attack an enemy, so it hopped every 10 s between useless tiles; and the idle trigger always went for the most advanced enemy, even when another ground enemy could have been reached. **Real** (reproduced on 0.1.1): with one speed-0 enemy that no landing tile reaches (it stands on plain floor, and a RIGHT-facing melee member reaches it only from that tile or the floor tile behind it), the member jumped at 10 / 20 / 30 / 40 s between (10,9) and (10,8) beside it and never attacked; a second, reachable enemy was ignored. On the five real runs of the F5 test (`test/sim/feedback1f-downed-tile.test.js`) 26 of 60 jumps landed with no enemy in range — e.g. three members jumping to one enemy whose two reachable tiles the first two took.
- **Official**: the bond text (activity_table bondInfoDict / effectInfoDataDict; PRTS 盟约记录, no note under it) "【突袭】干员10秒内未进行攻击或技能就绪时,若范围内没有敌人,则保留技力立即再部署至一名地面敌人周围" names no tile rule; whether an official jump can land out of range is not known (the reporter: the text "按字面允许落在不能立即攻击的位置").
- **Cause**: the covers-the-target requirement of `raidPoll` bound only the 技能就绪 trigger (`if (!idleOk && !tile[2]) continue;`), and `raidTile` scored covering tiles first but still returned a non-covering one when none covered — so the idle trigger jumped to its first candidate whatever the tile.
- **Now** (the owner's decision — the reporter's proposal; stricter than the 「优先」 of our first reply on #51: a covering tile or no jump, never a fallback landing): both triggers jump only to a landing tile from which the member's attack range (its own grid + 攻击距离 rangeExtend, turned by its direction; a huge enemy counts on any tile it occupies) covers the chosen ground enemy. The poll walks the candidates in the existing order — its own field's ground enemies (another field's only while its own has none), the most advanced (least remaining path) first, at most the 8 most advanced — and takes the first one that has such a tile (the nearest within 2 tiles, the facing-RIGHT tie-break as before). When none has one the member stays where it is: its idle time keeps counting and the 0.25 s poll looks again, so it jumps as soon as an enemy can be reached. After a jump the idle time restarts from the landing (then from each attack). Unchanged: never onto a knocked-out operator's tile (§21.24), never into the 深水区 (§21.3, `grid.canStand`), SP kept, a real redeployment (部署时 effects), the ATK / HP bonus.
- **Cost**: a search that finds nothing now repeats every 0.25 s, so it is cheap: `raidTile` looks only at the tiles a range offset leads back to from the enemy's body (before: a 25-tile scan building a range per tile), and a player's members share one sorted candidate list per poll (rebuilt after a jump). On the heavy 2-player boss field of `test/sim/robustness.test.js` (≈ 130 enemies, 72 on the ground) with 18 members that never reach anything — every poll searches — the bond costs +5.8 µs per tick, as much as 0.1.1's +5.5 µs with its jumps every 10 s (the requirement alone on the old scan: +29.6 µs). Whenever a covering tile existed the landing tile is the old one (200 000 random cases: grids, 攻击距离, directions, huge bodies).
- **Players notice**: a member with nothing it can reach waits on its tile instead of hopping; it passes over an unreachable leading enemy for one it can hit; when several members go for one enemy and its reachable tiles are taken, the rest wait. The five F5 runs: 35 jumps instead of 60, none out of range.
- [ASSUMED]: "never a useless landing" (the text says only 再部署至一名地面敌人周围; issue #51); the cap of 8 candidates (a member waits when the 8 most advanced ground enemies are all out of reach — the 技能就绪 path's bound since 0.1.0, kept for the cost); the target order and the 2-tile search (research 02 §3.18, now written as above). Issue #49 (a passive skill never counts as 技能就绪) is unchanged and separate: it decides when a member may jump, this section where it lands.
- Tests: `test/content/bonds_addon.test.js` "突袭 #51" ×2 (no reachable landing: neither trigger jumps in 45 s, then a reachable enemy → both jump within one poll; an unreachable leading enemy is passed over for the second, which it attacks, and the next jump comes the idle time after its last attack); the other 突袭 tests (`bonds_addon`, `feedback1-water`, `feedback1f-downed-tile`, `facing-invariance`) unchanged.
### 22.3 阿戈尔's devour skips members it has already knocked out (#33 item 1) — `content/bonds/core.js devour`, `content/items/battle.js deploymentOf` (exported through `content/items.js`), `kits/tier5.js` (乌尔比安 S3, #33 item 2)
- **Seen** (GitHub #33 item 1, "阿戈尔阵营复活机制看起来不会触发"): the 5-tier revive works on its own (5 distinct 阿戈尔, one lethal hit each: the first 3 come back on their tile at full HP, normal and 联防 battles alike), but the usual food chain used it up at t = 0. One row, all facing right, 浊心斯卡蒂 → 乌尔比安 → 幽灵鲨 → 海霓 → 深巡 → 隐现: the battle-start devour knocked out 幽灵鲨, 海霓 and 深巡, the revive brought each straight back (all 3 charges), and 乌尔比安's marks still pending on them knocked them out a second time — down until their 70 / 80 s redeploy, no revive left for the fight (联防 battles alike).
- **Official** (PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 阿戈尔 备注): "标记按付与顺序触发【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果"; the 5-tier "前3名【阿戈尔】干员首次被击倒时立刻复活" (bonds.json `max_free_respawn_cnt` 3), implemented as "受益者因移动之外的原因退场时下次部署的再部署时间和费用归零". So a devour knock-out is a first knock-out and spends a revive, and the revived member takes no further mark.
- **Cause**: `devour()` skipped a mark only while its target or marker was dead (`!t.alive || !m.alive`); a member revived in the same pass was alive again, so its pending marks fired. 埃芒加德's revives went the same way (it revives in place: the next pending mark knocked the revived unit out again and spent a second charge), and so did 不屈's 立刻重新部署 (the redeployed unit was knocked out again in the same instant, where 不屈 does not fire twice, and stayed down).
- **Now**: when the marks are placed, the pass records each target's deployment (`items/battle.js deploymentOf`, the deploy counter plus the in-place revives — exported for this). A mark is skipped once its target is off the field or in another deployment, i.e. was knocked out during the pass — whether the 5-tier revive or 不屈's 立刻重新部署 redeployed it or 埃芒加德 / M3茧甲 revived it in place. The marker side is unchanged since 0.1.0 (PRTS describes only the target): a marker off the field gives no further mark (withdrawn in 0.1.4: a marker knocked out during the pass still resolves its marks, §24.7), one knocked out and back in the same pass still gives its marks. The chain above: 浊心斯卡蒂's five marks resolve, the three members fall once each, every later mark on them is cancelled. (0.1.2 revived all three, spending the 3 revives at t = 0; since 0.1.3 they belong to the first 3 members by position, not to the first knocked out — here 幽灵鲨 comes back and 浊心斯卡蒂 / 乌尔比安 keep theirs for the fight, §23.20.) When nothing brings a devoured unit back during the pass (no 5-tier revive, 不屈 redeploy, 埃芒加德 or M3茧甲), the devour resolves exactly as before.
- **联防 (#33 item 3, not changed)**: the members knocked out twice at t = 0 were still down at the end of the 45–60 s own combat (70 s redeploy), so they entered every 联防 knocked out (forced out, §19.3) and the chain there formed without them. After this fix that is far rarer: with the 5-tier, the members the devour knocks out (up to 3) stand again and enter 联防 down only when the fight knocks them out once more (with 3–4 members no 阿戈尔 revive brings them back, as before). The order of the 联防 forced exit and the battle-start devour is forced exit first (§23.2).
- **乌尔比安 S3 (#33 item 2) — seen**: "乌尔比安3技能结束返回会清空阿戈尔阵营增益,转职突袭后疑似不会返回(未转职时三技能会在原地留下一个图标,转职后无图标)". **Official** (PRTS 乌尔比安 S3 备注; the chess skill is `skchr_ulpia_3`, skill range 6-1 = his own tile and the 6 ahead): the anchor's target is "自身所在地块(仅阻挡敌人时)>技能范围内距离自身最近的包含敌人的地块>技能范围内距离最远的地块"; the 【移动】 goes to the landing tile > the tile beyond it > his own tile, and "若目标地块不为当前地块,会在原地部署一个从不混淆的方向"; "【移动】后仅继承下列效果:技能进度、第二天赋叠加层数". **Cause**: the anchor search started one tile ahead, so while he blocked — typical right after a 突袭 landing next to the enemies — he threw ahead, walked off the enemy he blocked (the blast could miss it — with an enemy one tile ahead its 1.8 radius still reaches the one he blocks) and left the marker. **Now**: while he blocks an enemy the anchor lands on his own tile: the blast (radius 1.8) hits what he blocks, he stays put, no marker, nothing to return from. His own tile is a candidate only while he blocks ("仅阻挡敌人时"): without a blocked enemy the search starts one tile ahead, exactly as in 0.1.1, so a flyer over his tile does not keep him there. Considered and not taken: reading the own tile of the 6-1 range as distance 0 for the second rule. Measured with the triage's batch (8 real stages × 5 round templates × 2 seeds, 突袭 through 变形同构体 + 突袭手雷), before the 突袭 landing change (#51, §22.2) is merged: with 突袭, 56 of 84 casts used to move him off his landing tile, now none does — 82 of the 84 came while he blocked, the other 2 (act2 m03, tile (10,8)) have no standable tile ahead (pre-existing); without 突袭, 64 of 80 casts still move him, as before.
- **乌尔比安 S3 — not reproduced** (the triage, real data): no 阿戈尔 gain is cleared on the return. The devour's base ATK / block (`bond:egir:devour`, e.g. +399 ATK, +1 block), the 阿戈尔 HP and the 血脉的哺养 stacks are there before, during and after the S3 (`Battle.relocate` keeps every buff); what ends is the S3's own +40–60 % HP / +200–230 % ATK. With 突袭 he does return and the marker is drawn, both on the landing tile next to the enemies, not on his board tile (8 stages × 5 round templates × 2 seeds); with this fix a cast while he blocks there leaves no marker at all, as officially. **Deviation, kept**: officially the 【移动】 keeps only the skill progress and the second talent's stacks, so the official game drops the devour gains on the move; the remake keeps them through the 【移动】 and the return.
- [ASSUMED]: (1) a marker revived during the pass keeps the base ATK / block it gained at marking (a persistent buff, as through every knock-out since 0.1.0). (2) With the anchor on his own tile, 乌尔比安's 【移动】 does not try "the tile one beyond the landing" (he would otherwise step one tile forward).
- Tests: `test/content/bonds_core.test.js` (the chain in a normal and a 联防 battle: one knock-out and one revive each, all five standing, the next knock-out final; a marker revived by the 5-tier revive still gives its marks; a target 埃芒加德 revived in place takes none — one revive spent, not two; a target 不屈 redeployed takes none and stays standing), `test/content/kits_t5.test.js` (乌尔比安 S3 while he blocks: the anchor on his own tile, no move, no marker; a flyer over his tile, not blocked: the anchor ahead and the move, as in 0.1.1).
### 22.4 活性源石 stays on an enemy after it leaves the tile (#33 item 6) — `server/sim/content/devices.js` (`touchInfection`, `enterTerrain`, `terrainFor`, `tickMire`, `deepWaterDamage`), `content/enemies.js` (`rebirthCleanse`, `kitDeepsea`), `tools/build-data.mjs` (`special.mire`)
- **Seen** (public issue #33, other part 3: 「红地板图敌方单位经过后,红地板附带的效果会消失」): on 战场#04 an enemy lost the red floor's effect the moment it walked off it. Measured on v0.1.1 (the real stage): an enemy on (10,6) from 15.67 s to 19.67 s lost 4 × 70 HP; 12 s later it still had 99720 / 100000 HP and no ATK / ASPD bonus.
- **Official**: the red floor is 活性源石 `tile_infection`, the only one in the season's data — 战场#04 (`act1autochess_m04`) at (10,6), (11,6), (10,14), (11,14) and on the boss rows at (3,6), (4,6), (3,14), (4,14); level blackboard `damage` 70, `atk` 0.2, `attack_speed` 20, `duration` 300. PRTS 特殊地形 tile template (`特殊地形/trapper/tilesformat.json`): "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}真实伤害,攻击力提升{atk×100}%,攻击速度增加{attack_speed}" (main-story stage pages: "在五分钟内"). The crisis-contract tag `global_tile_infection_1` 「目标:可控感染」 (PRTS 切尔诺伯格 6区废墟/历史合约): "踏过活性源石地块的敌人不再持续损失生命值" — without it an enemy that has walked over the tile goes on losing HP. Same-name effects do not stack (PRTS 作战机制: "同名buff的默认叠加策略buff只能表现出一个"). Its damage is 环境伤害 (PRTS 自然环境 lists 活性源石). A 重生 clears every buff but a whitelist (PRTS 特殊机制 §重生 "清空自身身上除白名单外所有Buff"; its 非首次标记 row: "如无特殊说明,也默认同常规Buff一样可被重生清除").
- **Cause**: `devices.js` read `duration` as a lifetime of the tiles (`terrainFor` switched them off after 300 s of battle time) and tied the effect to standing on the tile (`enterTerrain` removed it as soon as the unit's tile changed).
- **Now**: every tick a unit is on the tile (allies by their tile, ground enemies by their rounded position; flyers, hovering / levitated and hidden units never touch it, as before), `touchInfection` gives it the timed effect `terrain:infection` for `duration` s — 70 true damage per second, ATK +20 %, ASPD +20 — or, when it already carries it, gives it back its full time. Leaving the tile no longer removes it: an enemy that crossed it drains 70/s with ATK +20 % / ASPD +20 for 300 s from its last contact — longer than any own-field or 联防 battle lasts (≤ 230 s of battle time). All 12 ground legs of 战场#04 (own field, 联防 field, boss field) cross one of these tiles, so every ground enemy there carries it once past the tile. One effect per unit: a second pass or the next tile of a pair restarts its time and never adds a second effect (ATK +20 % once, 70/s, no extra tick). The tiles never switch off — the old lifetime only ever ran out on the boss / hidden fields (no time limit), at 300 s. An operator deployed on it is always in contact, so it drains until it is knocked out or withdrawn, past 300 s too (this follows from the [ASSUMED] refresh rule; v0.1.1 drained it the same way within the first 300 s). An operator moved off the tile (`Battle.relocate`: 乌尔比安's S3 move and return, 夕's 小自在) keeps the effect for its time — 乌尔比安 landing on (10,6) carries it home for 300 s (measured by the review); leaving the field (knock-out, withdrawal, 突袭's jump) drops it with every other buff. A 重生 (`rebirthCleanse`: 逐火 embers, 假想敌:再生's 傀儡, 锏, 守墓石像 …) clears it like any buff, and contact gives it again while the enemy stands on the tile. Kept through the 重生 (the first version of this fix), its 1 s ticks — each a hit of a husk's 特殊生命值 — killed a 深池逐火战士 ember that had crossed (11,6) at ~6 s instead of letting it stand up at 1 + 10 s (护卫's 火灰 at ~11 s, 傀儡 at ~16 s; found by the review). The damage itself is unchanged: true damage no unit deals (无来源), tagged `terrain` (环境伤害), a damage instance, not a 流失.
- [ASSUMED]: the time counts from the last contact (it is refreshed every tick on the tile) and the per-second rhythm runs on through a refresh — hence an operator on the tile drains past 300 s; contact gives the effect again to an enemy reborn on the tile; not 持续伤害 (no `dot` tag: PRTS 伤害分类's list of BUFF damage names 深水区, 源石污染区 and 活性源石风暴 but not 活性源石, so 锡人's 凋敝魂灵 does not raise it).
- **Checked**: no other terrain reads a `duration` — 沼泽 (layers, cleared on leaving), 深水区 (`sea_drown[enemy]` has none), 烟雾 and the blowers; 沼泽's interval and 深水区's damage kind were wrong, though (below). Nothing relied on the old lifetime: the client draws the tile glow from the stage map (it never went out), terrain effects have no status icon, the enemy abilities that care about the tile (萨卡兹枯朽前锋's arts attacks, “灵幛”, 孽罪奇美拉) read the tile they stand on (`onTerrain`), and 联防 carries no effects (the HP ratio and the SP only, §23.1). 纠缠藤蔓 (`enemy_2052_smgia`, a 悬赏 enemy: "受到来自自然环境的伤害时,自身在短时间内获得高额脆弱"; content/enemies.js reads the `terrain` tag) that has crossed the tile stays ×2 fragile for 300 s and the tile's tick hits it for 140/s — official, PRTS 自然环境 listing 活性源石 as 环境伤害.
- **Other terrain found while fixing #33 item 6** (the review):
- **沼泽, official** (PRTS 沼泽控制 备注; skill_table `sktok_mire`): a unit in the mire "每秒将会触发一次" its 【陷入沼泽】 (the skill charges in `maxChargeTime` 1 s): an enemy gains 1 layer of ASPD −5 % and move speed −5 %, "若其重量大于等于3,改为获得2层"; any other unit 1 layer of ASPD −5 % only; at most 10 layers (`max_stack_cnt`); "上述减益于单位不再位于沼泽之中时解除". The skill's `value` 3 is that 重量. **Cause**: build-data and the devices.js fallback read `value` as the interval (3 s): 10 layers after 27 s for light and heavy enemies alike, and operators got the move part too. **Now**: `special.mire` has `intervalSec` 1 (the skill's charge time) and `heavyWeight` 3 (`value`); `tickMire` triggers on entering and then every second — 1 layer, 2 for an enemy of 重量 ≥ 3 (`s.massLevel` at the trigger), operators the ASPD part only, at most 10, cleared on leaving: 10 layers after 9 s (重量 ≥ 3: 4 s). [ASSUMED] the first trigger comes on entering (as before). A layer stays −5 ASPD (the remake's reading of the fraction, `aspdOf`).
- **深水区, official** (PRTS 涨潮控制 技能3 深水: "【水蚀】的敌方单位每秒受到40点无来源真实持续伤害(不属于环境伤害,不会触发受击回复)"; PRTS 自然环境: 涨潮's damage to enemies "并非环境伤害"; PRTS 伤害分类 lists 深水区/涨潮水蚀 as BUFF damage). **Cause**: the deep-water tick shared the `terrain` tag with 活性源石, so 纠缠藤蔓 turned ×2 fragile in deep water (360 HP lost in 5 s instead of 200) and 锡人's 凋敝魂灵 ("受到的持续伤害提高") never raised it. **Now**: `deepWaterDamage` — 无来源 true, `canDodge: false`, `sourceless`, `noSp`, tags `dot` / `periodic` / `deepsea`, no `terrain`. The 免疫水蚀 swimmers (潜水员) cancel only `deepsea` / `drown` damage, not 环境伤害.
- Tests: `test/content/tokens_devices.test.js` — 活性源石 (the operator and the enemy standing on it), an enemy keeps the effect after leaving the tile (20 s later: 70/s, ATK, ASPD, the time left), one effect per unit (two passes give one effect that ends `duration` s after the last contact, 70/s while it lasted; an operator drains for 3 × `duration`, keeps the effect after `Battle.relocate` and loses it `duration` s later), a 重生 clears it (a 深池逐火战士 that crossed the tile stands up from its ember after 1 + 10 s), 沼泽 (the data; operators ASPD only; 重量 1 vs 3: 1 vs 2 layers a second, at most 10; cleared on leaving), 深水 (the tick's tags; 纠缠藤蔓 not fragile); `test/content/enemies_bosses.test.js` (潜水员: the deep-water tick is cancelled, 环境伤害 is not).
### 22.5 Emotes and guide pages without a local client; deployment docs (#42); the English title (#38) — `tools/assets/plan.mjs UI_EXTRAS / GUIDE_PAGES`, data/assets.json `ui['emoticon/<dir>/<picId>']` / `ui['guide/<key>']`, `data.js artUrls / nextArtUrl`, `ui/emotes.js emoteArtUrls / EmoteArt`, `ui/guide.js guidePages / GuideHost`, `tools/setup.mjs LOCAL_ART_FALLBACK / LOCAL_ART_COPY_HINT`, `tools/doctor.mjs`, README, docs/DEPLOY.md §6, `server/index.js` banner, package.json `name`
- **#42 seen**: "游玩时左下角的六页表情全部显示为默认图标" — a Linux server deployed from source (git clone, setup with the asset download, Node 22); copying `public/assets/local/` and `data/local-assets.json` from the release bundle fixed it. **Real**: both are extracted from a locally installed client (§13; setup extracts only when it finds one), and the emote art and the 玩法说明 pages came from there only, so a source deploy without the client drew the neutral glyph for all 36 emotes and the official tips text instead of the 19 pages. README, DEPLOY.md, setup and doctor said the local art did not matter ("不影响游戏", "其他功能不受影响") — wrong: the 3D board, part of the official HUD art and two enemy models fall back too.
- **Source**: the mirror the pipeline already uses (ArknightsAssets2 `cn`, assets/dyn) has all 55 files — the six theme bundles' `ui/emoticon/theme/[uc]<themeId>/icon/<picId>.png` (the 36 battle sprites, keyed by display_meta_table picId) and `arts/guidebookpages/[pack]autochess/autochess_{home 1–9, shop 1–6, handbook 1–4}.png`. Checked 2026-10-03: every URL answers 200 on raw.githubusercontent.com and on the jsDelivr fallback; the same pictures as the local extraction, each at the size of its local copy (all 55 compared; the pages 1024² squashed — still shown at 16:9), pixel differences only from the platforms' texture compression.
- **Now**: the asset plan downloads them like any other UI sprite (UI_EXTRAS, built from shared/constants.js EMOTE_CATALOG and GUIDE_PAGES — a test keeps GUIDE_PAGES equal to guide.js GUIDE_CHAPTERS), keyed by the data/local-assets.json group and name: `ui['emoticon/<dir>/<picId>']` → `/assets/ui/emoticon/<dir>/<picId>.png`, `ui['guide/<key>']` → `/assets/ui/guide/<key>.png` (55 files, 21.3 MiB; the download ≈ 270 MB, was ≈ 250 MB). The committed data/assets.json lists them (regenerated by a real fetch-assets run: only these 55 entries, `hash` and `stats` changed), so `npm run setup` — which the start scripts run on every start — on an install made before them finds 55 files missing and runs fetch-assets; §21.25's conventions hold: ledger, retries, the jsDelivr fallback, and a run where they fail keeps the committed manifest (shrink guard), so the next setup tries again. The client looks both manifests up by the same names (`data.js artUrls`): the local-client picture first, then the mirror copy; `EmoteArt` and the guide viewer move to the next URL when an image fails to load (`nextArtUrl`: a copied data/local-assets.json without its files falls through to the mirror copy); when none is left they keep 0.1.1's fallback — the glyph, and for a 玩法说明 page the official tips text (`guideStage`), also when data/assets.json lists the downloaded pages but the files are not on disk (a `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter tabs, dots and keys still reach the other pages. `localAsset` stays local-only.
- **Docs now**: README, docs/DEPLOY.md (new §6 本地客户端素材, the 排错 rows), docs/PLAYING.md, docs/ASSETS.md and the setup / doctor messages say what still needs the client — the official 3D board (→ the 2D board with procedural tiles), part of the official HUD art (the emote button and panel frames, the pause plate, the equip-replace dialog, the loadout screen, the team status and miss tags, module type icons … → CSS look-alikes, icons or text) and 灼热 / 炽焰源石虫's models (→ the tinted web alias, §21.14) — and README, DEPLOY §6 and doctor that a server without the client can copy `public/assets/local/` and `data/local-assets.json` from the release bundle of the **same version** (both are read per request: no restart); setup's row, which the launcher prints on every start, only points to DEPLOY §6. The Docker parts are unchanged (deferred by the owner).
- **Not done here**: the mirror also has most sprites of the three autochess UI bundles that the local `ui/battle` / `ui/common` / `ui/outer` groups hold (by name 377 / 389, 137 / 160 and 659 / 659 under `ui/autochess/[uc]autochess{battle,common,outer}/**`; the emote panel frames, `matte_pause` and `bg_miss_enemy` checked at the local sizes), so the HUD row of DEPLOY §6 could shrink in a follow-up.
- **#38, the English title** — **official**: EN client data, activity_table `basicInfo.act2autochess.name` = "Stronghold Protocol: Alliance" (CN 卫戍协议:盟约; ArknightsAssets/ArknightsGamedata `en/gamedata/excel/activity_table.json`, 2026-09-29 — Kengxxiao/ArknightsGameData_YoStar stops at EN client 31.5.80, 2025-11-13, before this season, with act1vautochess "Stronghold Protocol" only). **Now**: the README title and English summary, the server start-up banner and the package name (`stronghold-protocol-alliance`, package.json and the lockfile) say Alliance instead of "Covenant"; the Chinese title 卫戍协议:盟约 and the repository name stay. "Covenant" remains only in 新约能天使's official appellation ("Exusiai the New Covenant") and its buff key `angel2:covenant`. Only the title is settled here: #38 itself (a full English UI) stays open.
- **PR #27 follow-up**: tools/doctor.mjs's comment now says directly that tun0 / tap0 are matched first by VPN_IF (substring `tun\d` / `tap`) and classed as vpn — it spoke of no longer listing `^tun\d`, which master's VIRTUAL_IF never had; comment only.
- [ASSUMED]: none.
- Tests: `test/assets.test.js` (the 55 plan entries and URLs, a rebuilt manifest, the committed manifest lists them), `test/ui/localArt.test.js` (the lookup order, a server without the client, `guideStage`, GUIDE_PAGES = GUIDE_CHAPTERS), `test/fetch-assets-shrink.test.js` (a failed mirror run keeps the manifest), `test/static-local-art.test.js` (the docs and messages), `test/version.test.js` (the English title), `test/doctor.test.js`.
### 22.6 钩索师 and 推击手 on 高台 (#32 item 4) — `tools/build-data.mjs PLACE_ON_RANGED_RE / branchTraitText / validateAll`, chess.json `placement`, `match/board.js positionClass / basePositionClass`, `ui/gameLogic.js piecePosition`, `match/bot.js isBlocker / planLayoutSteps`, `tools/balance.mjs`
Withdrawn in 0.1.3 (§23.35): only elite 歌蕾蒂娅 carrying HOK-Y may use a 高台. The paragraphs below are what 0.1.2 did.
- **Seen**: public issue #32 item 4 — "哥蕾蒂娅无法部署到高台". 0.1.1 refused every 高台 tile for her (server `BAD_TILE`; the client lit no 高台 and said 近战单位只能部署在地面), and the same for 崖心 (钩索师) and 见行者 (推击手), normal and elite.
- **Official**: the branch trait of both 钩索师 chess (歌蕾蒂娅 `chess_char_4_12`, 崖心 `chess_char_2_03`) reads 「技能可以使敌人产生位移\n可以放置于远程位」, the 推击手 见行者's (`chess_char_3_07`) 「同时攻击阻挡的所有敌人\n可以放置于远程位」 — character_table `description` (their `trait` is null: the branch's own text), position MELEE; act2autochess `charChessDataDict` sets only status, bonds and garrisons for them. PRTS 新人入门 (branch table): 钩索师 and 推击手 "可部署在高台和地面". PRTS 卫戍协议/帮助 §战斗部署: "所有行动内远程干员可部署在近战位", and 部署效果 do not apply in this placement — "携带Y模组的教官仍无法部署至高台位". That 教官 ability is a module talent ("可以额外部署在远程位", battle_equip_table uniequip_003_pallas / uniequip_002_doberm / uniequip_002_rdoc, blackboard `buildable_type` 2), a deploy effect; the 钩索师 / 推击手 line is the branch trait itself — no talent, no blackboard, and every module of the three keeps it (HOK-X, HOK-Y, PUS-X). No Y-module 教官 is in the pool (诗怀雅 has INS-X). No source states the 钩索师 case for 卫戍协议 in so many words: **the remake reads the branch trait as still applying** [ASSUMED]; the old "melee never on ranged-only tiles" was itself an assumption (research 03 C2).
- **Cause**: build-data copied position MELEE and nothing read 可以放置于远程位; `board.js positionClass` mapped MELEE to 'melee' (melee tiles only), so `PlayerState._legal` refused the 高台 (`g.move` → `BAD_TILE`), and the client mirror (`gameLogic.js piecePosition` / `tileAllows`) agreed.
- **Now**: build-data sets `placement: 'all'` on a chess whose branch trait without module (`branchTraitText`: the trait candidate's text, else the description) contains 可以放置于远程位 — exactly 崖心, 见行者 and 歌蕾蒂娅, normal and elite (6 records, the only data change; `validateAll` allows it on a MELEE record only; module-only text never counts). `positionClass` returns 'all' for them, so the server takes them on every deployable tile — `g.move`, swaps, a merge's elite (`mergeTile`), the terrain re-check (`_evictIllegal`), invariants and audit —, and the client's `piecePosition` 'ALL' lights the 高台 while dragging; every other melee unit keeps the refusal and its text. The battle keeps position MELEE: on a ground tile they block as before; on a 高台 (`unit.ground` false) they block nothing and attack from there — the sim needed no change (act2 m01, one 萨卡兹枯朽前锋 on the lower lane, 40 s: 歌蕾蒂娅 on (10,4) facing it 2 hits, no block, on (9,5) 12 hits, blocking; 崖心 and 见行者 alike; no errors).
- **Bots** read the record's own position (`basePositionClass`): they still count these units as blockers (`isBlocker`; with the widened class they would not, and on act2 m01 the planner put 歌蕾蒂娅 and 崖心 on the 高台 as non-blockers, leaving 见行者 the only blocker on the road) and plan them on the ground tiles, as in 0.1.1 [ASSUMED]: offered the 高台, the planner still chose road tiles in the cases measured, but the lineup's blocker count assumes they block and a greedy 高台 pick could take a ranged dealer's tile — not benchmarked. `tools/balance.mjs` uses the same blocker test. Tokens are unchanged; the sim's automatic placements (the 突袭 landing tile, summon / device tiles) still treat them as melee, ground tiles only [ASSUMED].
- **Players notice**: 歌蕾蒂娅 can be dragged onto a 高台 (PLAYING §4); there she attacks but blocks nothing. 崖心 and 见行者 get the same rule but are hidden chess this season (`isHidden`, never in the pool, the shop or a 突变细胞 roll), so only 歌蕾蒂娅 is reachable in play; 见行者's elite PUS-X refund (half the DP of a redeploy onto a ranged tile, kits/tier3.js `!unit.ground`) is therefore moot.
- On a 高台 the effects that read the tile (`unit.ground`: the 只攻击地面单位 enemy, 污染秽蚀's 高地 rate) treat her like a ranged operator up there; position-based effects keep her 近战 (精准 tier 2, 诗怀雅's talent) — since 0.1.3 the effects worded 地面干员 too (不屈, the 战栗维式重锤 proc, 休谟斯 回收利用: 地面干员 = the melee position on any tile, see §23.28; the tile reading 0.1.2 [ASSUMED] here was wrong).
- Normative lines rewritten with it: §3 (placement), §6.6 (the bot); research 03 C2; DATA §2 (`placement`).
- Tests: `test/match/feedback1-placement.test.js` (#32-4: the data rule, cross-checked against character_table when the cache exists; `g.move` onto (10,4) / (11,4) / (12,4) of 战场#01(下半) for the six records; a 重装 refused with its text; ranged unchanged; client highlights = the server's tiles; swaps; the merge's elite on the 高台; the battle input and the sim on / off the 高台; bots), `test/match/board.test.js` (`positionClass` / `basePositionClass`), `test/ui/gameLogic.test.js` (the client mirror).
### 22.7 史尔特尔's 余烬 holds 禁疗 (GitHub #52) — `kits/tier5.js` (史尔特尔 余烬 / S3), `sim/damage.js heal`, `sim/buffs.js` (`healFree`), `render/style.js STATUS_ICON`, `render/textures.js STATUS_KEYS / drawStatusIcon`
- **Seen** (GitHub #52 「史尔特尔进入锁血后仍能被治疗」): during the 8 s of 余烬 medics kept healing her. v0.1.1 measured: a 赫默 beside her healed her twice for 738 HP; with a 50 % HP 深巡 also in range, 赫默 spent 1,107 of its 1,845 HP of healing on her — on a unit that leaves anyway.
- **Official** (checked on PRTS 2026-10-03): chess talent 余烬 (`chess_char_5_07_a` / `_b`) 「受到致命伤害时持续使生命值不低于1,8秒后强制退出战场」 (bb `surtr_t_2[withdraw].interval` 8); PRTS 史尔特尔 天赋备注 "持有不死的情况下不会触发此天赋" / "触发本天赋后,获得禁疗与不死" / "强制退出战场视为撤回干员"; PRTS 异常效果 禁疗 (HEAL_FREE) "无法成为治疗类能力的目标,且受到的治疗量变为0 ※…增减生命回复速度或生命回复速度(百分比)属性的效果不会被识别为治疗类能力"; PRTS 史尔特尔 技能3 备注 "生效后立刻治疗自身当前最大生命值100%的生命值(无视禁疗)".
- **Cause**: the talent only prevented the blow and scheduled the retreat. Nothing marked her 禁疗, so every heal pick (`Battle.injuredAlliesInKeys` and the kits' own) chose her as the lowest HP ratio and `heal()` let the heal through. The sim's `noHeal` (the 禁疗 summons, 孤立, 武者 / 收割者 / 不屈者, 艾丝黛尔 S1, 烛煌's 倒地) stops only heals from other units (`!self` in `damage.js heal`), the "不能被其他…治疗" of those traits.
- **Now**: the trigger adds `surtr:ember` = flags `noHeal` + `healFree` with the status `'healFree'` (a green cross struck through in the unit's status row) until she leaves; the retreat drops it. `healFree` in `damage.js heal`: no heal reaches the unit, its own included (休眠子裔's heal on attack, …), except an HP-regen attribute tick (`regen`: the engine's natural regeneration from `hpRegen` / `hpRegenRatio`, e.g. 安洁莉娜's 兼职工作) and a heal flagged `ignoreHealFree` (her S3's start heal, "无视禁疗": S3 starting inside the window heals her to full). Every heal pick already skips `noHeal` units, so medics heal the next injured ally. 不死 (a later lethal blow inside the window changes nothing and does not restart the 8 s) and the retreat at 8 s are unchanged. Other `noHeal` users are unchanged: the summons' 禁疗 (tokens.json `abnormal` `healFree`) keeps `noHeal` only — no self-heal reaches them but 伺夜 S2's 恢复 of its 禁疗 狼群, which the skill text grants.
- **Measured** (the same layout): 0 heals reach her (v0.1.1: 2 heals, 738 HP); 赫默 heals the 50 % 深巡 instead (1,107 HP in 12 s); she leaves 8.03 s after the blow.
- [ASSUMED]: on the same first lethal blow 余烬 (`fatal` −60) runs before 坚固维式重锤's lock (`PRIO_REVIVE` −100), so the hammer never starts while 余烬 is unused (PRTS gives no order); the status lasts until the retreat rather than a fixed 8 s. Known deviations, outcome-neutral (she is 不死 and leaves at 8 s anyway): 余's 闲云隐市 and 天马之盔's 8 %/s use `hp_recovery_per_sec_by_max_hp_ratio`, the 生命回复速度(百分比) attribute that PRTS 禁疗 exempts, but they run as kit heals (`battle.heal`) here, so 禁疗 stops them on her (moving them to the regen attribute would also change the bleed removal and heal-bonus hooks of other units); the forced exit's 「返还部分部署费用」 DP refund is not modelled (no retreat in the sim refunds DP); a chain healer's bounces skip her (ai.js `doHeal` chain filter, review of #52).
- Tests: `test/sim/feedback2-surtr-stealth.test.js` (the medic beside her, her own heals / regen / S3's start heal, the status events), `kits_t5`, `items`, `playtest6-combat` (the 禁疗 summons), `render/tiles` / `boardart` / `fxproj` (the status atlas).
### 22.8 An enemy's 隐匿 returns 3 s after a block; the 深池逐火 embers (#43 follow-up, players' "the stealth monster revives forever") — `sim/Battle.js _stealthSwitch / _unblock / releaseBlocked / foesInRadius`, `sim/ai.js enforceBlockCapacity`, `sim/targeting.js enemyStealthed / stealthOffKey / canTargetEnemy`, `sim/snapshot.js flagsOf`, `sim/constants.js STEALTH_RESTORE`, `sim/buffs.js` (`stealthOff`), `content/enemies.js` (stealth, STEALTH_RESTORE_BY_KEY, husk, kitInvisShield, kitShadowKiller, kitJazz, kitBoneSpike)
- **Seen**: §21.4 left "隐匿 returns at once after a block (PRTS: 3 s)" [ASSUMED]; v0.1.1 hid an enemy again one tick after its block ended (a 潜伏者 whose blocker falls: untargetable 0.03 s later). Players reported (via the owner) that the 深池逐火 "stealth monster revives forever and can't be killed": a warrior knocked out while blocked releases its block through the 重生's 无法阻挡, its blocker takes the next warrior at once, and when the 重生 ends the ember is not blocked again — 隐匿, out of reach of ranged operators and operator splash — walks on and stands up with full HP 10 s later.
- **Official** (checked on PRTS 2026-10-03): 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被阻挡的3秒后重新进入隐匿。这是敌方单位的隐匿所拥有的特殊机制,我方干员并不会因为阻挡而解除隐匿" and "少部分的隐匿表现上与我方干员相同…可能不具有这样的特殊机制"; §隐匿与Buff的关系 "在被阻挡时开关会被关掉从而失去隐匿,阻挡状态解除后3s开关重新被开启而恢复隐匿"; 异常效果 隐匿 "与'阻挡时解除'没有直接关系,该机制通常由其他效果实现". The enemy pages' 天赋 "隐匿(解除阻挡N秒后恢复)" (PRTS text search): 0 s on 21 enemies — here 业余竞演者, 节日爵士乐手, 假想敌:骨刺, 流泪小子 (both), 访问团强攻冠军, and the veil of 清明 / 堂皇 ("获得隐匿(解除阻挡0秒后恢复)"); 1 s on 家族灭迹人 / 家族暗影灭迹人; no page names another time. Every other stealthy enemy here has a plain 隐匿: 潜伏者, 隐形弩手 (+组长), 隐形术师, 山海众头目 / 秘使, 重弩突袭者, 架桥船工 / 扶桥老手, 深池伙友影刃精英, 潜水员 (in water), 锏's second form and the 深池逐火 embers (PRTS 深池逐火战士 / 精锐战士 / 护卫 "获得隐匿、缴械"). 山海众头目 / 秘使 天赋 "该隐匿每次生效后自身获得强击标记(不可叠加)", 技能 破隐一击 "仅持有强击标记且被阻挡时可触发…技能开始时消耗强击标记".
- **Cause**: `targeting.js canTargetEnemy`, `snapshot.js flagsOf` and `Battle.foesInRadius` read `blockedBy` live, so the 隐匿 came back with the block's end.
- **Now**: every block release — `Battle._unblock`, `releaseBlocked`, `ai.js enforceBlockCapacity` (a blocker's death, retreat or stun, a block-count drop, a push, 无法阻挡 / 浮空 / 恐惧 / 沉睡 (since §24.9), a teleport) — calls `Battle._stealthSwitch(e)`: each 隐匿 source buff the enemy holds gets a `stealthOff:<source key>` buff for its restore time, `constants.js STEALTH_RESTORE` (3 s) or the source's `data.stealthRestore` (`content/enemies.js STEALTH_RESTORE_BY_KEY`: the 0 s / 1 s enemies above; the veil 0 s). `targeting.js enemyStealthed(e)` — 隐匿 flag, not revealed (反隐), not blocked, and some source not switched off — is the one test for ally targeting (`canTargetEnemy`), operator area damage (`foesInRadius`), the b.snap stealth bit (drawn solid meanwhile), 节日爵士乐手 (attacks only without its 隐匿), 假想敌:骨刺 (3 targets only in 隐匿) and 山海众头目 (the mark comes at the spawn and whenever its 隐匿 turns on again, the next blocked attack spends it: a new block inside the 3 s gives no second ×2 hit — until 0.1.2 every block did). A new block inside the window lifts the 隐匿 again and its end restarts the window; a 反隐 that ends outside a window hides it at once, as before; a veiled 潜伏者 hides as soon as its block ends (the veil) and, if the veil runs out inside its own 3 s, is revealed until they end. Our operators' 隐匿 / 迷彩 are untouched (allies get no switch).
- **The 深池逐火 embers** (all three, `husk()` with `stealthy`): the knock-out releases the warrior's block at once (`_unblock` right after the 重生's 无法阻挡, as `applyStatus` does for a status) once the ember's 隐匿 is on, so the ember is revealed until 3 s after the knock-out — the 1 s 重生 stays 无敌 and untargetable, then about 2 s in which ranged operators and operator splash can hit it even though its blocker took the next warrior. An ember knocked out while not blocked is 隐匿 as soon as its 重生 ends (the switch only follows a block).
- **Measured** (before = the v0.1.1 sim, after = this change; the flat stage with its row-9 lane, and real rounds on 战场#01 built like the match — an official wave with the TIMES pick, full content): the integrator's seven lineups (艾丝黛尔 / 角峰 / 隐现 / 幽灵鲨 against 1–4 战士 or 护卫): identical — 艾丝黛尔 alone against four 战士 2 s apart stays 0 / 4 because only one warrior is ever knocked out, and no lineup there knocks out a 护卫. 28 runs of a blocker + 2–3 ranged / splash operators against 4–6 战士 / 精锐战士 0.5–3 s apart: 81 → 84 of 126 killed, stand-ups 12 → 7 (23 runs unchanged, 4 one more kill, 1 one fewer — its blocker fell in the changed fight). Real rounds (R3 / R5 精锐战士 pick, R9 / R11 护卫 pick, three lineups up to the tier-4 four of §21.4's test): unchanged (R3 7 / 7, R5 5 / 16, R9 0 / 18, R11 0 / 28 for the tier-4 lineup; 0–1 for the weak ones). Why so little: in real rounds most knock-outs happen while the warrior is NOT blocked — ranged operators finish it on the way after it passed a full blocker (R5, tier-4 lineup: 10 of its 18 knock-outs, and 10 of the 13 embers that stood up again) — so its ember is 隐匿 at once, which is official; an ember freed by its own knock-out gets about 2 s after the 重生 and still needs 5 separate hits (火灰 10). What beats embers, officially: a free block slot where they walk (a second blocker behind the first), 反隐 (银灰 鹰眼视觉, 伊内丝 影哨, the SPT-X modules, 信仰搅拌机's module), or many fast hits.
- [ASSUMED]: at the knock-out the 重生's cleanse comes first, then the ember's 隐匿 with the block-end switch, which outlasts the 重生 (PRTS documents neither the order nor whether the switch survives a 重生); 深池伙友影刃精英 and 锏's second form (PRTS writes their 隐匿 as the plain 异常效果 with no restore note) follow the general 3 s, not the "表现上与我方干员相同" kind; one switch per 隐匿 source — with several sources the enemy hides as soon as any is back on; a push or a teleport (DISAPPEAR) ends a block like any other release; a warrior released by its blocker less than 3 s before its knock-out leaves an ember that is 隐匿 straight after its 重生 (the window is opened by the knock-out's own release only — 4 of 55 knock-outs in the measured rounds, no outcome changed when prototyped).
- Tests: `test/sim/feedback2-surtr-stealth.test.js` (the 3 s window and its consumers, a new block inside it, the release paths, the 0 s / 1 s enemies and the veil, our operators, 山海众头目's mark, the three embers' window, an ember knocked out unblocked), `enemies_bosses`, `feedback1-transform`, `feedback1-followups`, `combat`, `playtest5-targeting`.
### 22.9 荒芜拉普兰德 S3's drones attack (#32 item 7) — `kits/tier6.js whitw2` (`WHITW2_SPREAD / WHITW2_CHASE / WHITW2_REAPPEAR_SIDE / WHITW2_ORBIT`, `releaseDrones / pickTarget / flyDrone / circle`)
- **Seen** (GitHub #32, item 7): 「易拉三技能浮游单元无法造成伤害,只能造成三技能自带dot伤害」 — 荒芜拉普兰德's S3 终幕·浩劫 dealt only its once-per-second area damage.
- **Official**: PRTS 荒芜拉普兰德 S3 备注, 技能流程: ① for 1.3 s after the cast or after a drone is added ("开启技能/补充浮游单元1.3s内") the drones spread evenly outward from her, one along her facing (初速度 0.1, 加速度 1.9, 最大速度 2.0); ② each then locks the target "距离自身最近>距离本体最近" anywhere on the field and flies at it (初速度 2.0, 加速度 1.0, 最大速度 4.0, 转向速度 1/6 per frame; a target that leaves or turns unselectable on the way → a new pick, the speed back to 2.0); ③ at the target it "锁定目标持续进行攻击,此状态下的攻击行为同正常浮游单元"; when that target leaves or turns unselectable it reappears at a random point of the 1.5-side square around it and picks again; with nothing selectable it circles (radius 0.9, 1.0 线速度, counter-clockwise, the circle on its left). Also: "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害"; the area damage / slow has radius 0.9, does not stack and carries 头狼's 沉默. PRTS 分支特性信息 驭械术师: a drone starts at 20 % of the operator's ATK, +15 per hit on the target it hit last, up to 110 %, back to the start on a different target; drones released by a skill "不受缴械类效果制约" — the same note says they "造成技能直接伤害", which S3's own 备注 overrides for this skill. skill_table skchr_whitw2_3 at the chess levels (Lv4 / Lv7): atk +0.45 / +0.6, attack@cnt 2, attack@times 1.3 (PRTS ①'s 1.3 s), attack@move_speed −0.3 / −0.4, attack@magic_atk_scale 1, attack@fear 2, attack@range_radius 0.9, attack@projectile_turn_speed 5 (× 1/30 s = PRTS's 1/6 per frame); trait init 0.2 / 0.35 (elite: module 狼时), delta 0.15, max 1.1; 头狼 every 20 s: cap × 1.1 (PRTS 备注: 121 %), 2 s 沉默, 数量+1.
- **Cause**: the S3 spec switched her attack off for the whole skill (`attack: { noAttack: true }`) and its tick only moved the drones (at attack@projectile_move_speed, 1 tile/s), feared a target the drone came within 0.9 of and dealt the area hit; no drone ever attacked. The kit's header said so ("she stops attacking while they roam"). The drones also preferred enemies no other drone held — an [ASSUMED] rule PRTS does not have.
- **Now**: her own attack stays off — every drone is out (浮游单元 +attack@cnt with her own one; +1 from 头狼 stage 3, and a drone added when stage 3 comes during the skill spreads along her facing). The drones fly ① ② ③ with PRTS's numbers (spread for attack@times s; the distance covered at the mean of the old and new speed, exact under constant acceleration; the reappearance point is drawn in her facing frame, so a battle turned with her direction plays the same). A drone that reaches its target fears it (attack@fear s) and stays on it; it attacks every attack interval of hers (her live ASPD) for ATK × its own funnel ramp (trait init, +delta per hit on the same target, the cap — raised by 头狼 stage 1 —, back to init on a new target), arts. That damage is neither a normal attack nor skill damage: no `attack` hook, `isAttack` / `isSkill` false, tag `droneAttack` — the on-attack items skip it (the 叙拉古 6 chance rolls on it since 0.1.3: 普通伤害, see the §23 「叙拉古」 subsection), 头狼's 沉默 ("造成伤害时") applies; 缴械 does not stop it. The once-per-second area hit and the slow are unchanged (around every drone, one hit per enemy; tags `skill` + `drone`, + `dot` since 0.1.3: PRTS 备注 "持续法术伤害"). A wolf knocked out or withdrawn in the middle of a tick (a kill hook, say) deals nothing more in that tick. fx: `droneLock` (the wolf-eye mark) when a drone picks its target, `drone` at a drone for each of its attacks and once per second for drones still roaming; the client draws them already.
- **Distance to a huge enemy** [ASSUMED] — the owner's decision of 2026-10-04: a drone measures every enemy's distance to its position (a huge enemy's centre, its 判定中心), so leader rounds stay close to 0.1.1. Considered and set aside: the sim's general convention for operator-side distance picks, the hit rectangle (body.js `bodyDist`: `targeting.js:135` `sortEnemyTargets` 'nearest' / 'farthest', `kits/tier3.js:86` `enemiesAround`, `kits/tier6.js:1108` 异客's storm chain, `kits/tier6.js:2525` / `:2549` 溯光星源's bounces and links, `kits/tier4.js:526` / `:995`; only the profession chain bounce, `ai.js:257`, takes the nearest by position) — with it a huge leader (胄, 管) was the nearest enemy of every drone around it and drew them all for the whole skill (胄 h07_01: her damage on the leader 106 309, on the other enemies 3 036, 2 deaths). A drone on a huge enemy sits at its centre; it reaches it on touching the hit rectangle.
- **Players notice**: once a drone reaches a target that stays alive, its hits outweigh the area damage (3 parked 重装防御者, RES 0, the full 40 s: drone hits 38 411 of 54 640); against streams of weak enemies the area hit still does most of it (the flat stage's two lanes, 10 + 10 walkers, 40 s: drones 11 049, area 50 188 of 61 236). All drones may lock the same enemy — each takes the one nearest to itself — so against a cluster the area hit covers fewer enemies than before. Measured, old → new: parked, first 12 s normal 12 901 → 12 089 (drone hits 7 512 + area 4 578), elite 17 658 → 18 085; the whole 40 s 47 857 → 54 640 and 65 504 → 77 369; the lanes 15 481 → 61 236 (kills 1 → 14), elite 49 213 → 72 224 (9 → 18). **Leader rounds** (one elite 荒芜拉普兰德 per side, S3 at once, kept alive, 60 s; her damage on the leader / on the other enemies / enemy deaths, 0.1.1 → now): 假想敌:胄 (act1 h07_01) 32 467 / 80 186 / 9 → 84 880 / 44 391 / 6; 假想敌:管 (h07_03) 19 936 / 62 176 / 5 → 37 520 / 69 927 / 5; 假想敌:铳 (h07_02) 31 260 / 49 982 / 4 → 62 794 / 30 221 / 0; 卢西恩 (act2 h07_05) 37 957 / 58 512 / 6 → 75 094 / 40 881 / 2 (both of normal size: a drone that picks the leader because it is nearest keeps the lock, and a leader never falls); the heavy two-player 胄 scene of robustness.test.js (the review bench: 4 wolves per side, 120 extra enemies at ×20 HP, 60 s): the wolves' damage 1 315 038 → 1 291 567, on the leader 50 170 → 42 907, enemies killed 12 → 13. With the hit-rectangle distance it would have been 胄 106 309 / 3 036 / 2, 管 57 202 / 36 427 / 2 and the heavy scene 406 075 / 125 271 / 2.
- [ASSUMED]: the centre distance above (the owner's decision); reading ① "补充浮游单元" as: after an addition only the added drone spreads, along her facing, while the others carry on; the turn rate (转向速度) is not modelled — a chasing drone heads straight at its target; a drone's attack clock runs all the time and its first hit lands on arrival when the clock is ready; the drones keep flying and attacking while she is stunned, frozen or silenced (PRTS only names 缴械); a drone reaches a huge enemy when it touches the hit rectangle and then sits at its position; the fear's source is her (they flee from her, as before); the area hit keeps `isSkill` (a skill DoT; PRTS's line speaks of 直接伤害).
- Tests: `test/sim/feedback2-whitw2-yu.test.js` (spread and chase numbers; per-drone hits, ramps and intervals, no normal attack, the damage flags, 缴械; nearest-target picks, the reappearance square and the ramp restart, the circle; 头狼 stage 3 mid-skill; a wolf knocked out mid-tick; facing UP / LEFT / DOWN = RIGHT with a reappearance), `test/content/kits_t6.test.js` (the S3 test now has one enemy: a nearer one would draw every drone).
### 22.10 余 S2: leaders teleported, no pull effect without a pull, cast with an enemy on its x-1 (#32 item 1) — `kits/tier6.js teleportEnemy / yu (skchr_yu_2)`, `tools/build-data.mjs TRIGGER_DEVIATIONS / resolveTrigger / validateAll`, data/chess.json `skills[].trigger`
- **Seen** (GitHub #32, item 1): 「余二技能有伤害和特效,但不能拉怪」.
- **Official**: PRTS 余 S2 厚礼上宾 (技能范围 x-1): "立即对周围所有敌人造成相当于攻击力100%的法术伤害并将其中的地面可达目标传送至自身位置"; 备注: "可达目标" = a target that can reach his tile by ground pathing, not through impassable tiles — "如果余或目标所在位置已经是一个不可通行地块(例如围栏、高台等),传送效果会因此失效"; "处于消失状态的/持有自缚的单位不视为可达目标"; 免疫传送 / 传送免疫 units are not teleported; the teleport is judged 0.13 s after the damage, the target is unblocked first and lands at a random point near one of the four corners of his tile. PRTS 卢西恩,“猩红血钻” and 假想敌:铳 (both sections): 传送抗性 无. Trigger: the 下半 TANK class row `TANK | | | 0 | TAKE_DAMAGE` and PRTS 卫戍协议/帮助's 重装 strategy "不受技能范围影响,受到伤害时释放技能" (§20.2, §21.29).
- **Mostly not a bug** (triage on the 0.1.1 sim): ordinary ground enemies inside x-1 are teleported — 余 in a lane of the flat stage pulled all 3 enemies inside x-1, on 战场#01(下半) all 3; on a 围墙 tile none (the official rule). The usual first hit came from a ranged enemy outside x-1 (a 深池暗影术师 2.7 tiles away): under TAKE_DAMAGE the skill fired with nobody to pull, so only its damage and effect showed.
- **Cause** (two real faults): `teleportEnemy` skipped every leader (`isBoss`) — no PRTS basis; and S2 played its `pull` fx even when nobody was teleported (n = 0).
- **Now**: a leader is teleported like any ground enemy unless it is 自缚 — the 7 huge leaders (`SELF_BOUND`), 守墓石像's 转换模式 and the 胄 parts' anchor keep `selfBound` (§21.19), so 卢西恩 and 假想敌:铳 are the leaders it can pull. A teleported leader re-plans from his tile: on the real rounds 假想敌:铳 (不可阻挡) walks on to its next patrol checkpoint and 卢西恩 is blocked by 余 (block +2), blinks past and resumes its loop. 静态刚体 is no exemption (the 备注 names 消失 / 自缚 / 免疫传送; PRTS 静态刚体 forbids physical movement) and every 静态刚体 of the mode flies or is 自缚 anyway (§21.9). The `pull` fx plays only when at least one enemy was teleported (each still gets its own `teleport` fx).
- **Deliberate deviation — S2 casts with an enemy on its x-1** (the owner's decision of 2026-10-04; it joins §21.29's six): players saw it fire from a ranged hit with nobody to pull, so instead of the official row's TAKE_DAMAGE it casts once its SP is ready and an enemy stands on its own 技能范围. Not the basic strategy (`DEFAULT`, §21.29's rule): his attack range is his own tile (0-1), so "about to attack with an enemy in the initial range, or one he blocks" fires only once an enemy is already on his tile — measured, an enemy parked two tiles away never set it off, under DEFAULT as under TAKE_DAMAGE. It takes `SKILL_RANGE` — the mode's official strategy for a MANUAL skill with a 技能范围 when no class row applies ("仅在技能范围内存在敌人(无视其不可选中)时释放技能", §20.2) — on x-1: `TRIGGER_DEVIATIONS` `chess_char_6_03_a: { skchr_yu_2: 'SKILL_RANGE' }` (per chess, the elite through its baseId); a SKILL_RANGE deviation carries the skill's own range as `customRangeGrid`, and `validateAll` checks both records. The two records read `{ rule: 'SKILL_RANGE', rawRule: 'TAKE_DAMAGE', customRangeGrid: <x-1> }` (rebuilt with `--offline`: nothing else in data/ changed). His S1 keeps TAKE_DAMAGE, his S3 its official charId row (CUSTOM_RANGE).
- **Players notice**: S2 never fires from a hit outside x-1 any more; it fires as soon as an enemy steps onto x-1 — any enemy, a flyer or a 隐匿 one too (a flyer alone: the burst hits it, nobody is pulled) — so in a stream it often pulls only the leading enemy where the hit used to come with several bunched up. Measured on the triage lanes (SP full from the start; cast / enemies pulled): flat stage beside the lane 5.90 s by the 深池暗影术师's hit, 0 → 6.33 s, 1; in the lane 8.13 s, 3 → 4.83 s, 1; 战场#01(下半) 11.87 s, 3 → 8.57 s, 1. One-line flip: remove the entry from `TRIGGER_DEVIATIONS` and rebuild.
- **Checked**: no enemy of the mode is immune to the teleport — every enemy name of data/enemies.json was looked up on PRTS: all list 传送抗性 无 (the four 悬赏 bosses on their "(敌方)" pages, “余音” on its own) except “斩胄之剑”, “破胄之锤” and 刺胄之弹, whose value is empty — they fly (the 剑 / 锤 also stay 自缚-anchored when shot down), so they are never teleported anyway; 碎铳之簧's page has no such row and “炎佑” is an ally-side unit.
- **Not modelled**: the 0.13 s between the damage and the teleport (same tick) and the corner landing points (the tile centre).
- Tests: `test/sim/feedback2-whitw2-yu.test.js` (S2 fires with a ground enemy two tiles away and nobody hitting him, the enemy on his tile; a ranged hit from outside x-1 no longer casts it; 卢西恩 and 铳 teleported, “萨米的意志” (自缚) stays, one `pull` fx for two; a flyer alone on x-1 → no `teleport` / `pull` fx; the real 铳 and 卢西恩 rounds), `test/sim/feedback1-tank-triggers.test.js` (the data and the real battle for 余 S2 beside the six), `test/data.test.js` (14 deviated records), `kits_alt_t6` (6_03 S2 now asserts the reason SKILL_RANGE); `feedback1-residuals` (守墓石像) and `playtest6-skills` (#15 audit, 余 S2 now among its SKILL_RANGE skills) pass unchanged.
### 22.11 归溟幽灵鲨's 替身 is drawn and sits the fight out (#44, 「归鲨死了没替身」) — `professions.js installDollkeeper` (`DOLL_SWITCH`, hook `dollSwitch`), `kits/tier5.js` (归溟幽灵鲨: trait `dollNoAttack`, S2 `onEnd`, 拥抱自我), `content/items/battle.js` (`PRIO_UNDYING_HELD`), `render/units.js FORMS` (`doll`, `leave`), `UnitView` (`_dieForm`), `render/spine.js` (`baseRoles`), `render/style.js` / `textures.js` (icon `doll`), `render/fx.js` (`dollEnd`)
- **Seen** (GitHub #44): 「归鲨死掉后没有替身」. The owner's triage: in 0.1.1 the sim did switch her to the 替身, but the client kept drawing her 本体; the 替身 also attacked and could cast S2 生存的渴望, whose end then knocked her out.
- **Official**: the trait (character_table / chess `trait`) 「受到致命伤时不撤退,切换成<替身>作战(替身阻挡数为0),持续20秒后自身再次替换<替身>」 (bb duration 20; `atk` / `max_hp` are the 替身's bonuses — 0 on both 傀儡师 but for the elite modules). PRTS 分支特性信息/data §傀儡师: "受到足以致命的伤害且未持有不死的情况下" → "一段切换动画", then the <替身> fights for 20 s holding 阻回; at its end a switch animation back; "切换动画开始时会清除自身一切Buff;切换途中重设自身生命至最大值,随后再次清除自身一切Buff"; during a switch animation 不死, 无敌, 阻回, 禁疗, 孤立, 强制缴械, 眩晕 / 冻结 / 睡眠 immunity; block ×0 "从切换至<替身>开始时持续至切换回非<替身>开始"; "部分机制也可以直接让干员切换为<替身>形态". PRTS 归溟幽灵鲨: 特性备注 「<替身>不进行普通攻击」 (and 天赋备注: 伤害与减速不可对空); S2's text corrected to 「技能结束后立刻切换为<替身>」 (原文 视为被击倒); S2 备注 「技能期间持有不死,因此技能期间不会因致命伤而切换为<替身>」. PRTS 风丸: 特性备注 「<替身>状态下可对空」 — her 替身 attacks; her 纸偶 leaves "持有者切换替身时". The 替身's HP: PRTS resets it "至最大值", the trait's 替身 HP bonus is 0, character_table has no 替身 of 归溟幽灵鲨 and 风丸's 纸偶 token (token_10022_kazema_shadow) has her own max HP at every level — so the operator's own max HP. Her skeleton (`char_1023_ghost2.skel`, read with the client's own parser): the `*_B` clips draw the 替身's own slots (`*_T`) and hide the 本体's — Start_B (1 s, fades in), Idle_B, Die_B (1 s, breaks apart and has faded out at its end), Die_B_2 (1 s, collapses and stays); Start_2 (1 s) brings the 本体 back; her Back skeleton has only Idle_B and Start_2. 风丸's: Start_B (its OnAttack at 0.267 s is 折纸生花), Idle_B, Attack_B, Die_B; her Back skeleton Start_B, Idle_B and Attack_B. Neither Back skeleton has the 替身's death clip.
- **Cause**: the 替身 was only a buff (block −99, HP × 0.5): nothing kept it from attacking or casting; S2's end emitted 'knockout' and `loseHp(hp + 1)` — a lethal HP loss, so the 替身, or the knock-out when she already was one (a cast as the 替身 killed her at its end). On the client an operator had no form: the 'substitute' / 'swap' fx carried no `form`, UnitInfo none, FORMS had no entry for her skeleton, and 'trait:substitute' had no icon. Already in 0.1.1: a running 坚固维式重锤 不死 window did not stop the switch (its battle-level hook and the 傀儡师's sat at the same `fatal` priority, the 傀儡师's registered first).
- **Now** (every 傀儡师): a lethal hit on the 本体 that nothing prevented before it — a skill's or talent's own 不死 (10 … −60) or a running 坚固 window (`PRIO_UNDYING_HELD` −99; the hammer's lock, a new window, stays at −100 after the switch, as in 0.1.1) — switches her: the running skill ends (reason `substitute`: S1 / S3, 风丸's S2 and its 纸偶) and her statuses are removed; for `DOLL_SWITCH` (1 s) she holds `trait:dollSwitching` (flag `u.trait.dollSwitching`) — 无敌, 阻回, 禁疗, 孤立, 缴械, 眩晕 / 冻结 / 睡眠 refused, a lethal 流失 stops at 1 HP; `trait:substitute` lasts the switch + 20 s: block 0 from the switch on, 阻回, max HP = her 替身 token's (风丸) or her own (归溟幽灵鲨, was 50 %), HP full; `u.form = 'doll'` and fx 'substitute' `{ form: 'doll', dur }`. When it ends, the switch back: the same 1 s animation and clearing, block and HP back, fx 'swap' `{ form: null }`. A lethal hit on the 替身 (after its switch) knocks her out; the form is then reset by fx 'dollEnd' `{ form: null }` after the 'die' event (drawn as nothing), so she redeploys as the 本体. Content switches her at once with the hook `dollSwitch` `{ unit, reason, done }`. 归溟幽灵鲨's kit sets `dollNoAttack`: her 替身 carries 缴械 and 沉默 — no normal attack and no cast (S2 / S3's basic strategy casts on an attack the 替身 never makes). Her S2 end emits `dollSwitch` (no lethal HP loss — so no 不死 / 复活 item takes it — and no 'knockout' fx, gone from the client's table); nothing when it ended with her, by her switch, or she already is the 替身. 拥抱自我 works while the 替身 fights (not during the switch). Client: FORMS `char_1023_ghost2.doll` — Start_B (`change`), Idle_B, Die_B as its closing clip over its last second (timed from the fx `dur`), Die_B_2 to die on, no attack or skill clip, and the new `leave` clip Start_2, played when a form ends (UnitView.setForm; not on a knocked-out view); `char_4016_kazema.doll` — Start_B, Idle_B / Attack_B, Die_B, `leave` Start. A form now ends in the unit's own roles (`SpineActor.baseRoles`: the equipped skill's clip — an operator came back on the manifest's default skill clip). 'trait:substitute' shows the puppet icon `doll`. The fx carry `form`, so no client stage drops them (`keepsState` / `keepEarly` / `isCosmeticEvent`) and a view built later starts from UnitInfo `form`. Facing UP, the Back model shows the 替身 and skips the clips it lacks; knocked out, she lies on the Front model like every operator facing UP (§22.1) — the view keeps the form it died in (`_dieForm`, set by `die()`, cleared when it stands up), so that model, built a frame after the 'dollEnd' reset, and any model rebuilt while she lies down show the 替身's Die_B_2 (风丸: Die_B); she stands up as the 本体 (facing UP: on the Back model, its deploy clip); a revive in the same frame as the knock-out (阿戈尔: die → deploy → 'dollEnd') brings the 本体 back on Start_2 and loads no model.
- **Players notice**: she turns into her 替身 (a puppet of her, with an icon) for 21 s; it no longer attacks and her SP waits until she is back; S2's end no longer kills her — she becomes the 替身; the 替身 has her full HP; after each switch she cannot be hurt and does not act for 1 s; S1 / S3 running at a lethal hit end; knocked out as the 替身 she lies as the collapsed 替身, facing UP too. With a 坚固维式重锤 whose 8 s window still runs (its lock set off by the 替身's lethal hit), a lethal hit on the 本体 leaves her at 1 HP instead of switching her again. 风丸 likewise (her 替身 drawn, the 1 s switches, 阻回, S2 and its 纸偶 end with the switch). Measured (the report's repro: a harmless enemy parked in her range, lethal hits by hand): 0.1.1 — the 替身 at 1 328 / 2 657 HP made 17 of her 19 attacks and her SP kept filling; with 38 / 40 SP she cast S2 as the 替身 (at 2.4 s, 1.9 s after the hit) and was knocked out at its end (17.4 s); now — 2 657 / 2 657, no attack and the SP held 22 s; S2 cast as the 本体 at 24.9 s, alive.
- [ASSUMED]: the switch animation lasts 1 s each way (the skeleton's Start_B / Start_2) and the 20 s run after it; the direct switch at S2's end (立刻切换) plays the same 1 s animation; "清除一切Buff" = the running skill and the catalogue statuses — every other buff survives, the remake's bond / item / talent effects (buffs too) and other units' timed buffs and shields alike, and element gauges and 爆发冷却 stay; 无敌 is modelled as damage immunity only, like every ally 无敌 in the code (PRTS also gives "无法被不同阵营选中": enemies may still target her during a switch); no S1 (技能范围) cast as her 替身 either; the 替身 can be healed (PRTS names 禁疗 for the switch only); 风丸's 替身 may still cast a skill that was ready at the switch (it attacks); Die_B over the 替身's last second is visual only; a view built from UnitInfo while she lies down (a field opened later: the sim has reset the form) shows the 本体's fall.
- Open: 风丸's 替身 attack range (PRTS x-4) and 对空 are not modelled — it keeps her own range and ground-only attack.
- Normative lines rewritten with it: §5.2 (`form`), §5.4 (`fatal`: `PRIO_UNDYING_HELD`), §8.2 (fx `form`, UnitInfo `form`).
- Tests: `test/sim/feedback2-doll.test.js` (cases A–D of the repro: the 替身's form, HP, block, 阻回, no attack, 拥抱自我, the switch back; S2's 不死 and its end; a knock-out as the 替身, the event order, the redeploy; no cast as the 替身 for S2 at 38 / 40 SP and a ready S1; S3 ending with the switch; the hook; a running 坚固 window stopping the switch; 风丸's 替身 attacks), `test/render/feedback2-doll.test.js` (the clips in the manifest, Start_B → Idle_B → Die_B → Start_2 with her S3 clip after it, Die_B_2 held through 'dollEnd' and the redeploy, UnitInfo `form`, the Back model, a late fx; knocked out facing UP — the Front model's Die_B_2 with the reset in the same frame or the next, 风丸's Die_B —, a model rebuilt while down, the redeploy on the Back model, a same-frame revive on Start_2 without a model load, a view built while down; 风丸, sim → view, the skeleton's clip meanings), `test/sim/professions.test.js` (the switch windows), `test/sim/fidelity.test.js` (her own max HP).
### 22.12 Enemy splash spares a 隐匿 operator that does not block the enemy (#32 item 6) — `sim/targeting.js areaSelectable / auraSelectable`, `content/enemies.js areaAllies / areaAlliesInTiles / fieldAllies / auraAllies / targetAndArea / zone / dmgZone`, `content/bosses.js`, `sim/Battle.js alliesInRadius`
- **Seen**: 「隐蔽单位仍会受到溅射伤害」. **Real** on the enemy side (the operator side was fixed in 0.1.1, §21.19): every enemy area effect took every ally in its area. Measured on v0.1.1: 碎骨 grenades 角峰 on (10,6); 古米 on (10,7) under a 999 s 隐匿, not blocking, took 4 splashes (2650 damage) and was knocked out. §21.22's audit had listed it ("enemy splash still hits 隐匿 allies").
- **Official**:
- PRTS 作战机制 §隐匿 "隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中", "隐匿的我方干员阻挡敌人时,因为“阻挡优先级最高”的效果,敌人会无视一切可选性对该干员进行攻击"; §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"; §隐匿与Buff的关系 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标", "目前明日方舟中使用能选中隐匿状态单位的Buff效果一定是无视隐匿状态起作用的" — its one enemy example being 寒霜's 攻速下降 Debuff.
- PRTS 异常效果 §无法选择: with 隐匿, 不可选中, 无敌, 塔不可选中 or 对地规避, "常见的、来自不同阵营的“选择”行为将无视这些单位进行(如同范围内不存在这个单位)"; the abilities PRTS marks "无视可选性" skip that check; "'直接选中'的能力不会进行具体的目标选择,故同样不受这些仅在选择时生效的异常效果制约".
- gamedata_const ba.invisible 隐匿 "不阻挡时不成为敌方攻击的目标" vs ba.camou 迷彩 "不阻挡时不成为敌方普通攻击的目标(无法躲避溅射类攻击)"; PRTS 异常效果 迷彩 "所有光环类能力、以及涉及中点判定/格子判定的效果均不受迷彩制约,但存在主动排除受迷彩影响单位的情况(此时站内会特别标注)"; PRTS 选择器 "非弹道类型的溅射攻击会自动无视迷彩", "所有触发选择器通常不无视迷彩".
- The exceptions are the notes on each PRTS enemy page ("无视无法选择", "无视(目标)可选性", "无视迷彩"); every area site below was checked against its page.
- **Cause**: `content/enemies.js` and `content/bosses.js` picked their area victims with `battle.alliesInRadius` / `alliesInTiles` / `allies()`; §21.22 had added only a 对地规避 filter, site by site.
- **Now**: one area selector, `targeting.js areaSelectable(src, a)` (helpers `areaAllies`, `areaAlliesInTiles`, `fieldAllies`): no 隐匿 ally unless it blocks `src`, no untargetable or sleeping (沉睡 = 无敌 + 无法行动) ally, no airborne 起飞 ally for a ground `src` (the damage pipeline's refusal, `evadesGround`; the per-site filters are gone). 迷彩 is not checked. A dead `src` (death blasts) blocks nobody; `src` null (no selecting enemy) keeps 隐匿 out but not 起飞. Buff auras use `auraSelectable` / `auraAllies`: the same without 对地规避. A target the ability locked beforehand — an attack's target, 死亡之眼's channel, a C4, an 爆炸箭 — is a direct pick (`targetAndArea`): a 隐匿 it gained meanwhile does not save it; only the units around it are an area selection. Per site:
- **Selects** (a 隐匿 operator that does not block the enemy is spared):
- splash — 碎骨's grenades (their DEF down too; its target keeps it), 自制投石机's stones, the 周围四格 of 控潮术师 / 领潮员 / 术师快艇 and 腐败骑士, 暴鸰's 8-tile bomb, 灼藤's first 3×3, the shells of 帝国炮火先兆者 / 中枢先兆者 (their damage stays 无来源);
- death and self blasts — 高能源石虫 / 冰爆源石虫 / 卷心籽 (PRTS 死亡爆炸 "…无视迷彩,不可对空", "来源无效" included), 烹泉 / 沏虹 (blast and steam), “巨大的丑东西”'s self-destruct, “庞贝”'s blast while blocked;
- area skills and statuses — W's C4, 鼠王's 唱沙 and 沙狱, 凋零骑士's 爆炸箭, 腐败骑士's 蓄力锤, 萨卡兹王庭军精锐术师's 死亡之眼 burst, “自在”'s 纬地经天 and 破桎而出, 锏's 速杀 (those on its way; its former blocker is still hit), 肆虐风雪 and 瞬息杀机, “萨科塔昂首”'s 祈祷邀约, “帝国的甲胄”'s barrage, the 自行炮's shots (each picks inside its 9 tiles — a 迷彩 operator can be shot now);
- pulses — the 神经毒素 of 元核孽生者 / 异光体孽生者, 鼎沸, 深溟巢涌者 (its pulse rides on its engine attack, which needs a target: a 迷彩 operator alone in its range is never hit), 水遁忍者's spin; zones — 集团军重型火炮's 燃烧区域 and 假想敌:蚀裂's sourceless 毒雾 (`src` null: a 隐匿 operator is spared, an airborne 蒂比 still reached — §21.22; until 0.1.2 it took everyone inside); jumps — 陷落雪祀's chain, the 碎铳之簧 element bounce;
- leaders and parts — the 刺胄之弹 / 斩胄之剑 / 破胄之锤 blasts (the flyer selects, so 对地规避 still does not apply), 铳's 最终之罚 and the springs' 末日布道 tramples, “余音”'s pulse and 合奏, 管's 裂管之奏, 弦's 断弦之奏, 昆图斯's 大潮 and tentacles, 卢西恩 / 不祥幻影's 【aoe】, 阿利斯泰尔's 王权号令 and 斥退, “萨米的意志”'s 冰凌 column and doom.
- **Normal attacks on every operator in range** take the targets of a normal attack (`canTargetAlly`: no 隐匿 or 迷彩 operator unless it blocks the enemy — PRTS 选择器: a normal attack does not ignore 迷彩): 斩胄之剑 / 破胄之锤's hover attack ("普通攻击对攻击范围内的所有我方单位…") also took 隐匿 and 迷彩 operators until 0.1.2 (`targetsNear … ranged: false`); “灵幛” already did not.
- **Cast conditions** of area skills count the targets of their trigger selection (`targetsNear`): 卢西恩 / 不祥幻影's 【aoe】 ("需要目标"), 锏's CircleAttack — a lone 隐匿 or 迷彩 operator no longer sets them off, as a lone airborne 蒂比 has not since §21.22.
- **Buff auras** (`auraAllies`): 深池伙友卫队's field and 扎罗's 远古威慑 no longer slow a 隐匿 operator that does not block them (their PRTS pages carry no 无视 note); an airborne 蒂比 is still slowed [ASSUMED, §21.20 / §21.22's ground-enemy auras].
- **Still reach every ally there**: 【污染秽蚀】, 【盲信之誓】, 萨卡兹悖谬暴虐兵长's 暴击, 假想敌:淤困's burst spread (PRTS "无视目标可选性" — now `ignoreSelect`, so it also reaches an airborne 蒂比, which the pipeline used to refuse), 远眺's death 暴露 and 圆仔's facing count ("无视其可选性"); 寒霜's aura (PRTS: "无视隐匿"); map, tile and terrain effects — “墓碑”'s halving (PRTS: "实际属于地图效果"), 【国度】, a chimera's 源石污染区 aura, 深水区 / 活性源石 / 源石污染区 tiles; the ticks of a debuff already on a unit.
- **起飞**: 锏's 【瞬息杀机】 no longer reaches an airborne 蒂比 — it used to set her SP to 0 directly, past the pipeline; its area selection now skips her (对地规避: 锏 walks).
- The operators' 隐匿 sources all set the flag the selectors read (`stealth`): 伪装服 (`applyStatus('stealth')` on the first damage), 叙拉古 6, 伊内丝 S2 and the 排气格栅 tile (PRTS 特殊地形: "即隐匿"); 迷彩 (`camou`): 忍冬 S3, 寒芒克洛丝 S1.
- [ASSUMED]:
- A 隐匿 operator that blocks the enemy stays selectable for that enemy's area abilities and auras (PRTS states the forced selection for its attacks).
- 迷彩 is hit where PRTS gives no basis — no splash type, no 中点判定 / 格子判定 or aura reading, no "无视迷彩" note — as since 0.1.1: 锏's 肆虐风雪, 瞬息杀机 and the bystanders of 速杀, 昆图斯's 大潮 (whole field) and tentacles, “萨米的意志”'s doom and 冰凌 column (a 格子判定 reading), “余音”'s pulse and 合奏, 铳's and the springs' tramples, “巨大的丑东西”'s self-destruct, W's C4 blast, “帝国的甲胄”'s barrage and the 自行炮's zone (格子判定 readings), 陷落雪祀's chain, the 碎铳之簧 bounce.
- 【国度】 and the chimera's aura reach a 隐匿 operator (tile / terrain readings).
- An airborne 蒂比 is still reached by ground-enemy buff auras and by 蚀裂's 毒雾 (§21.22).
- An invulnerable (无敌) operator is still selectable by areas, as by attacks (§21.22's open deviation; its damage is 0).
- **Fixed in 0.1.3 (§23.6)** — these were the enemy-number gaps left open here:
- 碎骨's grenade: PRTS's page gives 3×3, 26 % ATK, DEF −50 % for 5 s; the code has radius 1, 100 % ATK, 3 s.
- 扎罗: 远古威慑 radius 1.5 / ASPD −50 (code 2.5 / −30), the second form's range 1.25 (code 2.5), 溶血骇惧's loss curve (up to 30 % max HP/s at 40 s; code 5 %).
- “复仇者”'s 冲锋 trigger counts 隐匿 / 迷彩 operators (`targetsNear … ranged: false`).
- “庞贝”'s blast radius: PRTS 1.4, code 1.
- “巨大的丑东西”: its unblocked ranged 3×3 splash attack is missing; self-destruct radius 3.0 (code 2.5).
- “墓碑”, 烹泉 / 沏虹 and 集团军重型火炮: the splash of their normal attacks (3×3, radius 1.0, radius 1.0) is missing.
- 碎铳之簧: bounce radius 1.7 and ×0.8 per bounce (code 2 / 0.85), chase trample radius 0.35 (code 0.5).
- 锏's 速杀: PRTS hits only its former blocker, 0.3 s after the blink.
- W's C4: PRTS installs it on the unit in a 3.2 s cast, 180 % on it (code: 1 s fuse, radius 1).
- 管's 裂管之奏: the extra 0.8-radius 凋亡 hit "(无视其可选性)" is not modelled.
- “萨科塔昂首”'s 祈祷邀约 "不可对空" does not filter flying allies.
- Tests: `test/sim/feedback2-stealth-splash.test.js` (9 — the selectors; 碎骨 next to a 隐匿, a 迷彩 and a 伪装服 operator; “庞贝”'s blast on its 隐匿 blocker; 卢西恩's 【aoe】; the 无视无法选择 exceptions; 蚀裂's 毒雾 and the 深池伙友卫队 / 扎罗 auras vs 隐匿 and 起飞; 死亡之眼's locked target; the blade / hammer hover attack, the 自行炮 and 深溟巢涌者 vs 迷彩, 祈祷邀约 vs 隐匿 — every battle case fails on 0.1.1), `test/content/enemies_bosses.test.js`, `test/sim/feedback1f-liftoff.test.js`, `test/docs-consistency.test.js` (卢西恩's / 锏's cast conditions).
### 22.13 要塞 cannot hit air units; 莫斯提马's slow shows its icon (GitHub #35, #16; PR #48) — `tools/build-data.mjs classifyAttack` (fortress no longer in the can-hit-flying ranged set), data/chess.json `canHitFly` false for chess_char_2_18_a/_b (灰毫) and chess_char_5_08_a/_b (号角), `sim/professions.js` (`fortress`, `resolveProfile`), `kits/tier4.js` (莫斯提马's slow pulse `status: 'slow', visible: true`)
- **Seen**: GitHub #35 — the 要塞 operators 号角 and 灰毫 shot at flying enemies and splashed them; #16 — "莫斯提马三技能没有减速".
- **Official**: PRTS 新人入门 marks the 要塞 branch 不可对空; the 要塞 trait text never mentions air, and no 号角 or 灰毫 skill adds anti-air. 莫斯提马's slow was **not** broken: her talent's move_speed −0.15, ×3 during S3 (`talent_scale`), is 85 % / 55 % of the movement speed per PRTS, and it already applied in 0.1.1 — only no status icon was drawn on the slowed enemy, which is what the reporter saw.
- **Cause**: `classifyAttack` gave `canHitFly` to every ranged attacker unless the trait says 地面敌人 (投掷手); a 要塞 attacks from the melee position with a ranged normal attack (`MELEE_RANGED_SUBPROFS`), so it counted as ranged and the `fortress` profile did not set `groundOnly`. 莫斯提马's `pulse` carried no status.
- **Now**: `canHitFly` is false for the sub-profession `fortress` — the only data change is those 4 records of data/chess.json (normal + elite); the `fortress` profile reads `canHitFly: false, groundOnly: true` and `resolveProfile` forces both for any fortress or `groundOnly` profile (the data's generic ranged default cannot re-enable it), so the four records neither target nor splash a flying enemy (`targeting.canTargetEnemy`, the splash loop of `ai.js resolveHit`); ground enemies are still attacked and take the radius-1.0 splash. 莫斯提马's slow pulse passes `{ status: 'slow', visible: true }`, so the existing slow status icon shows on every enemy in her range; the slow itself is unchanged. §21.29's skill-trigger deviation for the six 重装 skills (号角, 灰毫) is untouched.
- **Players notice**: 号角 and 灰毫 (and their elites) ignore flying enemies completely — no shot, no splash — so flyers need another operator; against ground enemies they play as in 0.1.1. Enemies slowed by 莫斯提马 carry the slow icon.
- Contributed by the PR #48 author (Paper-Yuan), squash-merged 2026-10-04. Normative lines rewritten with it: docs/SIM.md (the profession table: 要塞 ground only, by the PR), docs/DATA.md §2.1 (`canHitFly`).
- Tests: the PR's — `test/sim/professions.test.js` (the four records read `canHitFly` false / `groundOnly`; a 要塞 beside a FLY enemy makes no attack and leaves it untouched, beside a ground enemy it attacks and splashes) and `test/content/kits_t4.test.js` (莫斯提马's slow buff carries the `slow` status).
### 22.14 Ranged enemies stand for each attack (#58) — `sim/ai.js attackStand / enemyAttack / updateEnemy / advanceRoute`, `sim/simdata.js normalizeEnemy`, `sim/Battle.js spawnEnemy`, `tools/build-data.mjs enemyAttackAnim / attacksOnTheMove / loadManifest`, enemies.json `attackAnim` / `attackMoves`, `render/spine.js SpineActor.windUp / attack (clipPerAttack)`, `render/units.js`
- **Seen**: 「1.1中敌方远程攻击单位在攻击时任会移动」. **Real** in the sim and the client: an unblocked ranged enemy stood only `ATTACK_PAUSE` = 0.35 s after each strike (no source for the number), while every ranged attacker's attack clip is longer (median 1.33 s, 0.37–3.5 s); the client stretched the clip over the whole attack interval and kept the attack pose for 1.4 intervals, so the clip looped while the enemy walked about 90 % of the time. Measured on feedback2 6b634fe (能天使 on (10,6), one enemy walking row 9): 隐形弩手 (interval 2.70 s, clip 1.0 s, strike frame 0.533 s) walked 0.24 tiles during the wind-up, stood 0.37 s and walked 2.33 s per attack; 深池暗影术师 (3.0 s, 1.2 s) stood 0.37 s and walked 2.67 s.
- **Official**: no source gives the length of the stop. The owner's decision (2026-10-04, from first-hand memory of the official game): a ranged enemy stops for each attack's animation and walks on between attacks. The enemy handbook treats attacking on the move as a special ability — “十字路口”量产型 / 试做型 「不停止移动的四向攻击」 (not an enemy of this mode).
- **Cause**: `ai.js enemyAttack` set `pauseUntil = now + ATTACK_PAUSE` after the strike and nothing before it; the data had no clip lengths (they lived only in the client's asset manifest); `SpineActor.attack` / `windUp` played the clip at `clip / interval` speed in a loop.
- **Now**:
- Data: `tools/build-data.mjs enemyAttackAnim` copies each enemy model's attack clip from the committed asset manifest (data/assets.json `anims.attack.loop` — the clip the client plays, not an Idle stand-in —, its length and first `hits` time) into enemies.json `attackAnim` { clip, dur, hit } (213 of 249 keys; the rest have no attack clip). An ability text with 「不停止移动」 gives `attackMoves: true` (no key of the mode). The sim reads both from its data, never from client files.
- Sim (`ai.js attackStand`): the stand is the clip — `hit` of it before the strike and `dur − hit` after it, both divided by `dur / interval` when the attacks come quicker than the clip. Before the strike: `enemyAttack` reports the wind-up (cooldown ≤ the wind-up, an ally it may target within its reach) and `updateEnemy` holds the enemy; after the strike `e.atkStandUntil` holds it. Only the walking is held (`advanceRoute(…, standing)` holds a MOVE leg, and a 诱导 walk): a checkpoint's WAIT keeps running and DISAPPEAR / APPEAR legs still happen — leaving the field ends the stand — so a wait is not lengthened by the clips (data/waves.json has 122 WAIT and 99 DISAPPEAR checkpoints; review finding). It is drawn standing (`moving` false, so the client's base clip is Idle until it walks). The first attack of an enemy that walks into range with its cooldown long over still lands at once (no wind-up to stand through; strike times are unchanged). No clip known: `ATTACK_PAUSE` after the strike, as before; `attackMoves` (data, or content through `e.profile.attackMoves`): never stops. A stun / freeze / sleep ends the stand, and a 恐惧 is not held by it. Blocked and melee enemies are unaffected; content holds are separate (暴鸰 has no normal attack — `noAttack` — and hovers through its drop on its own `pauseUntil`).
- Client (`SpineActor.clipPerAttack`, set for every enemy model by `render/units.js`): each attack plays the clip once, wound up so its strike frame meets the attack, at its own speed (faster only when the attack interval is shorter than the clip), then the resting clip — Move once the sim walks it again. An attack seen late starts on its strike frame. Operators keep the clip looping over their attack rhythm.
- **Measured**:
- Same layout as above (`attackAnim` in, this branch): 隐形弩手 stands 1.00 s per attack (0.50 s before the strike — one tick short of 0.533 — and 0.47 s after) and walks 1.70 s; 深池暗影术师 stands 1.23 s and walks 1.80 s; 萨卡兹百夫长 (5.0 s, clip 3.5 s) stands 3.53 s and walks 1.50 s (it used to stand 0.40 s). Over the 75 ranged attackers of the mode, the median share of an attack cycle spent walking went from 91 % to 63 %; three (clip ≥ interval: 深溟巢涌者, 富营养的巢涌者, 节日爵士乐手) no longer walk while they have a target.
- Real rounds: four seeded 4-bot co-op matches (HARD seeds 1–2, ABYSS seeds 1–2, rounds 1–12 / 14) saved every normal field as its BattleSpec. The 58 fields with ranged enemies were replayed on 50c9b5b and on this branch. Leaks 750 → 764 (+1.9 %), target LP 753 → 767, kills 886 → 876. The 423 ranged enemies: leaked 222 → 223, mean time on the field 24.6 → 27.2 s (+11 %), their attacks 2725 → 2948 (+8 %), mean tiles walked 6.67 → 6.65. Per field the change goes both ways (HARD s1 R9 one field 0 → 6 leaks, ABYSS s1 R9 another 14 → 8). The same numbers after the route-leg fix (none of these fights has a ranged enemy on a WAIT / DISAPPEAR leg while it attacks). The ranged enemies cross the field more slowly, but they also stand in range of the operators longer and hit them more often, so the net effect on leaks is small.
- [ASSUMED]: the stand lasts exactly the attack clip and is split at its strike frame; the clip's speed-up when the attack interval is shorter than the clip (the client's rule); no wind-up stand before an attack that lands as the target comes into range; a stun / freeze / sleep ends the stand, and 恐惧 is not held by it; an enemy in another form (掠海漂移体's crawl, 转译基底's forms, 杰斯顿's second form) stands for its base clip — known mismatches: 扎罗's second form stands 1.433 s (A_Attack) though its B_Attack lasts 2.0 s (2.5 s interval: about 0.4 s of walking during the recovery), and 转译基底's 特战术师 stands 2.0 s (B_Attack) though its D_Attack lasts 1.667 s; a per-form clip would need the client's FORMS mapping in shared data (later); an attack interrupted by 麻痹 keeps the old 0.35 s stand (PRTS 异常效果 麻痹: a 0.5 s 麻痹震颤 that also stops movement — not modelled); a model without an attack clip keeps `ATTACK_PAUSE`.
- Normative lines rewritten with it: §5.5 Enemy AI (the stand replaces the fixed 0.35 s pause); docs/SIM.md, docs/DATA.md (`attackAnim`), docs/ASSETS.md, docs/PLAYING.md.
- Tests: `test/sim/feedback2-enemy-attack-stand.test.js` (7 — `attackAnim` matches the manifest for every enemy; 隐形弩手 with an operator in range stands for its clip at each attack and walks between attacks; a 1 s attacker with a 2 s clip never walks while it has a target; an `attackMoves` enemy keeps walking while the same enemy without it stands; the fallback, and a stun ending the stand; on a 3 s WAIT checkpoint the wait ends at the same time with or without a target, and the enemy walks on once its current clip is over; DISAPPEAR / APPEAR legs proceed during the stands, and hiding ends the stand), `test/render/feedback2-enemy-attack-clip.test.js` (4, fake PIXI with the real manifest: wind-up, one clip per attack at its own speed, then Move; a quick attacker's clip at 2×, an attack seen late starts on its strike frame; an operator's loop unchanged; enemy views use a clip per attack, operator views do not); `test/sim/combat.test.js` keeps the 0.35 s fallback.
### 22.15 An operator forced off the field lies down and comes back there (#60) — `sim/Battle.js isDown / retreat` (with `_remove`, `_layBody`, `restTile`, `downOn`, `_checkRedeploys`, `snapshot` unchanged), `content/kits/tier5.js` (余烬)
- **Seen**: 「42突袭死后会消失」 (42 = 史尔特尔, a community nickname). **Real**: her talent 余烬 keeps her at 1 HP after a lethal blow and forces her out 8 s later (`battle.retreat`, reason `'retreat'`). `Battle.isDown` took only `'killed'` and `FORCED_EXIT`, so she left no body: no b.snap `down` entry, so the client played Die and faded her out, and no redeploy ring. `restTile` sent her home, so 70 s later she came back on her board tile, not where she fell. Measured on feedback2 6b634fe: on (10,3), out at 9 s, back at 79 s on (10,3), with nothing on the field in between; after a 突袭 jump to (12,8), out at 21 s, back at 90 s on (10,3), then she jumped again. The same path was used by 耀骑士临光 S2 (`tier6.js`), the 骑士戒律 + 竞技旗 combo (`items/battle.js`), 伊内丝 S3's first deployment (`tier4.js`) and a 商人 that cannot pay (`professions.js`, `tier3.js` 琳琅诗怀雅).
- **Official**: PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,并原地留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" This covers every 退场, not only a knock-out (the owner's decision, 2026-10-04: every operator that leaves the field leaves a 倒地干员 on that tile and is redeployed there). PRTS 史尔特尔 天赋 余烬: 「受到致命伤害时持续使生命值不低于1,8秒后强制退出战场」. Her page's 备注 "强制退出战场视为撤回干员" describes the regular game, where a withdrawal refunds part of the cost; 卫戍协议 has no withdrawal in battle.
- **Cause**: §21.20 / §21.24 [ASSUMED] "a withdrawn operator leaves no body": `isDown` listed two removal reasons.
- **Now**: `isDown` takes every removal of an operator that may come back, except the 突袭 retreat (`'raid'`, which redeploys at once on its landing tile): `'killed'`, `FORCED_EXIT` (联防), `'retreat'` (余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3) and `'merchant'`. The §21.24 rules then apply unchanged:
- `_remove` lays the body where the operator stood (its own home when that tile is another board piece's home and its home is free).
- b.snap `down` and the field meta list it, so the client draws the body and the ring. The client needed no change: it draws whatever `down` lists.
- `_checkRedeploys` / `redeploy()` bring it back on that tile, and `downOn` / `isReservedTile` keep allies off it.
- A forced exit is still no kill. Its death reason stays `'retreat'` / `'merchant'`: no `kill` hook, no 被击倒 effects, no 不屈 or 阿戈尔 redeploy, no knock-down count (`perPlayer.deaths`) and no knock-down sound.
- 联防 is unchanged: any operator not alive at the end of its own combat already entered 联防 down (`carryState.down`, `FORCED_EXIT`).
- After the fix, the same layouts: down on (10,3) — `down` [1, 79, 70, 0, 10, 3] — and back there at 79 s; after the jump, down on (12,8) and back on (12,8) at 90 s; 耀骑士临光 S2 lies on her tile with her ×1.25 redeploy ring.
- [ASSUMED]: `'merchant'` counts as a 退场 too (the PRTS sentence and the owner's "every operator that leaves the field"; the triage had left it out); the regular game's partial refund for a 强制退出 (视为撤回干员) is not modelled; the 突袭 retreat leaves no body, since it is redeployed on its landing tile in the same tick (should both tiles refuse it, it waits for its timer and comes back home).
- Normative lines rewritten with it: §5.4 DP / redeploy, §8.2 `down`, §21.20 ([ASSUMED] list), §21.24 (Now), §22.1 (the withdrawn-operator sentence); docs/SIM.md, docs/PLAYING.md.
- Tests: `test/sim/feedback2-forced-exit-down.test.js` (4 — 史尔特尔 after 余烬: `down` on her tile, the field meta, no kill / knock-down count, back on that tile after 70 s; after a 突袭 jump: down on the landing tile, the tile reserved, back there with her home unchanged; 耀骑士临光 S2: down on her tile with the ×1.25 ring and back there; a real knock-out unchanged); `test/sim/downelem.test.js` (a forced exit is `down`; the 突袭 retreat, permanent removals, summons, devices and enemies never are) and `test/sim/engine-requests.test.js` (a retreated operator lies where it stood and comes back there) follow the rule.
### 22.16 干员调配 shows the stats, 攻击范围, 特性 and 天赋 the chosen skill / module fight with (#64) — `screens/loadout.js statsPreview / LoadoutStats`, `ui/detailPanel.js chessStatsBlock / chessTalents / traitText`, `css/screens/loadout.css .lo-sec--stats`
- **Seen** (GitHub #64): the 干员调配 detail had only 技能 and 模组 — no 生命上限 / 攻击 / 防御 / 法术抗性 / 攻击间隔 / 阻挡数 / 部署费用 / 再部署, no 攻击范围, no 特性 or 天赋 of the operator (headless 1920 × 1080: block titles `["技能 SKILL", "模组 MODULE"]`, 0 `.dstats`, 0 `.rgrid`). A module card lists only its own overrides (`ModuleInfo`: 属性 +80 …), so a player could not tell what a module or a skill choice does to the operator without leaving for the board and opening its card.
- **Official**: no rule to look up — the screen adds none. The rule it serves is unchanged (§16, "开始游戏前无法调整干员的等级,但可调整其所携带的技能和模组"); the numbers shown are the ones the sim fights with, from the same composition (`shared/loadoutRecord.js`, §16): an elite's stats = `statsBase` + the module's `attr` (不装备: `statsBase`), its 特性 = the module's `traitOverride` (不装备: `traitBase`), its talents = the base ones + the module's `talentChanges`, its range = `attackRangeGrid` (a selected "被动效果:攻击范围扩大" skill, a module's own grid, the 特性's permanent 攻击距离 — §21.16).
- **Cause**: `Detail` rendered the two choice sections only; the card's stats block was inline in `ChessDetail` and its 特性 helper `traitText` private, so there was nothing to reuse.
- **Now**: a **局内数值** section between 技能 and 模组 (next to both choices; the first `.lo-seg` stays the skill-level toggle). `statsPreview(base, golden, entries, level, getChess)` picks the variant — the 精锐 record when asked for and the chess has one, else the normal one — and resolves it with `chessLoadout(variant, entries, getChess).record` (`gameLogic.js`, the call the shop card and the detail card make); no stat arithmetic of its own. `LoadoutStats` then draws `chessStatsBlock` (the card's eight-stat grid and 攻击范围 mini-map — the card now calls the same function, `blocks.stats`, so the two cannot drift), a 特性 / 天赋 card in the module-info card style (`traitText` and `chessTalents`, the card's own rules: the 特性 line exists when the chess has one and follows the chosen module; named, not hidden talents) and a caption. A 普通 / 精锐 toggle has its own state (`statLevel`, default **精锐**: the module exists only there, so a module click changes the numbers at once; it does not move the skill descriptions' toggle); 精锐 is disabled for a chess without an elite record. The numbers are the record's — no live entry, so none of the card's 实时 / 开战时 tag or green / red; the caption says what they leave out: "数值含所选模组;不含技能发动、装备、盟约等局内加成" (in 普通: "普通干员没有模组,所选模组在「精锐」中生效;…"). Measured over the data: 信仰搅拌机 SPT-Y 3 → 4 tiles, 莫斯提马 SPC-X 9 → 10, 引星棘刺 S3 13 → 19 (both variants); 隐现 elite 1,421 / 508 with MAR-X, 1,341 / 486 on 不装备, 1,123 / 399 as the normal chess; 异客's other module rewrites 机理分析 (80 % → 70 %).
- **Now (css)**: the card's `.dstats` / `.dstat` / `.drange` / `.rgrid` (game-panels.css, loaded on every page) at this screen's reading sizes — every text size keeps a px floor (labels 9, values 12, range cells 7 px at the phones' 1 rem = 40 px); the stats grid takes `flex: 1 1 max(3.2rem, 190px)` and the range box wraps under it when the column is narrower than about 260 px. Headless Chrome (`test/ui/loadout.e2e.test.js`, screenshots `test/e2e/out/loadout-stats-*.png`): 1920 × 1080 the grid with the range box beside it; 844 × 390 (a 240 px detail column) the range box under the numbers, no sideways overflow, no label cut off; 667 × 375 (the detail slid over the roster) beside again. Portrait phones get the app's "rotate device" screen, so they are no target.
- [ASSUMED]: the section's place (the issue offers before 技能 or after 模组 — between them keeps both choices within a screen of the numbers on a 1080p display), 精锐 as the default variant, the caption wording, no difference colouring against 不装备, and that the 特质 (garrison) block is not repeated (the issue names 特性 and 天赋 only).
- Tests: `test/ui/loadout-stats.test.js` (9: the variant and the loadout resolution, the sim's range changes, the section against `ChessDetail` for 11 chess / loadout / level cases, the eight values, 特性 / 天赋 following the module, the toggle, the section's place, the css floors), `test/ui/feedback1e-range.test.js` (the source contract now points at `chessStatsBlock`), `test/ui/loadout.e2e.test.js` (+2 opt-in browser tests: desktop and 844 × 390 touch).
- **Review (Grok, 2026-10-04)**: the 特性 line fell back to the class trait on a record cloned for a non-default skill or module (e.g. 隐现 on S1 showed 优先攻击空中单位 instead of MAR-X's 攻击空中单位时攻击力提升至110%; 信仰搅拌机 SPT-Y showed the class trait instead of 攻击距离+1) — the in-match card shared the bug since 0.1.1. `traitText` now returns the record's own module line whenever there is one (a 不装备 record carries none: checked on all 133 elite records); test in `test/ui/loadout-stats.test.js`.
+420
View File
@@ -0,0 +1,420 @@
# DESIGN §23 — 0.1.3 — community reports after 0.1.2
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 23. Community reports after 0.1.2 (v0.1.3)
Reports after the 0.1.2 release, fixed on `feedback3` in workstreams W3A–W3J. Each was checked against the official data and PRTS; anything with no source is marked [ASSUMED]. Where each is handled: 联防 carry (#82) → §23.1; 联防 devour of operators who enter down → §23.2; 不屈 on a 撤退 and a 替身 switch → §23.3; SP during stun → §23.4; 碎骨's grenade → §23.5; the enemy numbers left open in §22.12 → §23.6; 圣聆初雪 S2 and the blue gate → §23.7; 琳琅诗怀雅 S1 heals without an attack → §23.8; 芬's 信标 (#86) → §23.9; 远见 to 0 → §23.10; ready with funds left → §23.11; the host kicks a human → §23.12; 折桠's skill idle → §23.13; the ammo bar → §23.14; unit-sound mix → §23.15; a stuck model load → §23.16; shot heights → §23.17; the boss leader in prep → §23.18; spectator seats → §23.19; 阿戈尔 revives by position → §23.20; a 傀儡师 switch ends at full HP → §23.21; 联防 fights with the layers the combat reached → §23.22; deployment by column → §23.23; nest spawners and 节日爵士乐手 → §23.24; a split child's bounty → §23.25; a 隐匿 blocker and areas (#97) → §23.26; stacked 寒冷 → §23.27; 「地面干员」 → §23.28; 叙拉古 on every normal hit (#79) → §23.29; mode-off bonds on the strip → §23.30; an item gained as prep ends → §23.31; 乌尔比安 S3 as a free redeploy → §23.32; 波登可's spore radius → §23.33; 琳琅诗怀雅 S3 shoots at 10 coins → §23.34; 高台 only for elite 歌蕾蒂娅 with HOK-Y → §23.35; watching a board in prep (#87) → §23.36; 折射 while silenced → §23.37; a toast when a bond grants a chess → §23.38; an emote manifest timeout (#99) → §23.39.
Decisions of the owner, 2026-10-04: a 联防 devour counts members who enter already down (§23.2); 不屈 rolls on a 撤退 and on a 替身 switch (§23.3); the host may kick a human before the match (§23.12); a co-op room has two spectator seats (§23.19); the boss leader is shown in prep, on the red tiles it can be hit from (§23.18); 远见 can bring a price to 0 (§23.10); 琳琅诗怀雅 S1 heals an injured neighbour without attacking (§23.8); an item that arrives as 休整期结束 merges at the next prep (§23.31); a splitting enemy's bounty stays on the main body (§23.25); only elite 歌蕾蒂娅 carrying HOK-Y may use a 高台, and the bot places her on one that covers the road (§23.35); 琳琅诗怀雅 S3 shoots when the purse reaches 10 and a ground enemy is in the skill attack range (§23.34).
### 23.1 联防 carries the HP ratio and the SP only — no running skill (community report #34, GitHub #82) — `match/unite.js uniteBattleOpts`, `sim/skills.js` (`reset`, `spTotal`, `setSpTotal`), `sim/Battle.js` (`_buildResult` unitsEnd, `_deploy`, `_startDeploying`), `match/fields.js` (client-reported unitsEnd), `match/PlayerState.js battleInput`
- **Seen** (community report #34 「圣聆初雪2技能联防被吞技能」; GitHub #82): every skill that was running when a helper's own combat ended was already on at the 联防 start, with an empty bar — 圣聆初雪's S2 (a toggle: SP 0 once cast) looked "eaten" although it ran.
- **Official**: PRTS 卫戍协议/帮助 §联防阶段 "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,上一阶段为退场状态的干员强制退场)"; the official setup message carries per unit only `charBattleStatusList: [{ instId, hp, tech }]` (research 09 §3 `HelpBattleInfo`). The 技力 of a skill that is running: PRTS 技能 "触发技能后,触发技能的单位将消耗相应的技力,随后在技能持续期间进入阻回" — spent at the activation, nothing recovered while it runs. PRTS 技能 "可充能X次…当前技力上限等于该技能技力需求的X倍": a charge skill's 技力 includes its stored charges.
- **Cause**: `unite.js` copied `unitsEnd.skillActive` into `carryState`, and `Skill.reset` re-activated a carried timed skill for free (a fresh duration / ammo, no charge spent). Two more gaps in the carried SP: `unitsEnd.sp` was the SP towards the next charge only, so stored charges were lost (a full 2-charge skill came back with one); summons carried nothing (DESIGN §19.3 had left 召唤物仅修改技力 out).
- **Now**: `carryState` is `{ hpPct, sp }` for an operator, `{ sp }` for a board summon piece, `{ down: true }` as before. `unitsEnd.sp` is `skill.spTotal` (stored charges × cost + the SP towards the next charge); `reset` rebuilds the charges from it. A skill running at the end enters 联防 switched off with the SP it had left — 0 for a one-charge skill — and recharges from there (圣聆初雪 S2: off, SP 0, 60 s to recast — 52 elite; S3 likewise; her S1 with one charge and 5 SP stored comes back with exactly that). `unitsEnd` now also lists the board's summon pieces (a board uid); the server accepts them from a client-reported result (`fields.js`: `own.all`), `unite.js` gives a summon on the field at the end its SP, `battleInput` hands it over, and `_deploy` applies a carried HP ratio to operators only. A board piece that content deploys during the start deployment (a tactician's 流形 / 狼群 comes with its owner's deploy, `initial: false`) takes its carry on that first deployment (`_startDeploying`). The carried SP is set "部署完成后", as PRTS says: `_deploy` applies it in `reset` and sets it again after the `deploy` hook (`skill.setSpTotal`: SP and charges rebuilt, nothing fired, a passive or an already running timed skill left alone), so a deploy-time SP gift does not come on top — a bar that ended at 30 / 40 used to come back ready with 独行 ("初始技力+15") or 黄沙罗盘 ("初始技力+30"); later redeploys in the same battle keep those gifts (initial SP 10 + 15 / + 30). In practice only 缪尔赛思's 流形 (cost 100, initial SP 95) has an SP skill among the placeable summons: it now enters with its end SP. `unitsEnd.skillActive` is still reported, never carried.
- [ASSUMED]: a summon off the field at the end of the own combat enters fresh (its 技力 at the end does not exist). Not changed: the HP ratio is still applied at the deployment (PRTS sets it "部署完成后" too; no deploy-time effect is known to change it).
- **§22.4 needs a word changed**: its "联防 carries no effects (HP, SP and a running skill only)" now reads "(the HP ratio and the SP only)".
- Docs: DESIGN §5.1 `carryState` / unitsEnd comments (pinned in test/docs-consistency.test.js), §19.3's summon sentence, docs/SIM.md §1.1 and the result shape, docs/META.md (联防), docs/PLAYING.md (联防).
- Tests: `test/match/feedback3-unitecarry.test.js` (unite.js carry: HP + SP for an operator, never `skillActive`, `{ sp }` for a summon on the field, nothing for one off it, `down` as before; `battleInput` hands a summon its carry; a real own combat whose 30 s skill still runs at the end → unitsEnd sp 0, skillActive true → the 联防 battle: skill off, no activation, SP from 0, no skill ATK, a 2-charge skill's stored charges back; 缪尔赛思's 流形 with `{ sp: 40, hpPct: 0.3 }`: SP 40 instead of 95, HP full; 独行 and 黄沙罗盘 with a carried 0 / 30 of 40: the bar comes back as it ended, not ready — 15 / 30 more and ready before the fix —, a later redeploy 25 / 40), `test/sim/fidelity.test.js` and `test/sim/skills.test.js` (rewritten: a carried `skillActive` starts nothing), `test/match/{combat,clientCombat,unitedown}.test.js` (the carry shape).
### 23.2 联防: 阿戈尔's devour counts the operators down since their own combat (community report #3, GitHub #33 item 3) — `content/bonds/core.js devour`
- **Seen** (community report #3): "阿戈尔在进协防的时候,即使已经处于撤退状态的干员也应该会在协防阶段时重新参与进阿戈尔的吞噬攻击力计算,不是像现在这样数值被重置" — a member's devour gains vanished in 联防 when an operator of its chain had been knocked out at the end of the own combat.
- **Official / decision**: no text gives the order of the 联防 forced exit and the battle-start devour (§19.3, §22.3: [ASSUMED] forced exit first). **Per players' reports (two players), owner's decision 2026-10-04**: in 联防 the operators that were down at the end of their own phase still take part in the battle-start devour — marking order, the unit in front, base ATK / block gains — and are then forced out.
- **Cause**: `Battle.start` forces those operators out right before `battleStart`; `devour()` took only units on the field, so the chain broke at the down operator's tile: 乌尔比安 → 幽灵鲨 (down) → 歌蕾蒂娅 → 隐现 gave 乌尔比安 nothing (an empty tile in front of him).
- **Now** (the smallest change; the forced exit keeps its place, so no other `battleStart` handler — devices, 征召's row check, 炎, the add-on refresh, kits, garrisons, bands — sees anything new): `devour()` treats an operator forced out at this start (`carryState.down`, removal reason `FORCED_EXIT`, lying on its tile) as standing for the whole devour but what would land on it. It marks in its turn (left first, then top), it is found as "the unit in front" (`battle.downOn`; the chain goes on through it when it is a member), its base ATK / block count for its marker, and its own marks resolve: their 物理流失 lands on its targets, a kill is credited to it as usual, a member it knocks out spends a 5-tier revive like any first knock-out (whose pending marks are then cancelled). Only the marks on it resolve nothing (`knocked`: it is off the field) — it is never knocked out again, never revived, never devoured (no layers, no `deaths`). Its own marking gains are a persistent buff, as for every marker since 0.1.0: it has them when it redeploys. Measured (real data): the chain above (3 members) gives 乌尔比安 +1569 ATK (幽灵鲨 530 + 歌蕾蒂娅 640 + 隐现 399) and +5 block — exactly the gains with 幽灵鲨 standing; 幽灵鲨 lies forced out with no knock-out, no revive, no 物理流失 on her; 歌蕾蒂娅 and 隐现 fall to 乌尔比安's marks as with her standing (her own marks find them knocked out already). With 幽灵鲨 (down) at the head of 幽灵鲨 → 歌蕾蒂娅 → 隐现, her two marks knock both out, exactly as standing (3 members: same knock-outs; 5 members: 歌蕾蒂娅's revive is spent and she stands again at full HP).
- [ASSUMED]: nothing lands on a down operator (it is forced out right after; a 物理流失 on it could only knock it out a second time or spend a revive), so — unlike a standing one that an earlier mark knocks out — a down marker always resolves its own marks. **Review fix (Grok, round 4)**: the first version skipped the down marker's own marks too, so the unit in front of it took no 物理流失 and a 5-tier revive stayed unspent while it still passed its base ATK / block on.
- **§22.3 needs a word changed**: its "The order of the 联防 forced exit and the battle-start devour stays [ASSUMED] (§19.3: forced exit first); no source gives it" now points here. §19.3's [ASSUMED] sentence names the exception (edited in place).
- Tests: `test/content/feedback3-egir-down.test.js` (the chain 乌尔比安 → 幽灵鲨 (down) → 歌蕾蒂娅 → 隐现 in a 联防 battle: 乌尔比安's devour buff equals the chain with 幽灵鲨 standing; she is forced out — no `'killed'` death, no redeploy, no 物理流失 on her, one knock-out fewer in `deaths`; her marking gains kept; 幽灵鲨 (down) at the head of the chain with 3 and with 5 members: the resolved marks, kills, knock-outs and — at 5 — 歌蕾蒂娅's spent revive equal the chain with her standing, and she stays forced out); `test/content/bonds_core.test.js` unchanged and passing.
### 23.3 不屈 rolls on a 撤退 and on a 傀儡师's 替身 switch (owner's decision 2026-10-04) — `content/bonds/addon/battle.js` (不屈), `sim/professions.js installDollkeeper` (hook `dollSwap`)
- **Seen**: the triage of community report #24 (GitHub #33): 不屈 rolled on knock-outs only; a ground operator withdrawn by its own effect stayed down even at 210 layers (p = 1).
- **Official**: PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 不屈 — its 修正 (原因 6) replaces the text's "被击倒" by "被击倒、撤退、切换<替身>与<本体>": "地面干员…时,有(18+0.4×层数)%概率立刻重新部署"; "※“立刻重新部署”的实现方式为:令受益者下次部署的再部署时间和费用归零"; "※持有“复活”效果时,即使触发了不屈的“重新部署”效果,也还是会消耗一次“复活”次数". The tier-2 line "<在场3名不同【不屈】干员>地面干员被击倒时使场上所有干员技力+5" was not corrected. research 02 §3.19 already had the corrected trigger. **Owner's decision 2026-10-04**: follow PRTS.
- **Cause**: the `death` hook returned unless the reason was `'killed'`; nothing reported a 替身 switch (the garrisons poll `unit.trait.doll`).
- **Now**: a knock-out (`'killed'`) or a 撤退 rolls: the reasons `'retreat'` (an operator withdrawn by its own effect — 史尔特尔's 余烬, 耀骑士临光 S2, 伊内丝 S3, the 骑士戒律 combo) and `'merchant'` (行商 that cannot pay). A hit redeploys it at once, free, where it lies (the engine rest tile, §22.15) — once per unit and instant, as before. No roll on the 突袭 retreat (`'raid'`: it redeploys at once and free on its landing tile anyway) or on the 联防 forced exit (`FORCED_EXIT`: the battle's setup, not a 撤退 — §19.3; the operator keeps its full timer). A 傀儡师 switch rolls too — to the 替身 and back (new hook `dollSwap { unit, form }`, fired when a switch starts, not when she is knocked out as the 替身): she stays on the field, so a hit only zeroes her **next** deployment (`unit.mem.indomFreeDeploy`): the next time she leaves the field (knocked out as the 替身, withdrawn) she is back at once and free, whatever that exit's own roll gives; any deployment spends it (this redeploy, the 阿戈尔 5-tier revive, a 突袭 jump). Tier 2's +5 SP stays a knock-out effect. The 不屈 roll still needs a ground operator (`unit.ground`, §22.6) and the player's 不屈 bond; membership is not needed (unchanged).
- **Players notice**: with 不屈 lit, 耀骑士临光 coming back from her S2 withdrawal, 史尔特尔 after her 余烬, a 行商 short of DP and 伊内丝 after placing her 影哨 may redeploy at once (at 205+ layers always — 耀骑士临光 then re-runs S2 every 22 s, a 行商 that cannot pay keeps coming back); 归溟幽灵鲨 / 风丸 roll on each switch and a hit brings them straight back after their next knock-out.
- [ASSUMED]: no roll on the 突袭 retreat (it would change nothing — its redeploy is already immediate and free); one zeroed deployment at a time (a second hit while one is banked adds nothing), spent by the next deployment of any kind; the switch roll is silent (no fx — nothing is redeployed then). Open: the 阿戈尔 5-tier revive's implementation note ("受益者因移动之外的原因退场时…归零") may cover retreats too; it still fires on first knock-outs only.
- Docs: docs/SIM.md §1 (the 退场 paragraph) and the hook table (`dollSwap`).
- Tests: `test/content/feedback3-indom-exits.test.js` (p = 1: a `'retreat'` and a `'merchant'` withdrawal redeploy at once, free, on the tile; tier 2's +5 SP comes with a knock-out, not with a retreat; the 联防 forced exit and the 突袭 retreat roll nothing; p = 0.18: a missed roll leaves the operator down on its tile; 归溟幽灵鲨: the switch to the 替身 banks a free deployment — the knock-out of the 替身 brings her back at once and free, the bank spent; the switch back rolls on its own; with a bank, a knock-out brings any ground operator back at once whatever the roll), `test/content/bonds_addon.test.js`, `test/sim/feedback2-doll.test.js`, `test/sim/professions.test.js` unchanged and passing.
### 23.4 A stunned operator keeps its natural SP recovery (community report #18, 洛洛 S2) — `sim/skills.js tick`
- **Seen** (community report #18): "洛洛二技能结束眩晕好像不会自然恢复技力,而且好像持续时间也不太对,洛洛二技能是有过载的".
- **Official**: PRTS 技能 — natural recovery is a 1 s SP cooldown slot, and "在阻回状态或技力条已满时,保留剩余冷却时间,计时暂停": only 阻回 (SP_RECOVER_STOPPED, "停止技力自然回复…※通常情况下,处于技能期间的单位皆持有阻回") or a full bar pause it. PRTS 异常效果 STUNNED "状态机尝试切换至STUN(无法攻击、释放技能、阻挡敌人类单位;敌人类单位无法移动)" and gamedata_const ba.stun "无法移动、阻挡、攻击及使用技能" name no SP effect; ba.frozen likewise. So a stun does not stop natural recovery. 洛洛 S2 自负此轭 (skill_table `skchr_rockr_2`; the chess status skillLevel 4 / elite 7): duration 40, SP cost 45 / 40, initial SP 15 / 20, ATK +40 % / +50 %, ASPD +45 / +60, funnel cap ×1.6 / ×1.7; ba.overdrive "技能进行到一半时触发额外效果"; "技能结束后晕眩过载持续时间的等长时间".
- **Duration — correct, unchanged**: the overload starts half-way (20 s) and the stun lasts as long as the overload ran (20 s after a full skill; an earlier end stuns for the part already overloaded — the triage measured 5.03 s for a stop 5 s past half-way, 0 before it); the data match skill_table at both levels.
- **Cause**: `SkillRuntime.tick` returned when the unit could not act (`!canAct`: stunned, frozen, levitated) before the natural recovery, so a stunned operator's time SP stood still — written as a rule in skills.js and docs/SIM.md ("no SP gain … while stunned").
- **Now**: the time SP recovers first (unless 阻回 `noSp` or a running timed skill, both unchanged), then a unit that cannot act skips its trigger checks and casts. A general rule — every stunned / frozen / levitated operator with time SP. 洛洛 after a full S2: +20 SP during the 20 s stun (was 0), recast 45 s after the skill end (elite 40) instead of 65 (60). Others that change: self-inflicted — 幽灵鲨 S2 肉斩骨断 (10 s stun after the skill: +10 SP; recast 45 / 43 s after the end instead of 55 / 53), 圣聆初雪's 圣山的祝福 (4 s self-freeze after the saved knock-out: +4 SP unless a skill runs — S2 holds 阻回 anyway); 雷蛇 S2 反击电弧 is hurt-SP (受击回复, which already counted hits while stunned) — no change. Enemy stuns / freezes on operators: 粉碎攻坚手 / 组长 and 码头水手 / 水手长 (every Nth attack), 流泪小子 (鸭爵, 3.5 s), “巨大的丑东西” (its knock-out blast), 杰斯顿·威廉姆斯, 重弩突袭者 (直击), 乌顶巨角卢鲁 (角力对决) and the leaders' stuns (胄's 剑 / 锤 blasts, tentacles 5 s), the 神经损伤 burst on operators (10 s), cold-on-cold freezes (3 s) and 浮空 — the stunned operator's SP keeps filling, its casts wait until it can act. Attack SP (no attacks while stunned) and hurt SP are unchanged; units that are not deployed recover nothing.
- [ASSUMED]: none (the rule is PRTS's; frozen / levitated follow the same SP-cooldown text).
- Docs: skills.js header, docs/SIM.md §7.1.
- Tests: `test/sim/feedback3-stunsp.test.js` (洛洛 S2's data against skill_table for both pieces; a full S2: a 20 s stun, +10 SP after 10 s of it, 20.5 after the stun, recast 45 s after the skill end; frozen: +1/s; 阻回: nothing; an operator that fills its bar while stunned casts only after the stun), `test/sim/combat.test.js` (the stunned operator neither attacks nor casts, SP +1.9 over 1.9 s — the old assertion was "no SP").
### 23.5 碎骨's grenade: 26 % ATK on the target and its 8 surrounding tiles, DEF −50 % for 5 s (community report #14) — `content/enemies.js` (`splashAttack`, `noAirTargets`, `kitLeaderMisc` enemy_1500_skulsr, `SKULSR_GRENADE_SCALE` / `SKULSR_DEFDOWN_DUR`)
- **Seen** (community report #14): 「敌人碎骨的伤害有人反馈过高,复核有没有异常」. **Real**: its combat stats are right (level 0 in data/enemies.json = PRTS: HP 10500, ATK 1000, DEF 150, RES 30, range 2.6, interval 3.0; the 50 % ATK-up below half HP is in the blackboard and was already modelled) — the unblocked grenade was not. §22.12 had listed it under "Found, not changed".
- **Official**: PRTS 碎骨 天赋 "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力26%的物理伤害,并令其在5秒内防御力下降50%(直接乘算)", "※榴弹对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,可溅射飞行单位", and "不会攻击飞行单位". The enemy_database blackboard has only `atkup.atk` 0.5, `atkup.hp_ratio` 0.5 and `defdown.def` −0.5: the 26 %, the 5 s and the 3×3 are PRTS's numbers (no table carries them); the −50 % is the blackboard's.
- **Cause**: the kit let the engine deal a full attack on the target, then hit everything within radius 1 (the 4 orthogonal neighbours, no diagonal) for another 100 % ATK, with a 3 s DEF-down [ASSUMED "短时间"].
- **Now**: a shared helper `splashAttack` fixes the attack's mode when it starts (`before`: unblocked ⇒ grenade). The target takes the engine's normal attack at 26 % ATK (`profile.atkScale`, so on-hit effects still apply); when it lands, every unit on the target's tile and the 8 around it (`areaAlliesInTiles` 'box' 1, 格子判定) takes 26 % ATK physical as a splash (tag `splash`); everyone hit gets DEF −50 % for 5 s. A blocked attack is its plain melee hit (100 %, no splash, no DEF-down). The splash keeps 0.1.2's area rule (§22.12): a 隐匿 operator next to the target that does not block 碎骨 is spared, a 迷彩 one is not; a flying ally (the 炎佑 dragon) is splashed ("可溅射飞行单位") but never targeted (`noAirTargets`); an airborne 起飞 operator stays out (对地规避, as every ground enemy's area). fx `explode` kind `grenade` (box) on each landing.
- [ASSUMED]: the splash goes off on the grenade's landing even when the target dodges it (the main hit's `hit` event, before its dodge); a target that died or turned invulnerable mid-flight gives no splash.
- **Measured** (real bot rounds — coop NORMAL, 4 bots, matches seed 3 and 7, the first bot's own field at rounds 4 / 6 / 8, one 碎骨 bounty added as `bountySpawns` would, 3 sim seeds each, 18 runs; before = v0.1.2 code): leaks 73 → 5, 碎骨 itself leaked 9 → 0 times (killed 9 → 18), operator knock-outs 81 → 55, damage taken by operators 383 k → 337 k.
- Tests: `test/content/enemies_bosses.test.js` (碎骨: 26 % on the target as its attack, 26 % splash on a diagonal neighbour, nothing two tiles away, DEF-down 5 s / 0.5 on both; blocked: one 100 % hit, no splash, no DEF-down; ATK up below half), `test/sim/feedback2-stealth-splash.test.js` (the 隐匿 / 迷彩 / 伪装服 neighbours, unchanged).
### 23.6 Enemy numbers from the 0.1.2 review checked against PRTS and this mode's data (§22.12 "Found, not changed") — `content/enemies.js` (`splashAttack`, `noAirTargets`, `pathKeysAhead`, `kitWolfLord`, `kitUglyThing`, `kitTeapot`, `kitInvisShield`, `kitCrossbow`, `kitLeaderMisc` “庞贝” / “复仇者” / “墓碑”, 集团军重型火炮)
- **Seen**: §22.12 listed enemy numbers that differ from their PRTS pages; each was checked again on PRTS (the enemy pages, fetched 2026-10-04) and against this mode's enemy data (data/enemies.json = enemy_database level 0, the level every act2autochess enemy uses — activity_table act2autochess `enemyKey … level 0`; the act2autochess level files carry none of these enemies). Only what both confirm, or what PRTS states where the data has no number, changed.
- **Official → now** (until 0.1.3 in brackets):
- **扎罗,“狼之主”** — 天赋 "【远古威慑】:自身1.5半径范围内的我方单位攻击速度-50(指定状况下生效)", "重生期间【远古威慑】生效", second form "【远古威慑】生效 … 进行远程攻击,普通攻击为2连击,攻击范围半径1.25": aura radius 1.5, ASPD −50, during the 40 s 重生 and in the second form; second-form attack radius 1.25 [2.5 / −30 only while reborning / 2.5, all ASSUMED]. 【溶血骇惧】 (技能 "逐渐流失生命(从0/秒开始线性递增,在40秒后达到最大流失速度30%最大生命值/秒),持续时间无限 … 场上存在被此技能影响的单位时狼之主获得静默,每结束1个技能效果狼之主回复5SP,场上技能效果全部结束时狼之主获得7s静默 … 累计损失20%生命值后解除") = the FearCage blackboard: the loss rate rises to `hp_ratio` 30 % max HP/s over `duration_bleed` 40 s and stays there, no end; cured once 扎罗's HP ratio is `hp_ratio_offset` 20 % below its ratio at the cast (an offset from that ratio, so healing 扎罗 delays the cure); an effect whose 扎罗 left the field without a 重生 (a leak, a removal) ends at its next tick with no loss [ASSUMED — no official text; the 重生's onKo cure covers its knock-out]; its SP (the spCost 55 s cooldown) stands still while a unit is caught, each ended effect (cure, knock-out) gives back `sp` 5, and the last one starts `duration_wait` 7 s more of 静默 [5 % peak ASSUMED, 40 s buff, `hp_ratio` 30 % read as the cure, a recast every 55 s whatever was caught].
- **“复仇者”** — 冲锋 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含自身当前所在地块)的距离自身最近的我方单位…": a target it may select (`targetsNear`: no 隐匿 / 迷彩 / 起飞 / untargetable operator) within 3.0 on its own path to its next checkpoint (`pathKeysAhead`: the tiles its current move leg crosses, its own tile included) [any unit within the blackboard's `range_radius` 1.5, 隐匿 / 迷彩 ones included, off its path too]. The blackboard keeps 1.5; PRTS's 3.0 is used. It walks to that unit's tile along that path anyway, so PRTS's re-aimed checkpoint is not a separate move.
- **“庞贝”** — "被阻挡时,每10秒(受晕眩/无法行动/沉睡/冻结/浮空影响时暂停计时,解除阻挡时重置计时)对<固定半径>半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可对空)": radius 1.4; the clock runs only while it is blocked and can act and restarts at every new block; flying allies skipped [radius 1, a fixed 10 s clock from its spawn].
- **“巨大的丑东西”** — "未被阻挡时,可对半径2.5范围内的1名非飞行的我方单位进行远程攻击,对目标及其周围8格内的所有我方单位造成攻击力100%的物理伤害(※…可溅射飞行单位)", "被阻挡时进行近战攻击,造成攻击力300%的物理伤害", "重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力150%的物理伤害和16s晕眩": the data's attack radius is 0, so it never attacked unblocked — now a 2.5-radius ranged attack on a ground unit with the 3×3 splash (`splashAttack`), the blocked hit at `mcmstr_rage_attack.atk_scale`; the self-destruct 2.17 s into the 重生 (a telegraph first), radius 3.0 [no ranged attack; the blast at once, radius 2.5 ASSUMED].
- **“墓碑”** — "自身造成的远程途径伤害的攻击倍率降低至40%" (the blackboard's `atkdown.atk_scale` 0.4, unused until now), "未被阻挡时会进行远程攻击,对目标及其周围八格内的所有我方单位造成物理伤害,不会攻击飞行单位": unblocked, 40 % ATK on the target and its 3×3; blocked, its melee hit at 100 % [one target at 100 % either way].
- **烹泉 / 沏虹** — "普通攻击对目标对及目标周围半径1.0范围内的所有我方单位造成法术普通伤害(无视迷彩,不可对空)": every attack also hits the units within 1.0 of its target; "死亡爆炸(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】…不可对空)", 【烹泉减益】 "攻击速度-40(沏虹 −50),…可叠加,每层持续时间和效果独立计算": the blast reaches 1.25 and gives each unit hit one 15 s layer of its own [no attack splash; the blast took the attack radius 2 and left a 15 s steam zone re-applying the cut to whoever stood in it, ASSUMED]. The attack splash, a 法术普通伤害, can be dodged like the attack (`splashAttack` `dodge`); the blast (法术溅射伤害) and every other enemy splash here cannot.
- **集团军重型火炮** — "不会攻击飞行单位", "普通攻击向目标发射一枚炮弹,对目标半径1.0范围内的所有我方单位造成攻击力100%的物理伤害(此弹道会强制击中主目标,碰撞无视迷彩,不可对空)", 【燃烧区域】 "<1.5倍可变半径>碰撞半径1.5": the shell splashes 1.0 around its target; the zone's radius is `projectile_range` × 1.5 and it skips a flying ally (`dmgZone` `noAir`: the 炎佑 dragon) [no splash, zone radius 1, the dragon burned].
- **清明 / 堂皇** — 辉光照耀 "使【范围隐匿】生效3s" = the talent's `InvisibleShield.duration` 3 (both) [清明 took its skill blackboard's `duration` 5].
- **重弩突袭者** — 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100%的法术伤害与5s晕眩 ※技能持续2.5s", 天赋 "隐匿(被阻挡,主动攻击期间均可解除)": revealed for the skill's 2.5 s, the bolt 1.4 s in at the nearest unit still in line in the aimed direction [revealed for the blackboard's `duration` 2 s, the bolt at once].
- Every area added or changed picks its victims with `areaAllies` / `areaAlliesInTiles` (§22.12): no 隐匿 operator that does not block the enemy, no airborne 起飞 one, 迷彩 not checked; "不可对空" sites also skip a flying ally (the 炎佑 dragon), "可溅射飞行单位" ones take it.
- **Cause**: the kits predate these PRTS readings (the numbers were [ASSUMED] or read off the wrong blackboard key); 巨大的丑东西's and 墓碑's ranged attacks and the splash of 烹泉 / the 火炮 were never written.
- [ASSUMED]: a 溶血骇惧 ends when its 扎罗 leaks or is removed; 重弩突袭者 stands still and cannot attack for the 2.5 s, and a stun / silence or its death before the release cancels the bolt; 扎罗's SP is +1/s (its cast every spCost s, as before); a splash goes off on landing even if the target dodges (the main hit's `hit` event).
- **Found, not changed**: 【烹泉减益】 "可被抵抗" (a plain buff here, no 抵抗); 清明's 【范围隐匿】 is an aura during its 3 s (units entering the 2.0 radius are veiled, leaving ones not) — here the units inside at the cast get 3 s; “复仇者” "※重生后:此技能释放的动画动作期间免疫晕眩" (no cast animation in the sim); 扎罗's 无法撤退, 血债账款 / 【狂暴怒嗥】; enemy areas measure centre distances, so “庞贝”'s 1.4 misses a diagonal neighbour (√2); the rest of §22.12's list (碎铳之簧, 锏's 速杀, W's C4, 管, “萨科塔昂首”) is outside this item.
- **Measured** (real bot rounds — coop NORMAL, 4 bots, matches seed 3 and 7, the first bot's own field, one bounty enemy added as `bountySpawns` would, 3 sim seeds each; before = v0.1.2 code; leaks / operator knock-outs): 巨大的丑东西 R9/11/13 (18 runs) 190 → 251 / 78 → 113; “庞贝” R8/10/12 (18) 164 → 164 / 110 → 113; “复仇者” R9/11/13 (18) 193 → 218 / 88 → 92; 扎罗 R11/13 (12) 147 → 158 / 74 → 86; “墓碑” R11/13 (12) 353 → 358 / 96 → 96; 烹泉 R5/7 (12) 43 → 31 / 21 → 20; 清明 R5/7 (12) 39 → 36 / 21 → 23; 重弩突袭者 R6/8 (12) 0 → 0 / 31 → 35; 集团军重型火炮 R9/11 (12) 27 → 25 / 40 → 43. 扎罗, “墓碑” and 重弩突袭者 bounties are not drafted (`draftExcluded` 'unseen'); 集团军重型火炮 is a DOT 特训 enemy of the hard / abyss modes (`inactiveEnemyKeys` of the funny and normal ones) — added here to normal rounds all the same.
- Tests: `test/content/enemies_bosses.test.js` — 扎罗 (loss rate, no end, no recast while caught, cure at 18 % no / 20 % yes, the 7 s 静默 then the recast, every effect ends at the 重生, the aura −50 within 1.5 only, in the second form too, range 1.25), “复仇者” (a unit on its path 3 tiles ahead; none off its path, 4 tiles ahead, 隐匿 or 迷彩), “庞贝” (radius 1.4, the paused and restarted clock), “巨大的丑东西” (the blocked ×3 hit, the bomb at 2.17 s; the unblocked 3×3 at 100 %, the 3.0 blast), “墓碑” (40 % 3×3 unblocked, one 100 % hit blocked), 烹泉 / 沏虹 (splash r 1.0, blast r 1.25, one 15 s layer), 集团军重型火炮 (splash r 1.0, zone r 1.5; the 炎佑 dragon inside the zone is neither burned nor hit), 清明 / 堂皇 (3 s), 重弩突袭者 (no bolt before 1.4 s, revealed and standing for 2.5 s, a stun cancels it); 扎罗 leaking in its first form (every 溶血骇惧 ends, no loss after the leak); 烹泉 (an arts-dodge neighbour dodges the splash, the blast still hits a dodger). The three last fail on the branch's first version (Grok review).
### 23.7 圣聆初雪 S2 freezes the protection point, and only it (community report #32) — `content/kits/tier6.js sbell2` (`addSnow`, `freezeTile`), `render/units.js` (`ICE_TOKENS`), `render/textures.js diamondTexture` (`ice`), docs/ASSETS.md
- **Seen** (community report #32): 「圣聆初雪天赋不能冻住蓝门(积雪能不能对怪物正常生效也查一下)」. **Real**, for her S2 霜涛覆岭: 5 layers of snow on the blue gate never froze it, and nothing showed — the kit gave up because no operator can stand on a gate. Her talent 无垠的雪景 freezes nothing officially (snow, arts on passing, slow per layer); its snow works on ground enemies (checked on a walker: the mode's talent_magic_scale 0.75 × ATK arts on stepping onto a snowy tile — 539 at ATK 718 —, move ×0.64 at 3 layers = 1 − 0.12 × 3).
- **Official**: skill text 「积雪在目标点积累至5层时,使目标点变为冻结状态」 (skill_table skchr_sbell2_2; blackboard `talent@token_key` token_10058_sbell2_icetgt, 保护目标(冻结状态), 「能够阻挡三个敌人」). PRTS 圣聆初雪 S2 备注: 「目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标(冻结状态)(无视部署属性),并去除相应地块上的积雪(且存在自身的该召唤物的地块不会积雪)」, 「可以被'变为冻结状态'的目标点包括常规的保护目标点与促融共竞的保护目标点」; PRTS 保护目标(冻结状态): 使用条件 「技能发动后于保护目标叠加5层积雪」, 备注 「持有禁疗、无法撤退」, 「圣聆初雪退场时强制撤退」. So 目标点 is the protection point — the gate — not any tile.
- **Cause**: `freezeTile` froze any tile that reached max snow but only where a melee operator could stand (`grid.canStand`), which a gate (build NONE) never is; the reverse of the official rule. The client has no model or avatar for the token (data/assets.json: owner only; PRTS 无头像), so a token showed the token fallback — 圣聆初雪's own face in a diamond (assets.js `tokenAvatarUrl`).
- **Now**: a layer bringing a goal tile (glyph E, `special` 'end') to max snow while S2 runs summons her 保护目标(冻结状态) there, deploy attributes ignored (`spawnToken` does not check them); its snow is used up and no snow gathers under her token. Any other tile at max snow just stays at max (S2's spread goes on); a gate that reached max while her one token stood elsewhere freezes on its next layer. The token blocks 3 (enemies walking into the gate are held there instead of leaking until it falls) and keeps its 禁疗 (PRTS 备注 「持有禁疗」, already modelled). On screen the frozen gate is an ice diamond: frame 0x9fe6ff and a snowflake, no picture looked up (`ICE_TOKENS`, `diamondTexture({ ice })`) — there is no ice model to load; docs/ASSETS.md (Tokens) says so.
- [ASSUMED]: the token's 70 s 再部署时间 does not gate the next freeze (as before); one token at a time per 圣聆初雪 (maxDeployCount 1).
- **Found, not changed**: PRTS 无垠的雪景 备注 「积雪的基本效果无视可选性,但无法影响具有无敌/隐匿/不可选中的单位」 — the sim's snow also slows / hurts a 隐匿 or untargetable ground enemy; the slow's 「叠加时减速数值翻一倍(具体可参考显示算法)」 note is not modelled (−12 % per layer, linear).
- Tests: `test/content/kits_alt_t6.test.js` (S2: a free tile of her range reaching 5 layers does not freeze; the gate at 4 + one layer freezes into the token on (9,2), blocks 3, its snow used up, none gathers under it; a walker is held at the gate, no leak), `test/render/unitview.test.js` (the token's ice diamond and frame).
### 23.8 琳琅诗怀雅 S1 heals an injured neighbour without an attack — a deliberate deviation (community report #5, owner's decision 2026-10-04) — `content/kits/tier3.js chess_char_3_04_a installS1`
- **Seen** (community report #5): 「琳琅诗怀雅1技能不会主动奶身边受伤的干员」 — with no enemy to attack, an injured operator next to her was never healed.
- **Official**: S1 仗义疏财 (skill_table skchr_swire2_1; this mode's data/chess.json) 「消耗一枚金币,下一次攻击会为周围八格内血量不足70%的一名友方单位恢复相当于攻击力40%的生命」 (`attack@heal_scale`; 携带此技能时金币上限为2): the heal rides on her next attack, so without an enemy to attack there is none — the sim did exactly that. Not a bug against the official text.
- **Decision**: the owner wants S1 to heal whenever an ally in her 8 surrounding tiles is injured, attack or no attack (owner's decision 2026-10-04) — a deliberate deviation from 「下一次攻击会为…恢复」, like §21.29's six 重装 skills that cast with an enemy in range instead of the official TAKE_DAMAGE row.
- **Now**: the kit's rules stay — the target is the lowest HP ratio below 70 % (the kit's reading of 「一名」) among the units of the 8 surrounding tiles (no 禁疗 or 孤立 unit, no device), a coin per heal (none without a coin), heal_scale × ATK, fx `coin` with the healed id as before. What changes is when: while she attacks, the heal still comes with the attack; when her last attack attempt found no target (`trait.hadTarget` false — no enemy in her range), she heals on her own timer. Either way at most one heal per attack cycle (her attack interval, ASPD included: `nextAt` = the heal + `s.interval`), so an attack right after a timer heal does not heal again; the `tick` hook runs after the attacks, so an attack due in the same tick takes the heal. A stunned (cannot act) or undeployed 琳琅诗怀雅 does not heal.
- [ASSUMED]: the cadence — one heal per attack cycle, the first one as soon as a coin and an injured neighbour are there.
- Tests: `test/content/kits_alt_t3.test.js` — no enemy on the field: an ally at 80 % is left alone and keeps the coin, one at 50 % is healed at once (heal_scale × ATK, one coin spent, no attack); kept injured with coins to spare, one heal per attack interval; with an enemy in her range, one heal per attack, never a second in the cycle; the existing S1 test (attack-driven heal, 周围八格 only, cap 2, no bombs) unchanged.
### 23.9 芬's 信标 sends the original operator, and the gift survives its sender (community report #6, GitHub #86) — `match/builtinMeta.js` (`use_equip_recruit_new_char_and_give_char_to_player_most_bond`, `mostBondMate`, `effect:builtin_gift` `afterElimination`), `match/effectsMeta.js EffectDispatcher.dispatchEliminated`, `match/Match.js startRound`
- **Seen**: community report #6 「精锐干员信标送出去变成一阶的了」 — a 精锐 operator sent with the beacon arrived at the teammate as the normal card of the same tier. GitHub #86: when the sender was eliminated before the next round start, the gift never arrived.
- **Official** [DATA]: act2autochess eff_acarm109 / eff_acgarm109 「装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的干员,免费获取其中一名 / 若在同盟模拟中且存在其他队友,下个休整期向相应盟约人数最多的队友发送1个原干员 / 相应盟约人数相同则随机发送」; 芬 band_fang 「原干员在下回合传递给对应盟约人数最多的队友」. The gift is the original operator; nothing makes it depend on the sender still being in the match.
- **Cause**: (a) the gift stored `baseIdOf(target)` — the normal id — so an elite came back as its normal card holding 1 pool copy. (b) the gift was an EffectRef on the sender resolved by the sender's own `onRoundStart`; `Match.startRound` dispatches only to living players, and `PlayerState.eliminate` keeps `effects`, so an eliminated sender's gift waited forever. It was also removed before the grant, so a grant that failed (no copy left in the shared pool) lost it.
- **Now**: the gift carries the carrier's own id (`chess_char_*_b` for an elite) and its bonds (as counted at equip time, 变形同构体 pairings included). Destroying the carrier still returns its pool copies at once (an elite's 3); the grant takes them again (`grantChess` → `acquireChess`: an elite takes up to `goldenCopies`, like every effect grant), so the teammate gets the elite with 3 copies. The handler is flagged `afterElimination`: after the living players' `onRoundStart`, `Match.startRound` calls `EffectDispatcher.dispatchEliminated` for each eliminated player, which runs only such EffectRefs (nothing else of an eliminated player dispatches). The receiver is still the one picked at equip time; the effect is removed only after the operator was granted, then the CHAR_GIFT ticker names the sender.
- **Players notice**: a beaconed 精锐 arrives as that 精锐 next round; a gift sent by a player who drops out (LP 0 at the settle, or quits) still arrives; the sender's own pick of two is unchanged (two operators of the carrier's tier).
- [ASSUMED]: a receiver eliminated before the round start is replaced by the living teammate with the most members of the operator's bonds (ties at random, the text's rule applied again); with no teammate left alive the gift is dropped; a grant that fails (no copy of the operator left in the pool — the carrier's copies may be bought between the beacon and the next round start —, or the receiver's 整备区 and temp full) keeps the gift and is retried at every following round start (the official text names no refund); an elite whose copies are partly taken meanwhile arrives holding fewer than 3 (the existing rule for effect grants, as 升华 / 博士投影).
- Tests: `test/match/feedback3-gift.test.js` (an elite arrives as the elite with 3 copies and the pool count is restored; a normal carrier still sends the normal card; the sender eliminated at the R1 settle — the gift and its CHAR_GIFT ticker arrive; an empty pool keeps the gift until copies return; a receiver eliminated meanwhile → the next teammate), `test/content/items.test.js` (信标, unchanged).
### 23.10 远见's price −1 has no floor but 0 (owner's decision 2026-10-04) — `content/bonds/addon/meta.js` (`visiShip.onPrice`), research 02 §3.15
- **Seen**: the owner: with 远见 at 150 layers 至简 still cost 1; the text promises −1 on every operator, so 至简 should be free.
- **Official** [DATA]: bond 远见 (visiShip) 「<首次达到80层>所有【远见】干员购买价格永久-1资金 / <首次达到150层>以上效果改为调度中心内所有干员的购买价格永久-1资金」 (buff `bond_multi_layer_char_goods_price_bond_discount` `discount` 1). No floor is named.
- **Cause**: `visiShip.onPrice` lowered a price only while it was above 1 (`Math.min(disc, price − 1)`), research 02 §3.15's [ASSUMED] "price never below 1"; DESIGN §19.9 then kept 至简 (购买价格为1, a 特质 run first on `onPrice`) at 1.
- **Now**: both tiers take the full `discount` off whatever price reaches the bond — `ctx.modifyPrice(−1)`, which never goes below 0. Dispatch order unchanged (§19.9): the priced chess's own 特质 first (至简 3 → 1), then globals, the strategy, the bonds (远见 1 → 0). `PlayerState.priceOf` already clamps at 0; buying at 0 needs no funds (`buy`: `funds < price` is false), the shop card draws a price of 0 as FREE (`shopBar.js`, `priceTone` 'discount'), the client's 资金不足 check (`shopBlockReason`) and the bot's affordability (`price > funds`) both accept 0. The bot's full-board "worth more than the refreshes its price would pay for" comparison keeps its `max(1, price)` (a value heuristic, not a price floor).
- **Players notice**: at 150 layers 至简 is free; at 80 layers a 远见 operator brought to 1 by another discount becomes free; nothing else changes (every other operator was ≥ 2 before the −1).
- Open: 休露丝 雪域礼赠 (「每回合购买的首名<谢拉格>干员消耗资金为1」) runs as the strategy step, before the bonds, so with 远见 150 that first 谢拉格 operator now costs 0 too; whether the official game applies 远见 after the strategy's fixed price is not known.
- Docs: DESIGN §19.9 (至简 now reaches 0), research 02 §3.15 / 02-bonds.json `assumed` (→ data/bonds.json `visiShip.spec.assumed`, regenerated offline; the only data change), code comments in `garrisons/meta.js`.
- Tests: `test/content/bonds_addon.test.js` (150: 3 → 2, 2 → 1, 1 → 0, 0 → 0; 80: a 远见 operator at 1 → 0, others unchanged), `test/content/garrisons_meta.test.js` (至简 sees 1 in a global modifier, then 0 with 远见 150; `m.private` slot price 0; bought with 0 funds).
### 23.11 准备 with funds left asks first (community report #4) — `ui/gameLogic.js readyFundsPrompt`, `screens/game.js toggleReady`
- **Seen**: community report #4 「点准备继续时候如果商店里还有钱的话不会像原版那样弹提示框」 — the 准备 button (and Space) readied at once with funds left; the official client warns.
- **Official**: act2autochess `autoChessData.constData.noMoneyTipsBand` = ["band_cannot"] [DATA] — the strategies that get no "funds left" tip (坎诺特 利滚利 「每回合剩余的资金可以继承」), so every other strategy gets one; data/config.json carries it as `economy.leftoverFundsKeptByBands` (research 01 rules: noMoneyTipsBand). PRTS 卫戍协议/帮助 (休整期): 「本回合的剩余资金将清零,未被冻结的干员/装备会随之清空。进入作战阶段。」 The dialog's own text is in neither the tables nor PRTS.
- **Cause**: the client sent `g.ready` straight away; only the temp overflow row could block it (`shopBlockReason('ready')`).
- **Now**: `toggleReady` (the 准备 button and Space) asks `readyFundsPrompt(m.private, { keptBands, autoplay })` first; with a prompt it opens the usual `confirmDialog` (title 剩余资金, 「还有 N 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?」, buttons 准备就绪 / 继续整备) and readies only on 准备就绪, if the prep is still on and the player not ready yet; a prep that ends while the question is open closes it. No prompt when un-readying, with 0 funds, already ready or eliminated, for a strategy in the list (坎诺特), or under AI 托管 (`m.public players[].autoplay`); bots never use the client. The server rule is unchanged: `g.ready` is accepted with funds and `PlayerState.endPrep` wipes them (坎诺特 keeps them).
- **Players notice**: pressing 准备 with money left asks once; 继续整备 keeps shopping, 准备就绪 (or Enter) readies; 坎诺特 and an empty purse ready at once as before.
- [ASSUMED]: the dialog wording (the rule sentence follows PRTS 帮助).
- Docs: PLAYING.md (剩余资金 row).
- Tests: `test/ui/feedback3-ready.test.js` (the prompt and its text; none at 0 / negative funds, for 坎诺特 — also without config —, when un-readying, already ready, eliminated, under AI 托管, without state; data/config.json lists band_cannot; the server still accepts g.ready and endPrep wipes the funds, 坎诺特 keeps them; game.js asks before `actions.ready` for the button and Space).
### 23.12 The host removes a human before the match (community report #17, owner approved) — `server/lobby.js kick`, `shared/protocol.js` (`room.kick {seat, playerId}`), `screens/room.js SeatCard / kick`, `main.js CLOSE_REASON.kicked`
- **Seen**: community report #17 「多人在房间准备阶段时给真人添加跟人机一样的踢出按钮,不然有的人离线了但是不会退房」 — only AI seats had the host's remove button; a human who stopped answering kept the seat.
- **Official**: none — the official room (research 06 §3.2: 4 seats, creator, ready) has no kick for players, PRTS neither; a lobby convenience the owner approved (owner's decision 2026-10-04). Before this, a human who dropped in the room lost the seat only after `lobbyGraceMs` (60 s), and one still connected but idle kept it.
- **Now**: `room.kick {seat, playerId}` (C→S, host only; `playerId` = the player the host confirmed): refused when the seat changed hands while the confirm was open — that player left and someone else sat down (`BAD_TARGET` 'seat changed hands', the newcomer stays; integration review); refused outside the LOBBY (`ROOM_STARTED` — during a match the seat is the player's to reconnect to), for the host's own seat, an AI seat (`room.removeBot` stays its message), an empty seat (`BAD_TARGET`), a non-host (`NOT_HOST`). The seat is freed at once (`removeMember`: grace timer cleared, result replay dropped, room state broadcast), so an offline seat no longer blocks `room.start`. The player gets `room.closed {reason:'kicked'}` — at once when connected, else on the next resume (`session.notice`, plus the result replay as after a grace timeout); the client goes back to the lobby with 「你已被移出同盟」 (`CLOSE_REASON.kicked`, already there). The room screen shows the host the same ✕ button on every other human's seat (tooltip 将该博士移出同盟) behind a confirm (移出同盟 「确定将「名字」移出同盟吗?对方可以凭同盟密钥重新加入。」).
- **Reconnect**: net.js reconnect tokens belong to the player's session, not to the seat; after the kick the seat binding is gone, so resuming with the old token keeps the identity (same playerId, its loadout) but lands outside the room (`room.closed {kicked}`, no `room.state`).
- [ASSUMED]: no ban — a kicked player may join the same room again with its code; no kick during a match; the result replay of the last match still follows `room.closed` for an offline kicked player (as the grace timeout).
- Docs: DESIGN §8.1 (`room.kick`, `room.closed.reason` 'kicked'), README (和朋友一起玩 step 1), the server/lobby.js header.
- Tests: `test/lobby-kick.test.js` (the schema; a connected guest: room.closed {kicked}, the seat freed, no more room.state, `NOT_IN_ROOM`, joins again; an offline guest: freed at once, the host starts alone, the old token resumes the identity with room.closed {kicked} and no room.state; refusals: non-host, self, AI seat, empty seat, no playerId (BAD_MSG), outsider, during a match; a seat that changed hands — Alice confirmed, Alice leaves, Bob takes seat 1 — is refused and Bob keeps it, naming Bob works), `test/lobby.test.js` / `test/lobby-loadout.test.js` unchanged and passing.
### 23.13 A skill with its own idle clip stands in it between attacks (community report #23) — `render/spine.js SpineActor.setSkill / _skillIdle / update`
- **Seen**: community report #23 「折娅开了技能就会不停做跳起来打人的动作,哪怕没有接敌」 — 折桠 (chess_char_2_17_a, char_4207_branch) kept jumping and striking during her S2 生存决心 with no enemy engaged.
- **Official** [DATA]: her battle model has `Skill_2_Begin`, `Skill_2_Loop` (the jump strike, OnAttack at 0.8 s), `Skill_2_End` and `Skill_2_Idle` (4 s). `Skill_N_Idle` carries no OnAttack event — it is the skill's resting pose, `Skill_N_Loop` its attack (research 07 §5.4 roles; no source spells out the official state machine, the clip names and events are the evidence). S2 starts when she takes damage (rule TAKE_DAMAGE) and her range is her own tile, so she can be in her skill with nobody to hit — the sim makes no attack then.
- **Cause**: `setSkill(true)` queued the skill's loop after the begin clip and nothing left it while the skill ran, so the attack clip looped with no attack. The skill idle (`anims.skill.idle`) was only reached after the first spell of attacks ended, and then through the skill's End clip, which belongs to the end of the skill.
- **Now**: when the equipped skill has an idle clip of its own (`skill.idle` present and not the loop) and its loop is the attack clip, the begin clip is followed by that idle; an attack plays the loop (wind-up and strike frame as before); when the attacks stop the model goes straight back to the idle; the End clip plays only when the skill ends. The manifest has 92 such skills on 62 operators' Front models (e.g. 史尔特尔 S3, 耀骑士临光 S1, 荒芜拉普兰德 S1–S3), all handled the same way; the 6 without a begin clip (耀骑士临光 S3, 缄默德克萨斯 S2 / S3, 引星棘刺 S3, 灰毫) already started in the idle and are unchanged at the start. Unchanged: a skill whose loop *is* its idle (蕾缪安 S2 / S3, 缇缇 S2, 信仰搅拌机 S3 — a stance), skills without an idle clip (begin → loop held as the stance; their spell of attacks still ends through the End clip), attacks borrowed from the normal attack (`via: 'attack'`), enemies.
- **Players notice**: 折桠 (and every operator whose skill has its own idle pose) stands in that pose while the skill runs and nothing is in range, and only swings when the sim actually attacks.
- Tests: `test/render/feedback3-skill-idle.test.js` (折桠 S2: begin → Skill_2_Idle, the loop only on attacks, back to the idle without Skill_2_End, End on stop; 史尔特尔 S3 begin → Skill_3_Idle; 耀骑士临光 S3 unchanged; 蕾缪安 S3 and 宴 unchanged).
### 23.14 The ammo bar counts the extra bullets (community report #35) — `sim/skills.js Skill.ammoMax / activate / addAmmo / end / reset`, `sim/snapshot.js unitTuple`
- **Seen**: community report #35 「拉特兰盟约叠层之后蕾缪安3技能锁定敌人消耗的子弹不会实时让技力条一格一格减少,而是最后突然消耗(最终效果好像是正确的)」.
- **Official** [DATA]: 拉特兰 「拉特兰干员开启技能获得的弹药量提升」 (`base_ammo_percent` 0.05 + `ammo_percent_per_stack` 0.015 × layers); 蕾缪安's talent 逃犯引渡手续 adds bullets at the skill start too. The bullets themselves were right (the lock / bombard count followed `ammoLeft`); only the bar was wrong.
- **Cause**: `installLaterano` raises `ammoLeft` in skillStart while `skill.ammo` stays the base 5, and the snapshot drew `ammoLeft / skill.ammo`; the client clamps the bar at 1 (`render/units.js`). With 30 layers 蕾缪安 starts S3 with 9 bullets: the bar stayed full for the first four locks, then shrank by fifths — "consumed at the end".
- **Now**: the skill keeps `ammoMax`, the most bullets this activation has held: set at activation, raised after the skillStart handlers (any bullets they added — 拉特兰, 逃犯引渡手续, talents writing `ammoLeft` directly as SIM §7.5's example does) and by `addAmmo` above it (refills under it leave it), 0 when the skill ends. The snapshot's draining bar is `ammoLeft / ammoMax`, so it starts full and every bullet takes one share (9 bullets: 8/9, 7/9 …). `skill.ammo` (the base the next activation starts from) is unchanged — raising it would compound the bonus on the next cast. No sim number changes; only the wire `sp` of a running ammo skill.
- **Players notice**: with 拉特兰 layers the skill bar drops one segment per bullet from the first shot.
- Tests: `test/sim/feedback3-ammo-bar.test.js` (蕾缪安 S3 with 30 拉特兰 layers: 9 bullets, the full mark 9, the bar k/9 after each lock; a refill under the mark keeps it, above it raises it; reset at the end), `test/content/bonds_core.test.js` (拉特兰 counts, unchanged).
### 23.15 Unit sounds keep their official play chance and volume (community report #30) — `tools/assets/audio.mjs bankMix / indexAudio().mixOf`, `tools/assets/plan.mjs unitSounds`, data/assets.json `audio.sfx.units[id].mix`, `public/js/audio.js unit / unitGain / unitSoundPlays`
- **Seen**: community report #30 「狗、飞机敌人音效声音过大」.
- **Official** [DATA] (excel/audio_data.json): `battle.ON_ABILITY_ON.enemy_1000_gopro(_2/_3).combat` = an empty asset (weight 80) + `e_atk_gopro_1` (10) + `e_atk_gopro_2` (10), volume 1; `enemy_1165_duhond(_2).combat` = `e_atk_gopro_1` (20) + empty (80) — the dogs bark on about one attack in five. `enemy_1005_yokai_2 / _3.attack` (妖怪 / 威龙) = one vehicle clip at volume 0.7. Across the manifest's unit sounds 29 units have a bank that is not "always, volume 1": the three dogs (chance 0.2) and 26 with another volume (0.45–0.9 quieter, e.g. 妖怪, 盾卫's impact 0.65, 大鲍勃 0.45 / 0.6; 1.5–2 louder, e.g. 缄默德克萨斯's impact).
- **Cause**: `indexAudio` kept only the files of a bank (empty assets, weights and volumes dropped), so 猎狗pro / 深池侦察犬 played `e_atk_gopro_1` on every attack, and `audio.js unit()` played every attack / impact at a flat 0.55.
- **Now**: the asset tool records each bank's mix — `p` = the weight of its sounds that have a file over all the weights, `vol` = the played file's volume (mean of min / max) — and `plan.mjs` writes it, when either differs from 1, as `sfx.units[id].mix[role]` for attack / hit / die / born. data/assets.json was patched offline from the cached audio_data.json with the tool's own functions (each role's URL checked against the bank's first file; the JSON compare shows only the 29 `mix` entries and the content `hash`); the next `fetch-assets` run writes the same. `audio.js` plays a unit's own sound with chance `p` (a silent roll still counts as the unit's own sound: no generic `enemyHit` instead) at its base gain (0.55 attack / hit, 0.8 die / born) × min(1, `vol`): 妖怪 / 威龙 0.385, the dogs 0.55 on 20 % of their attacks; a bank louder than 1 stays at the old gain (the report asked for quieter, never louder). The roll is `Math.random` — presentation only, no determinism needed. Skill-start banks (4 official ones are quieter, 0.6–0.85) keep the old gain.
- [ASSUMED]: 猎狗pro's bank names two barks (10 + 10); the manifest has only `e_atk_gopro_1`, so it barks that one on its full 20 %. The volume is the first file's (the one the manifest downloads); a fallback file, if ever used, keeps it.
- **Players notice**: dogs bark now and then instead of on every bite; 妖怪 / 威龙 and the other officially quieter units are softer; nothing is louder.
- Tests: `test/ui/feedback3-audio-mix.test.js` (bankMix of the official dog / 妖怪 banks and `mixOf` on a picked list; data/assets.json mix vs audio_data.json; a dog barks only on rolls < 0.2 and never falls back to `enemyHit`; 妖怪 0.385, a volume-2 bank stays 0.55, 余's born 0.6), `test/ui/audio.test.js`, `test/assets.test.js` (unchanged).
### 23.16 A model load left hanging by a hidden tab is started again (GitHub #68) — `render/units.js SPINE_STUCK_MS / retryAssets / update / _acquireSpine`, `assets.js RefLru.restart / _attempt / _withTimeout`, `forgetPendingSpine`, `loadSpineData(entry, { fresh })`, store `spine.restart`
- **Seen**: GitHub #68 — after switching back to the game's tab some units stayed the placeholder (the diamond with the glyph) instead of their model.
- **Cause**: `UnitView.retryAssets` (run when the tab is shown again, §21.27) only reloads a view with no model *and no load in flight*. A load begun while the tab was hidden can stay pending — a request the background tab left hanging —, and every later try joined it: `PIXI.Assets.load` of the same URL hands out the same pending promise, and the spine cache shares an entry that is still loading. Its 20 s timeout and the bounded retries (§21.27) then ran into the same pending request until they gave up.
- **Now**: a view remembers whether its load began while the tab was hidden. When the tab is shown and that load is still in flight, `retryAssets` gives it `SPINE_STUCK_MS` = 5 s [ASSUMED] (a load about to succeed is never doubled); if it has not settled by then, `update` calls `assets.spine.restart(entry)`. `RefLru.restart` starts a new attempt of the same cache entry: its references and the promise every holder waits on stay (no acquire / release changes), the hung attempt is abandoned at once (its concurrency slot freed — a hung load no longer blocks the queue — and a late result ignored: PIXI shares resources by URL, so nothing is unloaded), the first attempt to succeed settles the entry, only the newest one's failure fails it. A restart — and any load after a remembered failure (a timeout may have left PIXI's request pending) — is `fresh`: `loadSpineData` first drops PIXI's still-pending requests of that skeleton (`forgetPendingSpine`: the .skel's entry in the loader's promise cache and, unless the atlas finished, the atlas's and its pages'; pages another cached skeleton uses stay), so the new load asks again instead of joining them. A load begun while the tab was visible keeps the old path (20 s timeout, bounded retries).
- [ASSUMED]: the 5 s grace; if the abandoned request completes after the restart, its PIXI resources are left to the garbage collector (a one-model leak on that rare path, preferred to unloading textures a shown model may share).
- **Players notice**: units whose models were loading while the game sat in a background tab show their models a few seconds after coming back, instead of staying placeholders.
- Tests: `test/render/feedback3-stuck-load.test.js` (RefLru.restart: the waiting promise settles with the new fresh attempt, refs kept, a late old result ignored; the abandoned attempt frees its slot; only the newest failure counts; forgetPendingSpine drops only unfinished requests and keeps shared pages; a view whose load began hidden is restarted SPINE_STUCK_MS after the tab is shown and shows its model with one reference; a load finishing within the grace is not doubled; a load begun while visible is not armed), `test/render/issue8-placeholders.test.js`, `test/render/assets.test.js` (unchanged).
### 23.17 Shots leave the hands and meet the chest; no attack jolt on a shown model (GitHub #61) — `render/projection.js Camera.liftFor`, `render/fx.js SHOT_HEIGHT / bodyZ / _bodyPt` (shots, boomerang, mortar, beams, lock marks), `render/units.js update` (the jolt)
- **Seen**: GitHub #61 — shots looked as if they came out of the operator's crotch and hit the enemy's hips; on every attack the whole model jumped a little toward its target (a ranged operator shooting up the screen hopped upward).
- **Official**: no data gives the official muzzle points (weapon tips are in the prefabs, not in the tables we read); the official models do not move toward their target when they attack — the attack is the Spine clip.
- **Cause**: (a) `fx.js chestZ` = feet + 0.45 × the unit's head height, used as a WORLD height; the battle models are upright screen-space billboards and the camera is pitched 30° from straight down, so a vertical world height draws at about half its length: shots started and aimed at ≈ 18–23 % of the model. (b) `units.js` shifted the body 0.12 tiles toward the target on every attack (`lunge`) — the avatar diamond's stand-in for an attack clip — also while the Spine model played its own clip.
- **Now**: `SHOT_HEIGHT = { launch: 0.45, aim: 0.5 }` [ASSUMED: ≈ a battle chibi's hands / chest; enemies' torsos] are shares of the drawn model's height above its feet; `bodyZ(cam, view, share)` turns one into a world height with `Camera.liftFor(x, y, z, px)` (the exact lift whose projection is `px` higher on screen). Shots and boomerangs leave at `launch` (the boomerang returns to it) and fly to `aim`, shells still fall to the feet, the mortar starts at `launch`, lock reticles sit at `aim`; beams go from the shooter's `launch` (a chain bounce from the previous target's `aim`) to the target's `aim`. Other unit-anchored effects (`_chest`: buff sparkles, auras) are unchanged. The jolt runs only while no Spine model is shown (the avatar diamond, DESIGN §9's "bob / lunge" fallback).
- **Height used**: the unit's head height (`_headTiles`, the HP bar's): the uniform chibi headroom for operators and tokens, the setup-pose bounds for enemies (clamped as for the bar). Operators' setup-pose bounds are not their drawn body — they include weapons and effect sprites (median 1.34 tiles, 90th percentile 2.35, one skeleton 7.62) —, so shots from them would leave above the visible model; the review's tall-operator case (秋白, 史尔特尔 …) is such bounds, not a taller chibi. A tiny enemy whose bounds sit under the bar's 0.55-tile floor (青瓷 / 彩瓷茶器) fires from near its top.
- **Players notice**: arrows, bolts and beams come from about the hands and strike the upper body; a model no longer hops toward its target on each attack.
- Tests: `test/render/feedback3-shot-height.test.js` (liftFor exact on the normal / prep / boss / unite cameras; an arrow starts at 45 % and aims at 50 % of the drawn models — the old rule drew < 25 %; a raised / hovering unit measured from where it is drawn; the diamond still lunges, a shown Spine model stays put and plays its attack clip), `test/render/fxproj.test.js`, `test/render/projection.test.js` (unchanged).
### 23.18 The leader stands on the boss field in the prep; its hit tiles light red beside a range preview (community report #12) — `match/waves.js previewOf` (`start`), `render/prepfield.js leaderStand`, `render/pick.js hitTiles`, `render/app.js setLeader / leaderShown / syncLeaderHits / leaderAt / LEADER_HIT_STYLE`
- **Seen**: community report #12 「最终boss应该在准备时间就放到关卡中,点击干员拖动选面朝方向并且展示干员攻击范围时候也展示那样大体型boss实际受击地块,干员攻击范围目前用的橙色,boss受击范围可以像官方原版那样用红色」 — in the Final Assault / Hidden Core prep the camera shows the boss field, but the leader waited in the enemy pen off screen, and choosing a direction showed only the operator's orange range.
- **Official**: research 09 §2.2 [DATA + WIKI]: during a boss round's prep the leader previews on the boss field, standing by its spawn with its HP bar (the PRTS 最终攻势 intro image). Research 08 §4.1 decoded the client's preview row offset (+13 for leader rounds), which lands in the pen — the two disagree; the owner's decision 2026-10-04 (approving report #12) puts the leader on the field. The hit area is the sim's: data/enemies.json `hitArea` (PRTS 巨型单位 "长4.95、宽2.95的长方形,向上偏移1.0", DESIGN §19.4) — 胄 / 管 / 昆图斯 / 阿利斯泰尔 / 萨米的意志 occupy 5 × 3 tiles (rows 3–5, cols 8–12 from (3, 10)); 铳 and 卢西恩 are point units (their tile). No source shows the colour of such tiles: red is the players' request.
- **Cause**: `previewOf` dropped the leader's route start, so the client could only lay it out in the pen (`gateOf` + 13 → the pen's upper zone, `layoutPen`), which only the pen camera shows; the range preview (`highlightTiles` groups `facing` / `selRange`) had no notion of the leader's body.
- **Now**: the leader's preview entry carries its spawn tile `start` ([row, col] on the boss field; escorts and bounties do not). In the prep the field view takes the first such entry out of the pen layout (`leaderStand`) and stands its model there (`setLeader`: idle, its full HP bar, facing the player's half — left half → left [ASSUMED look] —, a tap opens its details like a pen enemy), shown only by the boss-field prep camera (`leaderShown`, also during a flight to / from it); a real battle clears it. Whenever a range preview is shown there — the direction wheel's (`facing`) or a selected piece's (`selRange`, any RANGE_GROUPS highlight) — its hit tiles (`hitTiles` = the sim's `body.js bodyKeys`: every tile its rectangle overlaps) are lit in red (`LEADER_HIT_STYLE` 0xff3b30, fill 0.3 [ASSUMED]) beside the orange range, and go with it. Display only: no sim or rule change. The escorts stay in the pen (research 09 says they preview on the boss field too — not part of the report; left open).
- **Players notice**: in the boss rounds' prep the leader stands where it will fight, with its HP bar; while choosing an operator's direction (or looking at a selected operator's range) the tiles where the leader can be hit show in red next to the orange range, so one can see at once whether the range reaches it.
- Tests: `test/render/feedback3-leader-prep.test.js` (previewOf carries `start` for the leader only; every boss template's leader preview = its route start; `leaderStand` + `hitTiles` = the sim's bodyKeys for every leader, 15 tiles for the huge ones, 1 for 铳; the leader view shows a full HP bar; app.js wiring checks), `test/render/fieldview.browser.test.js` "Final Assault prep (L / R): the leader stands on its spawn tile …" (RENDER_E2E: on (3, 10), facing the player's half, not in the pen, no red tiles until a `facing` preview, then the 15 hit tiles, cleared with it, hidden by the own-board camera; screenshots fa-leader-L / -R), `test/match/waves.test.js`, `test/render/pen.test.js` (unchanged).
### 23.19 Spectator seats: up to two per co-op room, watching like an eliminated player (community report #26, owner approved — a remake feature) — `server/lobby.js` (`Room.spectators / spectatorOf`, `spectate`, `removeSpectator`, `freeSpectatorSeat`, `roomOf`, `onHello / onDisconnect / runResync`, `routeGame`, `memberSessions / sendToPlayer`, `buildReplay`, `disposeRoom`), `server/match/Match.js` (`opts.spectators`, `addSpectator / removeSpectator / _spectator / _viewers / _resync / _spectatorSpec`, `handle`, `sendTo / _sendStart / _humansShowing`, `finish`), `server/net.js HEAVY_TYPES`, `shared/constants.js MAX_SPECTATORS / ERR.SPECTATOR`, `shared/protocol.js` (`room.spectate`, `room.removeSpectator`), `store.js isSpectating`, `main.js onRoomState`, `screens/lobby.js` (观战), `screens/room.js SpectatorBar / roomFacts`, `screens/game.js`, `ui/hud.js TopBar`, `ui/combatHud.js`, `ui/matchChrome.js ExitModal`, `screens/briefing.js`, `screens/bandDraft.js`
- **Seen**: community report #26 「添加一到两个观战席」.
- **Official**: none — **not in the official game**. The official room has the players' 4 seat cards and nothing else (research 06 §3.2); watching exists only for the players themselves: PRTS 卫戍协议/帮助 「阵亡的玩家可在模拟中观战」, in 联防 「所有玩家于该阵地观战」, and a teammate's field after the own battle (research 09 §3.1, §6.3; §14 Spectating). A remake feature the owner approved (owner's decision 2026-10-04); no rule of the match changes.
- **Now (the seats)**: `room.spectate {code}` (C→S) takes one of a co-op room's `MAX_SPECTATORS` = **2** spectator seats — in its lobby **or while its match runs** (a player seat can only be taken in the lobby). Refused: unknown code (`ROOM_NOT_FOUND`), a solo room or both seats taken (`ROOM_FULL`), a player of that room (`ALREADY` — nobody switches to spectating in place), a session whose own room is in a match (`ROOM_STARTED`, as `room.join`); repeated by the spectator it is idempotent, and from another lobby room it leaves that room first (as `room.join`). `room.state` carries `spectators: [{ playerId, name, connected }]` (always present, `[]` without). A spectator is **no player**: never in `seats`, never counted for the 1–4 players, the ready count or the `room.start` gate, never host (`migrateHost` reads `seats` only), and never keeps a room alive — when the last human leaves, the room closes and its spectators get `room.closed {empty}` (players who left themselves get nothing, as before). The host frees a spectator seat with `room.removeSpectator {playerId}` (any time; `NOT_HOST` / `BAD_TARGET` otherwise): the spectator gets `room.closed {reason:'kicked'}` at once, or on its next resume when offline (with the result replay, as `room.kick` §23.12 and the grace timeout). `room.leave` / `g.leave` leave a spectator seat; in the lobby a spectator takes a free player seat with `room.join` of the same code (UI 入座).
- **Now (what a spectator may do)**: `g.watch` (the per-socket heavy bucket, 2/s burst 6, like every watcher), `g.leave` / `room.leave`, and `room.loadout` (checked and stored on its session for a later match, never handed to the match). Everything else → the new `ERR.SPECTATOR` (「观战中无法进行该操作」): the lobby refuses every other `g.*` / `b.*` before the match sees it (`routeGame`), `room.ready` too; host-only messages stay `NOT_HOST`; `Match.handle` refuses anything but `g.watch` from a spectator as well. **Emotes are off for spectators** [ASSUMED] — they see the players' bubbles but cannot send any, so they cannot spam the match.
- **Now (what a spectator sees)** — what an eliminated player watching sees, never more: every broadcast of the room (`room.state`, `m.public`, `m.ticker`, `m.emote`, `b.pool`: the public data every player gets); the match registers the spectator (`opts.spectators` at the start, `addSpectator` when it joins mid-match and on every resume / resync) and shows it fields through `_viewers()` — the loop that already served eliminated humans: each 各自行动 the first field (`b.start watch: true`, a display replica; server-run mode `m.field` + `b.snap`), the 联防 spec as an observer, the first boss field of 最终攻势 / 隐秘核心, any field on `g.watch` (alive false: none of the fighting player's limits — own battle running, the other pair's boss field), a player's board in prep on `g.watch 'n:<pid>'` (the one-shot scouting `m.field {prep: true}`), `b.end` of the field it watches, and `m.result` at the end (the rows every player gets; `playerId` = the spectator, no row of its own). Never an `m.private` (shop, hand, 临时整备区, funds, effects, loadout), an `m.toast` or an `m.unitStats`; never a field's player or authority.
- **Private-data check**: the watch view's `b.start` spec carries `players[].contentInfo`, battle-start counters written by `global:contentb_info` (sim/content/support/meta.js): `funds`, `handUnits`, `handChess`, `roundStats` … — every watcher (a teammate's 前往查看, an eliminated player) receives them, no screen shows them. A spectator's copy leaves out **`contentInfo.funds`** (`Match._spectatorSpec`, cached per field): no battle effect reads it (only the prep-side meta reads `ctx.funds()`), so the replica still plays the same battle. The other counters stay: battle effects read them (`handUnits` — the prep-gated 机变 cards' fallback; `roundStats.gainedChess` — an item), and a replica with other inputs could show another fight. `m.public players[]` (LP, strategy, shop level, board count, bonds, status) is what every teammate already sees.
- **Reconnect**: a spectator seat is kept like a player's — in the lobby `connected: false`, freed after `lobbyGraceMs` (60 s; then `room.closed {timeout}` on resume); during a match kept for the reconnect window (10 min), its disconnect not told to the match (it plays no field). A resume (or a repeated hello, coalesced per `resyncMinGapMs` as for players) answers `room.state` + `Match.addSpectator`: `m.public` and the watched battle (first field by default), or the result once ended.
- **Client**: the lobby has **观战** beside 加入同盟 (tooltip: 不占博士席位,只能观看,每个同盟最多 2 名,模拟进行中也可进入). The room shows a **观战席 N/2** strip under the seats while it has spectators — names, offline marks, the host's ✕ (no confirm [ASSUMED]: a spectator can come back with the code), and **入座** for a spectator while a player seat is free; a spectator's ready button reads 观战中 (disabled) and its status line 「观战中 · 不占博士席位,模拟开始后可切换观看各位博士」. `main.js` keeps the room for a spectator (`onRoomState` checks `spectators` too). In the match a spectator takes the eliminated player's path (`alive` false): no shop, no ready toggle (`TopBar spectator`), no 交流 wheel, no 返回自己 / 返回战场; the briefing button reads 观战中, the draft sends no `g.bandFocus`; the pill reads 「观战中 · 点击左侧成员头像切换查看」 in the watching pill's amber and the combat HUD 「观战中…」 — never 你已被淘汰; in 休整期 / 机变 / round start it is shown the first player still in by itself (once per phase; a team row switches); the exit dialog offers only **离开观战** (no 暂离).
- **Protocol version**: `PROTOCOL_VERSION` stays 1, as for every change since the first import (`room.loadout`, `g.pause`, `g.unitStats`, `m.unitStats`, the `b.*` frames): the change is additive — two new C→S types, a new `room.state` field, a new error code, an optional match option and two optional match methods. The server serves its own client, so only a stale tab could meet them, and the build guard reloads such a tab (ui/buildGuard.js).
- **Server load**: at most 2 per room; a spectator's frames are the room's broadcasts (encoded once, `broadcastRoom`) plus the `b.start` / `m.field` of what it watches; `g.watch` and `room.spectate` (a mid-match join resends the state) draw from the heavy bucket; the per-network socket cap is unchanged. `/healthz` counts `spectators`.
- [ASSUMED]: solo rooms have no spectator seat; spectators cannot emote; no ban after a removal (like `room.kick`); no confirm on the ✕; the host may also remove a spectator during a match (server; the UI offers it in the room only); the spec without `funds`; the auto-shown board in prep; no spectator count in the match HUD.
- Docs: DESIGN §8.1 (spectator-seat paragraph), META §1.4, PLAYING §8, README (观战 row), the headers of `server/lobby.js`, `server/match/Match.js` (interface), `screens/room.js`, `screens/lobby.js`, `screens/game.js`.
- Tests: `test/lobby.test.js` (+3, StubMatch / RecordingMatch over sockets: join / idempotent / `ALREADY` / cap / never a player seat / `SPECTATOR` and `NOT_HOST` refusals / 入座 / leave / host removal online and offline (`room.closed {kicked}` on resume) / solo and unknown codes / a room's last human leaving → `room.closed {empty}`; during a match: broadcasts but no `m.private`, a mid-match join, refusals, `room.loadout` kept off the match, drop and resume give the seat back, staying through the result; lobby grace like a player, `opts.spectators`, only `g.watch` routed, `addSpectator` on join / resume, `removeSpectator` on `g.leave`, never `onDisconnect / onReconnect / onLeave`; the protocol fuzz now also takes / frees spectator seats and checks the cap, no spectator in solo rooms, never seated twice, never host, the session pointing at its seat), `test/match/spectator.test.js` (3: a whole client-combat match — 各自行动, 联防, 最终攻势, settlement — every `b.start` watched and never authoritative, the first field like an eliminated player, `b.end` of the watched boss field, the same `m.result` rows, no frame (unicast or broadcast) with a private key, every player intent refused; mid-battle `g.watch` of any field, the spec without `funds`, a spectator added mid-battle gets the state, a removed one gets nothing; server-run mode `m.field` + `b.snap`), `test/match/lobby-integration.test.js` (+1: the real Match over sockets — briefing / draft / prep refusals, the prep board, a watched `b.start` without funds, drop and resume, no private frame, the room closing for it when the last player leaves), `test/client-static.test.js` (+1: `isSpectating`, `roomFacts`), `test/ui/spectator.e2e.test.js` (opt-in browser, `SP_E2E=1`: lobby 观战 → room strip and 观战中 → the host's ✕ → briefing / prep / battle views, no `m.private`, a reload, 离开观战; screenshots `test/e2e/out/spectator-*.png`).
### 23.20 阿戈尔 5: the 3 revives belong to the first 3 members by position (community report #24) — withdrawn in 0.1.4, see §24.3 — `content/bonds/core.js installEgir` (`egirOrder`, shared with `devour`)
- **Seen** (community report #24, many players): 「阿戈尔盟约复活有bug,只能复活一个干员」「阿戈尔5人羁绊不生效」. The usual food chain puts the carries back-left and the food front-right; the battle-start devour knocks the food out first, and our rule ("the first 3 members knocked out", since 0.1.0) gave it the 2–3 revives at t ≈ 0 — seen only as a fall and a redeploy in the deploy frame. In the fight the carries fell and nobody came back. Measured (real co-op Match 险境, seeds 3 / 11, R1–R14 with 联防 and the boss, the 46 battles of the player holding 5 阿戈尔): 126 instant revives at t = 0; 4 of the 16 later knock-outs were revived.
- **Official**: PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 阿戈尔: "<在场5名不同【阿戈尔】干员>前3名【阿戈尔】干员首次被击倒时立刻复活" (bonds.json `max_free_respawn_cnt` 3); the revived unit is a 受益者 ("“复活”的实现方式为:受益者因移动之外的原因退场时…"); the devour note starts "从最先部署(更靠左和靠上的)的【阿戈尔】干员开始". The 2026-03-14 notice (ak.hypergryph.com news 5114) raises the 阿戈尔 revivers, a 特定数量, to 前3名 — worded unlike 埃芒加德's first N knocked out. Players' videos read it as by position: BV146QXB4EUK (2026-03-22, danmaku 207–284 s: by position, left to right and top to bottom, not by who is eaten first), BV11XwCzXEH9 (2026-03-19, 66 s: column by column from the left until the count runs out), BV17jSsBpEER (act 1: the uploader's pinned reply — by position, bottom / left in act 1, unrelated to the eating order). So the 3 are the first 3 阿戈尔 in the deployment / devour order (left column first, top to bottom; mirrored on the right-hand boss side), and each revives once on its own first knock-out, whatever the order of knock-outs.
- **Cause**: `installEgir`'s death hook revived any member on its first knock-out until 3 revives were spent.
- **Now**: at `battleStart`, before `devour()`, the beneficiaries are fixed: the first `max_free_respawn_cnt` member operators on the field in the devour's order (`egirOrder`, now shared by both: board column — a mirrored player counts from the field's right —, then top first, then id). The death hook (priority 11, still ahead of 不屈's 10) revives only a beneficiary, once, on its first `'killed'`; the other members' knock-outs are left to 不屈 and the rest. A member the devour knocks out comes back only when it is a beneficiary; its pending marks are cancelled as in 0.1.2 (§22.3). §22.3's chain 浊心斯卡蒂 → 乌尔比安 → 幽灵鲨 → 海霓 → 深巡 → 隐现: 幽灵鲨 (third by position) falls and is back at t = 0, 海霓 and 深巡 stay down until their redeploy, 浊心斯卡蒂 and 乌尔比安 come back on their first knock-out in the fight. Every battle (normal, 联防, boss) fixes its own beneficiaries. Measured again (the same 46 battles): 47 instant revives at t = 0 (only food among the first 3 by position); 10 of 25 later knock-outs revived (the carries; the rest were second knock-outs or other members).
- [ASSUMED]: (1) A member entering 联防 down (forced out before `battleStart`, §19.3) is no beneficiary; the next members on the field are. A 调和 member counts like any other member. (2) A beneficiary that 埃芒加德 or M3茧甲 saves in place (the remake's stand-in for PRTS's 0-time redeploy, §21.21) has used its 阿戈尔 revive, so its first knock-out after that is final — by analogy with PRTS's 不屈 note "持有“复活”效果时,即使触发了不屈的“重新部署”效果,也还是会消耗一次“复活”次数" (a held 复活 is consumed even when another effect brings the unit back at once). Read when the beneficiary is knocked out: its in-place revive count (`mem.revives`, items `revivedInPlace`) is compared with the one at battle start.
- Not changed: the devour (§22.3) and its order; 埃芒加德's own rule (the battle's first 3 knock-downs, as the notice words it — the devour's food still takes its charges at t = 0, community report #37: not a bug since 0.1.2). No icon marks the 3 holders yet.
- **§22.3 edited**: the one sentence that said the 3 revives are spent at t = 0 "as officially" now says what 0.1.2 did and points here [integrator: renumber "§23.20" there]. research 02 §3.6 and its JSON spec say "by position" (data/bonds.json `egirShip.spec` regenerated with `build-data --offline`; a JSON compare shows only that string changed).
- Tests: `test/content/bonds_core.test.js` — '阿戈尔 5: the first 3 members by position …' (rewritten: the bottom of the first column and the second column, knocked out first, stay down; the three top-left revive in any order, at full HP on the same tile with SP reset; a second knock-out is final; the relocation case; 3 members: none), '阿戈尔 5 + the devour chain (GitHub #33) …' (rewritten for the new outcome, normal and 联防), new '阿戈尔 5 beneficiaries: fixed at battle start …' (a 联防 member entering down keeps the slot (the forced exit is not a 击倒; reviewed 2026-10-04), a 调和 member included, the mirrored boss side from its own left), new '阿戈尔 5: a beneficiary 埃芒加德 or M3茧甲 save in place …'. `test/content/feedback3-egir-down.test.js` passes unchanged; `test/content/feedback1f-hammer.test.js`'s devour case has 3 members (no 5-tier) and passes unchanged.
### 23.21 傀儡师: the switch ends at full HP (community report #2) — `sim/professions.js installDollkeeper` (`startSwitch`)
- **Seen** (community report #2): 「傀儡师归溟幽灵鲨死后(阿戈尔盟约吞噬)之后无法正常切换形态复活」. 0.1.2 (§22.11) gave her the 替身 on a lethal hit, and a single devour mark works (full-HP 替身, back to the 本体 at 21 s). With two or more marks on her — e.g. 乌尔比安 → 斯卡蒂 → 归溟幽灵鲨 → 深巡 → 幽灵鲨 → 隐现 — the first mark switches her and the next lands inside the 1-s switch: the switch is no knock-out, so the mark is not cancelled; 无敌 does not stop a 流失 and 不死 holds it at 1 HP. She then fought the 替身's 20 s at 1 HP (1/3875), fell to the first hit and was down for 70 s.
- **Official**: PRTS 分支特性信息 §傀儡师: "切换动画开始时会清除自身一切Buff;切换途中重设自身生命至最大值,随后再次清除自身一切Buff", and during the switch animation she holds 不死 and 无敌 (with 阻回, 禁疗, 孤立, 强制缴械 and stun / freeze / sleep immunity). The HP reset comes after the start of the switch and 不死 holds throughout, so she always leaves the switch at full HP.
- **Cause**: `enter()` / `leave()` set the HP to the max only at the switch's start; the `fatal` hook's 不死 then kept a later lethal 流失 at 1 HP, and nothing set the HP again.
- **Now**: the 1-s `trait:dollSwitching` buff also sets the HP to the form's max when it expires (both directions: to the 替身 — its own max, 风丸's 纸偶 HP — and back to the 本体), if she is still on the field. Its early removal (knocked out, withdrawn) only clears the flag, as before. The 2-mark case: 1 HP at t ≈ 0, full HP (3875) when the switch ends at 1 s; a lethal 流失 inside the switch back is held at 1 HP and the 本体 comes out at full HP.
- [ASSUMED] (kept from §22.11): the switch lasts `DOLL_SWITCH` (1 s); the reset is placed at its end — PRTS says "途中" without a time, and the end is the latest point that still makes every lethal 流失 inside the switch harmless. The second "清除自身一切Buff" after the reset is not modelled (statuses landing during the switch stay; she is 无敌 and immune to stun / freeze / sleep then).
- docs/SIM.md (Professions, dollkeeper) says it.
- Tests: `test/sim/feedback2-doll.test.js` new 'E: a lethal 流失 inside the switch (a second 阿戈尔 devour mark) …' (the real chain with 5 阿戈尔: two devour marks on her, 1 HP inside the switch, full HP when it ends; then a lethal 流失 inside the switch back, held at 1 HP, the 本体 at full HP; never knocked out — fails on 0.1.2's code); cases A–D, the hammer and 风丸 cases, `test/sim/professions.test.js`, `test/render/feedback2-doll.test.js` and `test/content/feedback3-indom-exits.test.js` pass unchanged.
### 23.22 联防 fights with the layers the helpers' own combat reached (community report #24) — `match/PlayerState.js battleInput` (`reached`), `bondsView`, `match/unite.js uniteBattleOpts`
- **Seen** (community report #24): 「两百多层不屈盟约复活有时候依然不触发」. The 不屈 chance itself matches the official (0.18 + 0.004 × layers: 196 → 287/300 hits, 200 → 292/300, 204 → 299/300, ≥ 205 → 300/300). But the 联防 battle ran on the round-start layers while the strip showed the layers reached in the own combat: with +20 in the own combat, 联防 used 230 while the strip showed 250 (a real co-op Match, real sim). Near 200 layers that is the gap between a sure redeploy and a miss.
- **Official**: PRTS 卫戍协议/帮助 §联防阶段: "所有防卫成功的玩家(最多2名)将以其阵地当前的状态,迎接所有防卫失败玩家未击倒敌人的集合。该阶段不能叠加层数。"
- **Cause**: `uniteBattleOpts` → `PlayerState.battleInput` → `bonds: bondSnapshot(this.bonds)` — the persistent state; the round's in-battle gains sit in `pendingLayerGains` until SETTLE, and only the views (`bondsView`, §20.15) added them.
- **Now**: the 联防 input takes its bonds from `bondsView()` (`battleInput({ reached: true })`): the persistent layers plus the own combat's pending gains, floored and capped at 999 exactly as `settle()` adds them (`bondsWithGains` / `layerGainRoom`) — the count the strip shows. Nothing else changes: the 联防 still gains no layers (`layerGainsEnabled` false), its result's gains are never read, and SETTLE adds the own combat's gains to `ps.layers` once, as before (no double count: the next round starts from 250, not 270). The normal and boss inputs keep the persistent layers (a boss round has no own combat before it).
- [ASSUMED]: "当前的状态" includes the layers the own combat reached that round (PRTS lists no field of that state; the strip shows them and the official mode carries layers within the round). The helpers' order between two (unite.js `helperOrder`, "盟约层数最高(存疑)") uses those same reached layers — persistent plus the pending gains, capped like the battle — so the right-hand helper is the one who actually fights with more layers. [ASSUMED] the 存疑 tie-break follows the layers the 联防 battle uses; the official line does not say "persistent only".
- Exit paths for 不屈 re-checked on this head (no change here): knocked out on the ground, 撤退 / 商人 (0.1.3, workstream W3A), 流失 to 0: rolls; the 联防 forced exit: no (official); an operator on a 高台 tile: no (by tile, community report #33). Pith: no 不屈 effect of her own (调和 +1 is for core bonds; 不屈 is not core).
- docs: DESIGN §5.1 (`bonds`), §6.1 UNITE and §20.15, META (bond layers in the views), PLAYING (联防), research 01 §4.4 say it.
- Tests: `test/match/watch-bonds.test.js` new 'the 联防 field fights with the layers the helper's own combat reached …' (fake battles: p_0 perfect with +20 不屈 / +9 炎, p_1 leaks; the own combat's input 230 / 995; the 联防 input 250 and 999 (capped), equal to the strip; nothing persistent before SETTLE; after it 250 / 999, and the round-2 combat starts from 250 — fails on 0.1.2's code); the other `watch-bonds` cases and `feedback3-unitecarry`, `unitedown`, `combat`, `clientCombat`, `observe`, `results`, `hardening`, `teammate-loadout` match tests pass unchanged.
### 23.23 The initial deployment goes by column: the left column first, top to bottom (the report's side finding) — `sim/Battle.js start`, `match/board.js mergeTile` (+ `boardOrder`'s comment), `ui/gameLogic.js mergeTarget`
- **Seen** (re-verification of the 阿戈尔 reports): `Battle.start` deployed row-major — the top row first, left to right within a row (the mirrored right boss side: right to left in field columns) — while the 阿戈尔 devour, written from PRTS's own wording, already ran by column. Act-2 videos (BV1rVwjziEQt, BV1pGAPzrEpZ) show the operators coming in column by column from the left, and the aggro that follows the same order.
- **Official**: PRTS 卫戍协议/帮助 §作战阶段 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物(如果有)", with the note "自卫戍协议:盟约 下半(2026/3/14)起,部署顺序由“从左到右>从下到上”更改为“从上到下>从左到右”"; §多人 "分属于右侧场地的玩家,其与方向相关的盟约效果、部署顺序等都将左右镜像变化". Read as a scanning order — down each column, the columns from left to right (act 1: along each row, the rows from the bottom) — it agrees with PRTS's 阿戈尔 note "从最先部署(更靠左和靠上的)的【阿戈尔】干员开始", the bond text's devour priority ("优先更靠左和靠上"), act 1's (bottom, then left) and the videos. Read as sort keys (the top row first) it contradicts all of them.
- **Cause**: §5.5 / §19.1 took "从上到下>从左到右" as row-major ([ASSUMED] in §19.1).
- **Now**: per player the operators deploy by column — the left board column first, top to bottom within a column — then the summon pieces the same way; the right-hand boss player starts from its own left (the highest field column), as the devour does (`egirOrder`). The shared-field interleave (the i-th operators of every player together, then the summons) and every other rule are unchanged; unit ids keep the battle input's reading order (`board.js boardOrder`, now described as what it is: the input / dispatch / view order, not the deployment order). What follows from the order: the 仇恨 tie-break "最后部署的目标" (`aggroSeq`) — an unblocked ranged enemy at equal taunt now picks the bottom of the rightmost column —, 耀骑士临光's "上一名部署干员", the order of `deploy` effects. A merge's elite among several deployed copies takes the tile of the copy that deploys first [ASSUMED, §20.11]: `mergeTile` and the client's `mergeTarget` (the gold tile in prep) now sort col asc, then row desc (in board coordinates: the mirrored boss half deploys in the same board order).
- [ASSUMED]: the scanning reading of the PRTS sentence (the integrator's reading of the report; PRTS gives no example). Kept [ASSUMED] from §19.1: the shared-field interleave.
- **Measured** — every test that moved (the full sim, content, match, ui, render and root folders were run; details in the workstream notes): `test/sim/battle.test.js` (the rule itself: new expected order, two cases added to tell column-first from row-first, also on the mirrored boss side), `test/sim/playtest5-targeting.test.js` #5 (the latest deployed is now the bottom of the rightmost column: new expected order and target), `test/content/enemies_bosses.test.js` 烹泉 / 沏虹 and 碎骨 and `test/content/kits_t6.test.js` 余 S3 (their enemy's target was "the latest deployed", which moved to another unit; the layouts — or, for 余, a taunt on the operator behind the wall — now keep the same target, the assertions are unchanged), `test/ui/playtest6-elite.test.js` (mergeTarget: the left column wins over a higher row; a same-column case added), `test/match/merge.test.js` (the expected first deployed tile of the 突变细胞 SETTLE case is computed by column), `test/docs-consistency.test.js` (META's merge sentence). Bots, client-combat parity, 突袭, replays and every other match test pass unchanged.
- docs: DESIGN §5.5, §19.1 (#5) and §20.11, SIM (start), META (board.js, garrison dispatch order, merges), PLAYING (作战, 晋升精锐, 仇恨), research 00 / 01 (§4.3, A1 rows 4.3 and 7, the promotion addendum) / 03 / 09 say it.
- Tests: as listed under Measured; plus the comment fixes in `test/sim/combat.test.js`, `test/match/hardening.test.js`, `test/match/playtest6-elite-to-board.test.js`.
### 23.24 深溟巢涌者 do not attack; a revealed 节日爵士乐手 only hits what it blocks — `content/enemies.js`
- **Seen**: 深溟巢涌者 and 富营养的巢涌者 swung a normal attack. A 节日爵士乐手 that had been revealed and was not blocking still picked a target and attacked.
- **Official**: the nest-spawner pages walk and pulse an aura; they are not attackers. The jazz page's revealed unblocked form only attacks what it blocks.
- **Now**: both nest spawners have `noAttack`. While they walk, a 1 s aura pulse hits allies in range, and the pulse pauses while the enemy is hidden. A revealed unblocked 节日爵士乐手 is melee, so it only swings at its blocker.
- [ASSUMED]: the pulse radius and the 100 % 无途径 arts plus the 5 % 神经 on the enriched spawner follow the pages read for 0.1.3; the pause-while-hidden is the same hidden-enemy rule as the rest of the sim.
- Tests: `test/content/enemies_bosses.test.js`, `test/sim/feedback2-stealth-splash.test.js` (the nest pulse still hits 迷彩).
### 23.25 A split or summoned child does not carry the kill bounty (GitHub #67, #89 item 2) — `content/enemies.js spawnChildren`, `match/unite.js planUnite`
- **Seen**: a child spawned when an enemy split or summoned paid the parent's kill bounty again. In 联防 a leaked child could also pay the card bounty of a different enemy.
- **Now**: `spawnChildren` drops `bountyId` and `bountyCoins` on the child and leaves them on the parent. Killing the body pays once; killing a child pays nothing. `planUnite` attaches a card bounty only when the leaked enemy's key is that card's enemy key.
- [ASSUMED]: none. The parent's bounty is unchanged.
- Tests: `test/sim/feedback3-bounty-children.test.js`.
### 23.26 A 隐匿 blocker is hit only by the blocked enemy's attacks — not its areas, zones or auras (GitHub #97) — `sim/targeting.js areaSelectable / auraSelectable`, `content/enemies.js areaAllies / auraAllies`, `content/bosses.js`
- **Seen**: GitHub #97. A 隐匿 operator that is blocking an enemy was still selected by that enemy's splash, blast, zone and buff aura. 0.1.2 (`targeting.js`, DESIGN §22.12) had made that the rule on purpose.
- **Official**: PRTS 作战机制 §隐匿 "隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中", and "因为“阻挡优先级最高”的效果,敌人会无视一切可选性对该干员进行攻击" — the attack, not the area. §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定". §隐匿与Buff的关系 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标". PRTS 异常效果 迷彩: splash, 中点判定 / 格子判定 and auras are not stopped by 迷彩 (ba.camou "无法躲避溅射类攻击"). Direct attacks still land.
- **Cause**: `auraSelectable` returned true for a 隐匿 ally when `src.alive && src.blockedBy === a`. `areaSelectable` delegates to it, so splash, blasts, zones and auras all kept the blocker. That reading of 阻挡优先级 for area abilities was marked [ASSUMED] in §22.12.
- **Now**: `auraSelectable` rejects every 隐匿 ally, including the one blocking `src`. Areas follow. `canTargetAlly` is unchanged: the blocked enemy's own attack still hits (ranged attacks from other enemies still do not). 迷彩 is still not checked. A dead `src` (a death blast; the body is unblocked before the `death` hook) never gets a blocker exception — there is no exception left to apply. Abilities marked 无视无法选择 / 无视目标可选性 still do not call this selector. Owner's decision 2026-10-04: the 0.1.2 [ASSUMED] is withdrawn.
- **Players notice**: a 隐匿 blocker (伪装服, 叙拉古, 伊内丝 S2, an 排气格栅) no longer takes the enemy it blocks' self-blast, grenade splash, zone or aura. It still takes that enemy's attacks. A 迷彩 neighbour still takes the splash.
- Docs: SIM.md (the area-selector paragraph, the zone line, the `stealth` row). DESIGN §22.12 stays as the 0.1.2 text; this draft replaces the blocker exception.
- Tests: `test/sim/feedback2-stealth-splash.test.js` (9 — the selector skips a 隐匿 blocker for areas and auras and still takes 迷彩; “庞贝”'s blast misses its 隐匿 blocker and a 隐匿 bystander, hits a 迷彩 bystander, and its attacks still land on the blocker; a dead source does not select its former 隐匿 blocker).
- Interaction (script, not a separate file): a 高能源石虫 death blast (`enemy_1021_bslime`, radius 1.25) damaged the 迷彩 neighbour and not the 隐匿 blocker, including when `blockedBy` was forced back onto the dead body; 深池伙友卫队's force field did not stick to the 隐匿 blocker and did stick to the 迷彩 neighbour. No new [ASSUMED].
### 23.27 A 冻结 from a second 寒冷 lasts the longer of the two colds, not a fixed 3 s — `sim/Battle.js applyStatus`, `sim/constants.js COLD_FREEZE_DURATION`
- **Seen**: stacking 寒冷 always froze the target for 3 s (`COLD_FREEZE_DURATION`), whether the colds were 1 s or 10 s. A longer cold still on the target was cut short; a longer incoming cold did not lengthen the freeze.
- **Official**: gamedata_const ba.cold "攻击速度下降30,如果在持续时间内再次受到寒冷效果则会变为冻结" (no length). PRTS 术语释义 寒冷 (id ba.cold): two kinds of cold that do not mix. 敌方 cold applied to a unit that already has 敌方 cold or 敌方 冻结 becomes 敌方 冻结. 友方 cold (operators and our summons, unless a page says otherwise) "始终需要两两一对产生友方冻结,持续时间取双方之中最高". Frozen-immune units never turn a cold into a freeze.
- **Cause**: `applyStatus`, on `cold` when the target already has cold and is not immune to `frozen`, called `applyStatus(target, 'freeze', { duration: COLD_FREEZE_DURATION })` with that constant 3. The cold buff itself already keeps the longer time (`addBuff` refresh `'extend'` → `timeLeft = max(old.timeLeft, new duration)`), and the incoming duration at that point is already after 抵抗.
- **Now**: the freeze uses that same max(remaining cold `timeLeft`, incoming duration). `resistApplied` stops 抵抗 from halving it a second time. Immune-to-frozen still skips the freeze; the colds still take the longer duration. `COLD_FREEZE_DURATION` (3) remains only when neither side has a duration (a non-positive, non-infinite time). A shorter second cold does not shorten a longer remaining cold or its freeze.
- **[ASSUMED]**: the engine has one catalogue cold, so an enemy-applied second cold uses the 友方 max as well. PRTS states 「持续时间取双方之中最高」 on the 友方 line only. The 敌方 trigger "already frozen, even with no cold left" is unchanged (still only when cold is still on).
- **Players notice**: 灵知's charged cast, 谢拉格's second gust, 不融冰 on an already-cold enemy freeze for the longer cold. A 1 s cold on top of a 10 s cold no longer freezes for 3 s, and does not wipe the remaining cold.
- Docs: SIM.md `cold` row. DESIGN §5's "freeze 3 s" sentence is the 0.1.2 text; this draft replaces it.
- Tests: `test/sim/feedback3-cold-freeze.test.js` (6 — 10 then 1 → freeze ~10 s; 1 then 8 → ~8 s; a 1 s cold after 6 s of a 10 s cold freezes for the ~4 s left; 抵抗 0.5 turns an incoming 10 s into 5 s before the max, and that 5 s is not halved again; frozen-immune gets no freeze; 吉兆飞鳞), `test/sim/combat.test.js` (the old "freeze lasts 3 s" case on two 5 s colds now expects 5 s).
- Interaction: 吉兆飞鳞 【失温坠落】 still fires. Cold 1 s then 1 s: the 0.05 s stun lands when that ~1 s freeze ends, not at 3 s. Cold 10 s then 1 s: no stun at 3.2 s; the 0.05 s stun lands when the 10 s freeze ends. That 0.05 s stun still drops it into the existing 8 s grounded mode.
### 23.28 「地面干员」 is the melee position, not the tile — 不屈, 战栗维式重锤, 休谟斯 回收利用 (community report 「不屈盟约效果高台干员也错误的吃到了」) — `content/support/index.js isGroundOp`, `content/bonds/addon/battle.js` (不屈), `content/items/battle.js` (战栗), `content/bands/battle.js` (`act1autochess_band18_buff`)
- **Seen**: a community report: the 不屈 bond's effect reaches 高台干员 — a ranged operator standing on a melee tile came back at once when knocked out. Measured (act2 m01, 不屈 tier 2 at 210 layers, p = 1): 普罗旺斯 (狙击) and 阿罗玛 (术师) on the melee tile (9,4) were back at once and gave every operator +5 SP; 歌蕾蒂娅 (钩索师, melee) on the 高台 (10,4) stayed down.
- **Official**: 地面干员 / 高台干员 name the deploy class (position MELEE / RANGED), on any tile. Game data: this mode's map card 模拟战场演变·模式五 「场地右侧的射击台变为可以部署高台干员和地面干员的地面」 (a tile both classes may use), the 山脊 device 「可将高台干员部署于其上」, a module 「所有地面干员阻挡数+2」 (only the melee class blocks), 「地面位干员阻挡数之和」; training CH-2 「即使在地面作战时,高台干员也…」 (a ranged operator on a ground tile is still a 高台干员); PRTS corrected 琴柳's 地面干员 to 地面位干员. The three effects worded 地面干员 in this mode: 不屈 (both lines: 「地面干员被击倒时,有…概率立刻重新部署」 and 「<在场3名不同【不屈】干员>地面干员被击倒时使场上所有干员技力+5」), 战栗维式重锤 (「若携带者是地面干员则攻击时有10%概率使目标战栗2秒」), 休谟斯 回收利用 (「地面干员技能结束时使周围四格随机1名干员获得3点技力」).
- **Cause**: `isGroundOp` (support/index.js) read `unit.ground`, the engine's tile flag set at deployment (low tile, not elevated) — §22.6 marked that reading [ASSUMED].
- **Now**: `isGroundOp(u)` = an operator whose `def.position` is MELEE, whatever tile it stands on. Its only callers are the three effects above, so all of them follow: 歌蕾蒂娅 on a 高台 is a 地面干员 (不屈 brings her back, the 战栗 proc and 回收利用 work), a ranged operator on a melee tile (this mode lets any ranged operator stand there: PRTS 帮助 「所有行动内远程干员可部署在近战位」) never is. `unit.ground` keeps every tile reading: blocking, the enemies' 地面单位 targeting (the 只攻击地面单位 enemy, ground-only boss picks, 污染秽蚀's 高地 rate), 赫默's module 「治疗地面单位」 and 隐德来希's 血镰 「1名其他地面单位」 (worded 地面单位, left on the tile), 焰影苇草 S2 「部署在地面的干员」 and the 推击手 远程位 refund (tile wording). Other 地面干员 texts already read the position: 新约能天使 S3 「将一名再部署时间最长的地面干员部署至该处」 (`def.position !== 'RANGED'`); 陈's 以己之长 says 所有干员 since PRTS's 70→100-layer patch (it once said 高台干员).
- **Players notice**: a 狙击 / 术师 / 医疗 / 辅助 knocked out on a melee tile no longer comes back through 不屈 nor gives its +5 SP; 歌蕾蒂娅 on a 高台 does both, and her 战栗维式重锤 procs there.
- Docs: §22.6's last bullet (the tile reading) now points here; research 02 §3.19 (不屈: melee position, any tile) in .md and .json (→ data/bonds.json `indomShip.spec`, rebuilt offline; the only data change); code comments (support, 不屈, 战栗, 回收利用, 蒂比's 起飞 in kits/tier2.js).
- Tests: `test/content/feedback3-ground-ops.test.js` (real chess on act2 m01: 歌蕾蒂娅 on the 高台 back at once with +5 SP, 普罗旺斯 on the melee tile stays down with no SP, 歌蕾蒂娅 on the ground as before; 战栗 from a melee carrier on a 高台, none from a ranged carrier on a melee tile; 回收利用 likewise — all three fail on the old tile reading), `test/content/bonds_addon.test.js` (不屈: the old "elevated → no" case now redeploys; a ranged operator on a melee tile does not, no tier-2 SP).
### 23.29 叙拉古 6 rolls on every 普通伤害 hit, not only on normal attacks (community report 「叙拉古盟约真伤概率数值没有正确递增成长」, GitHub #79) — `content/bonds/core.js siracusaRolls / installSiracusa`, `kits/tier6.js whitw2` (S3 pulse tag), `kits/tier2.js` (拉普兰德 module), `kits/tier3.js` (忍冬 追凶)
- **Seen**: a community report: 叙拉古's true-damage chance does not grow as it should; GitHub #79 (closed, still true): 荒芜拉普兰德 S3 「咬了一整个技能也没出」. Measured on 0.1.3 before the fix: 966 of 2324 member hits never rolled (619 S3 pulses, 255 S3 drone attacks, 92 德克萨斯 剑雨 hits); a whole S3 rolled nothing, since she makes no attack of her own then.
- **Official**: PRTS 盟约记录 叙拉古: 「※仅在造成普通伤害时尝试造成来源为干员自身的真实附加伤害和恐惧」, 「每次造成普通伤害时尝试触发,基础概率0.139%…至多720次」 (the PRD rule, a counter shared by the members, unchanged). PRTS 伤害分类: 普通伤害 is the attack type NORMAL — 「站内省略伤害的攻击类型时,默认为此伤害类型」, and group damage stays 普通伤害 unless it is 溅射 (SPLASH); the other types are 持续伤害 (BUFF, 「(不依赖攻击行为的)每秒造成……」) and 附加伤害 (ADDITION). Per member: 荒芜拉普兰德 S3 备注 「该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害」 (no attack, no skill hit — still 普通伤害) and 「持续法术伤害…不可叠加」 for the per-second area hit; 缄默德克萨斯 S1 「伤害分类为法术持续伤害」; 拉普兰德's module 「造成预计算的法术附加伤害」; 忍冬 追凶 「伤害类型为法术附加伤害」; 德克萨斯 剑雨, 阿罗玛 S1 / S2, 忍冬 S1 / S2, 伺夜 S3 and 缄默德克萨斯 S3 carry no class note (→ 普通伤害).
- **Cause**: `installSiracusa` rolled only on `dmg.isAttack && !dmg.isSplash` — the main target of a normal attack.
- **Now**: `siracusaRolls(dmg)` — every damage instance a member deals to an enemy rolls unless it is 溅射 (`isSplash`), 持续 (tags `dot` / `periodic`), 附加 (tag `addition`), an item proc (tag `item`), a bond's own damage (tags `bond:*`: this proc, 卡西米尔's rider), element damage, 无来源 (`sourceless`: 坚守's thorns) or a 流失. So 荒芜拉普兰德's S3 drone attacks, 德克萨斯 剑雨 (both hits on every enemy), 缄默德克萨斯's deploy bursts and sword rain, 阿罗玛's and 忍冬's skill hits and 伺夜 S3's bonus roll too. Kits: the S3 area hit gets the tag `dot` (it is 持续伤害, so it does not roll — and 锡人's 凋敝魂灵, 「受到的持续伤害提高」, now raises it, as PRTS's wording implies); 拉普兰德's module rider and 忍冬's 追凶 get `addition`. The 隐匿 window, the PRD counter (shared, 0.139 % steps, sure by 720), 5000 + 50 × L and the 3 s fear are unchanged.
- **[ASSUMED]**: item procs (天马之枪, 铳骑之威, 海沟实验体, 耶拉冈德之泪, 家族徽章's strike) and the bonds' riders do not roll — PRTS gives them no class; we read them as 附加. The S3 area hit is not 普通伤害 by PRTS's own wording 「持续法术伤害」 (the 伤害分类 list of BUFF damage does not name it, but it says 「包括但不限于」). Other operators' 附加 / 持续 damage is only classified where a 叙拉古 member deals it (a 调和 operator at 叙拉古 6 rolls on everything not tagged).
- **Players notice**: with 6 叙拉古, procs come on skills too: 荒芜拉普兰德 S3 procs on her drone bites (one roll per bite; about 1 proc per 33 rolls on average), 德克萨斯 剑雨 can proc on each of its hits. A fight's proc count rises by the share of hits that used to be skipped.
- Open: PRTS 「※脱离隐匿10秒内若自身的异常效果/异常效果免疫发生变动将刷新加成倒计时」 (the 10 s window refreshes) is still not modelled. 卡西米尔 6's rider (「仅在造成普通伤害时附带此真实伤害」) has the same old filter (`isAttack`) — a separate fix.
- Docs: §22.9 (the drone damage now rolls; the area hit's `dot`), research 02 §3.7 in .md and .json (→ data/bonds.json `siracusaShip.spec`, rebuilt offline; the only data change), code comments (core.js header, whitw2).
- Tests: `test/content/feedback3-siracusa-rolls.test.js` (rng forced to 0 so every roll hits: the classification case by case — attack, skill hit, drone attack, unclassed talent hit roll; 溅射, 持续, 附加, item proc, another bond's rider, element, 无来源 do not; 荒芜拉普兰德 S3 over 10 s: procs = drone attacks, pulses tagged `dot` and never rolled; 德克萨斯 剑雨: 4 hits on 2 enemies → 4 procs — all three fail on the old filter); `bonds_core.test.js` 叙拉古 tests unchanged and passing.
### 23.30 Mode-off bonds the player has members of show as grey 本局禁用 discs; the 成员 header counts the hand (community reports 「投资人等在休整区就能生效的盟约不生效」 / 「…不会触发斯卡蒂与异德的突袭」) — `match/bondsMeta.js offBondCounts / bondList`, `PlayerState.privateView`, `Match` public view, `ui/bondStrip.js`, `ui/gameLogic.js memberHeadCount`
- **Seen**: in 标准模拟 the bond strip hid 投资人, 奇迹, 突袭, 独行 and the other bonds the mode switches off, even when the player had their members. A 投资人 count of 3 (the members sitting in the 整备区) read 在场 3/3(含整备区) in the popup while the 成员 header said 0/n, because that header counted only operators on the board.
- **Official**: activity_table `modeDataDict.mode_single_funny` (标准模拟, and the same list on `mode_multi_funny`) `inactiveBondIdList`: 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技. Those bonds never activate. 投资人 / 远见 / 奇迹 count `BOARD_AND_DECK` (the board and the hand, not the 5 temporary slots); 突袭 counts the board. The grey disc is this remake's mark for a bond the mode never activates (the briefing already drew one with ✕); the official mode simply leaves the bond off.
- **Cause**: `computeBonds` skips `modeInactiveBonds`, and the views listed only what `computeBonds` returned, so a bond with members and no effect was invisible. `BondPopup`'s 成员 numerator was `members.filter(onBoard)`.
- **Now**: `computeBonds` still omits them — the battle input (`bondSnapshot` of `ps.bonds`) never sees them, so they still cannot activate. `offBondCounts` counts the inactive bonds the player has members of the same way (`rawCount`: 投资人 / 奇迹 include the hand) and `bondList(..., { off })` appends `{ bondId, count, active: false, tier: 0, layers, off: true }` after the live bonds, on `m.private` and `m.public`. The strip draws that entry last as a grey disc with ✕ and 本局禁用 under it (no stack count). An inactive bond with no members is left out. A mode whose `inactiveBondIds` is empty (`offBondCounts` → null; 险境 and the rest) is unchanged. The 成员 header uses `memberHeadCount`: for a bond with `countsHand` (投资人 远见 奇迹) a row in the hand counts, a row only in the temporary slots does not, and one operator on the board and in the hand counts once.
- **[ASSUMED]**: 调和's +1 is not added to a 本局禁用 bond's count. The bond never activates, so the +1 would only inflate a count that does nothing; the tooltip's 「N 名成员」 is the raw member count.
- **Players notice**: in 标准模拟, holding a 投资人 (even only in the 整备区) or fielding 斯卡蒂 / 异德 shows that bond at the end of the strip, grey, marked 本局禁用, instead of it vanishing. Opening it, the 成员 number matches 在场, hand included. The fight is unchanged: those bonds still do not fire.
- Docs: DESIGN §8.3 (the `off` entries come last; the pinned bonds-array sentence is unchanged), DESIGN §20.15 (m.public passes `offBondCounts`), META.md (the same list), PLAYING.md (本局禁用 on the strip, and the 成员 header counts the 整备区).
- Tests: `test/match/feedback3-off-bonds.test.js` (标准: 投资人 in hand and 突袭 on the board are `off` entries and absent from `computeBonds` / `bondSnapshot`; a hand member counts for 投资人, not for 突袭, not from the temporary slots, and not twice; a mode with an empty inactive list returns null and those bonds stay in `computeBonds`), `test/ui/gameLogic.test.js` (`memberHeadCount`, `bondMembers` `inHand`, `sortBonds` puts `off` last), `test/ui/feedback1b-bonds.test.js` (three 奥术 on the board: still absent from `ps.bonds`, present on `m.private` as `off: true`, the chip still says 本局禁用).
### 23.31 An item gained as 休整期结束 waits in hand and merges at the next prep start (维多利亚's 战栗维式重锤; owner's decision 2026-10-04) — `match/PlayerState.js acquireItem`, `match/effectsMeta.js` (`_deferItemMerge`), `match/Match.js enterPrep`, `content/bonds/core.js payHammers`
- **Seen**: 维多利亚 pays a 战栗维式重锤 when a 25-layer milestone is crossed, including at 休整期结束. A player who already had one equipped went into the fight without it: the new copy had merged at once, the equipped copy was detached, and the golden hammer sat in the hand through the combat.
- **Official**: PRTS 卫戍协议/帮助 (the in-game tip, also `autoChessData.gameTipsList`): 「两件同名装备可以合成一件更强力的装备」 and 「已拥有2件同一初始装备时…自动合并」. 维多利亚 (bonds.json `layer` 25, `count` 1, pool `pool_equip_vict`): 「每叠加25层获得1件带有随机效果的维式重锤」. Nothing there says the merge has to finish in the same moment the prep ends, or that an equipped copy comes off for a fight that has not started. The item is `chess_item_2_03_e_a` 战栗维式重锤, golden `chess_item_2_03_e_b`, `upgradeNum` 2.
- **Cause**: `payHammers` runs on `onPrepEnd` (`HAMMER_HOOKS`). `grantItem` → `acquireItem` calls `completesItemMerge` / `_mergeItem` immediately, and `_mergeItem` detaches an equipped copy of the same item. `Match.endPrep` dispatches `onPrepEnd` and only then starts the fight, so the detach landed before combat.
- **Now**: while `onPrepEnd` is on the dispatch stack (the hook itself, and a grant nested under it — the next owed milestone is paid from the grant's `onGain`), `acquireItem` stows the item (hand, else temp, the existing stow rules) and does not merge, even if an identical normal copy is already in the hand, the temp slots or equipped. `enterPrep` runs `checkItemMerges` once before `onPrepStart`, so the pair merges before the player acts in the next prep, not in `endPrep` of the prep that granted it. A buy, an `onPrepStart` grant, an `onLayers` grant during combat and every other path still merge at once. Hand and temp both full keeps 「整备区已满,获得的装备已销毁」: the new item is destroyed and the equipped copy is not replaced by a golden one in its slot. At the next prep there is then nothing waiting to merge, so the equipped normal hammer is still there.
- **[ASSUMED]**: the deferral is every item granted during that `onPrepEnd` dispatch, not only 维多利亚's hammers. The owner's sentence is 「an item that arrives at 休整期结束」. Two milestones caught up at that moment both wait: the nested `onGain` payment is inside the same stack, so it does not merge the first copy early.
- **Players notice**: the hammer already on an operator stays on for the fight that is about to start; the new one sits in the 整备区 (or the temporary slots, if the hand was full). The golden hammer appears when the next prep starts. If the hand and the temporary slots were both full, the new hammer is destroyed and the equipped one is untouched, including at the next prep. Two hammers paid at the same prep end both wait; the next prep merges those two with each other and leaves the equipped one on the operator.
- Docs: META.md §2.5 (the auto-merge sentence now names this exception), PLAYING.md (同名装备: 休整期结束 waits, other gains still merge at once).
- Tests: `test/match/feedback3-prep-end-item.test.js` (one equipped 战栗维式重锤, 维多利亚 pays another at prep end: still equipped in the battle input, second copy in hand, no golden; the next prep has the golden and no normal. Two milestones: three normals through the fight, then one golden plus the equipped copy. Hand and temp full: two 「整备区已满,获得的装备已销毁」, equipped copy unchanged after the next prep. A buy during prep merges at once. `onLayers` during combat merges at once). `test/content/bonds_core.test.js` (the catch-up payment on `onPrepStart` still merges) and `test/match/feedback1b-items.test.js` (every other gain path) still pass.
### 23.32 乌尔比安 S3 【移动】 and 【返回】 are free redeploys (community report 「乌尔比安三技能结束后不属于再部署」) — `Battle.moveRedeploy`, `kits/tier5.js`, `tokens.js` 从不混淆的方向, `devices.js`, `render/interp.js`, `render/spine.js`
- **Seen**: his S3 only changed his tile (`Battle.relocate`). Deploy effects never saw the 【移动】 or the 【返回】 — 迅捷作战粮, 突袭手雷, 卡西米尔 / 叙拉古 / a garrison `<部署时>` — and the return did not empty his SP. A knock-out, 不屈 and 阿戈尔's revive must not see either move.
- **Official**: PRTS 乌尔比安 S3 备注 — both steps are 【移动】; "【移动】后仅继承下列效果:技能进度、第二天赋叠加层数"; the marker is the 【返回】 flag; "【返回】时将清空技力,但仍可以享受后续由其他效果提供的技力". PRTS 术语释义 【移动】 (ba.addash): "不退场,以当前血量在目标位置部署", "本质上为一次特殊的撤退-再部署行为", "因移动撤退时不会积累再部署惩罚", "可以通过移动行为多次触发部署时触发的效果", "部署时将清空技力". His skill is the special case: the outbound move keeps 技能进度, and only the 【返回】 names the clear. While he blocks, the anchor stays on his own tile (§22.3, unchanged): no marker, no new deployment.
- **Cause**: `relocate` keeps the same `deploySeq` / `deployedAt` and emits nothing.
- **Now**: `moveRedeploy` does `relocate`'s checks, then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy { initial:false, move:true }`. No `die` / `death`, so no knock-out, no redeploy timer, no cost. `clearSp` empties SP before the deploy handlers (the 【返回】, and the marker's fallback). The outbound move does not. HP ratio and buffs stay, including the 阿戈尔 devour gains (owner's deviation, DESIGN §22.3 — officially only 技能进度 and the second talent's stacks carry). A 【移动】 does not zero terrain memory (`ctx.move`), so the old tile's 气流 / 烟尘 is not stranded. The client snaps a unit whose deploy animation starts between snapshots, and a skill that ends on that deploy lets the Start clip finish.
- **[ASSUMED]**: a refused tile changes nothing (no 强制撤退, and a 【返回】 that cannot land does not 击倒 him). His own S3 never takes that branch — the note says the check can always fall back to his tile — so only a direct `moveRedeploy` onto an illegal tile hits it. The glossary's "部署完成后立刻流失一次生命值以匹配移动前的生命比例" and the return's 0.333 s 无敌 / 不可阻挡 are not separate events: the HP ratio is kept on the same body. The return runs in `onEnd`, while the skill's mods are still applied ("技能结束后" would be the other reading); nothing that reaches him on that deploy reads those stats. 大帝 加急调派's permanent redeploy stack grows once per deployment, so a move adds one stack ("每次部署后"); that was already [ASSUMED] cumulative.
- **Players notice**: after the skill he is back on his tile with empty SP plus whatever a deploy effect just gave (迅捷作战粮), the 突袭手雷 window counting from the landing, and his devour / 血脉 stacks still on him. 不屈 and an 阿戈尔 revive are not spent. A 傀儡师's 替身 standing nearby is not knocked out.
- **Docs**: `docs/SIM.md` (`deploy` ctx `move`, `moveRedeploy`, the client's `deploy` event). Not DESIGN §23.
- **Tests**: `test/sim/feedback3-ulpia-move.test.js` (both moves bump `deploySeq` and emit `deploy { move:true }`; no death / die / FORCED_EXIT; knock-out count stays 0; SP cleared on the return only, then the ration's gift; devour buffs stay; 突袭手雷 re-arms; blocking still does not move; illegal tiles change nothing; out and back leaves 风丸's 替身 up and does not spend 不屈 or the 阿戈尔 charge; 卡西米尔 +20 % once per move, 叙拉古 refreshes without a second stack, 大帝 one stack per deployment). `test/render/feedback3-move-deploy.test.js` (headless, no browser). `test/docs-consistency.test.js`.
### 23.33 波登可's spore cloud radius is 0.9 (PRTS 备注) — `kits/tier1.js` `SPORE_RADIUS`
- **Seen**: the cloud used radius 1.2, commented as "no blackboard key; ≈ the 3×3". An enemy on the next tile (centre distance 1) was inside it.
- **Official**: PRTS 波登可 技能2 孢子扩散 备注 "※孢子群范围半径为0.9,可对空". No blackboard key carries the radius.
- **Cause**: the constant was a guess at the 3×3 around the vial.
- **Now**: `SPORE_RADIUS = 0.9`. The zone fx and the pulse both use it. Air units stay in range (可对空; `foesInRadius` does not drop them). An enemy whose centre is 1 tile from the vial is outside.
- **[ASSUMED]**: none. The centre is still the current target's position (the skill text "在周围", no landing note).
- **Players notice**: the cloud no longer reaches the tiles beside the enemy it landed on. Damage, 停顿 and silence on the enemy under the vial are unchanged.
- **Docs**: the kit comment cites the PRTS note. No SIM / DESIGN sentence named 1.2 for this cloud (the 1.2s elsewhere are 孽罪奇美拉, `batPct` and 沸血燎原).
- **Tests**: `test/content/kits_t1t2.test.js` 「1_13 波登可」 — the zone event's radius is 0.9; one pulse per second and the silence / 停顿 only on the enemy the vial landed on; the enemy one tile away and the far one take none.
### 23.34 琳琅诗怀雅 S3 shoots her coins when the purse reaches 10 (owner 2026-10-04) — `kits/tier3.js` 千金一掷
- **Seen**: with the purse full and a ground enemy in the coin range, `onTick` called `skill.end('manual')`. GitHub #89-1 reported that auto-close. Removing it left no way to shoot the coins, because a fight never calls a manual close.
- **Official**: the skill text 「携带此技能时金币上限为10」 and 「主动关闭技能时消耗所有金币」. PRTS 帮助 「通常不会自动关闭技能」 is the general line #89-1 cited.
- **Owner 2026-10-04**: the written cap is the close. At 10 the coins are shot. No separate 关闭技能 button.
- **Now**: while S3 is active and `coins >=` the S3 blackboard `sp` (10), `onTick` ends it with `manual` once a ground enemy is in the skill attack range (the record's range, one tile ahead — the same range as her attack). `onEnd` is unchanged: that reason spends every coin on the ground enemies of range 2-4 and the ones she blocks, then pushes. Below the cap, or with nobody in the attack range, it stays open and the coins stay. `skill.stop()` (`reason: 'stopped'`) still does not spend.
- **Owner 2026-10-04** (second call): do not shoot into an empty attack range. An enemy only in the coin-mark 2-4 grid does not trigger it.
- **Players notice**: she double-attacks and collects coins until the tenth, then waits until an enemy walks into the tile in front of her. That is when the burst fires.
- **Docs**: the kit comment. No pinned SIM / DESIGN sentence described the auto-close.
- **Tests**: `test/sim/feedback3-swire-s3.test.js` (9 coins and an enemy, 6 s, still open; 10 coins, the enemy is hit and the purse is empty; 10 coins and no enemy, ends, spends, no hit, no error; a manual close at 4 still spends). `test/sim/feedback1d-push.test.js` 「琳琅诗怀雅 S3」 (the radial push, one coin).
### 23.35 高台 only for elite 歌蕾蒂娅 carrying HOK-Y (owner's decision 2026-10-04) — withdrawn in 0.1.4, see §24.2 — `shared/highGround.js`, `match/board.js placeClass`, `PlayerState._placementOf`, `ui/gameLogic.js piecePosition`, `match/bot.js planLayoutSteps`, `tools/build-data.mjs`
- **Seen**: 0.1.2 §22.6 set `chess.json placement: 'all'` on every melee record whose branch trait contains 「可以放置于远程位」 — 歌蕾蒂娅, 崖心 and 见行者, normal and elite — so `positionClass` let all of them stand on a 高台. The owner (2026-10-04) says that reading is wrong.
- **Official**: character_table still writes that line on the 钩索师 (歌蕾蒂娅 `char_474_glady`, 崖心 `char_173_slchan`) and the 推击手 (见行者 `char_4036_forcer`). PRTS 新人入门 says both branches deploy on 高台 and ground. PRTS 卫戍协议/帮助 §战斗部署 switches off 部署效果 ("携带Y模组的教官仍无法部署至高台位"). No table row names HOK-Y as the prep exception. The module id is the data: `chess_char_4_12_b.modules` lists `uniequip_003_glady` typeName `HOK-Y` name 淡金坠饰 (not the default; the default is `uniequip_002_glady` HOK-X 执政官手镜). 崖心's only module is HOK-X `uniequip_002_slchan`. The golden record is the elite (`chess_char_4_12_b`, `isGolden`); there is no further copy.
- **Cause**: §22.6 trusted the trait line. It is copied onto every variant, including ones the owner refuses.
- **Now**: build-data does not emit `placement`. At place time, `meleeOnHighGround(rec, moduleId)` is true only when the record is golden, `charId` is `char_474_glady`, and the equipped module (the player's loadout, defaults included) is `uniequip_003_glady`. That widens `placeClass` / the client's `piecePosition` to every deployable tile. A normal record, 崖心, 见行者, HOK-X, `none`, and a missing module stay ground-only. The same check is what `g.move`, swaps, `_evictIllegal`, the merge-tile legality, the audit and the invariants use. The client lights the same tiles. The bot plans with `placeClass`. Owner 2026-10-04: when a 高台 tile's range covers an enemy-road tile, HOK-Y 歌蕾蒂娅 is planned there even if the ground is free; otherwise she stays on the ground. Everyone else may not use a 高台. She is still a melee blocker in the lineup (`basePositionClass`). The battle is unchanged: position stays MELEE, so on a 高台 she attacks and blocks nothing. The loadout is locked after 干员调配, so the module does not change under a piece already on a tile.
- [ASSUMED]: the sim's automatic searches (突袭 landing, summon / device tiles, `grid.canStand` without `ranged`) still treat every MELEE unit, including HOK-Y 歌蕾蒂娅, as ground-only. A prep placement on a 高台 is the board tile the battle deploys and redeploys on; those searches do not hunt a 高台 for her. There is no ground-only tile that refuses a ranged operator (ranged chess may use melee tiles); a non-deployable tile still refuses them.
- **Tests**: `test/match/board.test.js` (the class table), `test/match/feedback1-placement.test.js` (the operator × module × tile table, client highlights, the swap, the battle input on (10,4), the bot plan), `test/ui/gameLogic.test.js` (the drag mirror). `node tools/build-data.mjs --offline` drops `placement` and nothing else in `data/`.
- docs: DESIGN §3, DATA (`placement`), PLAYING §4, META (`board.js`), research 03 C2. §22.6 itself is left for the integrator to replace with this section.
### 23.36 Watching another board during prep follows its moves (GitHub #87) — `Match.watch`, `Match._notifyPrepScouts`, `ui/screens/game.js`
- **Seen**: GitHub #87 — in the deploy phase, the board you opened with 前往查看 does not follow the other player's moves. Leaving and entering again shows the new layout.
- **Official**: research 09 §3.1 — a teammate's board in prep is read-only, including their pen. PRTS was not re-fetched for this report; the live update is what the report asks of the client, and the owner filed it as a bug.
- **Cause**: `g.watch 'n:<pid>'` while no battle field is up deleted the viewer from `watchers` and sent one `prepFieldMeta` (`m.field`, `prep: true`). Later `g.move` only refreshed `m.private` for the player who moved. The client places that one `m.field` and had nothing further to enter.
- **Now**: the scout stays in `watchers` under `n:<pid>`. When that player's board changes (a move, a facing, equipment on a deployed piece), `flush` sends `prepFieldMeta` again to everyone scouting it, including a spectator seat. A shop or funds change does not. Opening the watch still sends the current board at once. Combat start still clears `watchers` and assigns live fields, so a spectator's battle watch is the same path as before. A prep `m.field` with no units still places an empty board, so a withdrawn lineup does not leave the old pieces up. [ASSUMED] the signature is the deployed pieces only (uid, id, tile, facing, equipped items); a hand-only change is not pushed, which matches a board view.
- **Tests**: `test/match/prep-watch.test.js` (seat B moves in prep, seat A's `m.field` and a spectator's gain the piece before combat; freezing the shop does not). `test/match/spectator.test.js` kept.
### 23.37 折射 reads as off while the enemy is silenced — `ui/abilityLines.js`, `ui/detailPanel.js` EnemyDetail, `render/style.js statusIconKey`, `render/units.js`, `shared/protocol.js unitStatsEntry`
- **Seen**: silencing a 深池 enemy turns the 折射 RES bonus off (the sim already removes `ab:refraction`). The enemy card still prints the 折射 line in the active colour, and the status icon does not show that the ability is off.
- **Official**: the handbook line is `<$ba.refraction>折射</>` with `format: "SILENCE"` (`data/enemies.json`, 深池侦察兵 `enemy_1166_dusbr` and the other 折射 enemies). PRTS 异常效果 沉默: the unit cannot use abilities flagged that way. The RES rule is unchanged.
- **Cause**: `EnemyDetail` always drew `abilities[]`. `statusIconKey` had no entry for `ab:refraction`, and nothing looked at silence when choosing icons.
- **Now**: `unitStatsEntry` carries `silenced` from the aggregated flags. A SILENCE-format line (折射, and any other line with that format) gets `is-off` — grey text, the term underline dropped — while `live.silenced` is set. The 折射 buff maps to a status icon and `statusIconSuppressed` leaves it out of the row while a `silence` status is up. The sim's RES rule is untouched. A card with no live stats (the prep preview, a handbook line) stays fully lit: nobody is silenced yet.
- [ASSUMED]: every `format: "SILENCE"` line greys the same way, not only the word 折射. The icon is dropped rather than drawn grey.
- **Tests**: `test/ui/refraction-view.test.js` (the line and the icon rule; no browser). `test/match/unitStats.test.js` (the new `silenced` field).
### 23.38 A granted chess is named, and the new card flashes — `effectsMeta.js grantChess`, `render/app.js setPrep`
- **Seen**: 歌蕾蒂娅's row trait grants 斯卡蒂 / 幽灵鲨 / 深巡 with no notice. 余's SERVER_MOST_BOND is the same. The card appears in the hand with no flash.
- **Official**: garrison text 「获得1个斯卡蒂、幽灵鲨或深巡」 / 「随机获得1名当前人数最多盟约的干员」. No official toast wording was fetched; the line is the owner's.
- **Cause**: `grantChess` acquired the piece and did not toast. `setPrep` flashed `fx.deploy` only for a new board piece.
- **Now**: a successful `grantChess` toasts 「名字:获得X」 — the garrison's operator (歌蕾蒂娅, 余), the item's name, the band's effect name, or the choice card's name. `opts.toast === false` skips it, so a caller that already says the same thing is not doubled (nothing in the tree did). A full hand still only gets the existing 整备区已满 warning. A new hand or temp card, after the first prep layout, uses the existing `fx.deploy` flash — the same one a new board piece already gets. Not a new animation.
- [ASSUMED]: the toast names the piece actually owned, so a grant that immediately promotes names the elite. A shop buy is not a `grantChess` and does not toast; a bought card is still a new hand uid, so it gets the same flash.
- **Tests**: `test/content/garrisons_meta.test.js` (歌蕾蒂娅 and 余, the toast text equals a chess now in hand).
### 23.39 An emote manifest that times out shows the glyph — `data.js` `local` / `assets`, `ui/emotes.js` EmoteArt
- **Seen** (GitHub #99): the 交流 panel's cells are empty, including the stand-in icon. The screenshot is the size of the emote plate.
- **Official**: none. The 36 pictures and the glyph are already the §22.5 art rule. The timeout length is not from a table.
- **Cause**: `EmoteArt` draws an empty box while `local` or `assets` is `idle` or `loading`, and the glyph only once both have settled to `ready` or `missing`. `createDataStore` had no clock, so a fetch that never returned left every cell blank for the session. This is the data-store fetch (`useData('local','assets')`), not the separate image cache in `assets.js`.
- **Now**: one attempt at `local` or `assets` is abandoned after `ART_MANIFEST_TIMEOUT_MS`. A timeout or any other failure sets that file `missing` and notifies immediately, which is the existing glyph path. The status stays `missing` through the retry wait (going back to `loading` would blank the button again). A later attempt that succeeds notifies `ready` and the picture replaces the glyph. A 404 or bad JSON is still not retried. Chess and the other text files are not on this clock and still stay `loading` across retries.
- [ASSUMED]: 8 seconds. A response that arrives after that attempt's clock is ignored; the retry is a new request, and the in-flight fetch is not aborted. After the two existing retries the glyph stays until the next `invalidate` or page load.
- **Tests**: `test/ui/emotes.test.js` (stub timer, no network: hung `local` is `missing` before the retry wait, then `ready`; a thrown fetch is the same; a timeout with no retry and a 404 stay `missing`; `chess` arms no timer). `test/ui/playtest3.test.js` data-store retry and `test/client-static.test.js` data.js still pass. No browser render: `EmoteArt` already picks the glyph from `missing`.
---
+337
View File
@@ -0,0 +1,337 @@
# DESIGN §24 — 0.1.4 — community reports after 0.1.3
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 24. Community reports after 0.1.3
Reports after the 0.1.3 release. Each was checked against the official data and PRTS; anything with no source is marked [ASSUMED]. Where each is handled: a skill clip with no Begin and no own Idle plays once (德克萨斯 S2 剑雨) → §24.1; 高台 for every melee chess whose trait reads 「可以放置于远程位」 (GitHub #153, PR #69) → §24.2; the 5-阿戈尔 revives go to the first 3 members knocked out (GitHub #105, #140) → §24.3; 联防 阿戈尔 devours the operator in front whoever owns it (GitHub #140) → §24.4; a special terrain tile explains itself on a tap (GitHub #184, PR #185) → §24.5; 活性源石 was two different materials on the two boards (GitHub #184, PR #185) → §24.6; 阿戈尔's devoured base ATK is a 最终加算 and a fallen marker's marks still resolve (GitHub #165, PR #176) → §24.7; 缇缇 S2's sleep pulses count for her trait (GitHub #162) → §24.8; a sleeping enemy cannot be blocked and takes no block slot (GitHub #140) → §24.9. Merged pull requests keep their own notes where they changed the rules: deploy-timed skills as duration skills (PR #109, §5 skill contract), the teammate's hand / temp / equipment in prep scouting (PR #129), 灵巧's knocked-out aura and the hidden-layer bonds (PR #66, research 02).
### 24.1 A skill clip with no Begin and no own Idle plays once — 德克萨斯 S2 剑雨 (player report) — `render/spine.js SpineActor.setSkill / update` (`SKILL_CLIP_MIN`, `skillClipOnce`, `skillEndPending`)
- **Seen** (player report after 0.1.3): 德克萨斯's S2 剑雨 casts with no skill animation — she stays in her idle through the whole cast.
- **Official** [DATA]: her Front model has a single skill clip, `Skill` (2.167 s) — no `Skill_Begin`, no `Skill_Idle`, no `Skill_End`, so the manifest roles are `anims.skill {begin: null, loop: 'Skill', end: null}` and `anims.skills["0"] / ["1"]` are the same clip. Both her skills are instant (`durationType NONE`, duration 0; S1 冲锋号令·γ型, S2 剑雨). The official plays that `Skill` clip on the cast.
- **Cause**: `setSkill(true)` played a clip only when the skill had a Begin clip; with `begin === null` it fell through to `else if (this.mode === 'base') this._play(this._baseName())` — the idle. The loop clip was reachable only through an attack (`_attackClip`), and an instant skill makes no attack inside its activation window: sim `skills.js` fires `['skill', id, 1]` and `['skill', id, 0]` in the same tick (`activate` → `end('instant')`) and only holds the anim code `SKILL` for 0.5 s (`snapshot.js animOf`), so the actor was back on its idle before a frame passed.
- **Now**: a skill clip with neither a Begin nor its own Idle (its loop is not its idle, and it is not borrowed from the attack clip: `via !== 'attack'`) plays that clip once for its own length — `max(SKILL_CLIP_MIN 0.2 s, dur(loop))` — as the skill's own window; a `setSkill(false)` inside that window only marks the transition, so when the window ends the actor plays the skill's End clip (if it has one) or the idle. An instant skill therefore shows its animation instead of a one-frame flash, and a longer skill of the same shape still rests in its idle between attacks while its attacks use the loop, exactly as before. Untouched: a Begin clip with its queued idle / loop, a skill with an idle clip of its own (§23.13), `via: 'attack'` clips, `loop === idle` stances and enemies.
- **Scale**: 90 such clips on 61 Front models (96 clips have no Begin at all — the 6 with an own Idle keep the §23.13 path); 57 of the 90 belong to instant skills.
- **A PASSIVE skill reaches none of this through `activate()`**: `Skill.activate()` returns early for `kind === 'passive'` (`sim/skills.js`); a passive goes through `_startPassive()` (active + mods + `onStart`). Three of the 57 instant clips are passives in the data — 琳琅诗怀雅 S1 仗义疏财 / S2 “见面礼” and 凯瑟琳 S1; 凯瑟琳 S1 turned out to be `kind: instant` in the sim (so it already casts), and the two 琳琅诗怀雅 ones now fire the same window from `_startPassive` — on at the deployment, off after `SKILL_ANIM_WINDOW` (0.5 s) while the passive itself stays active. So all 57 are live.
- **Deploy-time passives with a duration were left alone here** (since PR #109, merged in the same release, they are duration skills — `activateOnDeploy`, §5 — with their own start / end events) (缄默德克萨斯 S1–S3 8–12 s, 野鬃 S1, 伊内丝 S3, 耀骑士临光 S2): the sim keeps a passive active until death, so how long its stance should show is a separate question. A clip that IS the normal attack clip (`anims.skill.loop === anims.attack.loop`: 缄默德克萨斯 S1, 砾, 普罗旺斯, 斯卡蒂 S2) is never played as a skill animation — the new branch requires a distinct clip, so a deploy-time passive on those shows no attack twitch.
- **Tests**: `test/render/feedback4-texas-skill-clip.test.js` (德克萨斯 S2: the clip plays, holds through the same-tick off, then the idle, with no base flash in between; two more real instant entries — `char_140_whitew` S1 日晷, `char_206_gnosis` S2 零度爆发; 银灰 S3 真银斩 — the same shape but long — rests without freezing on the clip's last frame and still attacks with the loop; the deploy-time passives 琳琅诗怀雅 S1/S2 hold their 1 s clip through the sim's 0.5 s window and then idle; a clip that is the attack clip falls through to the base clip; unchanged: 折桠 S2 begin → idle, 耀骑士临光 S3 own idle, 宴 begin → loop). `test/sim/skills.test.js` covers the sim side (the passive window opens at the deployment and closes 0.5 s later, the passive staying active; a timed passive fires nothing). `test/render/feedback3-skill-idle.test.js`, `feedback3-move-deploy.test.js`, `loadout-skill.test.js` and the full suite stay green.
### 24.2 高台 for every melee chess whose trait reads 「可以放置于远程位」 (GitHub #153, #148; PR #69; the owner's decision of 2026-10-05) — `shared/highGround.js`, `match/board.js positionClass / placeClass`, `PlayerState._placementOf`, `ui/gameLogic.js piecePosition`, `match/bot.js planLayoutSteps`
- **Seen**: GitHub #153 「歌蕾蒂娅无法放置在高台位置」: 0.1.3 (§23.35) let only elite 歌蕾蒂娅 carrying HOK-Y 淡金坠饰
stand on a 高台; a normal 歌蕾蒂娅, the elite with HOK-X (her default module) or with no module was refused
(`BAD_TILE`, client 「近战单位只能部署在地面」). PR #69 (@sunstricken) raised the PRTS source for the trait: the line
is the 钩索师 base branch trait, with no elite or module condition. (GitHub #148, a 突袭 melee operator shown on a
高台, is not this rule — see the note below.)
- **Official**: character_table writes 「可以放置于远程位」 in the branch trait of the 钩索师 (歌蕾蒂娅 `char_474_glady`,
崖心 `char_173_slchan`) and the 推击手 (见行者 `char_4036_forcer`); PRTS 歌蕾蒂娅 · 特性 lists it under the branch
trait (「技能可以使敌人产生位移 / 可以放置于远程位」), PRTS 新人入门 says both branches "可部署在高台和地面". Every module
of the three keeps the line in its trait override (HOK-X, HOK-Y, PUS-X). PRTS 卫戍协议/帮助 §战斗部署 switches off
部署效果 ("携带Y模组的教官仍无法部署至高台位") — that is a module talent (教官 Y "可以额外部署在远程位"), not a trait,
and no 教官 Y is in the pool.
- **Decision**: the owner's decision of 2026-10-05 reverses the one of 2026-10-04 (§23.35) and follows PRTS: every
MELEE chess whose trait reads 「可以放置于远程位」 may stand on a 高台 — normal or elite, with any module or none.
Raised with the PRTS source in PR #69 by @sunstricken (its implementation covered 歌蕾蒂娅 only; its test page was not
taken).
- **Cause**: `meleeOnHighGround(rec, moduleId)` required `isGolden`, `charId === char_474_glady` and the module
`uniequip_003_glady`.
- **Now**: `meleeOnHighGround(rec)` is true for a record with position MELEE whose trait without a module
(`traitBase.desc`, which an elite with modules carries, else `trait.desc`) contains 「可以放置于远程位」 — exactly six
records: 歌蕾蒂娅, 崖心, 见行者, normal and elite. The loadout is no longer read: `positionClass(rec)` is `'all'` for them,
and `placeClass(ps, rec)` (the server's entry point: `g.move`, swaps, the merge tile, `_evictIllegal`, the audit, the
invariants, the bot) returns `positionClass(rec)`. The client's `piecePosition` lights the same tiles. data/ is
unchanged (no `placement` field; `build-data --offline` gives identical files).
- **Bots**: `planLayoutSteps` already preferred a 高台 for a widened melee chess when one of its 高台 options covers an
enemy road tile (owner 2026-10-04); that now covers all six records, any module. They still count as blockers in the
lineup (`basePositionClass`), and with every ground tile taken a plain melee unit gets no plan while they still get a
高台.
- **Battle** (unchanged): position stays MELEE — on a 高台 they attack and block nothing (`unit.ground` false), on the
ground they block. 地面干员 effects keep reading the melee position (§23.28). 崖心 and 见行者 are hidden chess this
season (never in the pool, and a player cannot set their loadout), so 歌蕾蒂娅 is the one players meet; 见行者's elite
PUS-X refund on a ranged tile (kits/tier3.js `!unit.ground`) stays moot.
- [ASSUMED] (kept from §23.35): the sim's automatic tile searches (突袭 landing, summon / device tiles, `grid.canStand`
without `ranged`) still treat every MELEE unit as ground-only; a prep placement on a 高台 is the tile the battle
deploys and redeploys on.
- Note on GitHub #148 (「突袭近战干员部署在高台」, a battle screenshot on the 深水区 map): not changed here (0.2.0: a melee 突袭 member prefers a landing tile where it can block — the owner's decision of 2026-10-06, §25.11.3). The rule
above admits no 突袭 operator on a 高台, and the 突袭 landing search is meant to be ground-only (the [ASSUMED] above);
a melee 突袭 member standing on a 高台 after its jump needs its own reproduction.
- **Integrator**: §23.35 (title and **Now**) describes the 2026-10-04 rule; it needs a pointer here ("withdrawn in
0.1.4"), as §22.6 got in 0.1.3. §22.6's "Withdrawn in 0.1.3 (§23.35)" line can say the trait reading is back.
- Docs: DESIGN §3 (placement) and §6.6 (the bot), DATA (`placement`), PLAYING §4, META (`board.js`), research 03 C2,
code comments (board.js, gameLogic.js, bot.js, PlayerState.js, build-data.mjs).
- Golden: 16 scenarios moved, all of them the ones with 歌蕾蒂娅 laid out by `tools/golden.mjs layout` (it places by
`positionClass`, so a widened chess goes beside the enemy path — possibly on a 高台 — instead of on the busiest melee
tile, and the others shift): roster-010, -012, -029, -040, -041; bond-deputShip-high, bond-indomShip-low,
bond-preciShip-low, bond-preciShip-high, bond-suntShip-low, bond-swiftShip-high; boss-boss_2-pair, boss-boss_4-pair,
boss-boss_6-pair, hidden-boss_8-pair, hidden-boss_10-pair. roster-031 (elite 歌蕾蒂娅 with HOK-Y, already widened)
and the 16 matches did not move.
- Tests: `test/match/board.test.js` (the class table: the trait line, another wording, a ranged record, the six
records on (10,4) and the ground, 角峰 refused); `test/match/feedback1-placement.test.js` (the six records are exactly
the trait holders and every module keeps the line; `g.move` onto the three 高台 of 战场#01(下半) for each record with its
default / no module / every module (崖心 and 见行者: default only — hidden, no loadout); a 重装 refused; ranged
unchanged; client highlights = the server under four loadouts; the swap; the battle input and the sim on / off the
高台; the bots plan each record on a 高台 that covers the road, a 重装 on the road, no 高台 fallback for it);
`test/ui/gameLogic.test.js` (the drag mirror under four loadouts).
### 24.3 阿戈尔 5: the 3 revives go to the first 3 members knocked out (GitHub #105, #140; the owner's decision of 2026-10-05) — `content/bonds/core.js installEgir`
- **Seen**: GitHub #105 (comment 2) and #140 (comment 3), and a report the owner quoted: 「没被吃的阿戈尔干员也会占用复活名额」
— with 5 阿戈尔, "whatever the food chain eats, only 3 阿戈尔 stand at the end". #140 item 3 (screenshot): the chain ends
on 斯卡蒂 (elite, her module saves her) and 幽灵鲨 falls and stays down. 0.1.3 (§23.20) fixed the 3 holders at battle
start as the first 3 members by position, so uneaten front members held slots they never used while the eaten food
further right stayed down.
- **Official**: PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 阿戈尔: "<在场5名不同【阿戈尔】干员>前3名【阿戈尔】干员首次被击倒时立刻复活"
(bonds.json `max_free_respawn_cnt` 3); "※标记按付与顺序触发【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】
效果"; "※“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零". PRTS names no holder order; §23.20 read
"前3名" as the deployment order from the devour note and players' videos.
- **Decision**: the owner's decision of 2026-10-05, following the players' reports: the 3 slots go to the FIRST 3 阿戈尔
knocked out, in knock-out order, the battle-start devour (5000 物理流失) included; a member never knocked out takes
none (survivors = uneaten + 3); a member uses at most one slot, on its first knock-out, however many 阿戈尔 eat it; an
operator's own revive must not use a slot.
- **Cause**: `installEgir` fixed `holders` at `battleStart` from `egirOrder` (position) and revived only them.
- **Now**: the `death` hook (priority 11) takes a member operator's first `'killed'` of the battle; while fewer than 3
revives are spent it redeploys it at once (free) where it lies and counts one. A member's later knock-outs never take
one, and a first knock-out after the 3 are gone stays down (left to 不屈 at priority 10 and the rest). The devour is
unchanged: it resolves the marks in marking order and cancels a target's pending marks once it is knocked out (a new
deployment or off the field), so a member marked by several 阿戈尔 spends one slot at most. The `fatal` savers all run
before any `death` hook — kits' own savers (斯卡蒂's DRE-Y −50, others 10 … −60), 坚固维式重锤 `PRIO_REVIVE` −100,
M3茧甲 `PRIO_RESPAWN` −101, 埃芒加德 −110 — so a save is no knock-out and leaves the slot for the unit's first real
knock-out. The 0.1.3 rule that an in-place save spends the holder's revive is gone. A 联防 member entering down
(`FORCED_EXIT`) is not knocked out (no slot) until it stands up and falls again; a 调和 member counts; each battle
(normal, 联防, boss) counts its own; a mirrored boss player counts its own knock-outs.
- **Verified** (the report "each devour of the same operator wastes a revive count"): not reproduced on 0.1.3 — a
member marked three times (A → B → X with C facing X) is knocked out by the first mark and the other two are cancelled;
`used` let each holder revive once. What players saw was the fixed holders: in that layout 0.1.3 revived B and left X
down while uneaten A and C held the other two slots. Now B and X take two slots and one is left for the fight.
- **Measured** (the §22.3 chain 浊心斯卡蒂 → 乌尔比安 → 幽灵鲨 → 海霓 → 深巡 → 隐现, normal and 联防): 乌尔比安 survives his
mark (7046 HP, DEF 0), 幽灵鲨 / 海霓 / 深巡 are knocked out 1st–3rd and stand again at t = 0; all 5 members stand.
- [ASSUMED]: (1) 埃芒加德 (a band, not the operator's own kit or item) is treated like M3茧甲, the item revive the owner
named: its in-place save is no knock-out and takes no slot — so with 埃芒加德 and 5 阿戈尔 the battle's first 3
knock-downs are its, and the first 3 real 阿戈尔 knock-outs after that are the bond's. Officially both are the same
"复活" (0-time redeploy), and PRTS's 不屈 note says a held 复活 is consumed even when another effect redeploys the unit;
if the owner reads 埃芒加德 as consuming the slot too, it is one condition in the hook. (2) A slot is counted only when
the redeploy happens (a member that cannot be redeployed — removed, its tile taken — does not spend one).
- Not changed: 埃芒加德's own rule (the battle's first 3 operator knock-downs, `bands/battle.js`, a separate `fatal` hook
that shares no code with this bond) and its test; the devour; 不屈's priority.
- **Integrator**: §23.20 (title, **Now**, [ASSUMED] (1)–(2)) describes the position rule and needs a pointer here
("withdrawn in 0.1.4"); §23.2's "a member it knocks out spends a 5-tier revive like any first knock-out" stays true.
- Docs: research 02 §3.6 and its JSON spec (data/bonds.json `egirShip.spec` regenerated with `build-data --offline`; a
JSON compare shows only `spec.tiers[2].effect` changed), the core.js comments.
- Golden: no scenario moved — no corpus battle has a 5-阿戈尔 knock-out whose slot differs (bond-egirShip-high runs at
999 layers: nobody falls to the devour).
- Tests: `test/content/bonds_core.test.js` — rewritten '阿戈尔 5: the 3 slots go to the first 3 members knocked out,
wherever they stand …' (the last two by position revive, the first by position stays down, second knock-out final,
the relocation case, 3 members: none), '阿戈尔 5 + the devour chain (GitHub #33, #105, #140) …' (6 members: the 3 eaten
first revive, the 4th eaten stays down, the 2 uneaten take none — survivors = 2 + 3, normal and 联防), new '… a member
devoured by several 阿戈尔 spends one slot at most …' (three marks, one knock-out, one slot; a tougher target falls to
the third mark: still one), new '… an operator's own save uses no slot …' (斯卡蒂 elite at half HP in 联防: DRE-Y saves
her from 乌尔比安's mark, all 3 slots left, her first real knock-out takes one; M3茧甲; 埃芒加德 spends its 3, the
bond's 3 are untouched), rewritten '… in 联防 and on the boss field …' (a member entering down, 调和, the mirrored
player). All five fail on 0.1.3's code. `feedback3-egir-down`, `feedback2-doll`, `feedback1f-hammer`,
`feedback3-ulpia-move`, `bands`, `items` pass unchanged.
### 24.4 联防: 阿戈尔 devours the operator in front whoever owns it — a teammate's standing or down operator too (GitHub #140 comment 4, #105; the owner's decision of 2026-10-05) — `content/bonds/core.js devour`
- **Seen**: GitHub #140 comment 4 (two screenshots of a remake 联防): 「阿格尔在联防阶段无法吃再部署状态的队友,导致无法获得
阿格尔加成,官方卫戍协议则可以正常获得加成」. #105 (on 0.1.2): 「联防阶段……攻击加成吃不到,作战的时候深巡3000攻,联防的时候就
500了」 — players report the 联防 block / ATK gains differ from the own combat.
- **Official**: PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 阿戈尔: "战斗开始时【阿戈尔】干员依次吞噬身前一格干员(以及被自身吞噬的
【阿戈尔】干员身前一格干员)造成5000点物理流失并获得其基础攻击力和阻挡数"; the 备注's marking rule reads "身前一格干员" and
"有自身标记的【阿戈尔】干员的身前一格干员" — no own-side limit. PRTS 卫戍协议/帮助 §联防阶段: "该阶段不能叠加层数", "上一阶段为
退场状态的干员强制退场".
- **Decision**: the owner's decision of 2026-10-05: in 联防 an 阿戈尔 devours whoever is on her front tile — a teammate's
living operator, or a teammate's operator that entered 联防 knocked out (carry.down body) — and gains its base ATK and
block count exactly as in a solo battle. 联防 keeps its ban on in-battle layer gains.
- **Cause**: `devour`'s `opAt` took only the devouring player's operators (`S.allyAt(…, pid)`, `d.ownerId === pid`),
and the chain went on only through the player's own members.
- **Now**: `opAt` takes the operator on the front tile whoever owns it: a living one (`S.allyAt` without an owner), else
one lying there since the 联防 start (`egirDownAtStart`: forced out, carry.down). The chain goes on through a marked
阿戈尔 by `S.isMember` — for the player's own operators exactly its members, as before; a teammate's by its own bonds.
Everything else is the solo devour: the marker gains the target's base ATK (atkFlat when written; a 最终加算,
`atkFinal`, since §24.7) and block count; a mark on a
standing teammate's operator resolves (5000 物理流失 less its DEF, the kill credited to the marker), a mark on a down
one resolves nothing (it stays forced out — §19.3's "still counts as standing for its marking"); the knocked-out
teammate's operator belongs to its owner (their 5-tier slot, 不屈 …). Layers: none in 联防 (`layerGainsEnabled`
false, unchanged). The rule is the same on a boss field (two players, no own-side limit).
- **Measured / verified**: the 0.1.3 own-board case holds through unite.js's real carry: a 30-s own combat in which the
devour knocks out 幽灵鲨 → 歌蕾蒂娅 → fodder (3 阿戈尔, no revive) hands them to 联防 with `carryState.down`, and
乌尔比安 gets exactly the own combat's atkFlat / blockCnt, with no layers. A teammate's fodder in front gives the same
atkFlat / blockCnt / 物理流失 as an own one and as a solo battle. (§24.7 moved the ATK gain to `atkFinal`; the tests
compare that key now, with the same outcome.)
- **Geometry** (for the owner): the remake's two 联防 helpers stand on board cols 3–9 (left) and 11–17 (right, colOffset
8) with the road column 10 between them, and the boss halves are cols 3–9 / 11–17 too, so on the current maps no
teammate's operator is ever on an 阿戈尔's front tile at the battle start. The tests place one on col 10 to exercise
the rule. If the report meant the player's own operators waiting to redeploy, that has worked since 0.1.3 (§23.2;
the reporter's screenshots are of a remake 联防).
- [ASSUMED]: (1) the chain continues through a teammate's 阿戈尔 (PRTS "【阿戈尔】干员" names the bond's operators, not an
owner); (2) each player's devour is its own pass, in the players' order (p1's resolves before p2's starts), so a unit
p1's marks knock out is no longer "in front" for p2's members.
- Docs: research 02 §3.6 (the [ASSUMED] own-operators line replaced) and its JSON spec (data/bonds.json
`egirShip.spec.assumed[1]`, regenerated with `build-data --offline`; only that string changed), the core.js comments.
- Golden: no scenario moved (no corpus field has a teammate's operator in front of an 阿戈尔).
- Tests: `test/content/feedback4-egir-unite.test.js` (new): a teammate's living fodder on (10,10) in front of
乌尔比安 — the solo gains, the same 物理流失, knocked out and credited to him, no layer gain; a teammate's down operator —
the standing one's gains, no 物理流失, no knock-out, still forced out (and the same for an own down one); the chain
through a teammate's 幽灵鲨 equals the own chain, and her knock-out takes her owner's 5-tier slot; the own-board case
through `uniteBattleOpts` (carry.down from a real own combat, the own combat's gains). The first three fail on the
previous code. `feedback3-egir-down`, `bonds_core`, `unitedown`, `feedback3-unitecarry` pass unchanged.
### 24.5 A special terrain tile explains itself on a tap (GitHub #184, player request 「建议加入对于特殊地形的单击信息提示」) — `render/app.js` (`tileClick`, `emitTileClick`), `ui/fallbackField.js` (the board tile), `ui/screens/game.js` (`tileClick` → `terrainInfo`, `live.terrainStage` / `terrainTile`), `ui/gameLogic.js` (`terrainInfo`, `TERRAIN_TIPS`), `ui/detailPanel.js` (`TerrainDetail`, `resolveDetail`)
- **Asked** (GitHub #184, after 0.1.3): a tap on a special terrain tile should say what it is. The mode draws 活性源石, 沼泽, 排气格栅, 深水区, the gates and the teleports, but nothing in the client names them — the legend lived in `docs/DATA.md` and the data only.
- **Why the ground was free**: a press on a tile that had nothing on it only closed what was open (a piece / unit card, the selection). `render/app.js` now emits `tileClick { row, col }` when neither a battle unit, a prep piece (the drag controller), a pen enemy nor the boss leader took the press, and `ui/fallbackField.js` emits the same from its own tile divs, so both field implementations behave alike.
- **What the card says**: `gameLogic.terrainInfo(stage, row, col)` reads the glyph from `stage.rows`, its legend entry from `stage.tiles` (tileKey / height / buildable / passability) and **that stage's own terrain parameters** from `stage.special[<terrain>]` — the values `sim/content/devices.js` runs on: 活性源石 `damage 70 / atk 0.2 / attack_speed 20 / duration 300` (PRTS 特殊地形 "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}真实伤害…"), 沼泽's stack rule (`intervalSec` 1, `aspdPerStack` −0.05, `moveMulPerStack` −0.05, `maxStacks` 10, `heavyWeight` 3, cleared on leaving — PRTS 沼泽控制), 深水区's drowning (`damage 40`, aspd −0.6, move ×0.6) plus 拒绝部署 (PRTS 深水区 地形信息; `grid.js DEPLOY_REFUSED_TILES`), 排气格栅's "operators on it cannot be targeted by enemy ranged attacks" — completed in review with the two halves that matter in play: the operator it blocks still gets attacked (the tile's buff is a 隐匿-like `stealth` flag, `devices.js enterTerrain`), and 深水区's drowning damage is sourceless damage that no 干员's 增伤 / 穿透 / 装备 applies to and is *not* 环境伤害 (`tickDeepsea`'s tags are `dot` / `periodic` / `deepsea`, deliberately not `terrain`). The gates and teleports carry no `special`, so they key on `tileKey` — 红门 (enemy gate) / 蓝门 (protection objective) / 传送入口 / 传送出口, in the words `docs/PLAYING.md` already uses. The card is a normal `ResultDialog`-less `DetailPanel` card: read-only, no protocol frame, closed by the next field press (`closesOnFieldPress` now lists `terrain`) or its own ✕.
- **An ordinary tile still says nothing** (road / floor / wall / fence / bench → null): the press keeps its other meanings there — deselecting a piece, closing a card — and a drag still starts only on a piece.
- **Coordinate spaces (corrected in review)**: a tap reports BOARD coordinates — `emitTileClick` picks with `pickBoardTile`, so the boss-prep transform (`prepXf.toBoard`) is applied once, in the renderer. (The first version reported the DRAWN tile from `groundTile` and the screen mapped it again, so on a Final Assault / Hidden Core prep the tap was converted twice and explained another tile.) Everywhere but that prep board the two spaces are the same: a 最终攻势 / 隐秘核心 battle renders the stage's own rows (`GEO.BOSS_RECT`), the normal and 联防 rects are stage rows. A boss PREP draws the player's half — stage rows 2–5 — as board rows 9–12, and the screen turns the board tile into the stage tile with `gameLogic.fieldTile` before the lookup (and reads the stage the board on screen is built from: the own one with the player's 机变 tile overrides, `effectiveStage`, or the plain stage while watching a teammate).
- **[ASSUMED]**: that a player wants the official's 地形信息 here (the official shows terrain descriptions in its own level / terrain UI, not in-match — research 09 records none) and the wording of the mechanism lines: the numbers are the data's, the prose is ours (marked as ours in `TERRAIN_TIPS`). A 机变 card that switches a terrain off (模拟战场演变 · 沙尘暴停止) is not reflected — the tip explains the TILE, not the round's modifier.
- **Tests**: `test/ui/gameLogic.test.js` (every terrain of the real stages with its own numbers: 活性源石 in 战场#04, 沼泽 in 战场#06, 排气格栅 in 战场#07, 深水区 in 战场#05; the four gate / teleport tiles; an ordinary glyph → null; out-of-range, fractional and legend-less input; the fact lines from the legend entry; a stage missing its parameters still explains the mechanism), `test/ui/terrain-detail.test.js` (the tap's target resolves into the card; a tip-less or malformed one does not), `test/ui/terrain-tip.e2e.test.js` (headless Chrome on the mock harness: a tap on 蓝门 / 红门 opens the card with its own line on BOTH field implementations — the engine canvas at the tile's own screen position and the DOM fallback's tile — while a tap on a floor tile opens nothing; and on a Final Assault PREP the tapped tile is the one the board tile maps to on the boss field, the regression the review asked for).
### 24.6 活性源石 is ONE material on both boards (GitHub #184, same report's second half) — `render/style.js` (`ORIGINIUM`, `linearToSrgb255` / `linearToHex`), `render/textures.js` (`originiumCanvas` / `originiumOverlay`, `MAT_DRAW.infection` / `infection2`, `PROC_LAYERS.originium` / `originium2`, `DEFAULT_ART_LAYERS.infection` / `infection2`), `render/tiles.js` (`variantMat`, the pulse tint), `render/board3d/materials.js` (`infectionMaterial`)
- **Reported** (GitHub #184): 「感觉源石地板的图片有点奇怪(有点简陋,另外两个源石地板的贴图不一致)」 — two originium floors that do not match. They were two different materials: with the board art installed (or `?board=2d`) the tile came from the 2D atlas cell — a beveled brick with seven randomly placed orange crystal clusters — while the 3D board drew a world-space crust with pulsing veins over the official concrete. And that 3D crust was multiplied by a per-tile fade (`edge = smoothstep(0, 0.1, min(vUv, 1 − vUv))`), so every tile of a 活性源石 area faded out at its own border and the area read as separate patches rather than one floor.
- **One palette**: `style.js ORIGINIUM` holds the crust's dark and lit sides, the vein and the crystal grains. The values are the 3D shader's own working colours (its fragment ends in `#include <colorspace_fragment>`, so they are linear); the canvas converts them with `linearToSrgb255` — without that the 2D tile came out darker than the 3D one, which is what the first version of this change did.
- **One recipe**: two octaves of noise make the crust mask (`smoothstep(0.38, 0.62, n·0.7 + n2·0.45)`), the ridge of the fine octave its veins (`smoothstep(0.86, 0.98, 1 − |2·n2 − 1|)`, brightened where the crust is), the rare top of a third octave the grains — the shader runs it over world-space noise, `textures.js originiumCanvas` over a *toroidal* field (the noise wraps, so a cell that is repeated over the board lines up with itself: the veins run through the tile's edge instead of stopping at it).
- **Both reach the tile's edges**: the 3D material no longer has the per-tile fade, and the 2D cell has no frame any more (the old `bevelTile` is gone). The mire / water / smog materials keep their own fades — this section is about 活性源石, the tile that was reported.
- **With the board art installed** the 2D tile is composed exactly like the 3D one: `DEFAULT_ART_LAYERS.infection` draws the official concrete slab (`board3d/atlas.js concrete`, the same atlas rect) and then the crust as a procedural layer; without the art the cell falls back to `MAT_DRAW.infection` (the procedural ground + the same crust). A second variant (`infection2`, the same art mirrored; `PROC_LAYERS.originium2` for the art path) is picked by tile (`tiles.js variantMat`), so a field of 活性源石 does not repeat one pattern.
- **Not [ASSUMED]** — this is our own art: the palette and the recipe are ours, the tile it dresses and its numbers are the data's (a 机变 card can switch the terrain off, §24.5).
- **Tests**: `test/render/originium-art.test.js` (the couplings: both renderers take the palette from `ORIGINIUM`, the canvas converts it, the recipe's thresholds agree, the 2D base slab is the same atlas rect the 3D board lays its crust on, the infection shader has no per-tile fade while the mire keeps its own, two variants are registered and picked, the pulse tint is the shared one), `test/render/originium-cell.e2e.test.js` (headless Chrome: the cell's veins hit the palette's displayed colour (`linearToHex(ORIGINIUM.vein)`), veins reach the border like the middle (no frame), the veins at opposite edges are alike (the cell joins itself), and the second variant is a different pattern).
### 24.7 阿戈尔: the devoured base ATK is a 最终加算, and a fallen marker's marks still resolve (GitHub #165 points 2–3, PR #176 by @SpiritedAwayCN; the owner's decision of 2026-10-06) — `content/bonds/core.js devour`, `units.js _recalc` (`atkFinal`), `buffs.js ADD_KEYS`
- **Seen**: GitHub #165 point 2 (PR #176 by @SpiritedAwayCN): PRTS makes the devoured base ATK a 最终加算, but the remake
added it before the percentages, so the marker's ATK +% scaled it — 1000 base ATK, a +100 % skill and 2000 devoured
gave 6000 instead of 4000. Point 3: the marks of a marker knocked out earlier in the pass were dropped, while PRTS
cancels only the knocked-out target's pending marks — in A → B → C with B devoured first, C never took B's 5000.
- **Official**: PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 阿戈尔 ※: 「该付与来源获得所有标记单位的基础攻击力(最终加算)和阻挡数」;
PRTS 游戏数据基础 属性基本公式 `A_f = F_t[(A + D_p)(1 + D_t) + F_p]` — the 最终加算 F_p comes after the 直接乘算 D_t and
inside the 最终乘算 F_t. The marks: 「所有【阿戈尔】干员完成标记后,每个标记令目标受到一次5000点物理流失的【吞噬】效果」,
「标记按付与顺序触发【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果」 — only the knocked-out TARGET's
pending marks are cancelled —, and 「【吞噬】的物理流失来源为被付与目标自身;单位被【吞噬】击杀时,击杀来源始终为对应标记的付与来源」.
- **Decision**: the owner's decision of 2026-10-06: both as PRTS says (points 2 and 3 of #165, as PR #176 proposed).
- **Cause**: `devour` put the gained ATK in `atkFlat`, inside `(base + atkFlat) × (1 + atkPct) × atkMul`, so every ATK +%
scaled it; the resolve loop skipped a mark whose marker was neither alive nor down since its own combat
(`!(m.alive || downAtStart(m))`, the rule since 0.1.0 — §22.3's "a marker off the field gives no further mark").
- **Now**: a new additive mod key `atkFinal`, the 最终加算 (no existing channel had that place: 鼓舞 emulates one with a
compensated `atkFlat` refreshed by its aura): `ATK = ((base + ΣatkFlat) × (1 + ΣatkPct) + ΣatkFinal) × ΠatkMul`. The
devour buff is `{ atkFinal, blockCnt }`: 1000 base, +100 %, +2000 devoured → 4000 (0.1.3: 6000); a 最终乘算 (`atkMul`,
e.g. 虚弱) still scales the whole, the devoured part included, as F_t. Every other key keeps its meaning. Each mark
resolves unless its target was knocked out during the pass (or lies down since the 联防 start), whatever became of its
marker: B's mark lands on C after B fell to A's, and a knock-out by it is credited to B (`source: m`). Unchanged
(§24.3 / §24.4): the order of the marks and of their resolution, a target's pending marks cancelled once it is knocked
out (revived or not), the 3 revive slots in knock-out order, the 联防 front-tile devour of any owner's operator, the
down-at-start rules (it marks and resolves its marks, nothing resolves on it), the layers once per devoured unit.
- **Open (point 5)**: PRTS makes the 流失's source the target itself and only the kill source the marker. `Battle.loseHp`
has one source for the hooks and the credit (only `sourceless` splits them), so the `damaged` / `fatal` hooks still
see the marker; a split needs a new `loseHp` option and changes what content and every devour test read from the
hooks — not the small, contained change the owner allowed, so it waits.
- [ASSUMED]: a fallen marker's credited kill fires its kill effects like any credited kill in the engine (海霓's first
kill, 斯卡蒂's count): PRTS names the kill source, not whether a knocked-out operator's traits still answer.
- Docs: DESIGN §5.2 / §5.3 and SIM.md §3 (the formula, the mod key), research 02 §3.6 and its JSON spec (data/bonds.json
`egirShip.spec` regenerated with `build-data --offline`; only `spec.algorithm[2]` / `[3]` changed), the core.js
comments; §24.4's `atkFlat` lines are marked. §22.3's marker line above is withdrawn by this section.
- Golden: `bond-egirShip-high` — the three devourers (elite 深巡 / 幽灵鲨 / 斯卡蒂 on row 9) no longer scale the devoured
base ATK by their ATK +% (2867 / 2593 / 2024 ATK at 1 s instead of 3552 / 3288 / 2274), so its 19 enemies fall later
(74.03 → 89.03 s); `coop2-ABYSS-10-boosted` — from round 8 on ai_0 has 3 more 阿戈尔 and 3 more 奥术 layers (海霓's
first-kill trait, the one effect that adds both, now fires in that battle) and both bots' boss damage moves. The
fallen-marker rule moves no scenario.
- Tests: `test/content/bonds_core.test.js` — new '阿戈尔 devour ATK is a 最终加算 …' (1000 / +100 % / +2000 → 4000, a
直接加算 still scaled, ×0.5 scales the whole), rewritten '… a fallen marker's marks still resolve …' (g2 falls to g1's
mark, its own on the fodder lands: 2 × (5000 − 500)), new '… A → B → C with B devoured first …' (C falls to B's mark,
the kill is B's); all three fail on 0.1.3's code. `feedback3-egir-down`, `feedback4-egir-unite` and
`feedback3-ulpia-move` read `atkFinal` instead of `atkFlat`; `test/match/bosshp.test.js` recomputes ATK with the
最终加算. `facing`, `feedback2-doll`, `sim/core` pass unchanged.
### 24.8 缇缇 S2 封护: every sleep pulse of the ward is a new 沉睡 entry for her trait (GitHub #162; the owner's decision of 2026-10-06) — `kits/tier5.js` (缇缇 `skchr_titi_2` `onTick`), `Battle.applyStatus` (`reenter`)
- **Seen**: GitHub #162: the reporter remembers first-hand that in the official mode 缇缇's trait (garrison_125: 萨尔贡 /
精准 +1 per entry, elite +2, at most 24 / 48 per battle) climbs fast while S2 runs (「攻击范围内有陷入沉睡就开始迅速增加直至
上限」); in the remake an enemy held asleep by S2 counted once.
- **Official**: the trait 「<战斗中>每当范围内有敌人或干员进入沉睡或晕眩时,使已激活的【萨尔贡】【精准】层数+1(每场战斗至多24层)」
(elite +2, 48); S2 「…技能开启时使自身与攻击范围内生命比例最低的干员陷入沉睡直到技能结束,期间持续使自身与该目标周围的敌人陷入
沉睡…」 (12 s / elite 15 s). Neither the data nor PRTS gives the pulse's interval or sleep length.
- **Decision**: the owner's decision of 2026-10-06, following the report: each pulse counts as an entry.
- **Cause**: the pulse re-applies a 0.5 s sleep every 0.25 s (`AURA_DUR` / `AURA_IV`), so an enemy beside a ward never
leaves 沉睡 and `statusApplied.entered` (the target did not carry the status) is true at the first pulse only; the
garrison event counts new entries only.
- **Now**: the pulse calls `applyStatus(enemy, 'sleep', { duration: 0.5, source, reenter })`, a new opt-in that reports
`entered` anyway (the sleep buff refreshes as before): once per enemy and pulse — an enemy beside both wards re-enters
once — and not while a longer sleep from elsewhere holds it (more than 0.5 s left). The garrison rule is untouched: a
refresh elsewhere is still no entry (the 125 test's stun refresh), and the sleeper must be in 缇缇's attack range
(`S.inRange`) — an enemy at her back is slept by every pulse and adds nothing. One enemy in range: one entry per pulse
(the 0.25 s timer runs in 1/30 s ticks: every 8 ticks, 0.27 s), the cap (24 / 48, with the ward's own two operators)
5.63 s into S2. No enemy wakes between pulses (the 0.5 s outlasts the pulse) and no status event is added, so the
fight changes only through the earlier layers. `entered` is read by the two garrison events only (冻结 for 初雪 /
银灰; 沉睡 / 晕眩 by garrison_125_a / _b alone).
- [ASSUMED]: the pulse timing (0.25 s / 0.5 s, unchanged) and that each pulse is an entry (the reporter's memory; no
source gives the official interval); a longer sleep from another source is no re-entry.
- Golden: `roster-042` (缇缇 S2) — her trait's 24 layers now come within her first S2 (26.07–37.03 s) instead of over four
casts up to 176.73 s; the totals are the same (capped), and the earlier 萨尔贡 / 精准 layers end the fight at 166.97 s
instead of 182.03 s with 5 knock-outs instead of 7.
- Tests: `test/content/garrisons_battle.test.js` — new '缇缇 125 + S2 封护 (GitHub #162) …' (the real 缇缇, normal and
elite, S2 cast: an enemy at her back sleeps through 8 s of pulses and adds nothing beyond the ward's two operators; an
enemy in range beside both wards — one entry per pulse — takes the trait to 24 / 48 within 6.5 s; fails on 0.1.3's
code). The 125 test (a stun refresh is not an entry) and `kits_alt_t5`'s S2 test pass unchanged.
### 24.9 A sleeping enemy cannot be blocked: 沉睡 frees the block slot (GitHub #140; the owner's decision of 2026-10-06) — `sim/battle/blocking.js _checkBlock`, `sim/battle/status.js applyStatus`, `sim/ai.js updateEnemy`, `sim/buffs.js` (`sleep`), `kits/ops/chess_char_3_12-blemsh.js` (瑕光 S2) (the 0.2.0 files; 0.1.4 had them in `sim/Battle.js` and `kits/tier3.js`; the port: §25.9.1)
- **Seen**: GitHub #140 — a player tested 瑕光's S2 in the official mode (the 爱国者 stage): she put 4 enemies to sleep,
the hounds that came next were still blocked, and after the skill no more were — 「被沉睡的敌人是不占用阻挡数的」. In the
remake an enemy that fell asleep while blocked stayed blocked and kept its blocker's slot, so 瑕光 S2's sleepers filled
her 3 block for the whole skill and the next enemies walked past her (the owner's first reply on #140 had kept that
reading).
- **Official**: PRTS 异常效果 — the 异常组合 SLEEPING 沉睡 = 「无法行动+无敌+不可阻挡」, and the 异常效果 BLOCK_FREE 不可阻挡 =
「无法阻挡/被阻挡,自动解除阻挡」. 小睡 (DOZE) and 闭锁 (SHELTERING, 泥岩 S3) hold 不可阻挡 too — on operators, outside this
change.
- **Decision**: the owner's decision of 2026-10-06, as PRTS and the report say: a sleeping enemy cannot be blocked and
takes no block slot.
- **Cause**: 沉睡 was in neither block check. `Battle._checkBlock` refused `unblockable` / `levitate` / `fear` only, the
release in `Battle.applyStatus` fired for those three flags, and `ai.js updateEnemy` returns early for an enemy that
cannot act (a sleeper is `stun` too: `units.js`) before its own release check. The sleep template's `noBlock` only
stops a sleeping operator from blocking.
- **Now**: 沉睡 is one of the 不可阻挡 states. `applyStatus(enemy, 'sleep')` releases the enemy at once (`_unblock`: the
blocker's slot frees for the next enemy; a 隐匿 enemy's block-end window starts, §22.8); `_checkBlock` never blocks a
sleeper; a sleep added as a plain buff (`flags.sleep`) is let go on the enemy's next update (the cannot-act branch of
`updateEnemy`). The sleeper stays where it fell asleep — overlapping its former blocker and whatever that blocker takes
next — and when it wakes it is checked like any unblocked enemy: blocked again where it stands by a blocker in contact
with a free slot (usually the same one), else it walks on through the full blocker. 瑕光 S2 (the ground enemies on her
tile and the enemies she blocks): the sleepers free her 3 slots, she blocks the next 3 during the skill, and when S2
ends the sleepers wake beside her and, her slots full, walk on — the report. A stun, a freeze or a refused sleep (23
enemies are 沉睡免疫) keeps the block as before; sleeping operators are unchanged (they already released what they block).
- **Other readers of the block** (checked): `Battle.blockedTargets` — blocked-first targeting, the DEFAULT trigger, the
强攻手 / 重剑手 / 推击手 attack on every blocked enemy — no longer lists a sleeper. Only `hitSleep` attackers could target
one, and of those only 瑕光 blocks: her 仁慈 (sleepers in her range first, ×atk_scale) reads her range, so a sleeper she
let go in front of her is still her first target and one behind her is out of reach. A 要塞 that blocked nothing but a
sleeper stalled (its melee branch found no target); it now fires its ranged splash. The "while blocking" effects
(`unit.blocking`) do not count a sleeper; “庞贝”'s self-blast clock restarts when a sleep ends its block (as a 浮空
already did); the snapshot's blocked bit (`UF.BLOCKED`, the client's block marker) follows. 荒芜拉普兰德 S1's 「非移动敌人」
reads the sleep itself.
- [ASSUMED]: a woken sleeper is re-blocked in the enemy update order (an enemy that reaches the blocker earlier in the
same tick takes the slot first; PRTS gives no order) and where it stands, never pulled back to the blocker's front;
“庞贝”'s clock restarts although its talent lists 沉睡 among the pauses — the 不可阻挡 ends the block, and
「解除阻挡时重置计时」.
- Golden: every moved scenario fields one of the three kits that sleep enemies (小满 S2, 瑕光 S2, 缇缇) and sleeps enemies
that were blocked (counted with a probe): `roster-003` (小满 S2, 6 such sleeps: 82 kills instead of 83, 3 leaks instead
of 2 at the 110 s timeout); `roster-005` (elite 小满 S2, 3: cleared at 136.73 s with 83 kills and 1 leak instead of the
140 s timeout with 82 and 2); `roster-013` (缇缇 S3, 13: the same fight — only the `blocked` count, 23 → 34 with the
woken sleepers held again, and the snapshot hashes, the blocked bit, move); `roster-032` (缇缇 S1, 4: 7 knock-outs
instead of 5, 缇缇 among them, and 24 萨尔贡 layers instead of 34; same kills and leaks); `roster-038` (elite 缇缇 S2, 2:
ends at 197.43 s instead of 197.27 s, 4 knock-outs instead of 3); `roster-039` (瑕光 S2, normal and elite, 32 + 22: the
enemies they hold and put to sleep walk on when they wake with both blocks full — 41 of 52 killed instead of 47, 11
leaks instead of 5 at the 170 s timeout); `roster-042` (缇缇 S2, 4: ends at 161.17 s instead of §24.8's 166.97 s);
`bond-maniShip-high` (小满 S2 among the members, 1: only the `blocked` count, 29 → 30, and the snapshot hashes);
`coop4-FUNNY-6` (ai_0's 缇缇: from round 9 her trait's 萨尔贡 / 精准 layers and ai_0 / ai_2's boss damage move);
`coop4-ABYSS-7` (ai_2's 小满: rounds 11–12, one more kill and one fewer leak in round 11, four more kills in round 12);
`coop2-NORMAL-8-serverrun` (ai_1's 缇缇: from round 10 its battles move — one more leak in round 10 — and the team is
eliminated in round 13 instead of winning after round 14). `fields` did not move.
- Tests: `test/sim/feedback4-sleep-unblock.test.js` (new; every case fails without this change) — a block-1 operator
holding A: A falls asleep → released at once, the slot frees and B is blocked, A stays put; A wakes while B holds the
slot → not blocked, it walks on; B knocked out while A sleeps → A stays unblocked beside the free operator and is
blocked again the moment it wakes, never having moved; a plain-buff sleep is let go on the next update, a stun or a
refused sleep keeps the block; `blockedTargets` drops the sleeper and takes B; the real 瑕光 S2 — her 3 blocked enemies
sleep and are let go, she blocks the next 3 during S2, and when it ends the sleepers wake with no room and walk on.
`playtest5_blocking` (its 瑕光 S2 test included), `combat`, `engine-requests`, `kits_t1t2` / `kits_alt_t2` (小满),
`kits_t3` / `kits_alt_t3` (瑕光), `kits_t5` / `kits_alt_t5` / `garrisons_battle` (缇缇) and the other sleep and block
suites pass unchanged.
---
+831
View File
@@ -0,0 +1,831 @@
# DESIGN §25 — 0.2.0
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 25. 0.2.0 — the maintainability refactor, two languages, 补位, 自选编队, fidelity fixes, the 0.1.4 ports, release packaging
0.2.0 was built on `feedback5`, branched from feedback4 at 583899d (0.1.3, the golden-result harness and part of 0.1.4);
later merges of feedback4 brought the rest of 0.1.4 in. The workstreams, each on its own `fb5-*` branch, each merged
after review with its golden scenarios named: R1–R6 (refactor, tooling), W5A (golden results), W5B / W5D / W5G /
K1–K3 / W5N (补位), W5H / W5K / O1–O24 (自选编队 and its operator kits), W5I / W5S (languages, phases 1 and 2), W5C /
W5E / W5F / W5J / W5L (fidelity, wave 1), WE1 / WE2 (the follow-ups the kit waves reported), W5M, W5R and the feedback4
merges (the 0.1.4 ports), W5V (the owner's items of 2026-10-06), W5U (packaging) and W5T (the docs). Each rule was
checked against the official data and PRTS; anything no source backs is marked [ASSUMED].
The normative sections were rewritten with the streams: §0 (the DIY slots), §2 (the layout), §5.5 (the enemy damage
frame, the block-count target rule), §5.6 (the trigger rules), §8.1 / §8.3 (`welcome.diyKitted`, `standIns`, `diy`,
`diyBanned`), §16 (干员持有, 自选编队). Where each is handled: refactor and tooling → §25.1; Chinese and English →
§25.2; 补位 → §25.3; 自选编队 and the 71 operator kits → §25.4; trigger rules (the larger-range rule, AUTO skills) →
§25.5; fidelity fixes of wave 1 (GitHub #41, #49, #82, #96 / #137, #107, #116; 伺夜's 狼群; Touch) → §25.6; WE1, sim
rules (GitHub #181, #187 / #170, follow-ups) → §25.7; WE2, data / match / UI (follow-ups, GitHub #148) → §25.8; the
0.1.4 ports → §25.9 (the last two, W4D's sleep rule and W4C's board tap → §25.9.1 / §25.9.2); the settled decisions,
what stays [ASSUMED] and the questions left to the owner → §25.10. The closing additions: the owner's decisions of
2026-10-06 (望's stock, skill triggers through summon areas, the 突袭 landing, GitHub #148) and the items the sources
settled (泥岩's 闭锁; 忍冬, 深靛, 阿) → §25.11; release packaging and the asset manifest (the 自选 voices, the summons
with no Spine model) → §25.12.
Decisions of the owner: 2026-10-04 — the server stays on plain `node:http` + `ws`, no web framework (§25.1);
2026-10-05 — the 补位 plan (§25.3); the 自选编队 rules, the collab operators out of the pool, ISW-A refused (§25.4);
Chinese by default with an English switch, official English game texts, PR #70's translations reused with credit
(§25.2); the larger-range trigger rule on the basic strategy, the SEARCH row and 深巡 S2, every 自选 重装 cast with an
enemy in range, 纯烬艾雅法拉 S1 at full SP, 伺夜 S1 only with his pack on the field (§25.5); 2026-10-06 — a chess the player
does not own shows as its stand-in everywhere (the owner's recall of the official mode, §25.3); the enemy damage frame
kept (§25.7.2); 望's placed 棋子 count in her stock, skills that act through summons cast on an enemy in a summon's area,
a melee 突袭 member lands where it can block (§25.11).
### 25.1 Refactor and tooling: the big files split, golden results, lint, types, import boundaries (refactor plan; owner's decision of 2026-10-04: no web framework) — `server/http/`, `server/match/match/`, `server/match/player/`, `server/sim/battle/`, `content/enemies/`, `kits/ops/`, `public/js/ui/gameLogic/`, `render/fx/`, `render/app/`, `screens/game/`, `tools/golden.mjs`, `tools/check-imports.mjs`, `eslint.config.js`, `jsconfig.json`
- **Why**: the files every fix touched had grown to thousands of lines (`Match.js` 3237, `enemies.js` 3162,
`Battle.js` 2465, `fx.js` 2259, `render/app.js` 2046, `gameLogic.js` 1913, `PlayerState.js` 1673, the six kit tiers
864–3362 each), and the new operator kits needed one file per operator. **Official**: none — nothing a player sees
changes (refactor plan rule 1).
- **Safety net first** (W5A, merged on feedback4 before the refactor; `test/golden/README.md`): `tools/golden.mjs` runs a
seeded corpus through the production BattleSpec path and `Match` with a `VirtualScheduler` and reduces every scenario
to a digest — families `roster` (every chess record × skill × module, every stage and enemy kind), `bonds` (every
bond at 1 and 999 layers), `fields` (every leader, 联防 with 1 and 2 helpers), `matches` (bot matches to the end), and
since 0.2.0 `standins` (§25.3) and `diy` (§25.4). `npm test` checks the fast subset, `GOLDEN_FULL=1` / `npm run
golden` everything; a refactor never changes the files, a gameplay change regenerates them in the same commit and
names every scenario that moved (the rule every 0.2.0 stream followed).
- **Now** (R1–R5, each a pure move listed in `.git-blame-ignore-revs`, then a docs commit; golden unchanged after each):
1. R1: `server/index.js` (786 lines → 110) keeps `startServer()` and the same 15 exports; the code is in
`server/http/` (config, websocket, static, media, files, buildTag, routes, common, boot). Still plain `node:http` +
`ws` (the owner's decision of 2026-10-04: no Hono); response header order, the /healthz keys, the banner and the
`/data.js` stand-in text are byte for byte the same (a scripted fingerprint of 51 requests matched).
2. R2: `Match.js` (3237 → 383) and `PlayerState.js` (1673 → 239) keep the class, the constructor and the interface
header; their 167 / 87 members moved verbatim into 16 / 9 per-concern method containers (`server/match/match/`,
`server/match/player/` — W5K added `diy.js` later; shared constants in `common.js`), installed on the prototype in
a fixed order — a name defined twice throws.
3. R3: `Battle.js` (2465 → 189) the same way into `server/sim/battle/` (131 methods, 14 containers + `util.js`);
`content/enemies.js` (3162 → 321) into `content/enemies/` (helpers, archetypes, one file per special type,
leaders) with the `KITS` order kept.
4. R4: the 129 kits of `kits/tier1..6.js` became `kits/ops/<chessId>-<codename>.js` (the code name = the charId
without `char_<n>_`; the chess id is the unique key — 锡人, 耶拉, 华法琳 are two chess each), helpers shared by a
tier in `kits/shared/tierN.js`, the registry `kits/index.js` (a guarded dynamic import per file: a broken file only
drops its own kit to the generic one); `kits/README.md` is the kit guide with the fidelity checklist. The
`tier*.js` re-export shims were removed in WE2 (§25.8.8).
5. R5: the client — `ui/gameLogic.js` (1913 → 47) and `render/fx.js` (2259 → 45) became barrels over
`ui/gameLogic/` (18 modules) and `render/fx/` (15); the field view's helpers moved to `render/app/` and the match
screen's standalone parts to `screens/game/` (same exports).
- **Tooling** (R6): `npm run lint` — a correctness-only ESLint (`eslint.config.js`; no formatter, warnings allowed);
`npm run typecheck` — `tsc --checkJs` over a first slice (`jsconfig.json`: `shared/` but `protocol.js` /
`bandBonds.js`, `sim/rng.js`, `dir.js`, `constants.js`, the typedefs of `types/core.js`; `types/README.md` says how to
widen it); `npm run check:imports` — `tools/check-imports.mjs`: `server/sim/` (served to browsers) imports neither the
match, the lobby, the entry, the net layer, `public/` nor a Node builtin, `server/match/` neither `public/` nor the
net layer; the one listed exception is `sim/nodeData.js`, the Node-only loader (`test/check-imports.test.js` pins the
list); `tools/package.mjs` (+ `--lite`) builds the release zips from a runtime allowlist (W5U, §25.12).
CI gained a tooling job (lint, imports, typecheck; once, not per matrix entry).
- **Measured**: a match now loads about 340 small script modules; over HTTP/1.1 at 100 ms latency a cold load took
13.4 s instead of 9.8 s, so docs/DEPLOY.md recommends HTTP/2 at the reverse proxy (the browser sim warms up during the
band draft, so play is not delayed).
- Tests: `test/content/kits_layout.test.js` (every `ops/` file listed once, named after its chess id and charId, one
registry key, no Node-only import, no clock or `Math.random`), `test/check-imports.test.js`, `test/version.test.js`
(reads the banner / `/healthz` from their new files), `test/docs-consistency.test.js` (reads the classes as their
façade plus method modules); `GOLDEN_FULL=1 node --test test/golden.test.js` after every refactor commit.
- Docs: docs/ARCHITECTURE.md (the map of the code after the split) and CONTRIBUTING.md (setup, tests, the fidelity
rules, conventions) are new in 0.2.0; §2 lists every file.
### 25.2 Chinese by default, an English switch (W5I, phase 1; W5S, phase 2; PR #70 by @YuriRestia; the owner's decision of 2026-10-05) — `shared/i18n.js`, `shared/i18nData.js`, `tools/build-i18n.mjs`, `tools/i18n.mjs`, `public/i18n/en.json`, `data/i18n/en.json`, `public/js/ui/lang.js` (`sentText`), docs/I18N.md
- **Seen**: English-speaking players asked for an English interface. PR #70 (@YuriRestia) translated by replacing the
strings in place across every file, logic included; the owner did not merge it (reply on PR #70, 2026-10-05) and
asked for real i18n instead.
- **Official**: the English game texts exist in the official EN client data (the EN tables carry act2autochess with the
zh_CN ids); the YoStar repository the plan first named is archived and has no 盟约 season.
- **Decision**: the owner's decision of 2026-10-05 — Chinese stays the default (an English browser still opens in
Chinese), English is a switch; game texts from the official EN data; PR #70's translations reused with credit; no
i18n framework.
- **Now** (docs/I18N.md is the reference): UI strings go through `t('中文')` — gettext style, the Chinese text is the
message id, so an untranslated string shows Chinese, never a key (`{name}` parameters, one plural form, `tc()` for
context, `N_()` marks module-level tables); their English is `public/i18n/en.json`, seeded from PR #70's
translations. Game texts (operators, skills, talents, modules, traits, enemies, bonds, items, effects, strategies,
特质, 机变 cards, modes, stages, tokens) come from an overlay, `data/i18n/en.json`, which `tools/build-i18n.mjs` builds
by walking the zh_CN and EN tables side by side (exact, template and composite matches; fallbacks: the remake's own
strings, PR #70's, the UI table) and `public/js/data.js` applies: 97.6 % of 22 183 texts with the 自选 data (the
CN-only operators and modules are what is missing). Server toasts and ticker lines travel as `msgid` + `params`
(`wireMessage`; `text` keeps older clients working) and each client renders its own language. The switch: the title
screen (top right) and 设置, `?lang=en` once, kept in `sp.pref.lang`, re-rendered in place. Phase 1 converted the
title screen, lobby, room, settings, the app shell and every server message; `tools/i18n.mjs` (extract / check /
codemod / seed) finds what is left.
- **Phase 2** (W5S, 2026-10-06; docs/I18N.md §4): of the 1 128 Chinese literals `extract` still listed, 1 056 go through
`t()`, `N_()` or the new `tParts()` (a sentence with markup or a component inside: the translation split at its
placeholders, vnode params kept) — the screens (band draft, briefing, the game screen and its overlays, 干员调配 /
干员持有, the result), the HUD, shop bar, detail card, bond strip and popup, 机变 overlay, enemy drawer, team panel,
guide, emotes, components and the client models, the phase banners, refusal reasons and formats of `ui/gameLogic`
(`fmtNum` writes K / M / B in English, 万 / 亿 in Chinese), the canvas, 2D-fallback and facing-wheel labels and the
msgid tables of `shared/constants.js`; the other 72 are marked as no player text and skipped by the audit (error
details and logger lines, the invariant reports, the bilingual HTTP error pages, the AI seats' names, the dev STUB
match, data keys). `extract` now reports 0 and `check` 1 002 msgids, every one translated (`public/i18n/en.json`
1 015 entries; the 64 fragments the sweep replaced dropped). Game texts the server copies into the match views in
Chinese — a 机变 card, an effect entry, the draft's name, a reward offer's label, the result title — show the localized
record when the sent text is that record's own (`data.lookupRaw`, `ui/lang.js sentText`; names through `tName`); a
text the server reworded (the multi-round 悬赏's two-battle text, `choices.js bountyText`) stays as sent. A language
switch in the middle of a screen re-reads the game texts (`useGameData()`, the 干员调配 / 干员持有 rosters, the strategy
list and the open detail card list `data.locale()` among their memo dependencies). What stays Chinese in English:
player names (the AI seats' too), that reworded 悬赏 text, the game texts the official EN client lacks (some 自选
operators, newer modules) and the 玩法说明 pictures (their titles translated). The 虫动 emote set follows the official
EN name (Slug Style). The Chinese output did not change: a render of every converted file in Chinese equals the code
before the sweep, and the few rewrites it cannot follow were checked by hand.
- [ASSUMED]: the English of the remake's own strings (排气格栅 "Exhaust Grille" as PR #70 has it, the 联防 map names, the
server's own templates, the 126 msgids phase 2 added).
- Golden: unchanged. Tests: `test/i18n.test.js` (since phase 2 it scans the whole client, shared and the server
messages: no Chinese literal left unwrapped, every msgid translated — `extract` 0 and `check --strict` as a test;
floors 1 000 catalog entries, 1 350 msgid uses), `test/i18n-data.test.js` (coverage floors), `test/i18n-server.test.js`
(also the sent data texts), `test/ui/i18n.e2e.test.js` (`SP_E2E=1`).
### 25.3 补位: an operator the player does not own fights as its official stand-in (W5B, W5D, W5G, K1–K3, W5N; the approved plan, the owner's decision of 2026-10-05; the owner's recall of 2026-10-06) — `tools/build-data.mjs buildBackups`, `data/backups.json`, `shared/standIn.js`, `sim/simdata.js getStandIn`, `content/index.js kitOf`, `kits/ops/standin-*.js`, `shared/protocol.js room.ownership`, `screens/ownership.js`, `ui/gameLogic/standIn.js`
- **Seen**: the official mode fills an operator the player does not own with a stand-in; the remake always fielded
the real operator, and the data build dropped the official backup fields.
- **Official**: act2autochess — 133 chess: 74 PRESET (特许, always available), 55 NORMAL (each naming a backup: charId,
template, skill, module, potential), 4 DIY; PRTS 卫戍协议 — a missing operator is filled by a prototype of its class
with a fixed skill, and the bonds and 特质 stay the chess's. The 17 stand-ins: the eight 4★ 预备干员 and nine elite
prototypes (郁金香, Sharp, Mechanist, Stormeye, Pith, Touch, Raidian, Misery, 领主·Sharp).
- **Decision**: the approved 补位 plan (2026-10-05): only the 55 NORMAL chess can be marked not owned, everything is
owned by default, no borrowing from friends, bots own everything; the 重装 stand-ins (预备干员-重装, Mechanist) cast with
an enemy in range, like §21.29. The owner's first-hand recall of 2026-10-06: the shop, the hand, the board and the
result screen show the STAND-IN — this replaced the first build's cards that kept the chess with a 「替补:X」 badge.
- **Now**: data (W5B): every chess.json record keeps `backup`; data/backups.json `units` holds the 17 stand-ins in every
form (DATA.md §18). Battle (W5D): `getChess(id, { standIn: true })` = the chess's ids, bonds and tier with the
stand-in's body (`def.standInFor`); its kit is the stand-in's own (`kits/ops/standin-<codename>.js`,
`STANDIN_KIT_FILES`), never the replaced operator's; without one, the generic kit plus `genericTalents` — the eight
预备干员 get their talents, 一击即退 for 10 s from every deployment and 冲锋号令 +12 DP; the browser battle loads
data/backups.json too. Match (W5G): C→S `room.ownership { notOwned }` (the 55 droppable chess only; fixed for the
match); `battleInput` marks `standIn: true` in every field — the rules read the chess (price, bonds, 特质, merges,
pools), the body is the stand-in's (placement by its own position, range, no summons). Client: the 干员持有 tab of
干员调配 (export / import, `sp.pref.ownership`); since W5N the stand-in is drawn everywhere — shop and reward cards,
hand, 临时整备区, board, detail card, bond popups, result lineup, bench scouting, tickers — with a small ice 「替补」 mark
and the chess's tier, price and bonds. It speaks with its own voice (the §21.30 port; a 预备干员 stays silent).
- Kits (K1–K3; every skill on every form, the numbers from the composed record, the data's trigger):
| stand-in | kit (`kits/ops/`) | stands in for | notable [ASSUMED] |
|---|---|---|---|
| Sharp | `standin-acguad.js` | 忍冬, 银灰, 百炼嘉维尔, 隐德来希, 归溟幽灵鲨, 佩佩 | — |
| 领主·Sharp | `standin-sharp2.js` | 水月, 玛恩纳, 维娜·维多利亚, 妮芙 | 陷阵勇气 counts targetable enemies; LOR-X's 流失 follows a shielded hit |
| Misery | `standin-acspec.js` | 缄默德克萨斯, 山, 新约能天使, 锏 | 四维分离 counts her own tile and air units; S3's drag area is its x-4; S1 / S2 deploy-timed duration skills (WE1) |
| Stormeye | `standin-acnipe.js` | 空弦, 莫斯提马, 百炼嘉维尔, 妮芙, 蕾缪安, 异客 | 风雨欲来's delay = no attack for 2 s, counted from deployment |
| Pith | `standin-accast.js` | 烛煌, 焰影苇草, 迷迭香, 荒芜拉普兰德 | two Piths' 授我所授 / S2 never add up |
| Raidian | `standin-acsupo.js` | 灵知, 夕, 安洁莉娜, 浊心斯卡蒂, 仇白 | 诱引 rolls at an attack's first damage instance; new `applyStatus` option `stackAs` |
| 郁金香 | `standin-acpion.js` | 瑕光, 焰尾, 凛御银灰, 圣聆初雪, 缪尔赛思, 耀骑士临光 | the 集成战略 备注 applies; S3's 8 slashes at the clip's OnAttack events |
| Mechanist | `standin-acfend.js` | 星熊, 号角, 余 | S3 pulses from 1 s after the cast |
| Touch | `standin-acmedc.js` | 塞雷娅, 塑心, 纯烬艾雅法拉 | shares the 外勤医疗 strategy's Touch code (§25.6.6); S2's 所有医疗干员 = her own player's |
- [ASSUMED]: a stand-in is placed by its own position (melee tiles for a melee stand-in); without accounts any of the 55
may be dropped, also one the player owns; AI 托管 values a dropped chess by the chess record; the detail card names
「X的替补」 in place of the English name; the 「替补」 mark's look.
- Golden: new family `standins` (10 battles: every NORMAL record, normal and elite, fielded as its stand-in) and the
match `coop2-NORMAL-14-standins` (a human seat on AI 托管 owning none of 7 NORMAL chess); nothing else moved.
- Tests: `test/backups.test.js`, `test/content/standin.test.js` (the plumbing and the eight 预备干员),
`test/content/standin_*.test.js` (the nine kits), `test/match/standin-ownership.test.js`,
`test/lobby-ownership.test.js`, `test/ui/standin-ui.test.js`, `test/golden-standins.test.js`,
`test/ui/standin.e2e.test.js` (`SP_E2E=1`).
### 25.4 自选编队: the four DIY slots field the player's own 6★, and 71 operator kits (W5H, W5K, O1–O24; the owner's decisions of 2026-10-05) — `data/backups.json diy`, `shared/diy.js`, `sim/simdata.js getDiy`, `server/match/player/diy.js`, `shared/protocol.js room.diy`, `screens/diy.js`, `kits/index.js OPERATOR_KIT_FILES`, `kits/ops/op-*.js`, `kits/shared/summoner.js`
- **Seen**: the four DIY chess were removed from the shared pool (§0). A first attempt (PR #71) had put them in the
shared pool, one chess id meaning a different operator per player — the shape to avoid.
- **Official**: act2autochess `diyChessDict` / `shopLevelDisplayDataDict` — two slots at tier 5 (from 调度中心 level 5),
two at tier 6 (level 6), price 4, sold for 1; PRTS 盟约 下半 — the player's own 6★ or a 6★ prototype, no 特质, the
core bonds its factions meet (else 协防); forms: normal = E2 Lv1 with skills at rank 4, elite = E2 Lv60 rank 7 with its
module at stage 1 (tier 5) or 3 (tier 6).
- **Decision**: the owner's decisions of 2026-10-05 — two plus two slots per player; an owned 6★ with a kit, any of its
three skills, any elite module; a prototype keeps the skill and module of its 补位 rows; a per-player shop; levels
follow the chess forms; the six collab 6★ are out (copyright), and so is 焰狐龙梓兰 (MH05, a Monster Hunter 联动寻访
operator the team list does not name: `DIY_EXCLUDED_NUMBER_PREFIXES`) — 71 owned picks; the 集成战略-only ISW-A
modules are refused (WE2 adds SO-A / SO-B / RA-A, §25.8).
- **Now**: data (W5H): data/backups.json `units` adds the 71 owned picks in the three slot forms, `tokens` their 38
summons, `diy` the slots, the prototypes (15 at tier 5, 9 at tier 6), their locked loadouts, `ownedPool` and the
derived bonds (DATA.md §18); `shared/diy.js` (`diyPool`, `validateDiyPicks`, `diyRecordOf`); the sim fields a pick
(`getDiy`; kit `KITS[charId]`). In play (W5K): C→S `room.diy { picks }` (`checkDiyPicks`; `welcome.diyKitted` lists
the operators with a kit); the player's data view makes a slotted DIY slot its operator everywhere — bonds, placement,
summons, AI 托管 — with no 特质; a per-player stock never enters the shared pool and the player's own shop draws it from
the slot's 调度中心 level; a piece whose every bond is off this match leaves the shop (`m.private.diyBanned`, shown since
WE2); a placed operator's summons go to the hand (official counts since WE2, §25.8). Client: the 自选编队 tab of
干员调配 (two tier-5 and two tier-6 slots, the picker with skill and module, export / import, `sp.pref.diy`) and a mint
「自选」 badge on cards and pieces.
- **The kits**: 推进之王 first (W5H, the worked example of kits/README.md "How to add an operator (自选)"), then O1–O24 in
three waves — every skill at ranks 4 and 7, every talent, every module at stages 1 and 3, the range while a skill
runs, triggers, targeting, damage typing, summons and statuses, per the kit checklist; potential 0 for every pick
[ASSUMED: no account]. Engine additions, each additive (only the `diy` golden family moved): `palsyTrigger` (O3,
`ai.js`: a 麻痹 stack interrupting an enemy attack), the `hpDamage` hook after the shields (O5, `damage.js`), target
priority `heaviest` and the boomerang hooks `boomerangCaught` / `boomerangOnward` (O7), shields limited to damage
types (`shieldType`, O8 / O16) and a profile's `skipEnemy` (O8), enemy shots carrying `isProjectile` and priority
`elite` (O11), a healer profile's `healThrough` (O16), the 缚地 status `groundbind` (O20), the `enemyAttackStart` hook,
the `noNewBlock` flag and `holdsUndying` reading an `undying` buff (O22), ally attack targets
(`Battle.setAllyTarget`, O23), the air-block mod `blockRadiusScale` (O24); `kits/shared/summoner.js` (O21) holds the
召唤师 deck of 麦哲伦 / 令 / 电弧. Match: a placeable summon's hand count reads the active module's variant (O19: 望's
TRP-X 7). Client: visuals for the 13 fx kinds the new kits emit (`render/fx/kinds.js`); the picks' art comes with the
asset plan (`tools/fetch-assets.mjs --add-only`; their battle voices and the summons with no Spine model: §25.12).
| 干员 (summons) | kit (`kits/ops/`) | stream | notable [ASSUMED] (and later changes) |
|---|---|---|---|
| 推进之王 | `op-siege.js` | W5H | S2 跃空锤 reaches ground enemies only (no source says air); SOL-Y's extra SP goes to a random deployed other 【先锋】 |
| 陈 | `op-chen.js` | O4 | S3's ten slashes = the first ten of the clip's eleven OnAttack events; S3 refused with no ground enemy in its 技能范围 |
| 艾丽妮 | `op-irene.js` | O4 | S3's first bombardment 0.3 s after the opening strike, an empty one skipped; ISW-A refused in 自选 (W5K) |
| 赫拉格 | `op-helage.js` | O4 | 运筹帷幄 is the 生命回复速度 attribute (no PRTS 备注); the 武者 heal per damage instance (WE1) |
| 闪灵 | `op-shining.js` | O8 | S2's DEF bonus ends with her last barrier on that unit; PHY-X heals ×1.15 at or below 50 % (WE1) |
| 夜莺 (幻影) | `op-cgbird.js` | O8 | a held 幻影 returns after 20 s for 5 DP whether or not 夜莺 stands; 3 hand pieces (WE2) |
| 嵯峨 | `op-saga.js` | O8 | 清明 is a 生命回复速度 (hpRegenRatio) buff; SOL-Y stage 3 takes the blackboard's 6.5 % (the text says 6 %) |
| 艾雅法拉 | `op-amgoat.js` | O2 | S3's lava has no splash; 乱火 maxima 15 / 15; S3 now casts by the larger-range rule (「攻击范围增大」) |
| 刻俄柏 | `op-cerber.js` | O2 | 剥壳's ramp restarts per deployment |
| 霍尔海雅 | `op-heyak.js` | O2 | S3's whirlwinds hit air units, nearest first; one not yet released drops when she leaves; ISW-A refused |
| 早露 | `op-poca.js` | O7 | S3's strikes are her attacks, the first one tick after the cast; SIE-Y's distance bonus is linear |
| 提丰 | `op-typhon.js` | O7 | S3's arrows are skill damage, not attacks (no SIE-X / SIE-Y bonus) |
| 娜仁图亚 | `op-narant.js` | O7 | S1 (切换) stays on until she leaves; S3's boomerangs fly together and its blast is no attack |
| 逻各斯 | `op-logos.js` | O3 | 语汇演化 rolls once per attacked target and may draw the attacked enemy |
| 真言 | `op-mantra.js` | O3 | S2's first shot keeps the engine's bolt speed 11 (备注 10); only operators starting a skill grant 麻痹 |
| 维伊 | `op-veen.js` | O3 | 战争技艺 restarts its 5 s with every stack; her energy store is now the engine's 秘术师 rule (WE1) |
| 左乐 | `op-zuole.js` | O5 | S3's 7 slashes resolve at the cast and reach air units |
| 重岳 | `op-chyue.js` | O5 | S2's second segment always fires; 止戈 rolls only on hits that leave the target alive |
| 煌 | `op-huang.js` | O5 | S3's cuts deal 100 % ATK once a second (no blackboard key); the card keeps her 1-1 (the game shows 3-6) |
| 黑 | `op-shwaz.js` | O6 | 正前方 = her straight line ahead, her own tile included; S3 DEFAULT (「攻击范围改为」 is a range change) |
| 鸿雪 (打字机) | `op-bgsnow.js` | O6 | the 打字机 returns only while 鸿雪 stands and keeps its 25 s when she falls |
| 伊芙利特 | `op-ifrit.js` | O1 | BLA-Δ's extra 元素伤害 also follows 灼地 ticks (`_targetFamilyMask` 31); 灼地's 2 % 流失 as stated |
| 谬因 (中继器) | `op-aphris.js` | O1 | a huge enemy counts on every tile it covers; the 中继器's 无敌 / 孤立 / 禁疗 are kit flags; S2 area trigger (WE2) |
| 林 | `op-lin.js` | O1 | the 琉璃璧 sits in the buff order (an earlier shield absorbs first); its burst comes before a knock-out |
| 年 | `op-nian.js` | O14 | PRO-Y counts a teammate's 重装 on a shared field; 铁御 skips summons |
| 斩业星熊 | `op-hsgma2.js` | O14 | AST-X's extra damage with or without a running skill; 我执 on the engine's 1-HP floor; normal attack physical (WE2) |
| 珊比 | `op-thumpy.js` | O14 | the conveyor moves 0.533 tile/s (20 frames read per game second); S2 / S3 cast by the area rule |
| 森蚺 | `op-zumama.js` | O15 | a blockee turning 无敌 + 无法选中 loses the S2 stun after one pulse; RA-A refused in 自选 (WE2) |
| 斥罪 | `op-judge.js` | O15 | her barrier is spent oldest first (the client's priority −1000 would put it last); the thorns cannot be dodged |
| 黍 | `op-shu.js` | O15 | the 【播种标记】 and the teleport end with S3; S3's TRY_SEARCH_ALLY_SKILL is now resolved in the data (WE2) |
| 帕拉斯 | `op-pallas.js` | O9 | 休谟斯's own 精力充沛 is not merged with hers; S2 casts by the larger-range rule (「向前延伸」) |
| 止颂 | `op-lessng.js` | O9 | S3 casts nothing under 晕眩 / 冻结 / 沉睡; S2's strike spacing is not modelled |
| 贝洛内 (牵绊) | `op-demetr.js` | O9 | several 贝洛内 on one enemy share one 【手段】; every move is instant |
| W (此面向敌) | `op-cqbw.js` | O12 | no cap on mines; 此面向敌 cannot be targeted; her knock-out withdraws the mines |
| 菲亚梅塔 | `op-phenxi.js` | O12 | S1 follows the current client (no air targets, no range growth, trigger DEFAULT — PRTS 备注 of 2026-08-01) |
| 维什戴尔 (魂灵之影) | `op-wisdel.js` | O12 | aftershocks 0.3 s apart; S3's last shell loses the +ATK (the engine's ammo rule) |
| 棘刺 | `op-thorns.js` | O10 | the spikes carry the LOR-X addition; his poison keeps ticking after he left |
| 赤刃明霄陈 | `op-chen3.js` | O10 | the first slash at the cast; a hiding target counts as gone; the 【移动】 lands at once |
| 司霆惊蛰 | `op-leizi2.js` | O10 | currents outlive the skill, bounce off the field edge, use screen directions; S1 by the larger-range rule |
| 薇薇安娜 | `op-vvana.js` | O11 | enemy content projectiles carry no `isProjectile` (melee for 散华); S3 area trigger (WE2) |
| 怒潮凛冬 | `op-headb2.js` | O11 | the hammers are skill damage, lost while she is stunned; 高台 = the HIGH tiles of the field |
| 赫德雷 | `op-hodrer.js` | O11 | S3's 流失 may knock him out; attack riders land even on a dodged hit |
| 假日威龙陈 | `op-chen2.js` | O13 | the slime shot's selector range is her attack range at the attack; it outlives her |
| 莱伊 (沙地兽) | `op-ray.js` | O13 | the 沙地兽 scouts 25 s, attacks nothing, returns after its redeploy time for 3 DP while she stands |
| 阿斯卡纶 | `op-ascln.js` | O13 | 高台 = the level's HIGHLAND tiles (the forbidden rim too); separate 命中率 cuts roll apart |
| 涤火杰西卡 (机动盾牌) | `op-jesca2.js` | O16 | a 机动盾牌 not next to her gets no turn, mark or aura |
| 机械师 (结构性原理) | `op-mcnist.js` | O16 | the S3 charge lands free of DP; `_ignoreTriggerOnce` read as nothing |
| 凯尔希 (Mon3tr) | `op-kalts.js` | O16 | S2 / S3 are heal skills on DEFAULT (no injured ally, no cast); PHY-X Lv3's half-HP blast once per deployment |
| 琴柳 | `op-sleach.js` | O17 | 精神感召 saves DP only on a redeploy (the battle-start deployment is free here) |
| 可露希尔 (指挥中心) | `op-closur.js` | O17 | 部署费用下限 has no counterpart (costs never drop below 0); her own tile counts beside the 指挥中心 |
| 澄闪 | `op-gdglow.js` | O17 | her own attack stops in every skill (all drones out); S2 fires at full SP |
| 阿 | `op-haak.js` | O18 | S2 / S3 keep the data's DEFAULT: with no ally in range the cast does nothing (settled by the data and the skill prefabs, §25.11.5) |
| 傀影 (镜中虚影) | `op-phatom.js` | O18 | S3's push and status land on a dodged hit too; the twin is readied at his redeploy (WE1) |
| 弑君者 | `op-crosly.js` | O18 | S2's blast lands at once; any action-stopping state or 缴械 cancels it |
| 老鲤 | `op-lmlee.js` | O19 | 周围八格 = the 3 × 3 with his tile, flyers counted |
| 温蒂 (工程蓄水炮) | `op-weedy.js` | O19 | S2 reaches air units; the cannon's forced skill fires once per deployment (data DEFAULT, re-checked in WE2) |
| 望 (棋子, 跟子) | `op-wang.js` | O19 | 跟子 are kit records, no unit; placed 棋子 take and return her stock, so S1 / S2 keep cycling (the owner's decision of 2026-10-06, §25.11.1); 棋子 on any tile (WE2) |
| 多萝西 (共振装置) | `op-doroth.js` | O20 | a used-up piece returns 5 s after the LAST trap deployment; the hand holds at most 9 |
| 予愿安洁莉娜 (“一会儿见!”) | `op-aglna2.js` | O20 | flight 2 tiles/s, landing clips not modelled; the marker is no hand card (WE2); new status 缚地 |
| 黑键 (旧日残影) | `op-ebnhlz.js` | O20 | his energies land with the main bolt; S2 at full SP with a free tile (WE2) |
| 麦哲伦 (龙腾) | `op-mgllan.js` | O21 | 龙腾.F makes no attack (ATK 0); 3 summon pieces, 4 with SUM-Y (WE2); S1 casts on an enemy in a drone's range (§25.11.2) |
| 令 (清平 / 逍遥 / 弦惊) | `op-ling.js` | O21 | her summons strike during S2 even when stunned; 3 pieces (WE2); S3 casts on a ground enemy in a summon's x-5 (§25.11.2) |
| 电弧 (戴乌 / 赛柯 / 桑特拉) | `op-radian.js` | O21 | 桑特拉's link is its own attack with its own ATK; SO-A / SO-B refused in 自选 (WE2); S2 / S3 cast on an enemy in a summon's range (§25.11.2) |
| 淬羽赫默 (夜灯) | `op-slent2.js` | O22 | 丰润羽翼 as 生命回复速度 rests on the client template (kept, §25.11.5; owner question) |
| 遥 | `op-haruka.js` | O22 | her bubbles outlast her; the T2 heal uses the ATK of the grant (kept, §25.11.5; owner question) |
| 酒神 (本能的召唤, 迷狂牢笼) | `op-phatm2.js` | O22 | 本能的召唤 lures at most 4 in all; its redeploys cost no DP; the 迷狂牢笼 blocks no sleeper (§25.9.1) |
| 白铁 (支援装置, 铁钳号) | `op-ironmn.js` | O23 | the 铁钳号 is charged only by attacks that select it; 2 devices in hand (WE2) |
| 娜斯提 (支援装置) | `op-nasti.js` | O23 | the built 高台's operator bonus (m4–m6_bonus) never applies |
| 死芒 (悲叹的仆役) | `op-necras.js` | O23 | the servant consumed is the earliest made; S3's strikes are skill damage; S2's sleepers leave her servants' block (§25.9.1) |
| 凯尔希·思衡托 (战术锚点) | `op-kalts2.js` | O24 | S2's shot flies at the engine's orb speed 10 (official 12); 战术锚点 placement (WE2) |
| Mon3tr (重构体) | `op-monstr.js` | O24 | the 路标形态 marker is not placed; 重构体 placement (WE2); the chain heal jumps in the 3×3 (WE1) |
- [ASSUMED] (besides the table): the per-player stock, 8 at tier 5 and 5 at tier 6 (no table gives it); bots field no
自选 piece; 信标 sends no 自选 piece to a teammate; a prototype in a tier-5 and a tier-6 slot counts as two bond members;
the reward offers' temporary refresh draws the player's 自选 pieces from the slot's level on.
- Golden: new family `diy` (138 battles: every kitted owned operator in each form × module × skill at tiers 5 and 6,
then the prototypes) and the match `coop2-NORMAL-70-diy` (a human seat on AI 托管 slotting 推进之王 and prototypes);
each kit wave added its own scenarios, no other family moved.
- Tests: `test/backups.test.js`, `test/diy.test.js`, `test/content/diy.test.js`, `test/content/kits_layout.test.js`,
`test/content/op_*.test.js` (71: one per kit, every form × module × skill), `test/match/diy-shop.test.js`,
`test/match/diy-bonds.test.js`, `test/lobby-diy.test.js`, `test/ui/diy-ui.test.js`, `test/golden-diy.test.js`,
`test/assets-diy.test.js`, `test/ui/diy.e2e.test.js` (`SP_E2E=1`).
### 25.5 Trigger rules: a larger running range casts as soon as an enemy is inside it; AUTO skills that need no target fire at full SP (W5C, W5F, the kit waves; the owner's decisions of 2026-10-05) — `tools/build-data.mjs resolveTrigger / ACTIVE_RANGE_OVER / ATTACK_RANGE_CHANGE / UNIT_TRIGGER_CORRECTIONS / TRIGGER_ALLY_RULES`, `sim/skills.js setTrigger`, §5.6
- **Seen** (community report): a skill that enlarges the attack range waited for an enemy inside the normal range,
as the official basic strategy does (§20.2); the 0.2.0 audit after GitHub #124 found 35 AUTO skills among the 112
visible chess that never fired alone with full SP and no enemy.
- **Official**: PRTS 卫戍协议/帮助 技能策略 — DEFAULT casts on an enemy in the initial range; SEARCH on an enemy in the
initial range; AUTO skills take no strategy row, so DEFAULT waits for the unit's own attack.
- **Decision and now** (the normative text is §5.6):
1. **ACTIVE_RANGE** — the owner's deliberate deviation of 2026-10-05, like §21.29: a MANUAL skill whose running
range strictly contains the operator's own checks the DEFAULT condition on that running range every tick, no
attack needed (`rawRule` keeps the official row). First on the basic strategy (W5C: 33 skills / 66 records, e.g.
野鬃 S2, 银灰 S3, 莫斯提马 S3), then the SEARCH row and 深巡 S2 (W5F: 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2, 安洁莉娜 S3,
深巡 S2 — 39 skills / 78 records). The wording of a range change was widened as the kit reports found misses:
「攻击范围增大」 (艾雅法拉 S3), 「攻击范围改为」 (黑 S3: DEFAULT, not a 技能范围), 「攻击范围向前延伸」 / 「攻击范围朝…扩大」 (帕拉斯 S2,
司霆惊蛰 S1); `UNIT_TRIGGER_CORRECTIONS` follows a live-client change the text misses (菲亚梅塔 S1 is DEFAULT since the
2026-08-01 client, PRTS 备注: no range growth, no air targets). A range the data cannot express is set by the kit:
珊比 S2 / S3 (her tile plus the glue / conveyor area), 谬因 S2 (the beam path, `addTriggerRange`), 薇薇安娜 S3 (3-2
from her second cast, the new `skill.setTrigger`).
2. **重装 of data/backups.json** — the owner's decisions of 2026-10-05: the 重装 stand-ins and every 自选 重装 (森蚺,
涤火杰西卡, 斩业星熊, 年, 黍, 斥罪, 机械师, 珊比; 48 skill forms) cast with an enemy in range — DEFAULT, or ACTIVE_RANGE
when the running range is larger — instead of the class row's TAKE_DAMAGE.
3. **AUTO skills that need no target** fire at full SP (`SP_FULL`; kits/README.md checklist item 5): 浊心斯卡蒂 S2 and
伺夜 S2 (W5C, after GitHub #124's 引星棘刺 S1), 纯烬艾雅法拉 S1 (the owner's decision of 2026-10-05, W5F), and in the 自选
kits every AUTO skill that acts on the operator itself, its allies or its summons (e.g. 谬因 S1, 维伊 S1, 莱伊 S2,
澄闪 S2, 多萝西's three). Skills that need a target keep waiting (a "next attack / next heal" skill, attack buffs).
伺夜 S1 / S2 cast only while his pack is on the field (PRTS 备注; the owner's decision of 2026-10-05 for S1, W5F; the
战术点形态, §25.6.5 and §25.7.9), 黑键 S2 only with a free deployable tile in range (§25.8.4).
4. Data aliases: 黍 S3's TRY_SEARCH_ALLY_SKILL resolves to SKILL_RANGE with `allies` on its x-2 (`TRIGGER_ALLY_RULES`,
§25.8.4).
5. **Through summons** — the owner's decision of 2026-10-06, the larger-range rule read through the summons' areas:
麦哲伦 S1, 令 S3 and 电弧 S2 / S3 also cast on an enemy in a standing summon's area the skill acts through
(`kits/shared/summoner.js summonTriggerArea`, §25.11.2).
- Kept on their rows: 烛煌 S2 / S3 (their ranges do not contain her 3-1), TAKE_DAMAGE 重装 skills of the chess pool but
the §21.29 six, CUSTOM_RANGE skills, AUTO skills that need a target, SEARCH skills whose range does not grow.
- Golden: each step names its scenarios in its commit (the SEARCH / 深巡 step: roster-001 / 002 / 016 / 026 / 027 / 041
/ 042 / 044, bond-egirShip-low, bond-suntShip-high, boss-boss_2-pair, boss-boss_6-pair and the bot matches fielding
them; 纯烬艾雅法拉 S1: roster-038; 伺夜 S1: roster-028; the range-wording fixes: only `diy`).
- Tests: `test/sim/feedback5-active-range.test.js`, `test/sim/feedback1-tank-triggers.test.js`,
`test/sim/feedback1e-skillrange.test.js`, `test/data.test.js` (the counts), `test/content/kits_alt_t3.test.js`
(伺夜), `test/content/kits_alt_t6.test.js` (浊心斯卡蒂, 纯烬艾雅法拉), `test/content/feedback5-we2-area-triggers.test.js`.
### 25.6 Fidelity fixes, wave 1 (W5C, W5E, W5J, W5L)
#### 25.6.1 琳琅诗怀雅 S2 “见面礼” throws onto an enemy's tile on x-6, with no enemy needed (GitHub #116 item 2) — `kits/ops/chess_char_3_04-swire2.js` (installS2)
- **Seen**: the bomb landed only in front of her or nowhere. **Official**: PRTS S2 备注 — bomb range x-6; an enemy in range
gets it on its tile, else a random deployable tile (the client needs a free x-6 tile and her coin buff). **Now**: with a
coin and a free x-6 tile her attack turn throws: onto the first ground enemy's tile in her target order, else a random
free tile (in the field, empty, walkable melee ground, not a knocked-out operator's tile); damage, 停顿 and the 3 s
second hit unchanged. Item 1 of #116 (the solo 标准 shop prices) is not a bug. [ASSUMED]: the throw is instant and costs
one tick of her attack. Tests: `test/content/kits_t3.test.js` (3 cases), `summon_loadout_conflicts.test.js`.
#### 25.6.2 新硎 / 沉沙: one ATK layer while a 断刃 is held, gone with the last one (GitHub #107) — `content/enemies/times.js` (kitBlades)
- **Seen**: 新硎's ATK kept climbing (1350 → 3510 after two hits). **Official**: PRTS talent — ATK +80 % (沉沙's
blackboard 0.7) while holding 4 【断刃】, one spent per attack; the attack spending the last drops the bonus and clears
its cooldown; unspent blades become 矛头 on death (at least 1). **Now**: one layer from the spawn, removed by the
attack that spends the 4th blade, which is followed at once by the next (unbuffed) attack; death spawns max(1,
unspent) 矛头 at once (the 0.7 s delay is not modelled). Tests: `test/content/enemies_bosses.test.js`.
#### 25.6.3 A full 整备区 buys nothing; a freed slot pulls the 临时整备区 in (GitHub #82, second part, reported by Hucxious) — `PlayerState.buy / pickReward / _fillHandFromTemp`, `match/invariants.js`, `match/bot.js`
- **Seen**: a full hand still bought a third copy (it merged), and a freed slot did not pull the overflowed operator
back. **Official**: PRTS 卫戍协议/帮助 §手牌区 — with the 10 regular slots full, 招募 / 购入 are refused, merge or not;
temp pieces move in when a slot frees; board ↔ hand swaps stay allowed. **Now**: `buy` / `pickReward` refuse with
`HAND_FULL` (client: 整备区已满); every `recompute()` moves temp pieces into free slots (invariant: temp is non-empty
only while the hand is full); bots never buy into a full hand and shed their weakest single for a third copy.
[ASSUMED]: temp empties right → left into the hand's next free slot right → left. Golden: coop2-NORMAL-3,
solo-HARD-9-boosted, coop2-ABYSS-10-boosted. Tests: `test/match/feedback5-hand.test.js`.
#### 25.6.4 联防 plays on its own official map (GitHub #41 item 3, reported by Hucxious and shanzhaikabi) — `tools/build-data.mjs buildStages`, data/stages.json `kind: 'unite'`, `unite.js uniteStageId`, `match/unitePhase.js _uniteOpts`
- **Seen**: the 联防 field showed the round's battlefield (water pools, crates) opened to both halves. **Official**:
act2autochess `level_act1autochess_escaped_single` / `_multi` — one 19 × 21 road grid, no devices, runes or terrain;
the right half is the own board + 8 columns. **Now**: two `kind: 'unite'` stage records (1 / 2 helpers, `active:
false`) carry the 联防 battle in both combat modes; the client draws that map and restores the board after. No water,
crates, devices or terrain in 联防; the BGM (PR #110) is unchanged. [ASSUMED]: the records' names 联防阵地(1名玩家) /
(2名玩家). Golden: unite-1, unite-2 and the co-op matches. Tests: `test/match/feedback5-unite-map.test.js`.
#### 25.6.5 伺夜's 狼群 has the official 战术点形态; S1 acts by the pack's state (PRTS 备注; the owner's decision of 2026-10-05) — `content/tokens.js wolfPack / installWolfTacticalPoint / wolfReturnNow`, `kits/ops/chess_char_3_19-vigil.js`
- **Seen**: a pack losing its last 狼影 died and came back after 10 s with 2 狼影; S1 fired and paid its DP with no pack
on the field. **Official**: PRTS 伺夜 talent and 狼群 备注 — the last 狼影 lost or a 撤退 turns the pack into its
战术点形态 (无敌, no attack) for the 25 s talent interval, then back with 1 狼影; S1 casts only with the pack on the field:
① the form returns at once, ② +1 狼影, ③ at the cap HP to max. **Now**: `installWolfTacticalPoint` keeps a 'killed' /
'retreat' pack as a knocked-out piece (tile reserved) while 伺夜 stands, back after 25 s at full HP with 1 狼影; S1 is
the kit's own cast in either form (+cost DP, ① ② ③). S2 in the form: §25.7.9. [ASSUMED]: the return is a redeploy (仇恨
order, deploy hooks); a form at the combat's end enters 联防 fresh. Golden: roster-007 / 009 / 026 / 028 / 040,
bond-siracusaShip-high and the bot matches fielding 伺夜. Tests: `test/content/kits_alt_t3.test.js`, `kits_t3`,
`tokens_devices`.
#### 25.6.6 Touch: S3's extra heal, 「低于」 half HP, 攫升 on every heal, 超脱 on a dying exit (review of the stand-in kits) — `content/tokens.js touchGospel / mapCharTalents`, `kits/ops/standin-acmedc.js`
- **Seen**: S3's extra heal copied the boosted main heal; the boost applied at exactly 50 %; 攫升 missed self / zero
heals; 超脱 missed 余烬 exits. **Official**: PRTS Touch (卫戍协议) — the extra heal is 30 % of the main heal on x-5,
lowest ratio first, boosted itself on units 「低于」 half HP; 攫升 3 SP for any heal; 超脱 5 SP on dying exits too.
**Now**: the extra heal = `battle.heal(Touch, recipient, base × 0.3)` once per heal action with its own boost; the boost
strictly below `hp_ratio`; 攫升 on every heal she outputs; 超脱 on a knock-out or `Battle.retreat(…, { dying: true })`
(the 外勤医疗 strategy's Touch shares the code). [ASSUMED]: equal ratios go to the main target; 骑士戒律 + 竞技旗's exit
counts as dying. Golden: roster-041, standin-03 / 04 / 08 / 09 / 10, coop2-NORMAL-14-standins. Tests:
`test/content/standin_acmedc.test.js`, `tokens_devices.test.js`.
#### 25.6.7 突袭: a passive skill that is on counts as 技能就绪 (GitHub #49) — `content/bonds/addon/battle.js raidPoll`
- **Seen**: 突袭 members with passive skills (缄默德克萨斯, 宴, 斯卡蒂 S2 …) waited for the 10 s idle trigger. **Official**:
PRTS 【突袭】 — idle 10 s or 技能就绪, no enemy in range → redeploy beside a ground enemy; nothing on passives. **Now**:
`raidPoll` counts a passive that is on, and since the PR #109 port a deploy-timed duration skill while its window runs
(§25.9); §22.2's landing rule kept. [ASSUMED]: 「技能就绪」 = the skill is running. Golden: roster-039 / 046,
bond-raidShip-low / high, boss-boss_2 / 4 / 6-pair, standin-01 / 06, coop2-HARD-4, coop2-NORMAL-14-standins. Tests:
`test/content/bonds_addon.test.js`.
#### 25.6.8 生命回复速度 is not 治疗 (GitHub #96 / #137; the separation proposed in PR #135 by @SpiritedAwayCN) — `sim/damage.js` (heal `regen`), `professions.js bardRegen`, kits of 魔王 ×2, 浊心斯卡蒂 (+ 海嗣), 调香师, 瑕光, 铃兰, 锡人
- **Seen**: per-second HP effects were heals, refused on 无法被友方治疗 / 禁疗 units (#96: 隐德来希 got nothing from 浊心斯卡蒂;
#137: 折桠 and 圣约送葬人 from 调香师). **Official**: PRTS 吟游者 raises 生命回复速度; the 备注 of 调香师, 瑕光 S2, 铃兰 S3, 锡人 S2 —
unaffected by 治疗加成 and 禁疗. **Now**: hpRegen buffs the provider refreshes, applied as the target's own regen tick (no
healing multiplier, no heal hook): the 吟游者 trait (`bardRegen`), 调香师, 瑕光 S2, 铃兰 S3, 锡人; direct heals unchanged
(引星棘刺 S1 / S2, 塞雷娅 S3, 流明's and 纯烬's HoTs stay heals). kits/README.md checklist item 11. [ASSUMED]: the buffs
refresh from the start; two providers add up. Golden: 50 scenarios that field a converted operator (listed in the
merge). Tests: `test/content/feedback5-regen.test.js`, the kits' tests, `test/sim/professions.test.js`.
### 25.7 WE1 — engine-fidelity follow-ups (sim rules)
#### 25.7.1 秘术师 store energy at the attack check, not one interval later (GitHub #181, reported by Paper-Yuan) — `professions.js installMystic` (`storeEnergy` / `releaseEnergy` / `hitsFn`), `ai.js updateAlly / performAttack`
- **Seen**: 深靛 stood 4.2 s after binding the only enemy; with no enemy the first energy came after 6 s. **Official**:
PRTS 分支特性信息 §秘术师 — storing shares the attack interval: at the check, a target → attack; none and energy not full
→ store (an attack action); idle only when full. **Now**: `updateAlly` stores at the check when nothing is valid and
restarts the interval; `releaseEnergy` spends the energies with the next attack, one arts hit each; 维伊 and 黑键 use
the shared check. [ASSUMED]: a redeployment holds no energy. Golden: roster-003 / 021 / 022, bond-preciShip-high,
diy-032, diy-114 and 13 matches fielding 深靛. Tests: `test/sim/feedback5-mystic-store.test.js`.
#### 25.7.2 Enemy attacks strike at their damage frame; a short stun before it cuts the swing (GitHub #187 point 1, #170) — `ai.js updateEnemy / enemyAttack / attackWindup / attackTargets`, `battle/spawns.js` (`enemy.swing`)
- **Seen**: short stuns never stopped an enemy attack (忍冬 S3's stun showed, the hit still landed): a stun only paused
the cooldown. **Official**: PRTS 异常效果 — STUNNED cannot attack, DISARMED interrupts; the damage lands at the clip's
strike frame (data/enemies.json `attackAnim.hit`). **Now**: an enemy with an attack clip strikes at its damage frame;
stun / freeze / sleep / 浮空 / 缴械 / 恐惧 or losing every target before it cut the swing (restart from the wind-up), a
stun in the cooldown only pauses it, shots in flight land; 36 of 249 kinds without a clip keep the old rule (§5.5;
withdraws §22.14's [ASSUMED]). [ASSUMED]: targets re-taken at the frame; an interrupted swing keeps its cooldown.
Golden: 275 of 283 scenarios moved (solo-HARD-2 now wins, coop2-NORMAL-8-serverrun loses in round 13). The owner kept
the rule on 2026-10-06 (follow-up 29, §25.10). Tests: `test/sim/feedback5-enemy-swing.test.js`,
`feedback2-enemy-attack-stand.test.js`.
#### 25.7.3 武者: the trait heal on every damage instance, through the own 禁疗 (follow-up 1) — `professions.js installMushaHeal / installReaperHeal`, `kits/ops/op-helage.js`, `op-zuole.js`
- **Seen**: a 武者 healed once per enemy struck (a double strike healed once). **Official**: PRTS §武者 — 常态持有禁疗,
ignored by the unit's own trait / talent / skill heals; the trait heal follows every damage output. **Now**: one heal
per damage instance dealt to an enemy (skill, item and bond damage too; not dodged hits, 流失, element 损伤, talent or
DoT damage), passing `ignoreHealFree`; 收割者 keeps its own rule. Golden: diy-007 / 008 / 009 (赫拉格), diy-033 / 034 /
035 (左乐); 宴 unchanged. Tests: `test/sim/feedback5-musha-heal.test.js`.
#### 25.7.4 庇护 is one effect per unit, the strongest counts (follow-up 18) — `kits/shared/tier1.js PROTECT / holdProtect`, kits 宴, 余, 缪尔赛思, 赫拉格, 左乐, 赫德雷
- **Seen**: the older kits used private 庇护 multipliers, so two sources multiplied (25 % + 18 % → 38.5 %). **Official**:
ba.protect — same-name 庇护 take the highest (遥's bubbles use another key). **Now**: `holdProtect` applies the shared
`PROTECT` effect (`applyStrongest`), refreshed by each kit; the 自选 kits already did. Golden: unchanged. Tests:
`test/content/feedback5-protect.test.js`.
#### 25.7.5 "治疗生命值低于50%…" compares as each module's client buff does (follow-up 19) — `kits/shared/tier4.js installLowHpHealBonus`, `kits/shared/tier5.js lowHpHealUp`
- **Official**: the client module buffs — LT for 瑕光 / 古米 / 塞雷娅 / 录武官 / Touch, LE for 华法琳 / 闪灵 / 黍 / 凯尔希.
**Now**: 华法琳's and 闪灵's PHY-X heal ×1.15 at or below 50 % (不高于), the LT ones strictly below; 黍 and 凯尔希 were
already ≤. Golden: unchanged. Tests: `test/content/feedback5-lowhp-heal.test.js`.
#### 25.7.6 链愈师: the heal jumps within the 3×3 around the last one healed (follow-up 27) — `ai.js chainHealNext / doHeal`, `kits/ops/op-monstr.js`
- **Seen**: 莎草's chain jumped within 2.5 tiles, Mon3tr's kit within the 3×3. **Official**: PRTS §链愈师 — jump range x-4
(3×3), no repeat, ×75 % per jump, lowest HP ratio first then the latest deployed, full-HP allies selectable, bound by
禁疗. **Now**: `chainHealNext` serves the profession and Mon3tr. [ASSUMED]: a 禁疗 unit is never a jump target. Golden:
roster-004 / 005 / 023 / 024, bond-sargonShip-low, diy-133 / 134. Tests: `test/sim/feedback5-chain-heal.test.js`.
#### 25.7.7 One reading of charge_token[born]: the owner's redeployment readies a waiting summon (follow-up 20) — `kits/ops/op-phatom.js`, `op-kalts.js`
- **Official**: the client buff template — `charge_token[born]` on the owner's birth (NORMAL timing); the other summoner
kits read it so. **Now**: 傀影's (re)deployment readies a waiting 镜中虚影 at once, as 凯尔希's does Mon3tr. [ASSUMED]:
NORMAL = usable now. Golden: unchanged. Tests: `test/content/feedback5-summon-recharge.test.js`.
#### 25.7.8 Misery's S1 / S2 are deploy-timed duration skills (follow-up 12) — `kits/ops/standin-acspec.js`
- **Now**: 物理的服从 / 战争的恭顺 (PASSIVE / ON_DEPLOY, 10 s) are `kind: 'duration'` with `activateOnDeploy` — a start and an
end at every deployment, the PR #109 contract; numbers unchanged. Golden: standin-01, standin-06. Tests:
`test/content/standin_acspec.test.js`.
#### 25.7.9 伺夜 S2 casts while the pack is in its 战术点形态 (follow-up 2) — `kits/ops/chess_char_3_19-vigil.js` (installGift)
- **Now**: S2 casts when ready while the pack is on the field in either form and holds no unused gift; in the form it
pays its DP at once and the gift waits for the returning pack. [ASSUMED]: S2's 备注 uses S1's words, so the form counts
as on the field. Golden: unchanged. Tests: `test/content/kits_alt_t3.test.js`.
#### 25.7.10 「同时攻击阻挡的所有敌人」 takes up to the block count of targets (the queued centurion question; Mon3tr S3) — `ai.js acquireTargets / targetCount` (`hitAllBlocked`)
- **Official**: PRTS §强攻手 / 重剑手 / 推击手 — max targets = the block count (≥ 1), blocked enemies first; the client
flag `_limitedMaxTargetNumToBlockedCnt`. **Now**: an attack with `hitAllBlocked` takes max(1, ⌊block count⌋) +
`maxTargets` mods targets from its range, blocked first (§5.5); Mon3tr S3 drops its `maxTargets: 1`. [ASSUMED]: the
mods add to the block count. Golden: 52 scenarios with such an attacker. Tests:
`test/sim/feedback5-block-targets.test.js`.
### 25.8 WE2 — fidelity follow-ups (data, match, UI)
#### 25.8.1 Summon hand counts from the tokens' own talents (follow-up 21) — `tools/build-data.mjs tokenTalentDeckBonus`, `match/player/diy.js placeableTokens`
- **Seen**: placing 麦哲伦 or 令 gave one summon piece, 白铁 one device, 夜莺 one 幻影. **Official**: PRTS 卫戍协议/帮助
§战斗部署 — the hand gets as many summons as the deploy limit, not the initial stock; the owners' talents give 3 at once /
5 held (麦哲伦, 令, 电弧), 2 / 3 (白铁, 娜斯提), 幻影 3. **Now**: the token talents' `max_deploy_count` /
`max_deck_stack_cnt` join the variant stats; hand counts 麦哲伦 / 令 3 (4 with SUM-Y), 电弧 3, 白铁 2, 夜莺 3, 娜斯提 2, 望 6
(TRP-X 7), 多萝西 9 (the hand's cap), others 1. Golden: unchanged. Tests:
`test/content/feedback5-we2-summon-hand.test.js`.
#### 25.8.2 望's 棋子 on any tile; no hand card for 予愿安洁莉娜's “一会儿见!” (follow-ups 22, 24) — `tools/build-data.mjs TOKEN_POSITION_CORRECTIONS / summonRecord`
- **Official**: PRTS 棋子 部署位置 全部位 (its 备注: the in-game summon info is wrong); PRTS “一会儿见!” is not selectable and
her `displayTokenDict` is empty. **Now**: the 棋子's `position` is ALL (a 高台 too); a summon is `placeable` only when an
owner displays it, so the marker stays out of the hand (the kit places it). Golden: unchanged. Tests:
`test/match/feedback5-we2-placement.test.js`, `test/content/op_aglna2.test.js`.
#### 25.8.3 Where 重构体 and 战术锚点 may stand (follow-up 26) — `tools/build-data.mjs summonRecord`, `match/board.js positionClass / canPlace`, `match/player/placement.js _legal / summonExcluded / _liftOutOfRange`
- **Official**: Mon3tr's 自我修复 — 重构体 on the ground inside her attack range; PRTS 战术锚点 — only ranged tiles outside
凯尔希·思衡托's attack range. **Now**: `ownerRange` from the owner's talent, new `ownerRangeOutside` / `rangedTilesOnly`;
an owner turned or promoted so that her range covers the 战术锚点 sends it back with a toast; client and bot follow.
[ASSUMED]: "ranged operators may stand on melee tiles" does not widen the 战术锚点. Tests:
`test/match/feedback5-we2-placement.test.js`.
#### 25.8.4 Triggers re-checked: 黑键 S2 at full SP with one free tile; 黍 S3's ally rule in the data (follow-ups 23, 17) — `kits/ops/op-ebnhlz.js`, `tools/build-data.mjs TRIGGER_ALLY_RULES`, `sim/simdata.js normalizeSkill`
- **Official**: 黑键 S2 (AUTO) 备注 — no deployable tile in range → no cast, so the condition is a free tile, not an enemy;
act2autochess `TRY_SEARCH_ALLY_SKILL` (黍 S3) = an injured ally on the skill range. **Now**: 黑键 S2 is the kit's cast at
full SP with a free attack-range tile; 黍 S3 resolves to SKILL_RANGE + `allies` in the data (the kit override is gone);
温蒂's cannon (DEFAULT) and 凯尔希·思衡托 S2 (ACTIVE_RANGE on an injured ally) stay. Golden: diy-114 / 115 / 116. Tests:
`test/content/op_ebnhlz.test.js`, `op_shu.test.js`.
#### 25.8.5 Kit-side larger-range triggers: 谬因 S2 and 薇薇安娜 S3 (follow-up 16) — `kits/ops/op-aphris.js`, `op-vvana.js`, `sim/skills.js setTrigger`
- **Now**: the scan of the 41 kitted 自选 MANUAL skills found two whose running range the data cannot hold (§25.5): 谬因
S2's beam path is a content trigger range; 薇薇安娜 S3 switches to ACTIVE_RANGE 3-2 after its first cast of a
deployment (new additive `skill.setTrigger`). [ASSUMED]: the owner's rule read through these kit ranges. Golden:
diy-045, diy-078. Tests: `test/content/feedback5-we2-area-triggers.test.js`. The skills that act through their
summons' areas (麦哲伦 S1, 令 S3, 电弧 S2 / S3) waited for the owner: §25.11.2.
#### 25.8.6 自选 rosters refuse SO-A / SO-B / RA-A like ISW-A (follow-ups 25, 10) — `shared/diy.js DIY_EXCLUDED_MODULE_TYPE`
- **Official**: their texts name other modes (集成战略 / 生息演算); battle_equip_table gates them (`validInGameTag`).
**Now**: `/^(?:ISW|SO|RA)-/` modules are refused by `validateDiyPicks`, dropped by `checkDiyPicks` and hidden by the
picker. [ASSUMED]: the reason of the owner's ISW-A decision applies. Tests: `test/diy.test.js`, `test/ui/diy-ui.test.js`.
#### 25.8.7 自选 in the match: random grants draw 自选 pieces, the banned line, AI 托管 (follow-ups 9, 11, 13) — `match/player/diy.js diyStockEntries`, `effectsMeta.js rollChess / rollPool`, `ui/gameLogic/diy.js diyBannedPieces`, `ui/matchInfo.js`, `match/bot.js buyScore / levelUp`
- **Official**: PRTS / the season notes — 自选 pieces widen the 调度中心's random range. **Now**: bands, items, 特质, 机变 cards
and reward pools that grant a random operator also draw the player's own 自选 stock (never another player's); a
slotted piece left out of the shop (every bond banned) is named under 本局禁用干员 in the briefing, the draft's match info
and the in-game tab; AI 托管 scores the player's own 自选 piece higher and levels 调度中心 one round earlier for its tier.
[ASSUMED]: the 托管 preference (bonus 12, one round). Golden: coop2-NORMAL-70-diy. Tests:
`test/match/feedback5-we2-diy-grants.test.js`, `test/ui/feedback5-we2-diybanned.test.js`,
`test/match/feedback5-we2-bot-diy.test.js`.
#### 25.8.8 Smaller items: 塑心's cap, 斩业星熊's normal attack, GitHub #148, the shims (follow-ups 30, 15, 3, 5) — `content/garrisons/battle.js fireGain`, `tools/build-data.mjs classifyAttack`, `content/bonds/addon/battle.js raidTile`
- 塑心 (garrison_90): the per-battle cap (10 / elite 20) is the trait instance's whichever bond is highest — a new highest
bond no longer opens a fresh cap (found in the PR #178 review; `test/content/feedback5-we2-garrison90.test.js`).
- 驭法铁卫 (斩业星熊): the data's normal attack is physical — arts only while a skill runs (display and AI only).
- GitHub #148 reproduced: a melee 突袭 member never lands on a 高台; on 战场#08 it lands on a 围墙 tile (deployable, drawn
raised), so no rule changed here (`test/content/feedback5-we2-raid-148.test.js`); the owner then chose a landing
tile where it can block (2026-10-06, §25.11.3).
- The `kits/tier1..6.js` re-export shims and `shared/standIn.js diyRecord` are removed.
### 25.9 Ports from 0.1.4 (W5M, W5R and the feedback4 merges)
0.1.4 (§24, CHANGELOG 0.1.4) shipped from feedback4 while the refactor ran; every fix was ported by hand into the split
layout, with the golden moves each named: PRs #150, #66, #146, #74, #72, #103, #149, #109, #160 and W4A (高台 by the
trait, the 阿戈尔 revive slots, the 联防 devour) in W5M; PR #129 (teammate scouting); the restarted-cast events and 伊内丝's
影哨 before 不屈; PR #73 (operator battle voices, §21.30) and the settlement line's speaker; PRs #186, #134, #185, #24, #183
(their 42 new strings through `t()`); W4B (阿戈尔's devoured ATK as a 最终加算, a fallen marker's marks, 缇缇 S2's pulses,
§24.7 / §24.8); W4D (a sleeping enemy cannot be blocked, §24.9; W5R, §25.9.1) and W4C (a tap on the board stays on
its tile, §18.1; §25.9.2). Where a 0.1.4 change met 0.2.0 work:
- **突袭 × PR #109**: deploy-timed skills became duration skills, which the #49 rule (a passive that is on) missed;
`raidPoll` counts a skill that needs no SP while its window runs (`test/content/bonds_addon.test.js`).
- **一击即退 × PR #109**: the generic deploy-timed passives (the 预备干员-特种 stand-in) are `kind: 'duration'` too
(`content/generic.js`; numbers unchanged).
- **The invite line (PR #103) × i18n**: the copied line goes through `t()`, Chinese byte-identical to the PR's.
- **高台 by the trait × 补位**: a stand-in is placed by its own trait (`placeClass`, `piecePosition`); of the 自选 picks only
温蒂 carries the line.
- **Voices × 补位 / 自选**: the stand-in or the 自选 operator on screen speaks for itself; the settlement line, which never
played (it looked up chess ids as character ids), now names the record the own piece shows (`screens/game.js ownShown`).
#### 25.9.1 沉睡 frees the block slot on the split layout; 酒神's 迷狂牢笼 skips a sleeper too (W4D, GitHub #140; ported by W5R) — `sim/battle/blocking.js _checkBlock`, `sim/battle/status.js applyStatus`, `sim/ai.js updateEnemy`, `sim/buffs.js` (`sleep`), `kits/ops/chess_char_3_12-blemsh.js` (瑕光 S2), `kits/ops/op-phatm2.js cageBlock`
- **Port**: §24.9 (the rule, PRTS 异常效果 SLEEPING = 无法行动+无敌+不可阻挡, the owner's decision of 2026-10-06), its §24
intro entry, the §5.5 Blocking sentence and §22.8's "(since §24.9)" came over from feedback4 unchanged; §24.9's heading
now names the 0.2.0 files above (0.1.4 had them in `sim/Battle.js` and `kits/tier3.js`). On the split layout
`_checkBlock` (battle/blocking.js) refuses `flags.sleep` and `applyStatus` (battle/status.js) releases an enemy that
falls asleep. WE1's swing cut (§25.7.2) runs before `enemyAttack`, but a plain-buff `flags.sleep` still makes the
enemy `stun` (`units.js`), so its release stays in `updateEnemy`'s cannot-act branch, after the cut: the swing is
cut, the enemy is let go, and it stands until it wakes.
- **New on feedback5**: 酒神's 迷狂牢笼 (O22, `cageBlock`) blocked every unblocked enemy on its tile but the old three
不可阻挡 flags; it now skips a sleeper too (PRTS BLOCK_FREE 「无法阻挡/被阻挡」: no blocker of any kind). Before, the 牢笼
held the sleeper, refreshed its own HP and block count for it and lost it on the enemy's next update. 予愿安洁莉娜's S3
helper needed no change (`canTargetEnemy` never returns a sleeper). Followed without code: `blockedTargets`, the 要塞
branch of `acquireTargets` (`u.blocking`), “庞贝”'s clock (`blockedBy`), the snapshot's `UF.BLOCKED` and the client's
block marker; 死芒 S2 折朽 (自选) sleeps the enemies it links — one her 悲叹的仆役 held is let go, and SOC-X's
×atk_scale on "an enemy one of her summons blocks" no longer applies to it.
- **Golden** (feedback5's own list; a probe over all 283 scenarios counted the sleeps of blocked enemies, in brackets,
and every scenario that moved has some; the outcomes differ from §24.9's because feedback5's baselines differ, WE1's
damage frame first): roster-003 (小满 S2 [5]: the same fight, `blocked` 27 → 29 and the snapshot hashes); roster-005
(elite 小满 S2 [3]: the 140 s timeout with 83 kills and 1 leak, where it cleared at 135.43 s with 84); roster-013 (缇缇
S3 [11]: the same kills, `blocked` 22 → 33); roster-032 (缇缇 S1 [10]: 71 kills instead of 68, 8 leaks instead of 11,
4 knock-outs instead of 8, 萨尔贡 layers 41 → 35); roster-038 (elite 缇缇 S2 [1]: ends at 197.40 s instead of
197.27 s); roster-039 (瑕光 S2 normal + elite [31 + 12]: the same kills and counted leaks, the 窃贼 gets away, `blocked`
53 → 87); roster-042 (缇缇 S2 [3]: ends at 168.77 s instead of 166.93 s); bond-maniShip-high (小满 [1]: `blocked`
29 → 30, the snapshot hashes); solo-FUNNY-1 (ai_0's 缇缇 [3]: only ai_0's damage dealt); coop4-FUNNY-6 (ai_0's 缇缇
[15 + 56]: from round 9 ai_0's 精准 / 萨尔贡 layers and boss damage move, ai_1 loses no LP from round 11, team LP
124 → 130); coop4-ABYSS-7 (ai_2's 小满 [18]: ai_2 one kill fewer, ai_0 one more LP lost in round 9);
coop2-NORMAL-8-serverrun (ai_1's 缇缇 [83]: only ai_1's damage dealt — WE1 had already turned this match into a loss);
coop2-NORMAL-70-diy (the 自选 seat h_0's 缇缇 [171], ai_0's 缇缇 [74] and 小满 [19 + 21]: h_0's layers move from round
4, then its AI 托管 buys differently, boards from round 7; ai_0 246 kills instead of 233, the same end); diy-131 (死芒
S2 at tiers 5 and 6 [1 + 1]: the snapshot hashes only); diy-132 (elite 死芒 S2 [2]: ends at 199.50 s instead of
198.13 s, the same kills). coop2-HARD-4 sleeps a blocked enemy [1] and did not move: the sleeper wakes with no room
and is blocked further on, and a match digest has no snapshot hashes (its kills, leaks, damage and funds are the
same). `fields` and `standins` did not move; no scenario spawns a 牢笼 over a sleeper.
- Tests: `test/sim/feedback4-sleep-unblock.test.js` (5 cases, from feedback4) passes unchanged on the split layout;
every case fails with the three sim hunks reverted, case 3 (the plain-buff sleep) with only the `ai.js` release
reverted.
#### 25.9.2 A tap on the board stays on its tile; long browser waits poll in slices (W4C, the 0.1.4 release candidate; PR #149) — `render/app.js onTapTarget`, `test/e2e/client.mjs waitForFunctionLong`
- **Seen** (the 0.1.4 release candidate, `test/render/models.browser.test.js` #4.1 touch): at 844×390 a tap on the row-12
unit opened the bond popup instead of selecting it. **Cause**: the browser's touch adjustment moves a tap onto a
nearby element that responds to clicks (pointer listeners do not count) when the finger's contact area reaches one,
and PR #149's 收起 toggle (§10) had pushed the bond discs one button (≈ 55 px) to the right, over row 12. **Now**
(§18.1 Touch): the field canvas is a click target of its own (a no-op `click` listener, `app.js onTapTarget`), so a
tap on the board stays on its tile; the browser test passes with #149's client diff reverted and fails with it.
Ported as is (`render/app.js` keeps `createFieldView`, §25.1).
- **Test harness**: the real-server browser suites' long waits poll in slices under the browser's 90 s CDP protocol
timeout (`waitForFunctionLong`, `PROTOCOL_TIMEOUT_MS`). Puppeteer runs a whole `waitForFunction` poll inside one
`Runtime.callFunctionOn`, so watch-bonds' 240 s wait died with a bare "Waiting failed" whenever the own battle
outlasted 90 s (playtest2.real's 120 s wait had the same gap) — a harness timing flake, no product change.
- Golden: unchanged (client and tests only). Tests: `test/ui/playtest2.test.js`, `test/e2e/client-wait.test.js`;
browser: `test/render/models.browser.test.js` (`RENDER_E2E=1`), `test/ui/watch-bonds.e2e.test.js` and
`test/ui/playtest2.real.e2e.test.js` (`SP_E2E=1`).
### 25.10 Settled decisions, what stays [ASSUMED], and the questions left to the owner
| Decision | Settled |
|---|---|
| No web framework: plain `node:http` + `ws` (§25.1) | the owner, 2026-10-04 |
| The 补位 plan: the 55 NORMAL chess droppable, all owned by default, no borrowing, bots own everything (§25.3) | the owner, 2026-10-05 |
| A not-owned chess shows as its stand-in everywhere (§25.3) | the owner's recall, 2026-10-06 |
| 自选编队: 2 + 2 slots, owned 6★ with a kit, any skill / module, prototypes locked, per-player shop, levels by the chess forms (§25.4) | the owner, 2026-10-05 |
| The collab 6★ out of the 自选 pool, 焰狐龙梓兰 included; ISW-A refused (§25.4) | the owner, 2026-10-05 |
| Chinese default, English switch, official EN game texts, PR #70 credited (§25.2) | the owner, 2026-10-05 |
| The larger-range rule (ACTIVE_RANGE) on DEFAULT, on SEARCH and for 深巡 S2 (§25.5) | the owner, 2026-10-05 (deliberate deviation) |
| The 重装 stand-ins and every 自选 重装 cast with an enemy in range (§25.5) | the owner, 2026-10-05 (deliberate deviation) |
| 纯烬艾雅法拉 S1 at full SP; 伺夜 S1 only with his pack on the field (§25.5, §25.6.5) | the owner, 2026-10-05 |
| Enemy attacks strike at their damage frame; a short stun before it cuts the swing (follow-up 29, §25.7.2) | the owner, 2026-10-06 (kept) |
| 望's placed 棋子 take and return her stock, so S1 / S2 keep cycling (§25.11.1) | the owner, 2026-10-06 (deliberate deviation from 「不消耗持有数量」) |
| Skills that act through summons cast on an enemy in a summon's area: 麦哲伦 S1, 令 S3, 电弧 S2 / S3 (§25.11.2) | the owner, 2026-10-06 (the larger-range rule) |
| A melee 突袭 member lands on a tile where it can block before one where it cannot (GitHub #148, §25.11.3) | the owner, 2026-10-06 |
Settled by the sources in W5V, no longer questions: 泥岩 S3's dormancy is 闭锁 — she lets go and blocks nobody (PRTS,
§25.11.4); 阿 S2 / S3 keep the data's DEFAULT, 忍冬 S2's and 深靛 S1's notes are what the kits do (§25.11.5).
Still [ASSUMED] (each also in its code comment): potential 0 for every 自选 pick; the per-player 自选 stock 8 / 5;
bots field no 自选 piece; SO-A / SO-B / RA-A refused for ISW-A's reason; the AI 托管 preference for 自选 pieces; a
stand-in placed by its own position; the 联防 map names; 伺夜 S2 in the 战术点形态; the enemy swing's re-taken targets and
kept cooldown; a woken sleeper re-blocked in the enemy update order where it stands (§24.9); 望's card back in her hand
the moment a 棋子 leaves, a 棋子 over the cap lost, TRP-X's 7th placed 棋子 taking none, her stock kept through her
redeployment; 电弧 S2's area = 赛柯's 3-2 range, standing summons only; the official 突袭 landing taken as random among the
legal tiles, ranged members on the old order, the target's own tile counted as a blocking tile; 泥岩's 反制 during 闭锁
not modelled; the English of the remake's own strings; the lite zip without `public/fonts`, the zips' unchanged
package.json (maintainer scripts fail there); the stand-in and operator kit items of the tables above.
Left to the owner (none changed in 0.2.0): 淬羽赫默's 丰润羽翼 as 生命回复速度 (the client template, no PRTS note) and 遥's
break heal on the ATK cached at the grant (both kept, §25.11.5); whether a passive counts as 技能就绪 for the whole
deployment or its window only (§25.6.7); Stormeye S2 / Pith S1 (AUTO attack buffs) on DEFAULT or at full SP; 赫默 S2 and
巫恋 S2 (wasted with nobody to heal / no enemy); the AUTO cast of an attacker of 白铁's 铁钳号. Answered on 2026-10-06 and
moved to the table: 望's stock and 阻回, the 突袭 landing (GitHub #148), the trigger areas through summons; settled by the
sources: the ally condition for 阿 S2 / S3, 泥岩 S3's 闭锁.
Open polish (no rule): 34 or more of the 38 summons of the 自选 kits have no Spine skeleton in the online sources, so the
field draws them as the avatar placeholder (§25.12); `scripts/make-windows-bundle.mjs` still copies every tracked file
but test/ (it could reuse the package selection); the THIRD-PARTY-NOTICES "In the release bundle" column does not tell
the lite zip (no fonts) apart.
### 25.11 The owner's decisions of 2026-10-06 and the items the sources settled (W5V) — `kits/ops/op-wang.js`, `kits/shared/summoner.js summonTriggerArea`, `content/bonds/addon/battle.js raidTile`, `kits/ops/chess_char_4_18-mudrok.js`
The questions WE2 and the kit waves had left open went to the owner as one batch on 2026-10-06; W5V implemented the
answers and checked the remaining items against PRTS and the client (W5V's commits name every golden scenario moved).
#### 25.11.1 望's placed 棋子 take and return her stock (the owner's decision of 2026-10-06) — `kits/ops/op-wang.js`
- **Seen** (O19, follow-up 22): in this mode her 棋子 are hand pieces deployed with the board, 「无视所属干员的持有状态,
不消耗持有数量」, so her stock stayed at 6; one S1 / S2 cast (「立即获得两枚棋子」) reached the cap 7, and 阻回 held her SP
for the rest of the battle — the skill cast once.
- **Official**: PRTS 卫戍协议/帮助 §作战阶段 (placed summons ignore the holding; one that leaves is redeployed in place once
the conditions are met); PRTS 望 铸子 (6 usable, 7 at most) and the S1 / S2 备注 (阻回 at the stock cap); the client's
charge_token templates (the stock = cnt at her deployment, the skills' +cnt, S3's overflow built as 跟子); a summon that
leaves returns to its owner's card while its redeploy time runs.
- **Decision**: the owner's decision of 2026-10-06, a deliberate deviation from 「不消耗持有数量」 — the placed 棋子 count in
her stock, and a 棋子 that goes off or returns replenishes it by the official rules, so her skills keep working.
- **Now**: every deployment of one of her 棋子 (the battle start's and each in-place return) takes one from her stock
(never below 0); a 棋子 that leaves the field (set off, or gone with her) puts one back at once, at most the cap; the
in-place return also waits for one in stock. At the cap a 棋子 that goes off is lost to the cap and its return takes
one, so the stock drops to 6, 阻回 lifts, her SP recovers and the next cast fills it again — S1 / S2 keep cycling.
S3's "+8, the overflow as 跟子" starts from the lower stock (2 placed: 4 + 8 → 7 with 5 跟子, was 7).
- [ASSUMED]: the card is back in her hand the moment the piece leaves (its redeploy time is the return's own wait); a
piece over the cap is lost; 7 placed 棋子 (TRP-X's deploy limit) on a stock of 6 — the 7th takes none; the stock carries
through her mid-battle redeployment.
- Golden: unchanged (no corpus battle places her 棋子). Tests: `test/content/op_wang.test.js` (9: the stock after the
placed pieces, a piece set off and its return, casts up to the cap, 阻回 only at the cap, a piece set off at the cap,
TRP-X's 7). Docs: docs/SIM.md (token pieces).
#### 25.11.2 Skills that act through summons cast on an enemy in a summon's area: 麦哲伦 S1, 令 S3, 电弧 S2 / S3 (the owner's decision of 2026-10-06) — `kits/shared/summoner.js summonTriggerArea`, `sim/skills.js` (`addTriggerRange` `{ keys, profile }`), `kits/ops/op-mgllan.js`, `op-ling.js`, `op-radian.js`
- **Seen** (WE2's scan, §25.8.5): their effects reach through the summons' areas, but the data's DEFAULT waits for an
enemy in the operator's own 3-1 (no data range can carry the larger area).
- **Official**: no trigger row (DEFAULT). The areas (PRTS, the client): 麦哲伦 S1 — 停顿 / 束缚 on her range and each drone's
range, air units too; 令 S3 — the x-5 around each summon, ground only (备注 「不可对空」); 电弧 S2 — 赛柯's bullets, ground
only; S3 — 停顿 + 法术脆弱 before her summons' damage, air units too (备注 「召唤物可对空」).
- **Decision**: the owner's decision of 2026-10-06, the larger-range rule (§25.5) read through the summons: cast when an
enemy is in the operator's range or in a deployed summon's area that the skill acts through.
- **Now**: `summonTriggerArea(battle, owner, skillId, area)` registers a content trigger range (checked every tick, on
top of the data's DEFAULT) built from the owner's standing summons, each giving `{ keys, profile }` — tile keys with the
enemy profile the effect selects by (`canHitFly` false: ground enemies only), a new additive entry form of
`addTriggerRange`. 麦哲伦 S1: each drone's range, air units too; 令 S3: each summon's x-5, ground only; 电弧 S2: each
summon's range (赛柯's 3-2), ground only; S3: each summon's range (桑特拉's 3-1), air units too. The other skills of the
three get no area.
- [ASSUMED]: 电弧 S2's area is 赛柯's 3-2 range, not its S2 bullets' longer flight (0.8 s ≈ 4 tiles at rank 7); only
summons standing on the field count.
- Golden: unchanged (no corpus battle places a 自选 summon). Tests: `test/content/feedback5-wv-summon-triggers.test.js`
(an enemy in a summon's area outside her range casts the skill; a flyer there does not for 令 S3 / 电弧 S2 and does for
麦哲伦 S1 / 电弧 S3; nobody in an area, or the drone off the field: no cast; all three fail with the helper switched
off). Docs: docs/SIM.md (content trigger ranges), the `skills.js` header.
#### 25.11.3 A melee 突袭 member lands where it can block (GitHub #148; the owner's decision of 2026-10-06) — `content/bonds/addon/battle.js raidTile`
- **Seen** (GitHub #148, reproduced in WE2, §25.8.8): a melee member never lands on a 高台, but it lands on a 围墙 tile
(drawn raised; it blocks nothing there) when that is the nearest tile covering its enemy, even with a road tile
covering it too.
- **Official**: 「再部署至一名地面敌人周围」 — no tile preference stated.
- **Decision**: the owner's decision of 2026-10-06 — prefer a landing tile where it can block an enemy over a tile where
it cannot; keep the rest of the landing rule.
- **Now**: for a melee member (position not RANGED) with a block count, `raidTile` ranks first the tiles where its block
applies — ground units pass the tile (not a 围墙 / 围栏: `Battle._blockerFor`) and an enemy ground path runs through it
(`Battle.groundPathTiles`) or its enemy stands on it — then the old order (nearest, then the facing frame). The rest of
§22.2's rule is unchanged: the 8 most advanced targets in turn, a tile from which its range covers the target,
`grid.canStand` (never a 高台, never the 深水区), free tiles, no useless jump. A 围墙 landing remains where nothing
else covers the enemy. §24.2's note on #148 points here.
- [ASSUMED]: the official landing picks among the legal tiles at random (the preference is the owner's); ranged members
keep the old order; "its enemy stands on it" counts as a tile where it blocks (a target off the computed paths).
- Golden: fields `boss-boss_2-pair`, `boss-boss_3-pair`, `boss-boss_4-pair`, `boss-boss_6-pair`, `boss-boss_7-pair`,
`hidden-boss_10-pair` — their 突袭 members (伊内丝, 缄默德克萨斯, 仇白) now land on a road tile of the enemy path instead
of a 围墙 tile (6 of the 8 changed landings) or an off-path road tile (2); roster / bonds / matches / standins / diy
unchanged. Tests: `test/content/feedback5-we2-raid-148.test.js` (updated on purpose: every stage × every low enemy
tile — never a 高台; whenever a tile where it blocks covers its enemy it lands on one; the old code fails at act1 m02).
#### 25.11.4 泥岩 S3's 10 s dormancy is 闭锁: she lets go and blocks nobody (PRTS) — `kits/ops/chess_char_4_18-mudrok.js`
- **Seen**: the kit kept her blocking through the dormancy of S3 秽壤的血脉 ("keeps blocking").
- **Official**: PRTS 泥岩 S3 备注 — the first 10 s are 闭锁, not 无法行动, with 眩晕 / 冻结 / 沉默 反制; PRTS 异常效果 — 闭锁
(SHELTERING) = 强制缴械 + 无敌 + 不可阻挡, and 不可阻挡 (BLOCK_FREE) 「无法阻挡/被阻挡,自动解除阻挡」: on an operator, it
blocks nobody and lets go of what it held (the enemy side of the same state: §24.9).
- **Now**: the dormancy adds the flag `noBlock` (with invulnerable and disarm): the enemies she held walk on (`ai.js` lets
go of a `noBlock` blocker) and she blocks none until she wakes. [ASSUMED]: the 备注's 反制 are not modelled.
- Golden: `roster-040` (it fields 泥岩 S3: the enemies she held walk on during the 闭锁). Tests:
`test/content/kits_alt_t4.test.js` (updated on purpose: the 闭锁 releases the three walkers and blocks none — enemies
placed on her tile neither —, awake she blocks again once their stun ends).
#### 25.11.5 Settled by the sources with no rule change: 忍冬 S2, 深靛 S1, 阿 S2 / S3; the shared 庇护 — kit comments, `test/content/feedback5-wv-sources.test.js`, `test/content/feedback5-protect.test.js`
- 忍冬: PRTS's 「无法选中被自身阻挡而没有位于技能范围内的敌人」 is the 备注 of S2 坠刃拷问 (技能范围 3-12), not of S3 (its only
备注 is the ASPD update; the client's S3 selector is the standard 「同时攻击阻挡的所有敌人」 one); the S2 kit already selects
on the 3-12 keys only — pinned.
- 深靛 S1: the S1 「每次攻击的攻击倍率」 applies to her stored energies (PRTS S1 备注; the client's ChargeAttackS1 reads the
same `atk_scale`), as the kit does (`ai.js resolveHit`) — pinned.
- 阿 S2 / S3: the data's DEFAULT — no trigger row names him; the skill prefabs cast with no target and keep the SP spent
(`_allowNoTarget` 1, `_checkHasTargetBeforeDoCast` 0, `_recoverSpIfNoTarget` 0); PRTS has no ally condition. With no
ally in his range the cast spends its SP and does nothing; the kit comment records the sources.
- 庇护: 淬羽赫默, 黍, 斥罪, 森蚺 and 娜斯提 grant it through the shared `holdProtect` (`kits/shared/tier1.js`, §25.7.4), 遥's
bubbles (their own key) through the shared `protectMods`; a guard test keeps kit-local copies out — a pure refactor.
- Kept as they are: 淬羽赫默's T2 丰润羽翼 as 生命回复速度 (the client's buff template atk_to_hp_recovery: 禁疗 does not
stop it, no 治疗加成) and 遥's T2 break heal on the ATK cached when the bubble is given (§25.10 keeps both open).
- Golden: unchanged.
### 25.12 Release packaging and the asset manifest (W5U; the 自选 voices) — `tools/package.mjs` (`npm run package` / `package:lite`), docs/DEPLOY.md §7, `tools/assets/plan.mjs`, `tools/assets/manifest.mjs`, `tools/fetch-assets.mjs`, data/assets.json
- **Seen**: 0.1.x releases came from a maintainer script outside the repository that zipped every tracked file (tests,
design docs, research notes, dev pages, the Windows-bundle maker) plus all of `public/assets` as it lay on the
maintainer's disk; there was no smaller download. 焰狐龙梓兰 left the 自选 pool (§25.4) but her art stayed in
data/assets.json, because fetch-assets refuses to shrink the manifest. The first real build found both
`module/dec-X.png` and `module/dec-x.png` listed — one file on Windows / macOS, so a zip built there carries one.
- **Official**: no game rule (release tooling). The collab exclusion is the owner's decision of 2026-10-05 (copyright);
the uniequip table spells one DEC X module `dec-X` (uniequip_003_aglina) and the others `dec-x`, and `WAH-Y` is the
other mixed-case type id.
- **Now**: `tools/package.mjs` builds `Stronghold-Protocol-v<version>.zip` (full) and `…-lite.zip`, each holding one
`Stronghold-Protocol/` folder, from an allowlist over `git ls-files`: `server/`, `shared/`, `data/`, `public/` (not
`public/dev/`), the start scripts and the tools a player runs (setup, vendor, fetch-assets + `tools/assets/`, doctor,
and what setup starts: `tools/local-extract/`, crop-board-atlas), the four research tables the server and fetch-assets
read (`03-operators`, `05-enemies`, `05-maps`, `07-assets`), package.json / lock, LICENSE / NOTICE /
THIRD-PARTY-NOTICES, README, CHANGELOG, docs/PLAYING.md and docs/DEPLOY.md; `npm ci --omit=dev` in the stage adds
node_modules and public/vendor. The full zip adds the files data/assets.json and data/local-assets.json list,
`public/fonts` and `public/assets/local/` — nothing else on disk, so her 15 files and other orphans never ship. Every
check also runs in `--dry-run`: the refusal list (0.1.x's plus test, .github, AGENTS.md, public/dev, `.env` / `.venv`
anywhere); imports and the player npm scripts resolve to shipped files; all listed art is present; no two paths differ
only in case; no shipped file holds a home-directory path or the machine's account name (text and binary, the name read
from the OS at run time); no shipped tracked file has uncommitted changes; a real build also checks the stage against
the plan. Measured on the 0.2.0 tree (before the voices below): full 421 421 613 bytes, 14 910 files, 589 MB unpacked;
lite 22 088 912 bytes, 6 430 files, 85 MB; the 0.1.x layout of the same tree zipped to 429 644 767 bytes (569 files
more, 25 MB more unpacked). Both stages boot; `setup --check` passes on the full stage and the lite stage plans the art
offline.
- **The manifest**: data/assets.json regenerated offline (`--offline --add-only --allow-shrink`) drops her 8 subtrees
(163 entries); module icons get one lower-case file per type (`module/<type in lower case>.png`: `modules.dec-X` and
`modules.WAH-Y` changed); `resolveTemplate` drops an array whose lines are all missing (it kept `{select: [], place: []}`
for the 71 自选 voice entries this machine had never downloaded); fetch-assets compares orphans (the `--prune` targets)
case-insensitively. Then (6499f4b) `fetch-assets --add-only` run online added the battle voices of the 71 自选 operators —
994 lines, 25.5 MB, `voiceChars` 120 → 191 (the manifest: 7 972 files, 485 MB) — and nothing else changed; the full zip
grows by about as much.
- **Open (polish)**: 34 or more of the 38 summons of the 自选 kits (Mon3tr, 麦哲伦's drones, 令's summons, 电弧's towers,
白铁's piles, 娜斯提's devices, 望's 棋子 …) have no Spine skeleton in the online sources (fetch-assets: "missing skel"),
and the manifest lists none of the 38 with a model, so the field draws them as the avatar placeholder; extracting them
from a local client (`tools/local-extract`) or keeping the placeholder is left for later.
- [ASSUMED]: the lite zip leaves out `public/fonts` too (fetch-assets downloads the fonts with the art); package.json
ships unchanged, so the maintainer scripts (`npm test`, `golden`, `package`) fail inside a zip; docs/img (the README
screenshots), docs/WINDOWS.md, docs/ASSETS.md and the Dockerfile stay out (Docker builds from a git clone).
- Golden: unchanged. Tests: `test/package.test.js` (9, no network: the selection on samples and on the real tracked tree,
the refusal list, a dry run and a `--no-install` build of a temporary checkout, the scans), `test/assets-diy.test.js`
(no `diy.excluded` operator keeps art; one lower-case module icon per type; no two manifest URLs differ only in case;
orphans compared case-insensitively), `test/assets.test.js` (the empty-array rule). Docs: docs/DEPLOY.md §7, the
README download section (full and lite), docs/ASSETS.md.
+39
View File
@@ -0,0 +1,39 @@
# DESIGN §17 — User playtest #3 (v2.2)
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 17. User playtest #3 (v2.2): models, picking, FX, rules, HUD
Nine reports from a real co-op session. Where each is handled: #1 lost operator models → §17.1; #2 the LP did not move while enemies entered the own blue gate → §17.5; #3 equipment pushed into the second bench row vanished after the battle → §6.2 temp overflow (server) + §17.5 (UI); #4 蕾缪安 S3 fired every shell at once → §17.4; #5 回环射手 attacked like a fast shooter → §17.4; #6 bullets / skill effects hardly visible → §17.3; #7 a click selected the unit one row lower (equipment too) → §17.2 (superseded by §18.1); #8 the operator's own effect (特质) sat at the bottom of the detail card → §17.5; #9 spinners next to texts → §17.5.
### 17.1 Lost operator models (#1) — the Spine store (`public/js/assets.js RefLru`)
Cause: `PIXI.Assets.unload` is asynchronous (the loader cache entry is dropped and the textures destroyed a microtask later), so a skeleton evicted and re-acquired in the same task came back as the doomed object and turned invisible for good (a tier chip without a body; the dead data stayed cached, so every later copy of that operator was invisible too). It happened at every battle → prep switch: `enterPrepMode → clearViews` made the reference count 0 for an instant, the LRU applied its "no scene on screen" budget (0 bytes) at that instant and dropped the bench models idle since the battle started, then `setPrep` took them back at once. Rules now: an `unload` may return a promise and a load of a key whose unload is still in flight waits for it (`unloadSpineData` returns one; `stats().spine.unloading`); the spine store runs eviction in one pass `SPINE_EVICT_DELAY_MS` (1 s) after a release (a scene switch takes its models back first); the quiet budget applies only after `SPINE_QUIET_DELAY_MS` (3 s) with nothing referenced (lobby / room) and no scene holding the cache (a field view in battle mode holds it, §21.27). The count-cap path (60 skeletons) waits the same way. Tests: `test/render/assets.test.js` (fake `PIXI.Assets` with the same async unload), `test/render/models.browser.test.js` (models alive after long battles, drag / wheel stress).
### 17.2 Picking and the drag target (#7) — superseded by §18.1
v2.2 answered "a click selected the unit one row lower" with body picking: every path took the drawn chibi (a head / body shape per unit, measured from its posed Spine model) and, where two bodies were close, a 1-px render probe of the pixel under the pointer; the dragged ghost stood on the pointer (on touch 0.6 tile above the finger) and an item went to the operator whose sprite was under the plate. User playtest #4 item 1 rejected it ("感觉你想复杂了,地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"; the model moved up away from the finger): picking is by the tile under the pointer and the dragged model is held under the pointer (§18.1). The shapes, the probe, the touch lift, `pieceDragOver` and the DOM-side `pickPieceAt` / `equipRetarget` are gone.
### 17.3 Battle FX (#6) — `render/fx.js`, `style.js PROJ`, `textures.js`
- **Projectiles per kind** (`PROJ[kind].look`), sized in tiles × the camera's px per tile (the same on the 3D board and the 2D fallback, 1280×720 to 1920×1080): `arrow` a bright tracer with a glowing head, a muzzle flash aimed at the target and a hit flare; `bolt` / `orb` / `enemy` a glowing ball with a particle trail (arts purple with a burst + shockwave, heal green with a sparkle, enemy red); `bomb` / `lob` a shell on a visible arc with a ground shadow, smoke and embers, exploding on landing; 阵法术师 / 轰击术师 strikes are instant `beam` lines to every victim (§21.18); `droneBomb` (暴鸰) a falling shell at 5 tiles/s (§21.14); `drone` a small cyan dart; `boomerang` (回环射手) a spinning boomerang with afterimages that flies out to the moving target at `PROJECTILE_SPEEDS.boomerang` and back to the thrower's **current** position at `BOOMERANG_RETURN_SPEED` (the sim emits no event for the return: the renderer models it; dropped when the thrower's view is gone or dead). A shot lingers 0.08 s in its target so fast shots still read. Flight times use the sim's own speeds, loaded at run time from `/sim/constants.js` (`style.js PROJ` keeps a test-guarded copy: `test/render/fxproj.test.js` fails when they differ), so a shot lands with its damage number.
- **Hits and skills**: a melee blow draws a thick crescent slash swept along the blow (attacker → victim on screen) in the damage-type colour with a white core, plus sparks; skill activation = gold light pillar with a white-hot core, flash, shockwave and hex ring on the ground, rising motes; an active skill keeps a slowly turning hex and a soft glow under the unit. Blasts share one explosion (fireball, flash, shockwave, coloured ring, sparks, smoke; `heavy` kinds — bombard, airstrike, meltdown — add debris and a scorch mark). Beams and chain lightning: glow + body + white core, flare at the target.
- **Anchoring**: an fx whose `extra.id` names a unit is drawn on that unit (following its rendered position) only when the event's (x, y) lies within 0.75 tile of it (or has no position); otherwise at (x, y) — the sim puts the caster in `id` of many area effects. `FX_KINDS` entries flagged `pt` (bombard, bombardShell) always happen at (x, y) (their `id` is the shooter; 蕾缪安's bombard used to explode on herself).
- **蕾缪安 S3 contract**: `'lock' {x, y, id: enemy, src: shooter}` → a reticle following that enemy (its view by `id`), at the spot it left once it is gone; `'bombardShell' {x, y, id: shooter, r, t: flight game s, i}` → a launch streak leaving the shooter and a shell dropping onto (x, y) after `t` (a flight ≥ 0.45 real s climbs out of her first), with a closing warning ring and the blast radius throbbing; it takes the shooter's nearest free lock; `'bombard' {x, y, id: shooter, r}` at the same spot → the heavy explosion and the end of that lock. A lock also ends on the S2 `'crit'` from the same shooter, when the shooter is gone (locks no shell took yet), or `LOCK_T` = 5 game s after that shooter's last lock / shell / bombard (an idle timeout, not an age: ammo grants and stuns stretch an S3 far past 5 s); ≤ 48 reticles.
- **Cost**: particle, projectile and lock records are pooled (the per-frame emitters allocate nothing); a freed particle is a zero-size transparent quad (Pixi's ParticleRenderer ignores `visible`); cosmetic extras (trails, muzzle flashes, afterimages, debris, scorch marks, motes) are off at quality 'low' and at adaptive load level ≥ 2, and trails stop at 75 % of the particle cap. Measured: the 120-unit stress scene and the crowded boss-m02 replay stay at 60 fps with no measurable per-frame increase. The FX atlas is 1024×512 with non-overlapping frames (the old light pillar carried two status icons at its foot). The FX are procedural: the local client's per-weapon projectile sprites are not used (the sim's `arrow` also covers gun snipers, and friends may not have the local art).
### 17.4 回环射手 (#5) and 蕾缪安 S3 (#4) — the sim (official PRTS notes; numbers from the data)
- **回环射手 boomerang** (`professions.js loopshooter`, `ai.js throwBoomerang`; PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75", "必须回收全部回旋投掷物才可以进行下一次攻击"): projectile `'boomerang'` (`resolveProfile` keeps it over the data's generic `'arrow'`) flies out at `PROJECTILE_SPEEDS.boomerang` = 15 tiles/s and hits on arrival, then flies back to the thrower's current position at `BOOMERANG_RETURN_SPEED` = 3.75 tiles/s without damage; the thrower attacks only while it holds its boomerang (every one it threw caught) **and** its attack cooldown is ready, so the real interval is the longer of the two. A target that died mid-flight is not hit (the boomerang still flies to its last position and back); a thrower knocked out / withdrawn loses it and a redeployed one holds a fresh one. 跃跃 S2's extra boomerangs share the one flight (cnt hits, the talent rolls per hit). Event contract unchanged: `['atk', thrower, target, 'boomerang']`.
- **蕾缪安 S3 礼炮·强制追思** (`kits/tier6.js lemuen`; PRTS S3 note — the timing is not in the blackboard): one lock every 0.5 s while an enemy is in range (unlocked targets first, then lowest DEF), each costing a round; the lock mark follows its enemy and stays where it left (death / leak). After the skill ends ONE shell every 0.3 s in lock order (PRTS "以0.3s为间隔"), aimed at a random point of the 0.4-side square around its mark (± `attack@emit_offset` 0.2, drawn with the battle rng in her facing frame so a turned board plays alike), landing 0.3 s after it is fired (flight **[ASSUMED]**: the data and PRTS give none; the PRTS count of ≤ 33 fits a first impact one interval after the end); each shell hits every enemy within `dist_2` once — `proj_atk_scale_1` within `dist_1`, else `proj_atk_scale_2` — with the ATK cached when the skill ended; at most 10 s of shells (≤ 33). Knocked out / withdrawn after the end: shells in the air still land, the rest are dropped; knocked out while locking: no bombardment. fx: `'lock'` per lock, `'bombardShell'` when a shell is fired, `'bombard'` at its impact (§17.3).
- Tests: `test/sim/professions.test.js`, `test/content/kits_t1t2.test.js` (跃跃), `test/content/kits_t6.test.js` (shell sequence, spacing, cap, knock-out, rotation invariance).
### 17.5 Game HUD (#2, #3, #8, #9) — `ui/hud.js`, `teamPanel.js`, `underframe.js`, `detailPanel.js`, `data.js`
- **Live LP (#2)**: in a normal round the top bar's LP tower and the own team row show `lp − min(lpCapPerRound, counted leaks)` as soon as the own battle's enemies enter the blue gate — the number in red followed by a −N plate that pops again on every leak, clamped at 0, with a tooltip (`ui/hud.js liveLp` / `pendingLoss` / `pendingTip`). The count is `ownLeaks(local, server)` = the larger of the local runner's `state().leaks[own field]` (§14) and `m.public players[].pendingLp` (§8.2; the authority's `b.progress` leaks, the field's result, the recorded result in 联防) — both only grow within a round, so a paused display replica never freezes the number. The pending part is dropped as soon as the settled `m.private` lands (its `lp` or `stats.leaks` changed; `Match.flush` sends it before the SETTLE `m.public`), the phase leaves COMBAT / 联防 or the round changes — never subtracted twice. 联防 (**revised by §20.6**, user playtest #6 item 7): a leaker's count is its enemies still standing — the local replica's while the 联防 field is on screen, else m.public `uniteLeft` — shown uncapped as ×N in the capsule and in the leakers' team rows, and the LP tower shows lp − min(10, N), tagged 联防中. Teammates' rows use their `pendingLp` (`teamPanel.js rowLp`); server-run fields (bots, taken-over fields) report none before their result is released. Boss rounds are unchanged (the team LP moves live through `b.pool`).
- **Temp row (#3, UI)**: while `m.private temp` holds pieces during the own prep, a dashed red frame surrounds the 临时整备区 row on the board and a label says how many are waiting, what to do and when they go (`underframe.js TempRowNotice` / `tempRowFrame`, placed through `view.tileScreen` — it follows the camera, the mirrored Final Assault prep and the DOM fallback; no pointer events; the label hides while dragging or placing). Its wording follows the server rule (§6.2, `tempRowRule(ready)`): not ready — "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁"; ready in PREP (so whatever lies there arrived after Ready) — "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)". The 准备就绪 button shows "临时整备区 N 个单位待处理" under it (not only on hover; the effects column moves down a line meanwhile, `.gm.has-readywhy`) and Space toasts the reason (`hud.js TEMP_RULE` / `tempReadyReason`).
- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden. A teammate's 策略 is not on the card — while scouting, the 本局信息 drawer shows the watched player's band in place of one's own (§14).
- **Busy indicators and texts (#9)**: every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data, `/data/*.json` loaded once per page and never fetched during a match; the "spinners" the player saw were busy indicators of requests in flight next to texts — and the 特质 chip's type icon: it was picked by the trigger, so every <休整期开始时 / 结束时> 特质 showed the spoked 特异化 glyph (it reads like a loading spinner) before its text; the chip now shows the garrison's own official `eventTypeIcon` (`detailPanel.js garrisonTypeIconKey`: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: no `cursor: wait / progress` anywhere; a loading `Button` keeps its icon and shows a thin sweeping bar along its bottom edge after 250 ms (no spinner ring); a 机变 card in flight shows a 选择中 strip until the pick appears in `m.public` (`choiceOverlay.js pickBusy`); the 作战结束,等待队友完成作战 hourglass is still; the hexagon `Spinner` is kept for genuine loading screens only (entering the match, the field view, connecting, guide images, the first 干员调配 load, which says the data loads once per page). `main.js warmGameData` downloads every in-match file (`gameComponents GAME_FILES`) in the background once the player is in a room, and the match screen waits for them; `data.js` retries a transient failure (network error, HTTP 5xx / 408 / 429) after 600 ms and 2 s (`RETRY_DELAYS_MS`) — a 404 or invalid JSON is final.
- Tests: `test/ui/playtest3.test.js`, `test/match/runner.test.js` (live leak counts), `test/match/temp.test.js` (§6.2), `test/docs-consistency.test.js`.
---
+58
View File
@@ -0,0 +1,58 @@
# DESIGN §18 — User playtest #4 (v2.3)
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 18. User playtest #4 (v2.3): picking by tile, confirms, timers, battle state, content
Thirteen reports after playing v2.2. Where each is handled: #1 picking should simply follow the tiles and the dragged model moved away from the finger → §18.1; #2 a confirm step for 机变 picks → §18.2; #3 a countdown although playing alone → §18.2; #4 the strategy draft's 10 s turn out of step with the top-right countdown → §18.2; #5 the special 维式重锤 and 突变细胞 were sold in the shop → §18.6; #6 纯烬艾雅法拉's skill sound rang outside her skill → §18.4; #7 the operator card showed fixed numbers → §18.5; #8 no official element gauges → §18.3; #9 knocked-out operators vanished instead of lying on their tile with a redeploy ring → §18.3; #10 the enemy drones' behaviour → §18.6; #11 赫默's drone deployed at the start instead of by her skill → §18.6; #12 the 炎佑 dragon returned to a fixed point and its flame was no continuous jet → §18.6; #13 once in a while a huge piece of UI covered the screen for a second or two → §18.7.
### 18.1 Picking by tile, the model under the pointer (#1) — `render/pick.js`, `render/app.js`, `render/drag.js`
- **One rule**: the ground tile under the pointer decides (`render/projection.js pickTile`, raised tops first). Prep presses / clicks / right-click / long-press / drag starts / hover (`app.js pieceAt`) and the enemy preview pen (`penUnitAt`) take the unit standing on that tile (board, bench row 7, temp row 8; `pick.js pickOnTile`). An empty tile selects nothing; a press on a unit's head drawn over the tile behind it is a press on that tile; a raised tile in front hides the near 0.17–0.24 tile of the one behind it, and a press there is on the raised tile — what is drawn. Several units on one tile (the pen's clusters, a piece held for a moment): the nearest to the pointer's point on the tile, ties → the front-most.
- **Battle** (`app.js battleUnitAt` → `pickBattle`): an ally on the tile under the pointer — a knocked-out operator lying on its tile (§18.3) included. An enemy within `ENEMY_REACH` 0.6 tile [ASSUMED], using whichever is nearer: its ground position from the pointer's point on the ground, or its drawn body on screen (the upright line from its feet up to its head, in its px per tile) — so a press on a tall enemy's or the boss's torso or head picks it; a flying enemy uses its drawn body only. When an ally tile and an enemy both qualify the nearer (in tiles) wins; ties → the ally, then the front-most. Dead and dying units are not picked (a knocked-out operator waiting on its tile is).
- **Drag**: the dragged unit is held under the pointer, mouse and touch alike — its drawn (lifted) feet `DRAG_HOLD_TILES` = 0.45 tile (in its own px per tile) below the pointer [ASSUMED: the v2.1 offset], so the pointer rests on the chibi; an item plate is centred on the pointer. The drop target, its highlight, `tileHover` and the direction wheel's tile are the tile under the pointer. An item dropped on a tile equips the unit on that tile (`ui/gameLogic.js canPlace` / `dropIntent`; an empty tile says 请将装备拖拽至干员身上). A release over DOM covering the canvas (the shop bar) is `'outside'`: the piece goes back.
- **Touch**: the field canvas cancels `touchend` (`app.js onTouchEnd`, non-passive). A tap's compatibility mouse events and click came after `touchend`, hit-tested at the finger again — on the underframe the tap had just opened over the unit's tile (clamped under the top bar for rows 11–12 at 844×390), which pressed 撤退 / 出售. The canvas is also a click target of its own (a no-op `click` listener, `app.js onTapTarget`; 0.1.4): the browser's touch adjustment moves a tap onto a nearby element that responds to clicks (pointer listeners do not count) when the finger's contact area reaches one, and once the 收起 toggle of PR #149 (§10) pushed the bond discs one button to the right, a tap on the row-12 unit at 844×390 opened the bond popup. Holding the finger's point, the canvas wins: a tap on the board stays on its tile.
- **Notched phones**: the shop bar keeps its place at the bottom edge instead of rising by the bottom safe-area inset (`css/devices.css .gm__hud > .shopbar`): raised by it the bar covered ~3/4 of bench pads 2–9 at 844×390 with the iPhone insets, and bench pieces are pressed and dropped on by their tile. (The v2.3 figures — 23 % of those tiles free before, 71 % after — are superseded by §19.8: the prep camera now clears the HUD, every bench pad 100 % free on phones.)
- Removed: the pixel probe (`drawnAt`, `hoverProbeAt`), body shapes (`pickShape`, `PROBE_MARGIN`, `BODY_H`), `TOUCH_LIFT_TILES`, the drag `dropPoint` hook, `pieceDragOver`, `ui/gameLogic.js pickPieceAt` / `equipRetarget`. `UnitView.bounds()` is a plain body rectangle again (tooltips and `pieceScreenRect` only).
- Tests: `test/render/pick.test.js`, `drag.test.js`, `unitview.test.js`, `test/ui/gameLogic.test.js`, `test/ui/playtest4.test.js` (shop bar); browser: `test/render/models.browser.test.js` (every tile of every board, high ground, bench and temp; battle allies and enemies incl. torso and head; the model under the pointer at every drag step; the wheel on the pointer's tile; equipment by tile; phone touch incl. the ghost click and the shop-bar release), `test/render/downelem.browser.test.js` (a knocked-out operator picked on its tile), `test/ui/playtest2.e2e.test.js` (the top-row tile under the bond strip; the notched phone's bench).
### 18.2 Confirms and timers (#2, #3, #4) — `ui/choiceOverlay.js`, `server/match/Match.js`, `screens/bandDraft.js`
- **机变 cards take two taps** (like the shop's cards): the stateful `ChoiceOverlay` wraps the pure `ChoiceView`. A first tap on a pickable card (bounty = extra enemies, 道具补给 / 机密商店 items, tactics) only selects it — gold frame, a 确认选择 · 再次点击 strip, the other cards dimmed, 确认选择 in the header; a second tap on it, or 确认选择, sends `g.choice`. Another card moves the selection; Esc or a press elsewhere drops it; so do the card being taken, the turn moving on and a pick in flight. Cards stay buttons (Tab / Enter). Each card shows its full effect text (§20.7). The other picks were already two-step (晋升奖励 in the shop bar, the strategy draft's 确认选择, 确认替换).
- **A single human is untimed** (`Match.loneHuman`: exactly one human seat at the start → `Match.soloUntimed`): 独立模拟 and a 同盟 room started alone or with AI teammates only publish no INFO_CHECK / BAND_DRAFT / SP_DRAFT / PREP deadline, and BATTLE_CHECK / ROUND_START / SETTLE run silently — the timers only ever made humans wait on each other. The co-op rules stay (shuffled draft order and skip, 6 机变 cards, 联防); pause stays solo-only; a disconnected lone human's match waits (the co-op reconnect window then ends it as abandoned). `Countdown` (`ui/components.js`) renders nothing without a deadline (it used to draw a `--` COUNTDOWN placeholder that only a CSS rule hid).
- **The strategy draft has ONE countdown**: every turn lasts `Match.BAND_TURN_SECONDS` = 30 s [ASSUMED] (× timerScale; `data/config.json timers.bandTurn` says the same — the official data only has the whole BAND_CHECK step, 50 s, now informational) and `m.public.deadline` IS the current turn's end (= `draft.turnDeadline`; `draft.turnSeconds` = the turn length, 0 untimed), so the step header and the picker's row show the same number. No step cap: the turns bound the step (≤ (seats + skips) × 30 s). AI seats pick at once. The draft screen reports its highlighted strategy (`g.bandFocus`); a turn that runs out takes it while it is allowed and free (`Match.timeoutBand`), else 华法琳 (`band_bldsk`), else the first free strategy (`defaultBand`, 队友已选 never). The selection starts on that default and the tip always names what a timeout gives ("每位博士有 30 秒,超时将自动选择当前选中的「X」"; untimed: "本局不限时"). `server/match/audit.js` checks it (deadline = the turn's, 30 s; nothing timed for a single human).
- Tests: `test/ui/playtest4.test.js`, `test/ui/bandDraft.test.js`, `test/match/draft.test.js`, `test/ui/gameLogic.test.js` (`phaseTotalSeconds` = the turn), `test/match/hardening.test.js` (the audit), `test/ui/mock.e2e.test.js` (two taps). Browser tests that need a timed co-op briefing now seat two humans (`test/ui/loadout.e2e.test.js`); a single human's 机变 is picked by the test itself (`test/ui/playtest2.real.e2e.test.js`), and the real-server drafts wait for BAND_DRAFT before picking (a client left in the briefing let its 30 s turns run out).
### 18.3 Knocked-out operators and element gauges (#9, #8) — `sim/Battle.js`, `sim/damage.js`, `render/interp.js`, `render/units.js`
- **Knocked out, not gone**: `Battle.snapshot()` lists `down: [[id, respawnAt, respawnTime, state, row, col]]` (§8.2; the tile since §21.24) for operators knocked out (reason `'killed'`, or `FORCED_EXIT`, entering 联防 knocked out, §19.3) or forced out by their own effects (余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3, a 商人 that cannot pay — every 退场 but the 突袭 retreat, §22.15), deployed at least once and with a finite respawn timer; `state` = sim/constants.js `DOWN_STATE` (0 counting, 1 timer done but DP < cost, 2 timer done but its tile is taken — the official auto-redeploy rule, research 01 §4.3; a safeguard since §21.24, no ally may take the tile a knocked-out operator lies on). `Battle.isDown(u)`; `fieldMeta()` lists them too, so a client joining mid-battle (reconnect, observer, replica) shows them. The renderer (`interp.js downAt`, `app.js syncBattle`, `units.js setDown`) keeps such an operator's view on the tile it lies on (the entry's row, col): its Spine Die clip plays once and holds its last frame (a collapsed / kneeling pose), lightly greyed (tint 0xb4b4b4, alpha 0.92), under a **redeploy ring** above the head — a dark disc with a mint arc filling as the timer runs and the seconds left (game s); timer done: a full pulsing amber ring "DP" (waiting for DP) or red "!" (its tile taken). The `deploy` event plays the Start clip and restores the normal look; a view created for an operator already down starts on the held pose; leaving the list without a redeploy fades it out. Summons, devices and enemies vanish as before. [ASSUMED] look: the official knocked-down / ring art is in no extracted autochess bundle.
- **Element gauges, official rules** (PRTS 元素 / 术语释义; `damage.js`): gain × `elemTakenMul` × (1 − 损伤抵抗 / 100), 损伤抵抗 = data `epResistance` (EP_RESISTANCE; it used to read `epDamageResistance` as a fraction). A burst's **爆发冷却** (= its duration: 灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s; the operators' 侵蚀 had none) locks EVERY element of the unit — nothing fills or recovers (`reduceElement`) — and its end resets EVERY gauge (`burstLocked(unit)` takes no element). 神经 applies the stun (operators) / 3 麻痹 (enemies) before its damage, 侵蚀 its DEF cut before its damage; the operators' 凋亡 burst holds 阻回 (`noSp`: `skills.js gainSp` refuses SP of every kind — time, attack, hurt, granted) + 静默, −1 SP/s and 100 arts/s.
- **The gauge on screen** (official display: one icon, the "当前损伤元素"): `damage.js elementView(u, now)` → b.snap `elem` (§8.2): the fullest gauge, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经 < 侵蚀 < 灼燃 < 凋亡); during a 爆发冷却 the bursting element, refilling over the cooldown. `interp.js` appends it to the unit's tuple; `units.js` draws it — **superseded by §20.8**: a row under the unit's own HP / SP bars, the element disc (0.15 tile, 8–15 px; operators with the glyph, enemies ×0.8 plain colour) and a white bar of the remaining 元素值 (1 − fill), refilling in the element's colour with a pulsing disc during the 爆发冷却 (v2.3 drew a white ring right of the bars; the procedural atlas `textures.js hudRings` keeps the discs and the redeploy ring), sprites made on first use and hidden when idle. The burst's lock status (`burnBurst`, `neuralBurst` …) is not repeated in the status row while the element icon shows it (only a feed without `elem`, e.g. an older recording, shows it there). [ASSUMED] placement (the official draws it under the model, the remake's bars are above the head) and look (§20.8).
- Recordings (`tools/record-battle.mjs`) carry both keys, so the render demo shows them. Tests: `test/sim/downelem.test.js`, `test/sim/combat.test.js`, `test/render/downring.test.js`, `test/render/interp.test.js`, `test/render/downelem.browser.test.js`.
### 18.4 Battle sounds (#6) — `public/js/audio.js`, `tools/assets/audio.mjs`
- Cause: every `'atk'` — heals included — made its source the target's "last attacker", and any later damage on that target (enemy projectiles, element fills, DoTs) played that unit's `hit`; 纯烬艾雅法拉 (who only heals) had her S3 impact (`p_imp_gtshpbrnch_s`, bank ON_ABILITY_HIT.attack.2) as `hit` in the manifest, so it rang all the time.
- Now only a hostile `'atk'` (from the other side) authors the target's next impact: it plays once, within 2.5 s real [ASSUMED], and only for phys / arts / true damage; heals and chain bounces never author one. An operator's skill-mode file (official names `_d` / `_h` / `_s`; the normal attack's end in `_n`) never plays for a normal attack (`normalAttackSfx`). At most 2 overlapping copies of one battle sound (`SfxLimiter`; official banks `maxSoundAllowed` 2 — a third copy is skipped, not the oldest stopped [ASSUMED]).
- Manifest: `tools/assets/audio.mjs pickUnitSfx(banks, { operator, projectile })` picks normal-mode banks for operators — the plain `attack` / `combat` ability first, never a bank with a skill-mode file — with the operator's own projectile banks (`ON_PROJECTILE_BORN` / `_HIT.projectile_chr_<name>`, `tools/assets/plan.mjs`) as fallbacks; enemies and tokens are unchanged. 56 operators' picks changed (e.g. agoat2 `hit` → `p_imp_gtshpbrnch_n`, nearl2 `attack` → `p_atk_goldspear_n`, whitw2 → `p_atk_whtolfdrksl_n`). Loop-bank skill sounds still play once (open).
- Tests: `test/ui/audio.test.js`, `test/assets.test.js`.
### 18.5 Live operator stats (#7) — `ui/detailPanel.js`, `battle/runner.js`, `Match.unitStats`
- **Battle**: `battleRunner.unitStats(id, fieldId)` reads the local sim's last computed stats (`unit._s`, never the lazy `unit.s`, so looking cannot shift the sim) and the detail card re-reads it 4× a second: current HP / max HP, ATK, DEF, RES, attack interval, block (enemies also move speed) and the range grid (`range`: the live 攻击范围 — a running skill's grid included; 全场 for a whole-field range, smaller cells beyond the box, §21.16). `battleRunner.unitIdOf(uid, ownerId, fieldId?)` maps an own board piece to its battle unit, so a card opened in prep and left open into the battle turns live.
- **Prep**: C→S `g.unitStats {seq?}` → `m.unitStats {seq, round, units}` (§8.2): `Match.unitStats` builds a Battle from the player's battle input after the `onBattleStart` meta (dispatched with `ev.preview: true` and no `spawns` — handlers must not change the match then, docs/META.md), with the flags of the battle it previews (a normal round gains IN_BATTLE layers from its start; the Final Assault is previewed as a normal field without them), starts it (initial deployment + battleStart hooks), reads it and drops it — never stepped, so skills and timed effects are not in it [ASSUMED display] (a passive range skill and a permanent 攻击距离 are part of the start range, §21.16); cached per input (≈ 0.3–0.7 ms a build). `screens/game.js` asks 120 ms after any `m.private` / phase change while an own board unit's card is open and keeps the newest answer only.
- The card colours each value against the unit's base — green when it helps (a shorter interval is up), red when it hurts — with the difference beside it, the base in the tooltip and a 实时 / 开战时 tag. The shape is `shared/protocol.js unitStatsEntry`. Tests: `test/match/unitStats.test.js`, `test/match/runner.test.js`, `test/ui/playtest4.test.js`.
### 18.6 Shop-only items, drones, summons, 炎佑 (#5, #10, #11, #12) — `tools/build-data.mjs`, `sim/simdata.js`, `sim/content/*`
- **#5 not sold in the shop**: the official `trapShopChessDatas` has `hideInShop` false for all 56 normal items, so an explicit sourced list (`tools/build-data.mjs SHOP_EXCLUDED_ITEMS` → items.json `shopExcluded` / `shopExcludedBy`) marks the effect-only items: 战栗 / 坚固 / 加速 / 灼燃维式重锤 (the 维多利亚 bond every 25 layers, 洛洛's 定制品) and 突变细胞 (the 昆图斯 strategy 不稳定要素; user playtest #4 first-hand). `sim/simdata.js isShopItem` is the one predicate for every shop-item draw — the shop's item slot, 道具补给 / 机密商店 cards, `pool_equip_normal` / `_shop_1` / `_kathe` / `_narant`, `Match.rollItemId` — so 51 normal items are sold (the plain 维式重锤 still is). 洛洛's 定制品 (`pool_equip_rockr`) = the same 4 special hammers as `pool_equip_vict`, uniform [ASSUMED]. The item card says 调度中心不出售 · 获取途径:… (`detailPanel.js ItemDetail`). Other strategy-granted items stay on sale (no source says otherwise; 变形同构体 / 骑士储蓄罐 / 盟约之币 appear in an official 机密商店 screenshot).
- **#10 drones** (stats and ranges = enemy_database level 0, re-derived by `test/data.test.js`; behaviour per PRTS): 暴鸰 makes no normal attack — it holds still and drops one bomb (released 8 ticks into its Attack clip, homing at 5 tiles/s) that hits its target and the 8 tiles around on arrival (100 % ATK, splash ignores camouflage); a stun before the release interrupts it; when the cast ends (landed, ≥ 0.667 s after the release) it flies on at ×2 speed (§21.14); 帝国炮火先兆者 / 中枢先兆者 fire a shell per attack that lands 3 s later at the target's spot and hits every ally within 1.2 for 100 % of the ATK at launch, with no source (`content/enemies.js kitShell`; new `ai.js enemyAttack` hooks `profile.deferHit` — content resolves the hit in its `attack` handler — and `profile.shot`, the `'atk'` kind: `'mortar'` draws no projectile; fx `bombardShell`); 假想敌:黑云 grabs within `range_radius` × 2.5 (3.75) at most 3 flyers (nearest first [ASSUMED], 0.5 s delay [ASSUMED]), both held 4 s, ammo capped by enemies.json `sp.maxSp` (3; new `sp` from enemy_database spData), 全弹发射 = one 130 % hit per ammo on random allies anywhere on the field; “萨科塔之眼” never attacks flying allies; 萨科塔 self-fear speed final ×1.5, while it flutters inside its tile (§20.4). Unchanged (already right): 妖怪 / 妖怪MKII / 威龙 / 寒霜 / 法术大师A1·A2 / 御4 / 护障 / 护障·P / 远眺 (威龙's drawn size was fixed in §20.4).
- **#11 skill summons are not prep pieces** — **superseded by §20.1**: v2.3 limited tokens.json `placeable` to `tokenDisplayType` DEFAULT summons made by a talent (海嗣, 狼群, 流形), so 赫默's 医疗探机 and 巫恋's 诅咒娃娃 appeared only when the skill fired. Since user playtest #6 every manually deployable summon is a hand piece again, and its placed piece deploys once at the battle start (PRTS), then with each cast.
- **#12 炎佑** (PRTS “炎佑” 级别0(卫戍协议), `content/tokens.js yanyouKit`; 孤立 — never healed or selected by allies, §20.3): it flies after the highest-aggro targetable enemy of the whole field (operator order) and hovers 0.25 tile [ASSUMED] from it (a second 炎佑 of 9 炎 0.8 row beside the first); **with no enemy on the field it stays where it is** (user playtest #4; PRTS says it then tracks a random tile). 祛恶之焰 (模式乙): fires like a normal attack when its 15 s cooldown is full, locks that attack's first target and channels up to 20 s — each second (the first at once) 60 % ATK arts to every enemy within 1.0 of the target, no normal attacks meanwhile — ending when the target is gone or 炎佑 is silenced (inferred); the cooldown then runs again [ASSUMED]. Every damage it deals adds burn = 20 % ATK (flame ticks included, official 下半 notice); 元素脆弱 aura of radius 1.5 around itself; it ignores element damage; ATK / HP = the template 600 / 12000 **plus** 30 % of the 炎 operators' sums (PRTS 最终加算; the 9-炎 ×1.5 on the whole ATK [ASSUMED]). The flame is drawn as one continuous jet (`render/fx.js _flame`): the sim emits fx `yanyouFlame {x, y, id: dragon, target, r, n, dur: 1}` once per game second, and each tick re-aims and extends the dragon's jet — a stream of fire from the dragon onto its locked target with a burning disc of radius `r` following the target — which burns `dur` + 0.2 s and goes out when no tick comes or the dragon is gone.
- Tests: `test/content/playtest4_summons.test.js`, `enemies_bosses.test.js`, `tokens_devices.test.js`, `bonds_core.test.js`, `choices.test.js`, `test/data.test.js`, `test/match/pool.test.js`, `test/render/fxflame.test.js`, `test/ui/playtest4.test.js` (item card).
### 18.7 The occasional giant UI flash (#13) — `render/fx.js pop`
The first in-battle layer gain of each bond (b.ev `'layer'`) pops that bond's icon over the field (`FxSystem.pop`). The icon is `PIXI.Texture.from(url)` — a 1×1 placeholder until its image has loaded — and the pop sized the sprite from it (46 / 1): the bond icon was drawn ~5000 px across the whole screen for the pop's 1.4 s, once per bond per page (73 official garrisons gain layers in battle, e.g. 送葬人's). Now it is sized (46 px along its longer side) only once its texture is valid, hidden until then. Found by sampling long real-server sessions every 80 ms (DOM boxes > 25 % of the viewport, images drawn > 300 px, Pixi objects > 25 % or showing a whole atlas page, CSS scale ≥ 2); every other large object seen is a designed full-screen layer. Tests: `test/render/fxpop.test.js`, `test/render/flash.browser.test.js`.
---
+138
View File
@@ -0,0 +1,138 @@
# DESIGN §19 — User playtest #5 (v2.4)
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 19. User playtest #5 (v2.4): hovering enemies, target order, blocking, 联防, elements, huge bosses, maps, phone HUD
Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in landscape). Where each is handled: #1 operators that only attack ground units hit 近地悬浮 enemies → §19.1; #2 an operator lying knocked out (濒死) vanished when 联防 began instead of counting down and redeploying → §19.3; #3 element damage against the 海嗣 felt weak, audit every operator's and enemy's element amounts and gauges → §19.5 (enemies, the gauge pipeline) and §19.6 (the player side); #4 an enemy held between two operators walked off when the rear one died instead of the front one taking it over → §19.2; #5 the enemies' target order differed from the official → §19.1; #6 act2 m01's normal field had its two wind lanes but no blower machines, which showed in the boss round instead → §19.7; #7 the tiles framed by the 1×3 pipes on the boss map are deployable officially, check every map → §19.7; #8 the bottom-left settings icon was deformed → §19.8; #9 on a long phone screen the shop bar covered the bench → §19.8; #10 the huge bosses could only be hit around one tile → §19.4. A follow-up message added #11 至简 should cost 1 (his 特质 says 购买价格为1) → §19.9. Normative lines rewritten with it: §3 range test, §5.1 `carryState`, §5.5 (initial deployment, DP / down, blocking, operator attacks, enemy AI, damage), §6.1 UNITE, §7 bosses; §18.3's `isDown` now includes `FORCED_EXIT`, and §18.1's notched-phone figures are superseded by §19.8. The integration also applied the workstreams' cross-checks (hasHp in the pipeline, 脆弱 vs 元素伤害, element healing, 淤困, 塑心 S2, splash as a 中点判定); they are listed in the sections they belong to. Three QA passes over the integrated branch then left residuals, fixed in their sections: 掠海漂移体's model crawls after its drop (§19.1); blocked-first and out-of-range selection are melee-only (§19.2 — superseded by §20.3); a forced-out operator's timer takes the battle-start redeploy effects (§19.3); the gauge intake's 5 % floor (§19.5); the fallback board draws the boss half in the boss prep (§19.7). The user's follow-up then settled two [ASSUMED] choices: the boss field's row-6 blowers do stand under the bench in the normal views (§19.7), and a forced-out operator's redeploy timer does restart in full (§19.3).
### 19.1 Hovering enemies and the enemies' target order (#1, #5) — `sim/units.js`, `targeting.js`, `ai.js`, `Battle.js`, `content/enemies.js`, `bosses.js`, kits, `match/bot.js`, `render/units.js FORMS`
- **#1 cause**: `float()` only cancelled hits from non-`'ranged'` profiles and otherwise left the floater a ground unit, so 迷迭香 (攻击对小范围的地面敌人) shot and splashed it (5.2k damage in a sim run) and terrain, traps and 浮空 treated it as ground. **Official** (ba.float "无法被阻挡或近战攻击"; PRTS 术语释义 / 行动方式): the unit is unblocked and counts as a real air unit (算作空中单位) that keeps ground pathing.
- **Now** `Unit.isFlying` is the one air-unit test: `motion` FLY, or an enemy flag `float` (近地悬浮) or `levitate` (浮空); pathing, displacement tiles and 诱导 read `motion`. canHitFly / groundOnly, splash, chain, the kits' "地面敌人" filters, ground terrain and traps skip air units; 失衡免疫 is the flag `noDisplace`; 静态刚体 (every air unit of the mode except “炎佑”, plus 昆图斯) is data `staticBody` and never displaced; 守墓石像 (statue and flight) and 喷气人 (flight) are 失衡免疫 (§21.9); 浮空 is refused only on data flyers and already-levitated units (PRTS 行动方式), so 阿罗玛 can lift a floater. Blocking follows (§19.2): only blockFly units block air units.
- **Enemy kits**: 掠海漂移体 — 晕眩 / 冻结 / 沉睡 → 爬行模式 for good (0.5 s stun), then it only attacks its blocker; 不会攻击飞行单位. Its model follows (integration QA: melee operators hitting an enemy that still looked airborne): the sim's fx `form` `crawl` switches the view to the skeleton's crawl clips (`*_02`, after its `Change` clip) — `render/units.js FORMS`, `spine.js setForm`, kept on the unit info for a view built later (a view built mid-battle starts crawling: UnitInfo `form`, §21.4). 吉兆飞鳞 — 晕眩 / 沉睡 → 8 s 晕眩模式 (it stays down while still asleep), a freeze grounds it when the freeze ends, its sprint is the final ×3. Of the 32 official floaters only these two hover for good in this mode; 喷气人 hovers for its 7 s 飞行模式 (§21.9) (the bot's `HOVER_KEYS`; `fieldModel` counts them as air routes on ground paths).
- **Skill effects** (per PRTS 备注; the official per-ability targetMotion is not in the data): ground-only — 隐德来希 S2 血镰, 归溟幽灵鲨 拥抱自我, 琳琅诗怀雅 S3, 乌尔比安 S1 [ASSUMED: a 捕网 like 雪雉's]; PRTS 可对空 — 德克萨斯 S2, 忍冬 S2, 焰尾 S2, 缄默德克萨斯 S3, 凛御银灰 S2, 玛恩纳 S3 (his melee skill attacks: `targeting.canHitFly`; §21.8), 锏 S3 归于宁静 (added by §20.3); no 对空 note, [ASSUMED] to hit air — 风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts (“耀阳”, 沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体.
- **#5 official**: PRTS 索敌 (敌方) "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被攻击)→最早出现"; PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", the right-hand boss player mirrored.
- **#5 changes**: `Battle.start` deploys operators row-major (by column since 0.1.3, §23.23), then summons; `aggroSeq` ranks every start-of-battle summon after all operators (`deploySeq` stays the deployment identity). An enemy's own rule `e.profile.canTarget` filters its candidates before the sort (萨卡兹枯朽战车 no longer idles when its preferred target stands on high ground; it, 掠海漂移体 and “萨科塔之眼” ignore flying allies). Special priorities break ties by taunt, then latest deployed (`targeting.js aggroCmp`): 铳 / 昆图斯 highest DEF, 胄 highest / lowest ATK, the 碎铳之簧 bounce, 唱沙 / 沙狱 max HP, “自在” nearest. Kept (PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿"; 索敌的概念): a stealthed ally (隐匿 / 迷彩 / 排气格栅) is attacked only by the enemy it blocks (迷彩 is its own flag `camou`, not 隐匿 for 隐匿 conditions, §20.2). [ASSUMED] "从上到下>从左到右" = row-major (superseded in 0.1.3: a column scan, §23.23); on a shared field (联防, boss) the players' i-th operators deploy together, then the summons (it used to be player after player, which made the leader prefer the later player's pieces).
- Tests: `test/sim/playtest5-targeting.test.js` (18; 13 fail on 731a01c), `test/content/enemies_bosses.test.js` (吉兆飞鳞), `test/match/bot.test.js` (a hovering wave counts as air), `test/render/forms.test.js` (the crawl clips); browser (opt-in) `test/render/forms.browser.test.js` (the real sim feeding the field view).
### 19.2 Blocking by contact radius, and the hand-over (#4) — `sim/constants.js BLOCK_RADIUS`, `Battle._checkBlock` / `blockedTargets`, `ai.js`
- **Cause**: an operator blocked only an enemy standing on its own tile (§5.5's old "|dx|,|dy| ≤ 0.5"). An enemy that passed a full front operator was blocked by the rear one, on the rear tile; when the rear operator died, the front one — which the enemy still overlapped — never took it.
- **Official** (PRTS 游戏数据基础 §阻挡半径; 作战机制 "中点判定…案例:阻挡"): contact = the enemy's position within the blocker's radius of its centre, compared as squared distances — ground 0.70709997 (² 0.49999037), air (起飞 / blockFly vs flyers) 0.8944, devices (阻隔工事 / 障碍物) 0.4472.
- **Now** (§5.5 Blocking): every tick, moving or not, an unblocked blockable enemy is blocked by the nearest operator in contact that has free capacity for its weight — the front operator takes over when the blocker dies, is withdrawn or is stunned (`stun` releases its enemies); an operator whose capacity frees up grabs an overlapping enemy; an enemy that finds no room walks on. A head-on enemy stops at contact, ≈ 0.71 tile from the blocker's centre, outside its tile (the small official deceleration is not modelled). A knocked-out or undeployed operator never blocks.
- **Consequences, both official**: "可以选择且优先选择阻挡单位" (PRTS 选择器: the selector of units whose range is smaller than their block radius) — a **melee** blocker (data `position` MELEE: 要塞 / 领主 / 哨戒铁卫 included; every blocker since §20.3) may always target the enemies it blocks, in range or not, and targets them first (`Battle.blockedTargets` → `ai.js acquireTargets`, `targeting.js sortEnemyTargets`, the DEFAULT skill trigger — PRTS 卫戍协议/帮助 "敌人被近战干员自身阻挡"); 瑕光 S2 also sleeps the enemy she blocks (PRTS 备注). A rear operator whose range ends at the blocker's tile no longer reaches the enemy held in front of it (a regression sweep of 143 blocker × skill pairs: own-tile defenders 0.96–1.05×).
- **Ranged operators on melee tiles** (卫戍协议 "所有行动内远程干员可部署在近战位") — **superseded by §20.3**: the integration QA had made them target by their range alone (PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人"): no blocked-first priority, no out-of-range target, no skill cast for the enemy they held. The user's rule after playtest #6 — "阻挡了就一定要能打到" — makes every blocker hit what it blocks.
- 灰毫 S2 专注轰击 — **superseded by §20.2**: v2.4 read the 下半 重装 special strategy "受到伤害时释放技能" as skill 1 only (BWIKI "重装职业干员 技能1,受到伤害后自动释放"; the data row has `skillIndex 0`, with `-1` as the all-skills sentinel — research 03 C1) and left S2 DEFAULT. The class rows now cover every MANUAL skill of the class, so 灰毫 S2 is TAKE_DAMAGE (the counter-evidence stays recorded in §20.2) — and DEFAULT again since §21.29, a deliberate deviation the owner decided (with 灰毫 S1).
- [ASSUMED] the nearest blocker wins, ties by scan order.
- Tests: `test/sim/playtest5_blocking.test.js` (16, incl. the user's case on act2 m01 with real operators, the ranged blocker and 银灰 as a melee-position ranged attacker), adjusted geometry in `kits_alt_t5` / `engine-requests` / `enemies_bosses`.
| Contact | Official (PRTS) | v2.3 | Now |
|---|---|---|---|
| ground operator | d² < 0.49999037 (r 0.7071) | the enemy's tile = the operator's tile | d² < 0.49999037 |
| air (blockFly / 起飞) vs flyers | r 0.8944 | same tile | d² < 0.79995137 |
| device (阻隔工事 / 障碍物) | r 0.4472 | same tile | d² < 0.2 |
| blocker gone / capacity freed | contact re-checked continuously | only on the enemy's own tile | every tick, nearest free blocker |
### 19.3 Knocked-out operators in 联防 (#2) — `server/match/unite.js`, `sim/Battle.js`, `sim/constants.js FORCED_EXIT`, `render/app.js`, `render/units.js`
- **Cause**: `unite.js uniteBattleOpts` dropped every operator that `unitsEnd` reported `alive: false` — and its summons — from the 联防 input (an old [ASSUMED] rule). Every client and the server build the unite field from that input, so the operator was missing for the authority, the partners, observers and the `SP_COMBAT=server` fallback alike.
- **Official** (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,上一阶段为退场状态的干员强制退场)".
- **Now**: the operator is fielded with `carryState: { down: true }` (§5.1); its summons are fielded as the board has them. `Battle.start` deploys everyone (the initial `deploy` hook fires), then before `battleStart` sets its HP to 0 (the end-of-phase ratio, as `kill()`) and withdraws it with removal reason `FORCED_EXIT` = `'forcedExit'`. `Battle.isDown` accepts that reason, so it is in b.snap `down` and `fieldMeta` with its ring, shows 0 HP on the live card, and redeploys by §5.5 (timer done, own tile free, DP ≥ cost) at full HP. The forced exit is no new knock-out: no `kill` hook, no `death` with reason `'killed'`, no `deaths` count — 深海 / 不屈 revives, 崇高牺牲 layers and selfdead garrisons fired in the own combat. The client's `'die'` with that reason goes straight to the held knocked-down pose (`UnitView.die(true)`: no fall, no burst, no sound).
- The timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`), and the user confirmed it after the v2.4 follow-up ("进联防复活时间确实是重新算" — a request to carry the remainder was withdrawn). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). Also [ASSUMED]: the forced exit happens before `battleStart` (since 0.1.3 the 阿戈尔 devour still counts the operator as standing for its marking — players' reports, owner's decision 2026-10-04); the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. 召唤物仅修改技力: since 0.1.3 `unitsEnd` lists the board's summon pieces too and they carry `{ sp }` (one off the field at the end enters fresh [ASSUMED]); until then summons entered 联防 fresh.
- Tests: `test/match/unitedown.test.js` (both combat modes, both helper halves, the real sim), `test/sim/downelem.test.js` (incl. 征召), `test/render/downring.test.js`, `test/match/combat.test.js`; browser (opt-in) `test/render/unitedown.browser.test.js`.
### 19.4 Huge bosses: hit area, 自缚 (#10) — `tools/build-data.mjs HIT_AREAS`, `sim/body.js`, `content/bosses.js SELF_BOUND`, `render/pick.js`
- **Cause**: every range test took the enemy's position tile, and the data had no body size, so a boss at (3,10) was hit only by ranges covering (3,10).
- **Official** (PRTS 天赋): "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" (管·隐秘核心 also 向右偏移1.0; PRTS盟约记录 gives the season's 阿利斯泰尔 and “萨米的意志” the same area); PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定". The same talent lines list 自缚 (PRTS 异常效果: 无法移动, not recognised as 束缚) and 无法被阻挡. 铳, 卢西恩 and the boss parts are regular units.
- **Now**: enemies.json `hitArea {w, h, dx, dy}` (by prefabKey); `sim/body.js` is the one helper behind every range test on enemies (the engine tile index, `enemiesInKeys`, `unitsInGrid`, `enemiesInRadius`, nearest / farthest priority, kits, tokens, bonds, professions, `support.onKeys`). A huge enemy occupies every tile its rectangle overlaps — 5 × 3, rows 3–5 × cols 8–12 around (3,10): the whole fence-framed pipe block, so an operator on the 1×3 fence tiles (§19.7) hits it. Radius rules measure to the rectangle (0 inside) [ASSUMED: PRTS calls collider tests the most common]; **splash around a struck target is a 中点判定** (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"): `enemiesInRadius(x, y, r, true)` counts every enemy by its position — the engine's profession splash and aftershocks and the kits' target-centred splash (焰影苇草's 灼痕 burst, 新约能天使 火力电台, 仇白 S1) — so a splash on an escort 0.53 tiles from the rectangle but 1.41 from the boss's centre no longer reaches the boss. Its position stays its 判定中心 (projectiles, its own attacks, splash centred on it, distance-scaled numbers). Integration pass: the reaperrange front line, 凛御银灰 S3's lateral line and the 香槟炸弹 tile trigger are body-aware too; movement, pathing, terrain, tile-entry tracking, placement heuristics and range keys taken from blocked enemies stay on the position (a huge enemy never moves and is never blocked).
- **自缚**: the co-op 昆图斯 walked its official route (3,10) → (2,2) off the block and leaked at ≈ 280 game s. `SELF_BOUND` (exactly the 7 hitArea keys) gives them a persistent `noMove` + `unblockable` buff at spawn (the mirrored co-op copy too).
- **Picking** (`pick.js AREA_PICK`): a press on the hit area or inside the drawn body box selects the boss, counted as 1 tile away, so an ally on the pressed tile or a nearer regular enemy wins.
- [ASSUMED] 胄·隐秘核心 keeps the upward offset although its PRTS section omits it: enemy_database gives `enemy_9013_acstmk_2` the prefabKey `enemy_9013_acstmk` — one prefab, one collider — while the 管 copy that PRTS lists with a different offset has a prefab of its own (`enemy_9021_acduml_2`); ask at the next playtest if the hidden round feels off. The rectangle is fixed in map space (not mirrored).
- Tests: `test/sim/playtest5_bossbody.test.js` (12, incl. the centre-point splash), `test/render/pick.test.js`, the Final Assault leak regression in `test/sim/battle.test.js`; browser (opt-in) `test/render/bossbody.browser.test.js`.
| Boss | Hit area (PRTS) | Tiles at its spawn | Talent |
|---|---|---|---|
| 假想敌:胄 (+ 隐秘核心 copy) | 4.95 × 2.95, up 1.0 (copy: [ASSUMED] same) | (3,10) → rows 3–5 × cols 8–12 | 自缚、不可阻挡 |
| 假想敌:管 / 管·隐秘核心 | 4.95 × 2.95, up 1.0 / + right 1.0 | (3,10) / (3,9) → rows 3–5 × cols 8–12 | 自缚,无法被阻挡 |
| 盐风主教昆图斯 | 4.95 × 2.95, up 1.0 | (3,10) → rows 3–5 × cols 8–12 | 自缚,无法被阻挡 (walked and leaked before) |
| 阿利斯泰尔 / “萨米的意志” (卫戍 versions) | 4.95 × 2.95, up 1.0 | (3,10) → rows 3–5 × cols 8–12 | 自缚,无法被阻挡 |
| 假想敌:铳, 卢西恩, the parts | none (point) | its tile | 铳 不可阻挡 only; 卢西恩 blockable |
### 19.5 Element gauges and enemy element damage (#3, enemy side and pipeline) — `sim/damage.js`, `content/enemies.js`, `content/bosses.js`
- **Verdict: official behaviour, not a remake bug.** Every 海嗣 has 损伤抵抗 (`epResistance`) 0 and 元素抗性 (`epDamageResistance`) 0 (in data/enemies.json only 转译基底·α has 损伤抵抗 10); gauges are 1000 (leaders 2000) and never decay; no 海嗣 talent changes element intake; every enemy element attacker deals ATK × its official ratio per hit × the round's ATK multiplier (47 checked). Boss levels override some values, which the runtime applies: 卢西恩 ATK 700 (solo 600) and 不祥幻影 200, × 0.12 per normal hit (skill 0.2) in boss_5 (level_act2autochess_h07_05(_s)); 盐风主教昆图斯 480 (solo 380) in h07_04. A blocked 底海滑动者 at 险境 R5 bursts 神经 on its 25th hit, ≈ 50 game s. "Weak" also has a player-side part — §19.6.
- **Official rules** (PRTS 元素 / 游戏数据基础 / 伤害分类; gamedata_const termDescriptionDict): gauge gain = 损伤值 × max(5 %, 1 − 损伤抵抗 %) (PRTS 游戏数据基础: DMG_e "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值改为目标的损伤抵抗即可"), then the 元素损伤倍率 effects; 元素伤害 = max(A(1 − 元素抗性 %), 5 %A); 元素脆弱 ("受到的元素伤害提升") raises 元素伤害 only; 脆弱 ("受到的物理、法术、真实伤害提升") never touches 元素伤害; bursts are 无来源 ("元素爆发通常造成无来源的伤害"; 无来源 = "无法被追溯伤害来源", yet the kill is traced).
- **Fixed (W3b)**: 元素脆弱 is the mod `elementalTakenMul` (元素伤害 only; it used to share the gauge rate `elemTakenMul`, so 炎佑's 20 % sped up fills instead of raising bursts); 元素抗性 reduces `'elemental'` with the 5 % floor (`expectedFinal` mirrors it); bursts carry DamageInfo `sourceless` — no damage-dealt multiplier or penetration of the filler, and the `hit` / `damaged` / `fatal` hooks see `source: null` plus `credit` (stats, kill and boss-pool share keep the filler; a real 深巡 bursting a 海嗣 dealt 10500 instead of 7000, 精准狙击镜 made a 侵蚀 burst 6500 instead of 5000); target-side reactions with no source condition read `source || credit` (sharing: 圣杯, 盲信之誓, 余音; 抵挡: 星熊 战术装甲, 拉普兰德 日晷). The enemies' 凋亡 虚弱 is 50 % × remaining ÷ 15 s each tick (it ran 1 s ahead); 麻痹免疫 is honoured (假想敌:铳, the 胄 parts); 淤困's host burst spreads 1000 (was 780); 灼藤's first attack covers the 3×3; 节日爵士乐手 channels on one locked target (20 % arts + 10 % burn every 0.5 s, 21 ticks, ends on 沉默, no re-cast mid-channel). Side effect: 隐德来希's 心烛 cancels bursts on a candle ("只受隐德来希攻击的影响").
- **Integration pass**: `damage.js hasHp` (alive, HP > 0; a boss: its pool) is the one predicate — `applyElement` refuses a target with no HP left, so no rider in a killing blow's `damaged` hook can burst a corpse (the content copies in tier5 / tier6 / items / tokens now import it); `'elemental'` damage skips `dmgTakenMul` (脆弱 and the other "受到的伤害±" effects [ASSUMED: they share that multiplier]) — only 元素脆弱 scales it; element healing (`reduceElement`) lowers **every** element type by the amount on its own (PRTS 菲莱 备注: 清除元素损伤 = "一次等同于自身最大元素值的全类型元素损伤治疗"; it healed the amount across all types in total); 淤困's "受到的元素损伤提高至130%" is an `elementHit` multiplier at priority 20 like every operator-side "受到的元素损伤±", before a 损伤屏障 (it was the `elemTakenMul` mod, applied after the barrier).
- **Verified unchanged**: the burst effects on both sides; cooldowns 10 / 10 / 15 s (enemies' 侵蚀 8 s), locking then resetting every gauge; leaders take no 侵蚀 in the Final Assault (PRTS 盟约记录); 炎佑's aura = 20 % 元素脆弱 within 1.5. The gauge's look is the official icon + white bar since §20.8 (it differed in v2.4). [ASSUMED] 爵士乐手's first tick one interval after the cast. The gauge intake's 5 % floor (`elementIntake`, integration QA) is unreachable with the data (损伤抵抗 ≤ 10).
- Tests: `test/sim/playtest5_elements.test.js` (18), `test/content/playtest5_followups.test.js` (pipeline guard, 脆弱, element healing), `combat.test.js`, `test/content/{kits_t5,tokens_devices,playtest4_summons,enemies_bosses}`.
| 海嗣 (user's case) | Official ratio | Per hit / s at base ATK | Hits to a 1000 gauge | Time |
|---|---|---|---|---|
| 底海滑动者 (ATK 280, BAT 2.0) | 15 % ATK 神经 | 42 | 24 | 48 game s |
| 富营养的滑动者 (360) | 15 % 神经 | 54 | 19 | 38 game s |
| 骨海漂流体 (250) | 20 % 侵蚀 | 50 | 20 | 40 game s |
| 掠海漂移体 (500, BAT 4) | 50 % 侵蚀 | 250 | 4 | 16 game s |
| 元核孽生者 (400, season) | 5 %/s 神经 in r 2.5 (season override; PRTS's base page: ATK 500, 10 %) | 20 / s | — | 50 s |
| 深溟巢涌者 (140) | 5 % ATK 神经 per damage output | ≈ 7 / s | — | ≈ 143 s |
### 19.6 Element damage of the player side (#3, operator side) — `sim/content/kits/tier{2,3,5,6}.js`, `tokens.js`, `items/battle.js`
- **Audit**: only these deal 元素损伤 from the player side in 下半 — 盟约·辅助干员 (band 优等生), 菲莱, 烛煌, 妮芙 (T5 / hidden T6), 余, 塑心, 炎佑 (炎 ×6), 灼燃维式重锤 (+ 蒸汽之心); no bond, band, garrison, 机变 card or device deals any. Every number matched its blackboard (character_table at the chess status, battle_equip_table modules); the discrepancies were form, trigger and scope. "N%攻击力的…损伤" = N × ATK, "伤害N%的…" = N × the damage just dealt.
- **The fix that matters most**: 盟约·辅助干员's 迭代元素 used to pick the "first element not bursting" — a burst locks every gauge, so she only ever filled 神经. Now every damage she deals (HP > 0; PRTS corrects the timing to "造成伤害时") attaches 18 % ATK of 神经 → 灼燃 → 凋亡 (`PITHST_ELEMENTS`); the element applied first bursts, so alone she bursts 神经 and in a team completes the 灼燃 / 凋亡 gauges 余 / 塑心 build — in the reviewer's runs the team's first 灼燃 / 凋亡 burst came ≈ 40 % sooner (9.0 s vs 16.0 s; 5.0 s vs 8.2 s). Teams without her are unchanged. [ASSUMED] the three apply together (the reading of the text).
- **Killing blows**: element riders inside `damaged` hooks run before `battle.kill`, while the target is `alive` at 0 HP; a lethal hit used to burst the corpse (a second fatal, 烛煌 熔点引爆's heal and 350 % hit, 妮芙's stack). The riders check HP left and, since the integration, `applyElement` refuses it (§19.5).
- **塑心 S2 (integration)**: PRTS "受影响的干员即将造成伤害时,因该效果造成的凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" — the rider is a late `hit` handler, so the 凋亡 lands (and may burst) before the damage, a killing blow included; a hit dodged or absorbed after that still carried it [ASSUMED]. **纯烬 氤氲** is keyed per source (`agoat2:mist:<id>`): two 纯烬 keep their own stacks, ATK and heal source.
- Tests: `test/content/playtest5_elements.test.js` (9), `test/content/playtest5_followups.test.js` (塑心 S2 order, 氤氲 per source), `kits_t1t2.test.js` (1_15, 哈洛德 with 侵蚀), `kits_t3.test.js` (菲莱).
| Source (normal / elite) | Official | v2.3 | Now |
|---|---|---|---|
| 盟约·辅助干员 迭代元素 | 18 % ATK each of 神经(优先)/ 灼燃 / 凋亡 per damage dealt; RIT-X 21.24 % vs 精英 / 领袖, talent only | per attack hit (dodged too), 神经 only | **fixed**: all three per damage > 0 |
| 菲莱 S2 冥河诅咒 | when attacked (cd 2 s): 110/140 % ATK arts + 25 % ATK 凋亡 to ground enemies in x-4 (3×3) | radius 1.5 | **fixed** (3×3, body-aware, 近地悬浮 = air) |
| 菲莱 PRP-X | "阻挡敌人时,自身造成的元素损伤提升15%" on every fill she deals | S2 blast only | **fixed** (all fills, a hammer too) |
| 受到的元素损伤降低: 菲莱 神河谕使 / 哈洛德 我即军营 / 纯烬 火山灰疗愈 | −10 % (+2 SP on 凋亡 even for 0) / −12/15 % for allies over half / −12/14 % (×talent_scale in S3); before the 损伤屏障 | the 元素脆弱 multiplier (also cut 元素伤害) | **fixed**: `elementHit` at priority 20 |
| 哈洛德 trait / T1 | "元素损伤最严重" and the over-half checks | 侵蚀 ignored | **fixed** (侵蚀 counts) |
| 纯烬 氤氲 | per stack / s: HP 10 % ATK, element 10 % × trait ratio (5/6 % ATK), cached ATK | element 10 % ATK (2×) | **fixed**; per source (integration) |
| 塑心 S2 安魂的弥撒 | self + top-ATK ally: each damage + 15/20 % of her ATK 凋亡, before the damage | after the damage, skipped on a killing blow | **fixed** (integration) |
| 塑心 S1 / T1 / T2 / RIT-X / RIT-Y | 85/95 % ATK 凋亡; 10 % ATK/s; 精神逆构 ×1.2 (elite ×1.33 + 5 % 元素脆弱 in the burst); ×1.18 vs 精英 / 领袖; RIT-Y 音乐家的旅程: field-wide ×1.2 + 150 元素伤害/s during the burst, 10 % 元素脆弱 in range | same | OK |
| 余 T1 / S3 / S1 / T2 / PRP-X | 40 % ATK arts + 12 % ATK 灼燃/s on blocked enemies; 火墙 6/7 %; 30/40 % ATK on the attacker; T2 1.5 % max HP of HP and element per s with ≥ 4 operators on the field (elite ≥ 2; elite ≥ 4 also +14 % arts vs 灼燃 bursts); ×1.15 while blocking | same | OK |
| 烛煌 S1 / S2 / S3 / T1 / PRI-X | 30 % of the damage as 灼燃; +60/70 % ATK 元素伤害 on burst targets; 熔点引爆 350 % + 12 % heal; ×1.1 vs burst targets | same | OK |
| 妮芙 S2 / T1 / PRI-Y | 18/22 % of her arts damage as 凋亡; 失魂 40 % ATK 元素伤害/s (PRI-Y 烦恼问诊匣: every 0.7 s, SP +0.2/s) | same | OK |
| 炎佑 / 灼燃维式重锤 | 20 % ATK 灼燃 per damage, 20 % 元素脆弱 aura r 1.5 / 10 % of the arts damage (蒸汽之心: own hammer ×2) | same | OK (元素脆弱 now raises bursts, §19.5) |
| any rider on a killing blow | a dead unit takes no element | burst the corpse | **fixed** (hasHp + pipeline guard) |
### 19.7 Maps: blower machines, boss-field deployment (#6, #7) — `render/board3d/layout.js`, `render/tiles.js`, `render/app.js`, `server/match/board.js`, `Match.startRound` / `bossGroupOf` / `deployFieldOf`, `ui/gameLogic.js`, `ui/fallbackField.js`
- **#6 cause**: the 3D board builds only the current view's area, and for the normal / 联防 views it stopped at row 12. act2 m01's own blowers (#001 / #002, the partner's #101 / #102) stand on the separator row 13 facing DOWN into rows 12–10 (level_act2autochess_m01 predefines: (13, 5/9/13/17) and (6, 5/9/11/15), all DOWN, none hidden), so they were never built; the Pixi airflow streaks and the sim were right. **Now** the normal / 联防 field rects are rows 6–13 and one device rule serves both boards: every active device standing on a built tile is drawn (3D `stageDevices`, 2D `_stageDevices` minus its faded margin). Normal / prep: (13,5) and (13,9) above the lanes, and the boss field's (6,5) / (6,9) on the wall under the bench; 联防 adds (13,13) and (13,17); 最终攻势 / 隐秘核心: (6,5), (6,9), (6,11), (6,15).
- **#6, the boss field's machines under the bench**: the integration QA hid the row-6 blowers (6,5) / (6,9) in the normal / 联防 views (reading the official shop camera as putting that wall under the shop bar); the user's follow-up, first-hand — "吹风机原版道中也该有" — puts them back: the official normal rounds show them, so every view draws every device on its built tiles again (the rule above, as the workstream had it). Their airflow covers drawn tiles only, so it shows in the Final Assault view. Unchanged (cosmetic, pre-existing): the official normal view also shows the partner's half beside the own field (its row-13 machine (13,13) at the top right); the remake builds the own half only.
- **#7 cause**: every legality check of a boss round's prep read the normal field (board (10–12, 8) = tile_forbidden) — `board.js buildDeployMap`, the client's `canPlace` / highlights, the bot and the invariants — while the pieces stand on the player's half of the boss field, where (3–5, 8) and (3–5, 12) are tile_fence_bound (low ground, buildable ALL, fly-only passable). **Now** `buildDeployMap(stage, { field })` takes `'normal' | 'bossL' | 'bossR'` (board row = boss-field row + `BOARD_ROWS_ABOVE_BOSS` 7; the right-hand player mirrored col c → 20 − c, research 09 §1.2 ConvertChessPositionInfoToBossMap); `Match.bossGroupOf` is the one pairing lookup (`deployFieldOf`, `nextEnemiesFor`, the bot); `Match.startRound` plans the round's enemies before `PlayerState.startRound`, so R14 → R15 re-checks boss-half pieces on the boss field (they used to be withdrawn with 地形变化; rngWaves is used only there, no random stream changes); a quit before the boss fight re-pairs and re-checks at once; the client's `gameLogic.deployFieldOf` feeds `placementContext({ field })`. Operators on the fence tiles hit the huge boss (§19.4) but block no ground enemy there (§21.23); ground enemies cannot walk onto those tiles.
- **Audit** (all 11 stages × normal / 联防 / boss L / boss R, server = client = builder deployTiles = the raw level files, except the 深水区 refusal and the 特制水上平台 exception of §21.3): the only wrong tiles were act2 m01's six boss tiles; on every stage normal-legal ⊆ boss-legal and boss L = boss R, also under every map card, so R13 → R14 and R14 → R15 move nothing. act2 m01 now deploys 13 melee + 3 ranged-only tiles on the normal board and 16 + 3 on each boss half. Devices: 3D = 2D for every stage × view; act1 m05's 特制水上平台 are ×64 (16 per normal half, 32 on the boss field). The DOM fallback board (`ui/fallbackField.js`, no WebGL / `?render=fallback`) draws the player's boss half in the boss prep too — `screens/game.js` now hands it the `bossPrep` camera, which it maps through `gameLogic.fieldTile` on the own board's layout (integration QA: it drew the normal field's walls under legal highlights). Limitations: `Match.unitStats` previews on the normal field (stats only); 射击台 stays ranged-only [ASSUMED].
- Tests: `test/match/playtest5-deploy.test.js` (incl. boss → boss R14 → R15 and the fallback board), `test/render/playtest5-maps.test.js` (every device on a built tile in every view, the row-6 machines under the bench included), `test/ui/playtest2.test.js`; browser (opt-in) `test/render/board3d.browser.test.js` (area key '6,13,0,10').
### 19.8 Settings gear; the shop bar over the bench on phones (#8, #9) — `ui/gameComponents.js`, `render/projection.js`, `ui/fieldHost.js`, `render/app.js`, `css/devices.css`
- **#8**: the ⚙ was a hand-written SVG path with uneven teeth on a rim that was not round (the extracted autochess UI has no gear sprite; 交流 is the official `emoji_btn`, 📖 / ⛶ were clean). `gearPath()` now generates a regular 8-tooth gear (tip r 10, root r 7.4, hole r 3.3); on phones the corner glyphs are `max(.28rem, 17px)`, half their 34 px buttons (were 11 px).
- **#9, the user's viewport**: the screenshot is 2772×1272 px with the page right of a 141 px cutout band; the page's own markers give DPR ≈ 3.48, so **756×366 CSS px (≈ 2.07:1)** — the mock rendered there puts the exit and 准备就绪 buttons on the screenshot's pixels within ±1 px.
- **Cause**: the official shop camera (`left_shop_camera_param`, boss `*_boss_shop_*`) fits the prep area exactly at 16:9 (bench near edge 811 px vs the shop bar at 813 px at 1920×1080), while the remake's HUD is in rem with a 40 px floor (css/theme.css), so below 432 CSS px of height it is relatively taller: v2.3 left the worst bench pad 54 % free at 756×366 (Final Assault prep 49 %), 71 % at 844×390, 49 % at 800×360.
- **Now**: the view receives the HUD bands, `hudBands(kind, size)` (top = the HUD layer's safe-area top + 2.16rem, the bond strip; bottom = 2.64rem + 3 px, the shop bar; the rem values copy the CSS, which a test checks), and `presetCamera('prep' | 'bossPrep', …, { hud })` runs `clearHud` on the band from the bench's near edge to the back row: the official camera when it fits (0.5 px tolerance), else the smallest pan, else a zoom-out about the centre — a 2D pan / zoom of the image, so picking, the three.js camera and flights stay consistent. Every desktop prep camera is unchanged; the bar stays on the bottom edge (§18.1); the collapsed shop kept the camera — **superseded by §21.27** (public issue #5): folding the shop moves the own prep board to the official shop-collapsed camera (`left_prepare_camera_param`, `presetCamera` `shop: false`) clear of the folded shop's band (`hudBands(kind, size, { shop: false })`: the tab and the corner buttons' hit areas), and unfolding back to the shop camera. Accepted: an armed shop card rises 4 px above the bar on a phone and covers ≈ 3 px of the pads' near corners (≈ 11 px under the official camera at 1920×1080). [ASSUMED] the bond strip's bottom (2.16rem, measured 2.14–2.15rem); the zoom anchor.
- Tests: `test/render/projection.test.js` (HUD clearance suite), `test/ui/playtest5-ui.test.js`; browser (opt-in, `SP_E2E=1`) `test/ui/playtest5-ui.e2e.test.js` (per-tile coverage at 16 viewports, the armed card, the gear).
| Viewport (CSS px) | Worst bench pad free, v2.3 → now (prep / Final Assault prep) | Camera now | Tile px |
|---|---|---|---|
| **756×366 (the user's Android)** | 54 → 100 % / 49 → 100 % | ×0.844 | 41.5 → 35 |
| 798×366 + 41 px inset (same phone over the cutout) | 53 → 100 % / 49 → 100 % | ×0.844 | 41.5 → 35 |
| 844×390 (19.5:9, with or without notch) | 71 → 100 % / 66 → 100 % | ×0.905 | 44.2 → 40 |
| 800×360 (20:9) | 49 → 100 % / 45 → 100 % | ×0.828 | 40.8 → 33.8 |
| 915×412 / 914×411 / 960×411 | 83 → 100 % / 83 → 100 % | ×0.95 | 46.7 → 44.5 |
| 1920×1080, 1680×1050, 1560×1040, 2560×1080 | 100 % | official | unchanged |
| 1280×720 | 100 % / 96 → 100 % | official (Final Assault prep: pan up ≤ 2.8 px) | unchanged |
### 19.9 至简's price (#11) — `sim/content/garrisons/meta.js SERVER_CHESS_PRICE`, `server/match/effectsMeta.js dispatch`
- **What the user saw**: 至简 (Ⅲ) cost 2 资金; the user remembered 1. His 特质 garrison_13 reads 购买价格为1, but `bb.price` is 2, and the handler set the price to `bb.price` ("blackboards are authoritative").
- **Official**: two chess carry this 特质 (`effectType` SERVER_CHESS_PRICE) and both texts say 购买价格为1 — 至简 (Ⅲ, tier price 3, `price` 2) and 红豆 (Ⅰ, tier price 2, `price` 1, hidden in 下半). Only "`bb.price` is the discount off the tier price" gives the text's 1 for both (3 − 2, 2 − 1).
- **Change**: the handler lowers the price by `bb.price` (`ctx.modifyPrice(-p)`), and `EffectDispatcher.dispatch` runs the priced chess's own 特质 first on `onPrice` (before globals / band / bonds), so 远见's discount and strategy caps act on the lowered price: 至简 stayed at 1 with 远见 150 (a floor of 1 then assumed for 远见; since 0.1.3 the −1 has no floor but 0, so 至简 drops to 0 — owner's decision 2026-10-04). The shop card keeps `basePrice` 3, so the price shows in the discount tone. docs/META.md §2.2 / §2.3 say the same.
- Tests: `test/content/garrisons_meta.test.js` (every SERVER_CHESS_PRICE owner: tier price − `bb.price` = the text's price = `priceOf`; buying 至简 costs 1; a global modifier already sees 1; 远见 150 takes 至简 to 0 since 0.1.3).
---
+292
View File
@@ -0,0 +1,292 @@
# DESIGN §20 — User playtest #6 (v2.5) and its follow-ups, up to 0.1.0
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 20. User playtest #6 (v2.5): summons, skill triggers, blocking, push force, enemies, boss pool, bounties, 机变 cards, element gauge; leader HP, elite to the board, official limits
Nineteen reports after playing v2.4 (#1–#3 from a friend of the user). Where each is handled: #1 凯瑟琳's device could not be placed by hand → §20.1; #2 赫默's drone could not be placed by hand → §20.1; #3 忍冬's 叙拉古 stealth never ended → §20.2; #4 the bounty enemies kept coming instead of stopping after two battles → §20.6; #5 the Final Assault leader at 0 HP kept fighting until the team LP ran out, and the result still said 胜利 → §20.5; #6 the 道具补给 cards showed no description → §20.7; #7 in 联防 the leaker's count should fall live as teammates kill (it seemed capped at 10) → §20.6; #8 新约能天使 S2's shield on teammates seemed not to work → §20.2; #9 威龙 was drawn far too large → §20.4; #10 on the phone the shop's confirm strip did not buy (only the middle of the card worked) → §20.7; #11 the element gauge seemed to fill at the wrong rate → §20.8; #12 萨卡兹枯朽战车 hit too softly → §20.4; #13 “萨科塔之翼”'s 战栗 (small random flight) was missing → §20.4; #14 enemy weight vs push force (野鬃 pushed too many enemies) → §20.3; #15 skills whose range is wider than the attack range did not fire when an enemy entered it → §20.2; #16 古米's ally heal fired only after she engaged an enemy → §20.2; #17 the front operator that took over an enemy held between two operators never attacked it → §20.3; #18 炎佑 was healed by medics → §20.3; #19 the 3-choose-1 promotion reward often offered two identical operators → §20.6.
Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB → WA → WF → WE); the reviews' last rounds and the cross-workstream notes were applied on top: a stocked skill summon deploys as soon as its owner is back (§20.1), the AUTO DP skills 焰尾 S1 / 凛御银灰 S1 fire on full SP (§20.2), 锏 S3's slashes give 无敌 and 晕眩 / 冻结免疫 (§20.3), the `AUTO_OP_COOLDOWN` comment and the SKILL_RANGE count (13 skills / 26 records) were corrected. The user then answered the open questions (2026-10-01); the answers are **settled decisions**, recorded in their sections and in §20.9, each with the one-line change that flips it: the placed skill summons deploy once at the battle start and 凯瑟琳's device stays a hand card (§20.1), every blocker hits what it blocks (§20.3), 萨卡兹枯朽战车 follows the re-research — ranged enemies reach an operator by its 0.25 collider (§20.4) — and multi-round bounties last two battles (§20.6). Normative lines rewritten with it: §2 (`fear.js`), §3 (the enemies' reach), §5.2 (ASPD floor 20), §5.3 (`camou`, `fear`, `noHeal`, `isolated`), §5.4 (push / pull / ally-selection helpers), §5.5 (initial deployment, blocking, operator attacks, healers, enemy AI, end), §5.6 (skill triggers, the operation cooldown), §6.1 / §6.2 (the Final Assault verdict, summon stacks, summons in the hand, the promotion reward), §7 (bounty draft, 教鞭), §8.2 / §8.3 (`uniteLeft`, `descRaw`, `counterText`, the `elem` fill, the stealth bit), §9 (gauge row, model scale), §10 (机变 card, the card tap), §14 (`left`, the pool floor, the verdict, `uniteLeft`); superseded or revised: §17.5 (the 联防 count), §18.3 (the gauge look), §18.6 #11 (skill summons), §19.2 (the melee-only blocked-first rule; 灰毫 S2), §19.5 (the gauge look).
**Follow-up (`playtest6b`, 2026-10-02).** Four more workstreams were merged into `playtest6b` in this order: the elite-to-board rule (§20.11), the 假想敌:胄 kit audit (§20.13), the Final Assault leader HP report (§20.10) and the official limits read from the client binary (§20.12). Reports: co-op 终极 / 绝境 leaders died within seconds although the official leader never dies that fast with such layers (§20.10); "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (§20.11, user-confirmed). The boss-HP and the limits workstreams both capped bond layers at 999 — one implementation remains (`shared/constants.js BOND_LAYER_CAP` / `layerGainRoom`, used by every writer), and the boss-HP workstream's reading of 限伤 as an engine fixed-point overflow "not modelled" is superseded by the binary evidence (`MAX_BATTLE_DAMAGE` 300000, the hit is cancelled). Normative lines rewritten with it: §2 (`promote.js`), §5.1 (`layerGains` capped), §5.2 (直接乘算 in `Σpct`), §5.3 (same-named debuffs), §5.4 (`addLayers`, `loseHp`), §5.5 (限伤 between the multipliers and the shields), §6.2 (where a merge's elite goes), §6.3 (the layer cap), §7 (leader parts, drones), §8.2 (`hitCap`), §9 (the promotion cue), §14 (the layer bound of client results); §20.9 records the decisions. Two QA verifiers then checked the integrated build; their residual issues are handled in §20.14. A later report after v2.5.1 — the bond strip should follow the watched teammate in every watch situation — → §20.15. The player reports after the public 0.1.0 release → §21 (v0.1.1).
### 20.1 Summons placed by hand (#1, #2) — `tools/build-data.mjs`, `match/gamedata.js placeableTokens`, `PlayerState`, `match/bot.js`, `sim/Battle.js`, `content/tokens.js`, kits tier2/3/4, `ui/detailPanel.js`, `shared/constants.js SKILL_SUMMON_START_DEPLOY`
- **Seen** (the friend): 凯瑟琳's device and 赫默's drone could not be placed by hand. **Cause**: v2.3 (§18.6 #11) limited hand pieces to talent summons after the user's playtest #4 report on the drone, which dropped 医疗探机 and 诅咒娃娃; 爬行号·防护单元 has no shop-state entry, so 凯瑟琳's kit placed it automatically.
- **Official** (PRTS 卫戍协议/帮助): §战斗部署 "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区" — deploy-limit copies ("非初始持有量"), one stack per operator, no deploy slot (token pages: 部署占用数 0); withdrawing the operator removes its summons, moving it "其所属召唤物全部退场并重置至手牌区". §手牌区: summons left in temp "会于下一回合返还". §作战阶段: placed summons deploy after the operators; "所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次(例如…赫默的医疗探机)"; one that leaves "将在满足条件后立即原地再部署1个". Token 备注: 诅咒娃娃 is withdrawn when 巫恋 leaves; a deployed 医疗探机 stays when 赫默 leaves. `shopStateTokenDict` (55 entries): 医疗探机 / 诅咒娃娃 / 狼群 / 海嗣 / 流形 DEFAULT, 投递坐标 and the battle-only tokens HIDDEN, and `token_10041_cathy_catsld` the only pool summon with no entry.
- **Now (data / prep)**: tokens.json `placeable` = not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 爬行号·防护单元, 海嗣, 狼群, 流形. `placeableTokens(chessId, loadout)` follows the equipped skill (赫默 S1: no card) and gives the deploy limit (凯瑟琳 2). A moved or swapped operator sends its placed summons back to their stack (`PlayerState._liftTokensOf`; a summon dragged onto an operator stays where it was dropped when it stays legal — a range-bound summon inside its owner's range from the owner's new tile, §21.3); `startRound` tops the board owners' stacks back up, so a stack removed from temp returns.
- **Now (battle)**: talent pieces (狼群, 海嗣, 流形, devices) deploy with the board, after the operators. A skill summon's piece also deploys once with the board, free (settled below), then is docked on its tile (`dockSkillSummons`, the tile stays reserved): `releaseSkillSummon` adds one to the owner's stock per cast (≤ 1) and the piece takes its tile once its 5 s redeploy time has passed, free, never while its owner is off the field — a stocked one deploys as soon as the owner is back (review round 3: a `deploy` hook on the owner; it used to wait for the next cast). The drone lasts 10 s and stays when 赫默 leaves; the doll lasts 15 s and leaves with 巫恋. Not placed: 医疗探机 / 诅咒娃娃 / devices / 海嗣 never appear; 狼群 / 流形 still come as 援军 on a tactical point (inside the tactician's range, never water, §21.3). The 爬行号·防护单元 kit shields the operator on the tile it faces (range 1-1): the full shield on each new target, invulnerable, key `cathy:shield`. Bots place the cards (a device next to the best operator no device faces yet, facing it); every bot layout first lifts the placed summons and places them again, so a device never stays beside a tile its operator left (residual QA: 3.5–8.6 % of the bots' devices faced nothing). The summon card says how it takes the field (`summonDeployHint`, reading the switch) and shows the token's talent / skill with the owner's variant (golden 巫恋's doll −30 %, golden 赫默's drone ATK 114).
- **Settled by the user**: (1) the placed skill summon **deploys once at the battle start**, the PRTS rule — `SKILL_SUMMON_START_DEPLOY = true` drives the sim and the card hint (PLAYING §4 and SIM.md follow; `test/ui/playtest6_summons.test.js` checks). The playtest #4 report ("是赫默开技能释放一次,不是开局直接就部署了") had been made against v2.2, which already deployed the drone the PRTS way. Flip: `SKILL_SUMMON_START_DEPLOY = false` (only with the skill). (2) 凯瑟琳's device **stays a manually placed hand card** (no shop-state entry read as shown). Flip: dropping `token_10041_cathy_catsld` from `placeable` in `tools/build-data.mjs` only hides the card — the v2.4 automatic placement in her kit would have to come back with it.
- [ASSUMED]: skill-summon redeploys cost no DP; no deploy while the owner is off the field; a swap lifts both operators' summons; the device is invulnerable.
- Tests: `test/match/playtest6_summons.test.js`, `test/content/playtest6_summons.test.js` (start deploy, each cast, the owner's redeploy), `test/ui/playtest6_summons.test.js`, `test/content/{playtest4_summons,tokens_devices,kits_t3,summon_loadout_conflicts}`, `test/sim/{loadout-summons,docs-examples}`; opt-in `test/ui/playtest6-summons.e2e.test.js` (`SP_E2E=1`).
### 20.2 Skill triggers and buffs (#15, #16, #8, #3) — `tools/build-data.mjs resolveTrigger`, `sim/skills.js`, `sim/Battle.js`, `sim/targeting.js`, `sim/snapshot.js`, kits
- **#15 seen**: a skill whose range is wider than the attack range did not fire when an enemy entered it (德克萨斯 剑雨 13 tiles vs 2; 凛御银灰 S2; 锏 S3). **Cause**: every MANUAL skill used the basic rule, and research 03 C1 read the class rows (`skillIndex 0`) as "skill 1 only" (BWIKI).
- **Official** (PRTS 卫戍协议/帮助 §作战阶段 技能操作): basic rule "技能就绪,且即将进行普通攻击/治疗"; a skill with its own 技能范围 (not an attack-range increase) "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能"; class strategies — 重装 "不受技能范围影响,受到伤害时释放技能" (下半, 2026/3/14), 战术家 / 执旗手 / 吟游者 when ready, 解放者 / 阵法术师 an enemy in the initial range, 澄闪 / 纯烬 / 荒芜 S3 "全场存在可选目标时"; "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却".
- **Now** (§5.6): the class rows cover **every MANUAL skill** of the class and never an AUTO one — an inference (PRTS names whole classes; under the old reading the 阵法术师 row could never cover 薄绿's default S2, a phalanx never attacks with its skill off); counter-evidence kept: BWIKI's "技能1" and the data's `skillIndex 0` / `-1` sentinel. `SKILL_RANGE`: 13 skills (26 records with the elites; 14 / 28 in the data since 余 S2's deviation, §22.10), any living enemy on the skill range, checked every tick (attack-range changes keep the basic rule). `GDGLOW_SKILL_2`: a valid target anywhere. The 3 s operation cooldown after each automatic cast and after the battle-start deployment (`flags.startOpCooldown`; the test harness sets it to 0); kit-driven casts of MANUAL skills (波登可 S1, 雪猎 bullets, the 流形 copy) check `skill.opCooling`; AUTO skills are exempt; a pending "next attack" never takes a second charge. Players notice: every 重装 manual skill (号角 S3, 瑕光 S3, 灰毫 S2 — reversing §19.2 —, 古米 S2 …) now waits for the operator to be hit — **partly reverted by §21.29**: by the owner's deliberate deviation (2026-10-03, community feedback) 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 cast with an enemy in range (DEFAULT) again.
- **Integration follow-up (WE review round 2)**: the AUTO DP skills 焰尾 S1 迅敏直觉 and 凛御银灰 S1 周旋的谋略 ("立即获得…部署费用") are `SP_FULL` like 伺夜 S1 and 德克萨斯's 冲锋号令·γ型 — an AUTO skill takes no 技能策略 (they used to wait 12–16 s for the next attack).
- **#16**: 古米 备用军粮 (AUTO) had picked up the 重装 row. PRTS 备注 "存在生命值不满的可治疗角色时可触发;…切换至治疗模式…直至古米完成一次普通攻击的治疗": it fires on an injured ally of its x-4 area and her next attack heals the most injured ally there (`heal_scale` × ATK); S2 食粮烹制 is MANUAL, so the 重装 rule. Audit: 塞雷娅 S1 急救 — PRTS 备注 "仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击", 修正 "血量小于等于一半" — is checked at her attack (DEFAULT + `allies` + `hpAtMost` 0.5) and that attack becomes the heal; a cast whose ally recovered first is withdrawn with its charge.
- **#8**: 新约能天使 S2 gave the shield to the fastest attacker; official (PRTS 备注) "攻击范围内仇恨值最高的我方干员" — now `aggroCmp` (taunt, then the latest deployed), through `battle.alliesInGrid` (孤立 units skipped). The amounts were already right: 180 % (精锐 200 %) of each holder's own max HP, decaying by initial / 30 per s.
- **#3 (friend)**: the bond 隐匿 already ended on time (`bondeffect_siracusa` 32 + 0.4 × layers s). 忍冬 S3 "进入迷彩状态,直至下一次开启技能" was the generic `stealth` under the key `stealth`, which made 伪装服's timed 隐匿 permanent (or stripped it) and counted as 隐匿 for 叙拉古 / 家族徽章. Now a separate `camou` flag and status (`vulpis:camou`; 寒芒克洛丝 S1 too) that works like 隐匿 for targeting and on screen — only the enemy it blocks attacks it (PRTS 异常效果 "※迷彩与'阻挡时解除'没有直接关系"; §19.1 kept). What stays see-through after a kill is that official 迷彩, until her next cast. Declined (optional review UX item): a distinct 迷彩 look — the base game draws both see-through, and the detail card has no status row.
- [ASSUMED]: enemy skills and splash selectors skip 迷彩 like target selection; every initial deployment gets the full 3 s (the remake deploys everyone at 0 s); a token skill without a `skillType` counts as MANUAL.
- Tests: `test/sim/playtest6-skills.test.js`, kit tests (incl. `kits_alt_t4` / `kits_alt_t5` for the AUTO DP skills), `test/data.test.js`, `test/sim/docs-examples.test.js`.
| Skill (examples) | type | before → after | why |
|---|---|---|---|
| 德克萨斯 S2 剑雨, 凛御银灰 S2, 锏 S2 / S3, 异客 S3, 焰尾 S2, 忍冬 S2, 诗怀雅 S1, 山 S3, 小满 S2, 归溟幽灵鲨 S1, 崖心 S2, 见行者 S2 | MANUAL | DEFAULT (kit CUSTOM_RANGE) → SKILL_RANGE | own 技能范围 |
| 深巡 S2, 古米 S2, 折桠 S2, 瑕光 S2 / S3, 信仰搅拌机 S2 / S3, 泥岩 S3, 塞雷娅 S3, 余 S2, 角峰 / 雷蛇 / 泡泡 / 菲莱 / 蛇屠箱 S2, 灰毫 S2, 星熊 S3, 号角 S2 / S3 | MANUAL | DEFAULT → TAKE_DAMAGE (深巡 / 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2 back to DEFAULT, §21.29; 余 S2 now SKILL_RANGE on its x-1, §22.10) | 重装 row (§21.29: a deliberate deviation for six skills) |
| 薄绿 S2, 蜜蜡 S2, 卡涅利安 S2 / S3, 玛恩纳 S2 | MANUAL | DEFAULT → SEARCH | 阵法术师 / 解放者 row |
| 伺夜 S3, 魔王 S2 / S3, 浊心斯卡蒂 S3 | MANUAL | DEFAULT → SP_FULL | 战术家 / 吟游者 row |
| 古米 S1, 瑕光 S1, 塞雷娅 S1 | AUTO | TAKE_DAMAGE → own rule (古米: injured ally; 塞雷娅: at her attack, ally ≤ 50 %) | AUTO: no 技能策略 |
| 焰尾 S1, 凛御银灰 S1 | AUTO | DEFAULT → SP_FULL | AUTO DP skill (integration) |
| 银灰 S3, 深靛 S1, 野鬃 S2, 蒂比 S1, 断崖 S2, 忍冬 S3 … | MANUAL | DEFAULT (unchanged) | attack-range change ("非攻击距离增加") |
### 20.3 Combat rules: blocking, ASPD floor, push / pull force vs weight, 炎佑 / 孤立 (#17, #14, #18) — `targeting.js sortEnemyTargets`, `Battle.blockedTargets / push / pull / pullToFront / allySelectable / alliesFor`, `constants.js ASPD_MIN / PUSH_TILES(_EFFECT)`, kits, `tools/build-data.mjs TOKEN_ABNORMAL`
- **#17 seen**: an enemy held between two operators; after the rear one died the front one took it over (§19.2), but only the enemy attacked. **Workstream finding**: every melee hand-over hit the enemy (3360 immediate hand-overs, 215 late ones — the enemy 0.52–0.69 tile behind the front operator's centre); the cases that did not were a ranged operator on a melee tile with the enemy behind it out of range, the playtest #5 QA rule (§19.2, PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员…甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人") — the likeliest reading of the report.
- **Settled by the user** — "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit it. **Now** every blocker — melee units, summons and a ranged operator on a melee tile alike — may always target the enemies it blocks, in range or not, whatever its facing, and targets them first (`Battle.blockedTargets` without the melee gate; `sortEnemyTargets` blocked-first for every attacker; the skills' DEFAULT trigger follows); `targeting.js meleeUnit` is gone. 深靛's kit (`canAttack`, the bound-target retarget) looked at her range only and now includes her blocked enemies; so do the skills that pick their own targets from the range (residual QA): 迷迭香 S3 (blocked enemies only — her own included), 荒芜拉普兰德 S2's drone locks, 佩佩 S2's random pick, 灵知 S3's unfrozen-first order and 远牙 S3's own-range filter. Two skill texts still win over the rule: 普罗旺斯 S2 "普通攻击不再以生命值高于80%的敌人作为目标" and 寒檀 S2's icicles on random tiles of her range. Healers (they keep healing while they block, §21.17) and 停止攻击 skills (泡泡 S2, 蛇屠箱 S2, 古米 S2, 凯瑟琳 S2, 菲莱 S2, 宴 S1, 铃兰 S3, 信仰搅拌机 S3) still never hit. Flip: gate `Battle.blockedTargets` and `sortEnemyTargets`' blocked-first on a melee position again (the v2.4 `meleeUnit`).
- **ASPD floor**: 沼泽 −50 + 寒霜 −50 took ASPD to 10 (塞雷娅 swung every 12 s); officially "ATTACK_SPEED 默认下限 20" (PRTS 数值范围; 游戏数据基础 "实际被限制了下限为20") — `ASPD_MIN` 20, also in the bot's DPS estimate.
- **#14 seen**: 野鬃 S2 pushed too many enemies. **Cause**: every push / pull moved 0.5 + 0.5·力度 tiles, cut 25 % per missing weight level. **Official** (PRTS 游戏数据基础 §重量公式, 推与拉): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3) − the current 重量等级 (失重 counts). Push, 弹道 column: ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; 特效 column (见行者 S1 / S2, named 特效类): 0.085 / 0.374 / 1.562 / 1.987 / 2.773 / 3.331. Pushes follow the client buff template (§21.13): `knockback[dir]` = directional (推击手, 野鬃 S2), turning radial at −2 when the target is > 45° off or < 0.25 tile away; `knockback[relative]` = radial (琳琅诗怀雅 S3, 山 S3, 莫斯提马 S3); 圣聆初雪 S1 朝部署方向 = KnockBackWithCharacterDirection (no correction); 见行者 S2 waives the angle only. Pull: ≥ 0 all the way (拉力起点 0.5 tile ahead, 急停 0.6708), −1 → 35 %, −2 → 0.03, ≤ −3 → 0; 薄绿 S2's 拖拽 is a push towards her. **Now**: `Battle.push / pull / pullToFront / forceLevel / pushDistance`, `displace` the raw mover; every source migrated. 野鬃 S2 on weight 0 / 1 / 2 / 3 / ≥ 4: 2.14 / 1.7 / 0.44 / 0.12 / 0 tiles (light enemies now move further — official). Enemy weights equal enemy_database for all 249 enemies.
- **锏 S3** (PRTS 备注 "※可对空…※多段斩击期间,自身获得无敌,晕眩免疫,冻结免疫"): its slashes reach air units (※可对空, added to §19.1's list) — the mode's air units are 静态刚体: hit, never moved (a non-static flyer would be pulled, §21.9) —, and during the slashes she is invulnerable (buff `blkkgt:slashes`) and refuses stun / freeze (integration follow-up of the WF note).
- **#18**: PRTS “炎佑” 天赋 "{{特殊机制|我方单位}},{{异常效果|孤立}}"; 孤立 = ALLY_TARGET_FREE — PRTS 选择器 可选判定: a friendly selector never picks it, and selectors decide heal, buff and talent targets. 禁疗 (“小自在”, “耀阳”, 斯卡蒂的海嗣, 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态)) blocks heals only; a 生命回复速度 attribute still applies (PRTS 安洁莉娜 备注). **Now**: tokens.json `abnormal` (`TOKEN_ABNORMAL`) gives `noHeal`, 孤立 also `isolated`; `Battle.allySelectable / alliesFor` back every ally selection (alliesInGrid(Of), auras, 全场 talents, zone / grid buffs, heal picks — a heal pick skips 禁疗 units instead of wasting the heal); enemies still target 炎佑; a summon aimed at injured allies (`findSummonTile 'ally'`) skips them too.
- [ASSUMED]: the 失衡 slide is instantaneous; a 静态刚体's 0.1 s 失衡硬直 is not modelled (§21.9); unclassified pushes use the 弹道 column; a puller never moves the enemy it blocks; 薄绿's inward push stops at the 急停 radius; 溯光星源's pull stops 0.3 tile from the target; 全场 talents select through a selector (skip 孤立); no unit-specific ASPD floor.
- Tests: `test/sim/playtest6-combat.test.js` (the user's layout on act2 m01 × 4 facings; the late hand-over and a plain block for every blocking chess × 4 facings, ranged blockers and out-of-range enemies included; the skills that pick their own targets, 普罗旺斯 S2 the exception; ASPD; push / pull tables; 孤立 / 禁疗), `test/sim/playtest5_blocking.test.js` (the ranged blocker hits and casts for the enemy behind it), `kits_t6` (锏 S3), expectations moved in core, engine-requests, facing and the kit suites.
| Displacement source | Official 力度 / kind | v2.4.1 | Now |
|---|---|---|---|
| 野鬃 S2 | 中力 directional (knockback[dir], §21.13); > 45° off / < 0.25 tile → radial −2 | 1.0 tile, −25 % per weight above 2 | 弹道 along her facing: w0 2.14, w1 1.7, w2 0.44, w3 0.12, ≥ 4 0 |
| 山 S3 (2nd hit) | 中力 radial (knockback[relative]) | 1.0 tile, −25 % per weight above 2 | 弹道: w0 2.14, w1 1.7, w2 0.44, w3 0.12, ≥ 4 0 |
| 莫斯提马 S3 | 小力 radial | 0.5 tile | w0 1.7, w1 0.44, w2 0.12, ≥ 3 0 |
| 琳琅诗怀雅 S3 | radial (knockback[relative], PRTS 备注), ground only (§21.13) | 0.5 tile along the facing, flyers too | push table, radial from her centre |
| 圣聆初雪 S1 | 中力 directional, fixed (KnockBackWithCharacterDirection), ground only | 1.0 tile along the facing, flyers too | push table along the facing |
| 见行者 S1 / S2 (hidden) | 中力 推击手, 特效类 | 1.0 / 1.5 tiles | 特效 column (w0 1.987 … w3 0.085) |
| 崖心 S1 / S2, 歌蕾蒂娅 S1–S3, 百炼嘉维尔 S2, 乌尔比安 S1 | pulls 至面前 | ≤ 1 tile or all the way, scaled | pull table (w ≤ 1 all the way, w2 35 %, w3 0.03, ≥ 4 0) |
| 锏 S3 | p_force 1 / e_force 2, 可对空 | ≤ 1 / 1.5 tiles, ground only | pull table, 急停 0.6708; air units hit, a 静态刚体 not moved (§21.9) |
| Air units of the mode | 静态刚体 (PRTS 特殊机制) | moved like ground ones | 0 tiles from every source, still hit (§21.9) |
| 薄绿 S2 | 小力 拖拽 = push towards her | pull ≤ 0.5 tile | push table towards her |
| ASPD floor | 20 | 10 | 20 |
### 20.4 Enemies: 萨卡兹枯朽战车, the “萨科塔” 恐惧, 威龙's size (#12, #13, #9) — `sim/fear.js`, `ai.js`, `grid.js straightClear`, `Battle.applyStatus`, `content/enemies.js` (kitTank, pollution), `constants.js ALLY_COLLIDER_RADIUS`, `tools/build-data.mjs MODEL_SCALES`, `render/units.js`
- **#13 seen**: “萨科塔之翼” below half HP should fly about at random in a small area (the user wrote 战栗) but hung still. **Cause**: every feared enemy was frozen. **Official**: the bird fears itself — PRTS "生命值首次低于50%时,对自身施加持续5s的恐惧,在5s内移动速度最终提升至150%" (之眼 / 昂首 the same); 恐惧 = "无法被阻挡并四散逃跑" (ba.fear), 战栗 = "被阻挡后无法进行普通攻击" (ba.tremble), so the behaviour described is 恐惧 — report and data agree, only the name differs. PRTS 诱发移动 §恐惧: 恐惧可达地块 = tile centres within 10 of the hit point, in the ±45° fan away from the source, passable, able to reach the goal, not the objective; next stop a random reachable tile within 5 path steps (offset in a 0.5 square), else its own tile; no source / itself as source ⇒ no reachable tile; 恐惧 outranks 诱导.
- **Now**: `fear.js`; the birds flutter within ±0.25 of their tile for 5 s at ×1.5, then fly on. Operator fears (叙拉古 6, 妮芙 S2, 荒芜拉普兰德 S3) make enemies flee into the fan instead of freezing (a visible change). The 恐惧 and 诱导 walks re-plan from where the enemy stands after a push or an obstacle change (`grid.version`; PRTS "恐惧效果结束时立刻清除【恐惧移动】"); a checkpoint on an obstacle tile counts as unreachable; an off-centre ground start steps back to its tile centre when the straight line would clip a wall (`grid.js straightClear`, shared). They used to walk through walls after a push (4–7 % of random pushes on m01–m04).
- **#12 seen**: 萨卡兹枯朽战车's damage felt low. **Already right**: stats (lv0 HP 12000 / 15000, ATK 750 / 850, DEF 750, RES 10, BAT 4.5, range 2.2, weight 4; no season override, no wave `overwrittenData`), the round multipliers, the target filter (只攻击位于低地的我方单位,且不会攻击飞行单位) and the 200 % blocked melee attack. **Fixed (WG)**: 秽蚀轰击 timing (initSp 2 = maxSp 2 ⇒ attacks 1, 4, 7 …, not 3, 6 …); the 【污染秽蚀】 radius 1.7 (was 1 [ASSUMED]); its damage "每秒受到50/25点真实普通伤害…同名效果不叠加" — true damage through `dealDamage`, one tick per second per unit (also 萨卡兹枯朽战士's death zone, r 2, 8 s; zones are now absorbed by shields, scaled by damage-taken modifiers and fire TAKE_DAMAGE skills). The skill hit is 100 % (PRTS skill text), also against a blocker [ASSUMED that it fires there]. Measured: roughly unchanged overall (first 20 s +4…+42 %, a long blocked fight −7…−9 %).
- **#12 re-researched (the user's follow-up: "远程攻击射程挺远、伤害很高")**: enemy_database (`enemy_1272_nhtank` / `_2`: rangeRadius 2.2, `PollutedRangedAtk` range_radius 2.2, atk_scale implicit 100 %, `Empty.attack@chuang_atk_scale` 2, spData 攻击回复 2 / 2), the PRTS pages (攻击方式 近战 远程; "仅半径2.2范围内存在位于低地的非飞行我方单位时可触发:对目标造成100%物理普通伤害"), activity_table act2autochess (DOT special, no stat override; the range-4 tank is 斗智场's 改装排污车, another mode) and the act1 / act2 autochess levels: **there is no higher scale and no longer data range**. The hard hits are the round multiplier (table below: 绝境 R7 998, 尖端 R13 1656 per ranged hit before DEF). What the data does support is a longer *effective* reach: PRTS 作战机制 §碰撞体积与位置识别 "我方干员碰撞体积基本均为以0.25格为半径的圆形…关于碰撞体积与远程索敌,碰撞箱碰撞是为最常用的方式,索敌抬手等均使用碰撞箱" — the remake measured enemy range from the operator's centre. **Settled**: every ranged enemy's normal attack takes an ally whose 0.25 collider touches its range circle (`ALLY_COLLIDER_RADIUS`, `ai.js enemyAttack`: centre distance ≤ rangeRadius + 0.25; content skills / zones keep their own point radius [ASSUMED]). 枯朽战车: reach 2.2 → 2.45 (13 → 21 tiles around it); a lone operator beside its lane takes 4 hits instead of 3 (绝境 R6, DEF 300: 2095 → 2793 attack damage, 2595 → 3593 with the zone); dense lineups unchanged (its 4.5 s interval is the limit). The user's memory of a *long* range is stronger than the data supports — said plainly in the user notes. Flip: `ALLY_COLLIDER_RADIUS = 0`.
- **#9 seen**: 威龙 drawn far too large. **Official**: the client scales each enemy Spine model in its battle prefab — 0.27 for 1454 of 2147 prefabs; 威龙 0.16, 妖怪 0.20, 妖怪MKII 0.22, the 岁 relics 0.4–0.6. **Now**: `MODEL_SCALES` (122 prefabs) → enemies.json `modelScale` = product / 0.27 (125 keys); `render/units.js` scales the skeleton and the bar height (enemies without setup-pose bounds: chibi headroom × `modelScale`). 威龙 is 1.28 tiles wide instead of 2.15 — 1.08 × a 妖怪, as in the game. 85 enemy skeletons still have no bounds in data/assets.json (asset pipeline, not addressed).
- Tests: `test/sim/playtest6-enemies.test.js` (flutter, determinism, operator fear, 恐惧 > 诱导, push / obstacle re-planning on m01–m04, the tank incl. its reach), `test/render/playtest6-enemies.test.js`, `test/local-extract.test.js` (`enemy_scales`), `combat.test.js`; opt-in `test/render/playtest6-enemies.browser.test.js`.
| Round | Tank | 险境 ATK · ranged hit · blocked | 绝境 | 终极 |
|---|---|---|---|---|
| R1 | 萨卡兹枯朽战车 | 600 · 600 · 1200 | 825 · 825 · 1650 | 825 · 825 · 1650 |
| R4–R7 | 萨卡兹枯朽战车 | 726 · 726 · 1452 | 998 · 998 · 1997 | 998–1208 · … · 1997–2416 |
| R8 | 尖端萨卡兹枯朽战车 | — (banned) | 1131 · 1131 · 2263 | 1369 · 1369 · 2738 |
| R13 | 尖端萨卡兹枯朽战车 | — | 1656 · 1656 · 3313 | 1656 · 1656 · 3313 |
### 20.5 Final Assault leader at 0 HP that would not die (#5) — `sim/constants.js BOSS_POOL_MIN_HP`, `match/finalAssault.js SharedBossPool`, `sim/spec.js LocalBossPool`, `match/Match.js _creditBoss / _onResult / _finishFinal`, `ui/hud.js PhaseCapsule`, `ui/gameLogic.js bossPctText`
- **Seen**: 2 humans (AI teammates eliminated), 绝境, act2 m01, client-side combat — the boss bar read "0.0%" with 95 s left, the leader fought on until the overtime drain took the team LP, and the result said 胜利. **Reproduced**: real 2-human client-combat matches (pool scaled to 5 %): on v2.4.1 11 of 40 fights stalled, every boss family; about 11 minutes of drain followed, ending 胜利 or 失败.
- **Cause**: float dust in the shared pool, not a boss script, hit area or part. `_creditBoss` subtracts each report per player and the pieces add up only in exact arithmetic (40000 − 12345.6 − 7654.3 − 20000.1 = 3.6e-12); the browser's `LocalBossPool` (server hp − (cum − acked)) absorbed every later hit (less than half an ulp of `cum`), so the leader never died, nothing new was reported, and m.public rounded the server's dust to "0 HP". When the team LP hit 0, the verdict was read from the pool after the final `b.result`, whose per-player damage is `Math.round`ed — rounding up emptied the dust (胜利).
- **Official**: PRTS 卫戍协议/帮助 §领袖作战阶段 "…计时结束后战斗仍然会继续,但每超时1秒扣除1目标生命值"; 卫戍协议:盟约 下半 "…使目标生命值扣除至0,则无视倒计时直接失败" (research 01 §10). The victory after the drain contradicted it; the user was right.
- **Now**: a shared pool never holds less than `BOSS_POOL_MIN_HP` (1) — the hit, or the per-player credit, that would leave less takes the rest (SharedBossPool, LocalBossPool), and a browser pool reads any finite rest below 1 as 0, so a pool shown at 0 is a dead leader on every field (`Battle._bossSync` / `_checkEnd`) and `b.end cleared` goes out at once. The first end the server registers decides (`Match._finalEnding`: pool 0 → victory, team LP 0 → defeat); after a forced end no report credits the pool. The boss bar prefers the live pool of the battle on screen over the ~1 Hz `m.public.bossHp` and never shows "0.0%" for a living leader ("<0.1%"). Checked and ruled out: the kill check reads the pool whatever the unit's own hp or invulnerability; mirrored and hidden-core copies share it; parts only pass damage; the Hidden Core, 4 humans and server-run mode end promptly.
- [ASSUMED]: the 1-HP floor (official HP is a float shown rounded — PRTS 作战机制 "红字在显示时会就近取整"); a client emptying the pool within its latency after the server registered team LP 0 still loses; the HUD prefers the local snapshot pool.
- Tests: `test/sim/playtest6_bosspool.test.js`, `test/match/playtest6-bosspool.test.js`, `test/ui/playtest6-bosshud.test.js`.
### 20.6 Match flow: the bounty draft, 教鞭, the 联防 leak counter, the promotion reward (#4, #7, #19) — `tools/build-data.mjs bountyDraftExclusion`, `match/choices.js` (`MULTI_ROUND_BOUNTY_BATTLES`), `content/items/meta.js`, `Match.addBounty`, `PlayerState.pushRewardOffer / effectsView`, `sim/spec.js uniteLeft`, `Match._uniteLeft`, `fields.js uniteBillBounds`, `ui/hud.js MissTag`, `ui/teamPanel.js rowLp`
- **#4 seen**: "悬赏决策的敌人…正常只会在选择之后出现两回合,现在是一直出". **Cause**: a 1–2-battle card already stopped after its battles, but the draft drew from all 129 ENEMY_GAIN cards (战术特训 and the hidden 鸭爵 set included) and every card was plain text (the server's stripped `desc` beat the rich text), so a 每场 multi-round card looked like a 下场 one.
- **Official**: PRTS 卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段 "敌人轮选" lists the 1- and 2-battle bounties, 源石虫·特训 and the 7 multi-round cards and comments out the 鸭爵 set; 战术特训 sits under "※以下悬赏任务仅由法术教鞭生成"; §法术 教鞭 "于3个战术特训的悬赏任务中选择一项"; 杜宾 加练! "若自身战斗完美作战可获得资金". The card text colours the duration (`<@ba.vup>下场作战</>` / `两场作战`, `<@ba.vdown>每场</>`).
- **Now**: cards.bounty `draft` = that table (105 cards: 56 next-battle incl. 源石虫·特训, 42 two-battle, 7 multi-round), drawn uniformly, no multi-round cap — **superseded by §21.2** (the official draft structure of each round); bounty cards carry `descRaw`; the effects column says 还剩 N 场作战 (`counterText`). 教鞭 draws 3 战术特训 cards and takes one at random (it used to draw the band bounties, so 战术特训 was unreachable); “神秘顾客” (granted by nothing in act2) keeps the band bounties [ASSUMED]. Not modelled: the one official draft screenshot hints at one series per draft.
- **Settled by the user** — "我不记得有过多轮悬赏": until confirmed otherwise every official multi-round card (山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6, and 法术大师A2·多轮战术特训 from 教鞭) **lasts two battles** exactly like the "接下来两场作战" cards (no draft offers one since §21.2; 教鞭's card still lasts two battles): `MULTI_ROUND_BOUNTY_BATTLES = 2` (`bountyBattles`; `bountyText` rewrites "之后的 / 后续<@ba.vdown>每场</>作战" to "接下来<@ba.vup>两场作战</>" on the card and in the effects column, 还剩 N 场作战); choices.json keeps the official `rounds` 99 / `multiRound`. Flip: `MULTI_ROUND_BOUNTY_BATTLES = null` restores the official red 每场 everywhere (no data rebuild).
- **#7 seen**: the leaker's number never fell during 联防 and seemed capped at 10. **Official**: PRTS 帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"; the capsule shows an orange runner tag ×N (research 09 `tag_miss`, drawn with ui/battle bg_miss_enemy [ASSUMED]); the 10-LP cap per round is unchanged. **Now** (revising §17.5): `uniteLeft` per leaker = not spawned yet + alive + counted leaks — it falls on kills and rises on splits / summons (children inherit `sourcePlayerId`); the server takes it from `b.progress left`, the headless timeline `[gt, killed, total, left]` or the exact `uniteSurvivors`, clamps live values to `uniteBillBounds`; m.public players[] `uniteLeft` is uncapped and `pendingLp` = min(cap, uniteLeft). ×N in the capsule and in the leakers' team rows; a client with the 联防 field on screen shows its local replica's counts there; a teammate's row tooltip names the teammate.
- **#19**: PRTS 帮助 "临时刷新3名高于调度中心等级1阶(最多6阶)的干员"; the official 玩法介绍 image shows three different operators at price 0. v2.4.1 rolled three times independently (14–21 % of offers had a duplicate). **Now** three different chess, copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED]; the offer reserves no copies. The integration QA found the same repeat in the other free pick-one chess offers — 信标 (same tier), 寻呼模块 (same bond ≤ shop level, content/items/meta.js) and the engine default `builtinMeta use_equip_reward_special_goods_char_chess` — they now draw different operators too (信标 tops up from the tier below, 寻呼模块 shows fewer cards when the pool has no other one), and `pushRewardOffer` drops a repeated id from any list it is given (`test/content/items.test.js`).
- Tests: `test/match/playtest6-matchflow.test.js` (the draft, two battles for a multi-round card end to end, 联防 counts, rewards), `test/match/followups.test.js`, `test/ui/playtest6-unite.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/items.test.js` (教鞭).
### 20.7 机变 card text; the shop's confirming tap on phones (#6, #10) — `ui/choiceOverlay.js`, `css/screens/game-panels.css`, `ui/shopBar.js`, `css/screens/game-shop.css`, `css/devices.css`
- **#6 seen** (screenshot 2, Android 756×366 CSS px, 终极 co-op): the 道具补给 cards showed only icon, name and tier. **Cause**: the card was a centred column sized for 16:9; with the root font clamped at 40 px a 3×2 card was 105 px tall and showed 0.2 lines of text (217 of 223 texts cut), in all four families; at 1920×1080 nine long texts were cut at 4 lines. **Official** (research 06 §11.5; screenshots Mb2mtd9 / Wrkhtxv / SEbySVl): a framed icon at the top-left with the bold name beside it, the description below, numbers in blue, the tier chip on the icon's corner, the taker's round avatar alone at the top-right. **Now**: that layout; the text from `cardText` (the card's own `descRaw`, else the data `descRaw` while its plain form equals the server's `desc`, else the server text), clamped to 7 lines (8 at line height 1.3 on phones under 700 px wide: with Noto Sans SC — the page's web font, metrics of Android's CJK font — 海沟实验体 wraps to 8 lines at 640×360, residual QA); short screens get a tighter overlay header (no CONTINGENCY micro line) and .19rem text; the taker badge is the avatar alone (name in its tooltip and the card's aria-label) and the taken card's head reserves its width. All 223 texts (51 items, 129 bounty, 43 tactic) fit in full, taken or not, from 640×360 to 2560×1080, measured with Noto Sans SC.
- **#10 seen**: on the phone the second tap on the 确认购买 strip did nothing; only the middle of the card worked. **Cause**: the card's invisible ⓘ corner had the generic 44 px touch area (its 30 px override lost on specificity: `:is()` scores 0,3,1 against 0,2,1), covering the bottom-right quarter of the card and the right half of the strip (120 / 144 grid points); below 768 px the left-docked detail card opened by the first tap also reached over the bar's first cards. **Official** (research 09 §5): first tap = detail + 确认购买, second tap buys; the card has no info corner. **Now**: the ⓘ is removed (a tap anywhere on a card is the card's tap), `.shopbar.has-armed { z-index: 10 }` keeps the armed bar above the detail card (z 9; below `.uframe` 12 and `.spov` 20), and a card that cannot be bought right now opens its detail (not aria-disabled, help cursor). 144 / 144 points on touch at 640×360 … 932×430 and with the mouse at 1920×1080; the strip centre, its right end and the bottom-right corner buy.
- [ASSUMED]: phone text 7.6 px; the avatar inside the card's corner; the gold strip may cover the last line of the longest texts while a card is selected; the micro line hidden on phones; the armed bar hides the detail card's corner on < 768 px phones; solo 3-card sizes.
- Tests: `test/ui/playtest6-ui.test.js`; opt-in `test/ui/playtest6-ui.e2e.test.js` (`SP_E2E=1`: every text at 9 viewports, each card taken once, the hit grid by tap target and real taps); mock.e2e (detail + Esc through a card tap).
### 20.8 Element gauge on screen (#11) — `sim/damage.js elementView`, `render/units.js`, `render/textures.js`
- **Seen**: correcting playtest #5, the user said the 海嗣's element damage is right after all, but the gauge "fills at the wrong rate: far from used up when the burst already hit". **Measured** (Node sim → interp → UnitView, and headless Chrome on the real view; 14 element enemies vs 古米, the reviewer with 5 operators against a mixed 海嗣 wave): the drawn gauge follows the sim within one frame. Steady attackers burst off a 1–5 % sliver; big hits legitimately burst far up the bar (掠海漂移体 does 50 % of its ATK × the round's ATK per hit — 25 % of the gauge at base, ≈ 44 % at 绝境 R11; 萨卡兹王庭军精锐术师 死亡之眼; 昆图斯's skills).
- **Display faults, all fixed**: `elementView` rounded the fill to the nearest 1 % (a gauge 1–4 元素值 short showed empty); the v2.3 ring right of the bars sat on the next operator's tier chip (read as the neighbour's gauge, and a shrinking ring reads like a filling one); during the 爆发冷却 the refill was the same white as the 元素值 left while the burst's stun / damage hit the unit.
- **Official** (PRTS 元素): "当单位存在一项不为最大值的元素时,模型下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies get a small colour-only icon; during the 爆发冷却 "单位的元素条显示缓慢恢复至上限(动画效果)" (no colour given).
- **Now**: fill rounded DOWN to 1 %, kept at 0.01–0.99 outside a burst (1 during one) — the bar only runs out when the gauge bursts; the gauge is a row under the unit's own HP / SP bars inside their span: the element disc (0.15 tile, 8–15 px; enemies ×0.8, plain colour; light rim) and a white bar of the remaining 元素值 (SP-bar height, dark track); during the 爆发冷却 the bar refills in the element's colour (`ELEMENT_RING[el].tint`) with the disc pulsing, white again when it ends.
- [ASSUMED]: the refill colour and pulse; the geometry; placement under the above-head bars (official: under the model); the disc rim. The render-demo recordings carry fills made with the old rounding (display only).
- Tests: `test/sim/playtest6_elemgauge.test.js`, `test/render/playtest6_elembar.test.js`, `test/render/downring.test.js`; opt-in `test/render/elembar.browser.test.js`, `downelem.browser.test.js`.
### 20.9 Settled decisions and what stays [ASSUMED]
The user's answers of 2026-10-01 (playtest #6 follow-up):
| Decision | Settled rule | One-line flip |
|---|---|---|
| A placed skill summon at the battle start (§20.1) | deploys once, free, then with each cast (PRTS §作战阶段) | `shared/constants.js SKILL_SUMMON_START_DEPLOY = false` |
| 凯瑟琳's 爬行号·防护单元 (§20.1) | a manually placed hand card | drop `token_10041_cathy_catsld` from `placeable` (`tools/build-data.mjs`) — and restore the v2.4 kit placement, or the device never appears |
| Multi-round bounty cards (§20.6) | last two battles like "接下来两场作战", blue text — 教鞭's card; no draft offers one (§21.2) | `server/match/choices.js MULTI_ROUND_BOUNTY_BATTLES = null` (official 每场) |
| 萨卡兹枯朽战车's reach (§20.4) | data range 2.2 + the operator's 0.25 collider for every ranged enemy; scales per data (100 % ranged, 200 % blocked melee, skill 100 %) | `server/sim/constants.js ALLY_COLLIDER_RADIUS = 0` |
| Blocking (§20.3) | every blocker hits the enemies it blocks, first, whatever its range or facing | gate `Battle.blockedTargets` / `sortEnemyTargets` blocked-first on a melee position again |
| A merge's elite (§20.11) | takes the consumed deployed copy's tile and facing, else the hand (PRTS 帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"; the user confirmed it, 2026-10-01) | none — official; restoring the v2.5 hand-first rule means reverting `PlayerState._mergeChess` (merge `5f3842f`) |
Official since the follow-up — read from the client binary (research 11, §20.12) or the official texts (§20.10, §20.13):
| Rule | Now | One-line flip |
|---|---|---|
| Bond layer cap (§20.12) | 999 per bond (`MAX_GARRISON_STACK`; `AddBondCount` stores min(L + n, 999)) — no longer [ASSUMED] | `shared/constants.js BOND_LAYER_CAP = 0` |
| 限伤 (§20.12) | a leader hit with ceil(d) ≥ 300000 in boss / hidden battles is cancelled (`MAX_BATTLE_DAMAGE`), not an overflow | `shared/constants.js BOSS_HIT_LIMIT = 0` |
| 直接乘算 (§20.10) | bond / strategy / item / 机变 / 特质 "+X%" are summed with each other and the skills' | `server/sim/constants.js DIRECT_BONUS_STACKING = 'multiply'` |
| Ground routes (§21.15) | the official flow field unless the playtest #2 route crosses strictly fewer floor tiles (a corner touch is no crossing) | `server/sim/grid.js _buildField` always `use = o` (reverses 战场#01's col-8 road) |
| Leader parts (§20.10, §20.13) | every damage a 剑 / 锤 / 碎铳之簧 takes is lost by its leader 1:1, 无来源 | `server/sim/content/bosses.js PART_TRANSFER = 0.5` (v2.5's share; v2.5 also passed a 剑 / 锤's damage only while grounded) |
Settled by the user ("保持固定血量"): the co-op leader pool does not shrink when players are eliminated — `config bossHpScale.aliveScaling` is **false** (pool = bloodPoint whatever the number of alive players; one community note, 巴哈姆特 12294, said otherwise) (§20.10); flip: `aliveScaling: true` (× alive / 4).
Settled by the user (2026-10-02, after v2.5.2: "共享阵地按你说的规则就行"): on a shared field (联防, Final Assault / Hidden Core) the bond strip follows the player of the half the ‹ › pill points at; on 全景 or an empty half it shows your own bonds when you fight there, else the teammate picked with 前往查看 or the field's first player — never your own (§20.15); rule: `public/js/ui/watchBonds.js bondOwnerId`.
Still [ASSUMED] (each in its section): the class rows covering every MANUAL skill (§20.2); skill-summon redeploys free and never while the owner is away (§20.1); the 1-HP pool floor and the server-order verdict (§20.5); a short promotion tier topping up from below and “神秘顾客” keeping the band bounties (§20.6); the 弹道 column for unclassified pushes and the other displacement details (§20.3); 枯朽战车's skill firing on its blocker at 100 % and content enemy skills keeping a point radius (§20.4); the gauge's refill colour and placement (§20.8); the 机变 card's phone sizes (§20.7); the elite's tile among several deployed copies (the first in deploy order) and the elite's fresh summon stack (§20.11; a transformed carrier's tile no longer counts, §21.1); "strongest" rather than "latest" for same-named debuffs, which "最大生命值" the 胄 drone link reads (the pool), the 剑 / 锤 dive hits passing on, the springs' split and the solo pool × 0.25 (§20.10); the scene server clamping prep gains like the client, a cancelled hit showing nothing, a passed-on loss being one hit and the order blocks → 限伤 → HP shields (§20.12); 胄's unread `max_hp` 0.5, the untargetable 初始模式 and a replacement copy inheriting only the HP (§20.13); talent debuffs keyed per source unit (two copies of one operator each apply theirs, §20.14).
### 20.10 Final Assault leader HP and damage (user report after playtest #6, 2026-10-01) — `server/sim/constants.js DIRECT_BONUS_STACKING`, `content/support directMods`, `sim/Battle.js applyStrongest / loseHp sourceless`, content bonds / bands / items / choices / garrisons / kits tier4, `content/bosses.js PART_TRANSFER / DRONE_LINK_BASE`, `match/gamedata.js bossPoolHp / bossPoolShare`, `match/finalAssault.js`, `data/config.json bossHpScale`
- **Seen**: co-op 终极 / 绝境 leaders died within seconds; with the user's layer counts the official leader never dies that fast.
- **HP and pool — official, unchanged**: `bloodPoint[difficulty]` = activity_table act2autochess bossInfoDict `bloodPoint` / `bloodPointNormal` / `bloodPointHard` / `bloodPointAbyss` of the current data (the cache equals upstream ArknightsGameData master as of 2026-10-01; `test/match/bosshp.test.js` pins all 40 values — e.g. 胄 247 500 / 675 000 / 1 800 000 / 3 600 000, hidden 胄 750 000 / 937 500 / 3 600 000 / 7 200 000). PRTS 盟约记录's leader table is an older (11月18日) revision: 铳 险境 and 胄 / 铳 / 萨米 绝境 differ, no 终极 column, no 卢西恩 row. The data has no per-player or per-field factor; leader HP ignores `enemyScale` ("领袖单位于服务器的生命值加成不受上述加成影响"). One pool for every boss field ("最终攻势中,所有人将一起对敌方领袖造成伤害"); notice 5114's "敌方领袖的总生命值不变" means the two mirrored copies of a pair field share it.
- **Pool size**: co-op = bloodPoint whatever the number of alive players — `bossHpScale.aliveScaling` (× alive / 4, one community note: 巴哈姆特 12294) stays **off** — the user chose the fixed pool (§20.9); solo × 0.25 [ASSUMED].
- **Fixed — why leaders fell too fast**:
1. **直接乘算**: "+X%" ATK / DEF / max HP from bonds, strategies, equipment, 机变 cards and per-layer 特质 are summed with each other and with the skills' "+X%" (PRTS 盟约记录 "…属性加成均为直接乘算"; 游戏数据基础 D_t = Σtᵢ, A = (A₀ + D_p)(1 + D_t)). v2.5 compounded them (×74 instead of ×12 at 600 精准 layers with four more bonuses). `DIRECT_BONUS_STACKING 'add'`, content/support `directMods`; 炎佑 ×1.5 and the char_attribute_mul 特质 stay multipliers (§5.2).
2. **Same-named enemy debuffs**: one instance per target, the strongest (PRTS 作战机制 "同名buff…只能表现出一个"; 巴哈姆特 12316 "共享型buff會跟對面搶") — 奥术, 灵知 坚冰, the 莱恩哈特 / 缄默德克萨斯 RES cuts (`Battle.applyStrongest`, §5.3). v2.5 kept one 奥术 per player, so a pair field multiplied it (×40 arts damage taken at +400 layers; ×7.6 now). "Strongest" rather than "latest" is [ASSUMED].
3. **Leader parts**: 剑 / 锤 / 碎铳之簧 pass every damage they take 1:1 as 无来源 HP loss, credited to the attacker (PRTS "受到等量的无来源生命流失"; `PART_TRANSFER` 1, `loseHp … sourceless`); v2.5 passed ½, a 剑 / 锤 only while grounded. The dive hits counting (PRTS talent "受到伤害时" vs the handbook "被击落时") and the springs' split to the other springs are [ASSUMED] (§20.13 for the 剑 / 锤 kit).
4. **坚守 thorns**: dealt 无来源 by the member who was hit and credited to that member (statistics only; up to 19 % of a co-op pool was nobody's).
5. **Bond layers**: uncapped in v2.5; now 999 per bond — official, §20.12 (this workstream first added the cap as [ASSUMED] from the community; the merge kept the limits workstream's single implementation).
- **Kept**: a 胄 drone costs 2 % of the leader's shown max HP = the pool (`DRONE_LINK_BASE 'pool'`, = v2.5; which "最大生命值" the official client reads is [ASSUMED] — the review tried the unit's data 600 000, which made drones 19–78 % of a solo 标准 fight, and went back). Under 限伤 it is one hit: 144 000 at most today (hidden 胄 终极), cancelled only above a 14 999 950 pool (§20.12).
- **Ruled out by measurement**: double crediting (client / server / replica), multi-hits through the 5×3 hit area, element bursts, %-max-HP damage on leaders (only the drone link).
- **Official, not a bug**: per-layer bonuses have no cap below 999 (谢拉格 vs cold ×(1.35 + 0.01 L) = ×11.34, 奥术 at low HP ×15.7, 精准 +1208.8 % ATK at 999 layers), so very high layers still kill fast — 绝境 with +400 on every active bond: median 8.6 s, fastest 3.6 s; 终极 under 20 s needs ≈ +400 on every bond (≈ 2 800 active layers per player; median 16.8 s); with two bonds per player at 999 (≈ 2 000 layers) 绝境 kills median 10 s, 7 of 28 under 5 s; with every active bond at 999 终极 median 8.3 s, 绝境 3.7 s — and there 限伤 (§20.12) cancels 6–77 hits per 12–28 fights. The community reports the same (巴哈姆特 12522, 999 谢拉格 "boss一秒死").
- **Measured** (real seconds — the fights run at 2×, so half the game seconds of the QA tables). Integrated build (QA 6b on `cca11e6`, after the elite-to-board merge): the R14 boards of real 4-AI co-op matches, seeds 7 / 8 / 9 / 12 × boss_1…7 = 28 Final Assaults per row, both pair fields on one pool, the same boards replayed on v2.5 → now: 终极 own layers median 206 → 258 s, +200 on every active bond 24.5 → 42.1 s (fastest 8.2 → 18.9 s), +400 11.3 → 16.8 s; 绝境 own 79 → 99 s, +200 11.6 → 14.8 s (fastest 1.9 → 7.3 s), +400 6.1 → 8.6 s; each player's top bond requested at 1500 (now held at 999): 终极 53 → 95 s, 绝境 17.5 → 33 s. On each version's own bot boards the medians moved only ×1.0–1.2 (终极 own 256 → 258 s, +200 47 → 54 s; 绝境 own 81 → 99 s, +200 13 → 15 s): the elite-to-board rule (§20.11) gives the bots slightly stronger R14 boards (终极 2.12 → 2.26 elites per player, 绝境 2.14 → 2.51; 绝境 6.0 → 6.9 active bonds), so a player whose board did not change feels the same-board numbers. No landed leader hit reached 300 000 (v2.5 landed up to 18.6 M); the largest at +400 is 144 000 (a hidden 胄 drone; 114 000 from an operator), at 999 layers 299 600. The boss-HP workstream's own measurement (42 pre-merge boards, seeds 7–9: 终极 +200 30.9 → 56.0 s, …) is kept in BALANCE §3, labelled as such; BALANCE §3 has both tables.
- Tests: `test/match/bosshp.test.js` (real Final Assault / Hidden Core matches, the 40 bloodPoint values; the 奥术 pair test runs seed 12 since the elite-to-board merge changed the bots' boards), `test/sim/bosshp_stacking.test.js`, `test/match/layercap.test.js`, `test/sim/battle.test.js` (Battle.addLayers cap), `test/content/bonds_addon.test.js`, `kits_t4.test.js`, `kits_alt_t4.test.js`, `enemies_bosses.test.js`, `items.test.js`, `garrisons_battle.test.js`, `choices.test.js`, `test/match/unitStats.test.js`, `finalAssault.test.js`, `balance.test.js`, `playtest6-bosspool.test.js`; opt-in `SIM_E2E=1 node --test test/sim/bossfield.browser.test.js` (the real boss / hidden specs of a 999-layer bot match give the same digest, pool and fx in headless Chrome as in Node; covers §20.12 and §20.13 too).
### 20.11 A merge's elite takes the deployed copy's tile (user-confirmed) — `match/PlayerState.js _mergeChess / transformChess`, `match/board.js mergeTile`, `match/audit.js`, `ui/gameLogic.js mergeProgress / mergeTarget`, `ui/shopBar.js armedSlotOf / onArm`, `screens/game.js MERGE_HL`, `render/promote.js`, `render/fx.js promote`, `render/app.js setPrep`
- **Seen** (the user, first-hand, after playtest #6): "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置".
- **Cause**: research 01 A1 row 7 read PRTS 帮助 as "1 elite goes to the hand, not to a board tile" (marked VERIFIED, repeated in `01-core-data.json` promotion.eliteGoesTo), so `_mergeChess` put every elite in the hand and used a consumed copy's tile only when the hand and temp were both full.
- **Official** (PRTS 卫戍协议/帮助): §干员的获得与精锐化 "…发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)"; tip "干员晋级后已配发装备会回收至整备区,需要重新配置"; §战斗部署 "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区", deploy order "从上到下>从左到右". The text does not say which tile wins among several deployed copies.
- **Now (server)**: when a consumed copy stood on the board the elite takes that tile and its facing, otherwise it goes to the hand, overflowing into temp (outside PREP too). Several deployed copies: the one that deploys first — col asc, then row desc since 0.1.3 (§23.23; row desc, then col asc before), the mirrored right boss half included — `board.js mergeTile(tiles, legal)` [ASSUMED]. A 突变细胞 transformation destroys its carrier before the gain, so the carrier's freed tile is no copy's (§21.1 — official footage; the 0.1.1 WA rule that counted it, `transformChess` `fromKey` / `fromDir` [ASSUMED], is withdrawn). A tile the elite may not use is skipped; with the hand and temp also full the elite stays on the first deployed copy's tile rather than being lost (as `_evictIllegal`). The deploy count never grows (no BOARD_FULL path). Equipment returns to the hand, overflow temp; with both full it stays on the elite up to its `equipPerChess` (2) slots, any further item destroyed with a log warning (unchanged). The consumed copies' summons are removed; an elite on the board gets its own fresh stack (`grantTokensFor`, after the items) following the §16 loadout [ASSUMED: placed summons are not carried over]. Every path (g.buy, g.reward, `ctx.grantChess`, 画卷, transformations), every phase (a SETTLE / Final Assault merge places the elite at once, its reward offer waits for the next prep) and the boss-field prep (legality on the player's half, §19.7). Reward offer, ticker and `stats.merges` unchanged; `onMerge` carries `area` ('board' | 'hand' | 'temp'). Bots plan the elite like any owned unit.
- **Now (client)**: `gameLogic.mergeTarget(priv, chessId)` mirrors `mergeTile`; a shop or reward card armed for a merge-completing buy lights the target tile in gold (`ShopBar onArm` → `screens/game.js` highlight group `mergeTile`); the 可晋升 tag says 精锐干员将出现在作战区原位置 or 精锐干员将进入整备区; an elite card never merges (`completesChessMerge` refuses `isGolden`, `mergeProgress` reports 0 copies: no pips, tag or tile); `render/promote.js promotionsOf` recognises a merge in setPrep and the elite plays `fx.promote` (gold pillar, shockwave, hex ring, streaks from the other copies) instead of `fx.deploy` (`view.debug.promotions`).
- **Checks**: `audit.js` checks every merge — the tile is `mergeTile` of the deployed copies legal for the elite, with that copy's facing; the deploy count did not grow; otherwise the elite is not on the board, except the no-room fallback. `tools/matchrun --check` sweeps are clean.
- Normative lines rewritten: §2 (`promote.js`), §6.2 (the economy line and the temp-overflow paragraph), §9 (the promotion cue).
- Tests: `test/match/playtest6-elite-to-board.test.js`, `test/match/merge.test.js`, `hardening.test.js`, `facing.test.js`, `test/ui/playtest6-elite.test.js`, `test/render/playtest6-promote.test.js`, `test/docs-consistency.test.js`; opt-in `SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js` (real server and prep: the armed card lights the deployed copy's tile, the elite stands there after the buy, 剩余可放置角色 unchanged).
### 20.12 Official limits: 999 layers per bond, 限伤 300000 on leaders — `shared/constants.js BOND_LAYER_CAP / BOSS_HIT_LIMIT / layerGainRoom`, `match/PlayerState.addLayers`, `Match` SETTLE, `sim/Battle.addLayers / loseHp`, `sim/damage.js leaderHitCancelled`, `match/fields.js validateClientResult`, `match/invariants.js`, `render/fx.js`, `tools/matchrun.mjs`, `tools/balance.mjs`
- **Asked**: the co-op leaders as hard to kill as in the official game. Research 11 (`docs/research/11-limits-official.md`) found two official caps in the client (2.7.71 il2cpp) that the remake lacked.
- **Official** [DATA]: `AutoChessBattleConst.MAX_GARRISON_STACK = 999` — `AddBondCount` stores `min(L + n, 999)`, per bond. `MAX_BATTLE_DAMAGE = 300000` — in step mode (every boss battle outside training) `AutoChessStepModeManager._OnBossEnemyTakeDamage` runs `modifier.Cancel()` when `ceil(value) ≥ 300000` on an `IsBossEnemy` unit; `IsBossEnemy` (disassembled, research 11 §2.3) = enemyId ∈ `autoChessData.bossInfoDict[*].enemyId` = data/bosses.json `enemyKey`. [COMM] bonds stay at 999 while fed; 「一次超过30w会被卡掉」: players avoid extra multipliers at 999 layers. 限伤 is a deliberate constant, **not** an engine fixed-point overflow — the boss-HP workstream's earlier reading (2³¹, "not modelled", §20.10) is superseded.
- **Now — layers**: the single implementation is `shared/constants.js BOND_LAYER_CAP` + `layerGainRoom(before, n)` = min(n, 999 − before), never negative (the boss-HP workstream's duplicate `sim/constants.js BOND_LAYER_CAP / layerRoom` was removed in the merge). Every writer uses it: `PlayerState.addLayers` (特质, items, bands, 机变 cards, bonds), the SETTLE of `BattleResult.layerGains`, `Battle.addLayers` (the live copy, clamped after the `layerGain` hook, like the client), and the dev tools' direct writes (`matchrun --layers N`, `balance.mjs applyBoard`). A gain at the cap adds 0: no onLayers, hook or 'layer' event, so the per-N milestones (远见, 奇迹, 维多利亚 …) stop with the count. Client results: per-bond `layerGains ≤ min(60 + 4·round, 999 − starting layers)` (+ the lineup's layer 特质, §21.26). `invariants.js` flags a bond above 999; the UI shows the server's capped count.
- **Now — 限伤**: kinds `'boss'` / `'hidden'` only; a leader is `isBoss` (the tag-'boss' units: those 10 keys and their mirrored copies — parts, 刺胄之弹, drones and escorts never count). A hit with `ceil(final) ≥ 300000` is cancelled: checked in `dealDamage` after DEF/RES and every multiplier, before HP shields, and in `loseHp` (a part's 传递, a drone's death). A cancelled hit causes no HP or pool loss, no credit or stats, no damage number, no `damaged` / `fatal` / kill; the attack, its SP, the `hit` hook and separate element 损伤 hits stay. Element bursts and DoT ticks are checked, gauge fills are not. The sim emits fx `hitCap {id, n}`, drawn as nothing (deterministic in Node and browsers; SP_VERIFY digests agree).
- **The line vs the pool**: 【死亡集群】 drone deaths cost 0.02 × the pool max (§20.10, §20.13) — at most 144 000 today (boss_8 终极 7.2M), so it lands; a pool above 14 999 950 would cancel every drone death. An alive-scaled pool (§20.9, open) can only shrink, but any larger pool must re-check it; a real-kit test pins the line.
- **[ASSUMED]**: the scene server clamps prep gains the same way; a cancelled hit shows nothing; a loss passed on to a leader is one hit; the order hit-step blocks → 限伤 → HP shields — 阿利斯泰尔's 莫非王土 vest (`boss:vest`, 5000): a hit of 300000–304999 is cancelled and the barrier stays; 假想敌:再生's blocks on 假想敌:管 (hidden h08_03) negate a hit past the line before the limit sees it.
- **Measured**: the competent and strong balance boards never reach the line (biggest leader hits 28k–72k), so BALANCE §3's model numbers stand (BALANCE §7); the co-op replays up to +400 per bond and a top bond at 999 cancel nothing either. Two bonds per player at 999 or every active bond at 999 do reach it: 6–18 cancelled hits per 28 Final Assaults and 14–77 per 12 Hidden Cores (QA 6b), and those kills take longer than with v2.5's uncapped hits (§20.10).
- **Flip**: `BOND_LAYER_CAP = 0` and / or `BOSS_HIT_LIMIT = 0` (or Infinity).
- Tests: `test/sim/playtest6_limits.test.js` (including the real-kit vest, 再生 and 【死亡集群】 cases), `test/match/playtest6-limits.test.js` (a 999-layer board through a real Final Assault + Hidden Core, both tools at the cap), `test/render/playtest6_hitcap.test.js`, `test/sim/playtest6_bosspool.test.js` (adapted), and the boss-HP workstream's `test/match/layercap.test.js` / `test/sim/battle.test.js` cap tests on the single API.
### 20.13 假想敌:胄 kit audit (PRTS) — `content/bosses.js kitHelm / kitBlade`
- **Audit** (a separate session, 2026-10-01), against the PRTS 技能 / 天赋 / 能力修正 texts and the skill blackboards:
- 灭顶之灾 fires **one** 刺胄之弹 below 20 % too (PRTS 能力修正 原因 6 "描述与游戏实际表现不符合": no extra shell; no blackboard holds a second one), at the highest-ATK operator **within attack range** for both copies ("触发索敌和普通攻击相同"; boss_1 used the whole field).
- 死亡集群: **any** drone death costs the leader 2 % max HP (= the pool, §20.10), credited to the killing operator if one killed it; a leak costs nothing (能力修正 "该无人机单位死亡时"). Drone HP: `summon.hp_ratio` (隐秘核心) scales it; skill 2's `max_hp` 0.5 is not read [ASSUMED] — no source defines it.
- 斩胄之剑 / 破胄之锤: 初始模式 hovers invulnerable (untargetable [ASSUMED]) and attacks everything in range (锤 150 % ATK, unblockable). 掷 → 出击模式: blink to the start of the level's `<hand>_blink` route, fly at the lowest-ATK operator, no normal attack. 15 hits → 【瘫痪】: stun 10 s (its end ends 瘫痪, not the 20 s), ground unit, damage taken ×1.3. Landing or 瘫痪 ending: a new 初始模式 copy with the old HP is summoned on the `<hand>_origin` route and the old one is killed (a part: uncounted, no bounty) [ASSUMED: only the HP is inherited, so 掷 comes back after its initial 25 / 55 s instead of the 80 s cooldown].
- Every damage a 剑 / 锤 takes passes 100 % to 胄 (PRTS "等量", also during the dive) — merged with §20.10 into the one `PART_TRANSFER` 1 (`BLADE_TRANSFER` is its alias), 无来源 and credited to the attacker.
- **Measured** (same-seed `balance.mjs --boss-only`, n = 3–5 per cell): boss_1 unchanged (≤ 0.5 s); boss_8 独立 绝境 44.7 → 45.2 s, 独立 终极 win 2/3 → 3/3, 同盟 绝境 23.3 → 23.7 s, 同盟 终极 72.0 → 64.2 s (BALANCE §3).
- Tests: `test/content/enemies_bosses.test.js` (shell count and range, drone deaths and leaks, the pool-based link, the sortie / 瘫痪 / replacement cycle with the 1:1 无来源 transfer and its credit).
### 20.14 QA residuals of the integrated build (2026-10-02) — `match/Match.js _onResult / heldResult`, `render/app.js promoBase`, `ui/shopBar.js mergeHint`, `ui/detailPanel.js`, `screens/game.js dSide`
Two QA verifiers ran the integrated `playtest6b` (real 4-AI and 2-human co-op matches for every leader, Node vs headless Chrome digests, the merge UI on the user's phone profile and on desktop). Every check passed; the residual issues and what was done:
- **Fast boss kills handed over (fixed).** Under client-side combat a pair that empties the pool faster than the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s, §14) had its `'cleared'` `b.result` rejected as implausible, the client demoted and the field handed to the partner or the server — seen at 绝境 with every bond at 999 (kills at 2.0–4.0 game s; official 999-layer play reports "boss一秒死"). Now a `'cleared'` result whose cumulative report covers what the pool holds (1 HP slack, the pool floor) is **held** (`f.heldResult`): the boss clock credits the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end completes the field with it (`_endFinal`); a team-LP defeat first still credits nothing more. The budget itself is unchanged, so a forged report gains nothing a `b.progress` could not already get. The verdict waits at most until the budget covers the report (≤ 5 game s from the field's start). Re-run of the QA cases (绝境 seed 7 boss_1 / boss_6 with every bond pushed to 999, seed 21 with 990 early layers, 3 more at 999 / 终极): 0 rejections, 0 takeovers, every boss field kept by its client, Σ credited boss damage = the cleared pools; in headless Chrome (2 humans + 2 AI, a scratch server with a 12× slower budget so the path runs) both boss rounds held the clearing client's result and went on to the Hidden Core and the result with no takeover and no console error.
- **No promotion cue for a merge outside the prep (fixed).** A merge completed between two preps (a band / 机变 grant at ROUND_START, a SETTLE merge) placed the elite silently: `enterBattle` emptied the prep state the cue compares with. `render/app.js` keeps the last prep pieces across a battle or scouting board (`promoBase`), so the next `setPrep` plays `fx.promote` on the elite (streaks from the consumed copies' slots). Re-run of the QA case (松桐 grants the third 古米 at R4 ROUND_START): `view.debug.promotions` records the elite on the copy's tile, copies 2.
- **The 可晋升 destination on touch (fixed).** The tag's text was a native `title`, never shown on a phone. The detail card the first tap opens now says it under the header — 「可晋升:精锐干员将出现在作战区原位置」 / 「…将进入整备区」 (`shopBar.js mergeHint`, gold `.dhint--merge`; shop and reward cards).
- **The detail card covered the gold tile (fixed).** At 1920×1080 the left card covered half of a target tile in the leftmost legal column; the card now docks on the side away from the armed merge tile (`screens/game.js dSide`, the underframe's `panelSide` rule), on phones too.
- **Measurements re-taken (docs).** §20.10 / §20.12 and BALANCE §3 / §7 now give the integrated build's numbers (real seconds; the earlier boss-HP numbers are labelled as pre-merge boards) and the 999-layer profiles where 限伤 cancels hits.
- **Not changed — per-source talent debuffs [ASSUMED].** Some talents key their debuff with the source unit (`etlchi:dot:<id>`, `ines:atkSteal:<id>`, `ines:sentry:<id>`, and similar slows / DoTs in other kits), so two copies of one operator (a pair field where both players own it) each apply theirs. PRTS 作战机制 says only that same-named buffs show one instance under the *default* overlap strategy; whether these talents use it across copies is not in the data the project has, and the effect on leader kill times is small (an arts DoT, an ATK steal, a slow). The rule stays applied to the debuffs that move leader kills (奥术, 灵知 坚冰, the RES cuts — §20.10).
- **Not changed — temp items behind a full hand.** With the hand full, items overflowing into the 临时整备区 (row 8) stand behind the bench row's operator models and are hard to see; the row's red frame, the 「临时整备区 N 件道具待处理」 notice and the refused Ready still show them. A layout change of the temp row is left for a later pass.
- Tests: `test/match/clientCombat.test.js` (the held result; a 'cleared' result not covering the pool is still handed over), `test/render/playtest6-promote.test.js`, opt-in `RENDER_E2E=1 node --test test/render/browser.test.js` (a merge between two preps is cued), `test/ui/playtest6-elite.test.js` (the detail hint), opt-in `SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js` (the hint in the real detail card).
### 20.15 The bond strip follows the watched player (user report after playtest #6, 2026-10-02) — `ui/watchBonds.js`, `ui/bondStrip.js`, `ui/combatHud.js`, `screens/game.js` (`watchWho`, `bondOpen`), `battle/runner.js bondLayers / ownerOps`, `battle/observe.js resumedWatch`, `match/bondsMeta.js bondsWithGains`, `PlayerState.pendingLayerGains / bondsView`, `Match.publicView / _finishCombat / settle`
User: 「观看队友时(不仅是休整时候,还有自己战斗结束时,联防时,最终boss战)盟约栏应该变成当前队友的盟约以及他的层数」.
**Before this change:**
- Only 前往查看 of a teammate's normal field switched the strip, and it showed the start-of-round layers.
- An eliminated player's auto-observed field, the 联防 field and the Final Assault pair field kept the viewer's own bonds.
- A teammate's popup listed the viewer's pieces as its members.
**Whose bonds** (`watchBonds.screenFieldId` / `bondOwnerId` / `screenStrip`): the player whose field or half the camera shows.
The field on screen is, in order of preference:
- the watched field (`g.watch`);
- else the battle the view shows. Under client-side combat this is the runner's battle on screen, which is also an eliminated player's auto-observed field; under server-run combat it is the field the view entered;
- else home.
In SETTLE it is the field the last battle left on screen. In prep without watching, it is the own board.
- **The own board / own battle** → your bonds (`m.private bonds`).
- **A normal field `'n:<pid>'`** → its owner's bonds (`m.public players[].bonds`). This covers:
- a teammate's board in prep (前往查看). Research 09 §3.1: "their board, read-only, including their pen and bonds". Official clients receive every board's bonds (`ChangePositionDn.boardStatus.updatedBonds`);
- a teammate's battle watched after the own one;
- the field an eliminated player auto-observes;
- a field picked in the legacy view switcher.
- **A shared field** (联防 `'u'`, boss / hidden `'b1'` / `'b2'`) — **settled by the user** (2026-10-02: "共享阵地按你说的规则就行"; no official source shows the strip there):
1. If the ‹ › pill points at a half that a player holds → that player. The pill's "你自己" means yours; "👁 name" means theirs.
2. Else (全景, an empty half "无人在家", or no pill), if you fight on that field → yours.
3. Else (you are not on the field: a 联防 leaker, an eliminated spectator) → never your own. The strip shows the teammate you picked with 前往查看 if they are on that field, else the field's first listed player (helper 1 / the pair's first seat).
Basis: official observing targets a player (`ObserveUp { obIndex }`, `DeadAutoObDn { obIndex }`, `m_leftBattleObIdx / m_rightBattleObIdx`), and the official non-helper pill reads "‹ 👁 helper# ›" (research 09 §3.1).
- **The picked teammate:** the game screen remembers `watchWho = { fieldId, playerId }` when a team row is tapped (`requestWatch(fid, playerId)`). The `g.watch` request still names only the field. `watchWho` counts only while that field is the watched one (`watchedPlayer`). It is cleared by 返回战场, the own row and a phase change, and restored if the server refuses the watch.
- **Camera:** picking a teammate on a two-half shared field moves the camera to their half (`playerLayer`), once per pick and per field entered. The ‹ › pill is also offered on a shared field watched with 前往查看, so the pill and the strip tag always name the same player.
- On such a field the ‹ › pill replaces the observing pill and carries 返回战场 (absent for an eliminated player).
- On a normal field the observing pill "👁 name" takes the strip owner's name.
- The default camera on entering a field is still 全景.
- **返回战场 without an own field** (client-side combat: a 联防 leaker, an eliminated player): no `g.watch` is sent, because it would be refused with `BAD_TARGET 'no such field'` (an error toast). The screen keeps the field it shows, and the strip falls back to the rules above.
- **A reload / reconnect while watching** a teammate's battle after the own one (client-side combat): the server already resends the watched field (`Match._resendBattle`, `b.start watch: true`). But a fresh screen starts with no watched field, so before this the HUD showed 「作战结束,等待队友完成作战」 with no observing pill and no 返回战场, and the own row did nothing.
- The screen now adopts that field as the watched one, once per battle (`observe.resumedWatch`, decided at the first sight of its battleId).
- It does so only for a teammate's normal battle in COMBAT, shown to a living player who watches nothing.
- A battle first seen while a watch is already set is only marked as seen. So a 返回战场 tapped while the own `b.start` is still on its way never brings the teammate back.
- A loading battle waits until it runs (its state carries no `watch` yet).
- 联防 leakers and eliminated spectators keep their own rules.
**Labelling:**
- A teammate's strip leads with an amber "👁 name" pill (the observing pill's spelling) and gets amber disc rings, `aria-label` "name 的盟约" and `data-owner`; slots carry `data-bond`.
- The pill is horizontal at every width, with a pixel floor: 10 px on desktop and tablet, 9 px below 768 CSS px (1rem is clamped at 40 px there).
- An empty teammate strip reads "name 尚未激活盟约".
**Detail card chips and the popup (whose bond a popup shows):**
- A unit card's bond chips use the bonds of `watchBonds.detailBondOwner`:
- a unit on the field → its owner, when that owner is a known player (yours or a teammate's: a shared field shows both halves' units, whoever the strip follows);
- your own piece (board / bench) → yours;
- a bond-member card opened from a popup → that popup's player;
- anything else (shop / reward / drawer cards, a unit without a known owner) → the strip's player.
- A bond popup carries the player it was opened for: `bondOpen = { id, ownerId, from: 'strip' | 'detail' }` (`toggleBond`). From the strip, that is the strip's player; from a chip, it is the same player the chips show. The same bond of the same player closes it.
- `popupView` builds the popup from that player, never simply from the strip:
- the entry with the live layers (as the strip and the chips show it);
- "👁 name 的盟约" for a teammate;
- the member list: your pieces, or that teammate's operators on the field on screen (`ownerBoard`: the prep scouting board's or a server-run battle field's units). Under client-side combat it also uses the battle on screen's operators (`battleRunner.ownerOps(ownerId, fieldId)`), because the field meta the runner publishes is taken before they deploy.
- Their hand is never sent, so a teammate's member counts as owned only when it is on the field.
- The strip marks a disc as open only for a popup of its own player.
- A popup opened from the strip closes when the strip changes hands: another teammate, a ‹ › half, or back to your own. A popup opened from a chip stays open, labelled with its own player.
**Layers:**
- **In battle:** the battle runner reports `state().bondLayers = { [playerId]: { [bondId]: n } }`. These are absolute live counts of every bond that grew in a battle it simulates (the own battle, a teammate's display replica), capped at `BOND_LAYER_CAP` (999). They are published when one grows and kept through the round.
- The client lays them over the view's counts with `withLiveLayers`: the higher value wins (a count only grows) and never shows above 999. They apply in the battle phases and SETTLE only.
- 联防 and boss battles disable gains, so they report none.
- **After COMBAT** (`_finishCombat`): until SETTLE, both `m.private bonds` and `m.public players[].bonds` add the finished normal battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` = the result's `layerGains`; `bondsMeta.bondsWithGains`: floored, at most up to the cap, like `settle()`). The strip of a player, and of a teammate watching him in the 联防, therefore keeps the layers his battle reached.
- `ps.bonds` / `ps.layers` are untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too. Since 0.1.3 the 联防 field fights with the same counts: the helpers' battle input takes its bonds from `bondsView()` (`battleInput({ reached: true })`, PRTS §联防阶段 "将以其阵地当前的状态" [ASSUMED] — it used to carry the round-start layers while the strip showed the reached ones); no other rule reads them.
**Data:**
- `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView(), { off: offBondCounts(gd, ps) }) : []`: every bond with members, layers or an active tier (since 0.1.3 also the mode-off bonds with members, `off: true`, last). It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json).
- An eliminated player's list is `[]`, for these reasons:
- nobody can watch them: `g.watch` refuses them and they have no field;
- the result screen reads `m.result`'s own bonds;
- their remaining layers would otherwise cost every `m.public` for the rest of the match.
- Measured in a 4-AI NORMAL harness match (seed 11, round 13, 3 players eliminated): `players[].bonds` is 771 B, against 2424 B without this rule. The review measured 705 B on v2.5.1; the difference is the living player's layers-only entries, which the strip needs.
- There is no new message and no new field. `m.public` stays throttled and deduplicated, so the extra traffic is the living players' layers-only entries plus one change at the end of COMBAT.
- Nothing is exposed that scouting (research 09 §3.1) or watching the battle does not already show.
+5 -2
View File
@@ -1,4 +1,5 @@
// Documentation ⇄ code consistency (docs/DESIGN.md, DATA.md, META.md, SIM.md, README.md, DEPLOY.md, PLAYING.md).
// Documentation ⇄ code consistency (docs/DESIGN.md with docs/design/ and docs/history/, DATA.md, META.md, SIM.md, README.md,
// DEPLOY.md, PLAYING.md).
// Every rule the final documentation sweep corrected is checked twice: the code still behaves as the docs now say, and
// the stale wording does not come back. Topics: combat / boss clocks in REAL seconds (overtime after 150 real s,
// m.public.overtimeAt), 联防 helper order (unite.helperOrder, research 08 §5), boss results handed over instead of a
@@ -48,6 +49,7 @@ import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.
import { RETRY_DELAYS_MS } from '../public/js/data.js';
import { DATA, makeMatch } from './match/harness.js';
import { KIT_FILES } from '../server/sim/content/kits/index.js';
import { designText } from './helpers/designDocs.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const doc = (p) => readFileSync(join(ROOT, p), 'utf8');
@@ -59,7 +61,8 @@ const matchText = () => [doc('server/match/Match.js'), ...readdirSync(join(ROOT,
const playerText = () => [doc('server/match/PlayerState.js'), ...readdirSync(join(ROOT, 'server/match/player')).sort().map((f) => doc(`server/match/player/${f}`))].join('\n');
// the sources of a former kits/tierN.js: its helpers (kits/shared/tierN.js) and its kit files (kits/ops/, kits/index.js)
const tierSources = (t) => [`server/sim/content/kits/shared/tier${t}.js`, ...KIT_FILES[t - 1].map((f) => `server/sim/content/kits/ops/${f}`)];
const DESIGN = doc('docs/DESIGN.md');
// the design document: the index docs/DESIGN.md and its sections in docs/design/ + docs/history/, in § order
const DESIGN = designText(ROOT);
const META = doc('docs/META.md');
const DATA_MD = doc('docs/DATA.md');
const SIM = doc('docs/SIM.md');
+58 -1
View File
@@ -2,11 +2,16 @@
// (inline code, the diagrams and commands in fenced blocks) and every relative Markdown link. A path with a
// placeholder (`<codename>`, `{a,b}`) or a glob (`*`) is checked up to its last fixed directory; a path ending in `/`
// must be a directory. Outputs that a fresh checkout does not have (downloaded art, vendored libraries) are exempt.
// The design document (0.2.0 split): docs/DESIGN.md is the index, every `## N.` section sits exactly once in
// docs/design/ (the current rules) or docs/history/ (the per-release revisions), the index names each file with its
// sections, and every "DESIGN §N" / "DESIGN §N.M" cited anywhere in the repository is a heading of it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { designText, DESIGN_DIRS } from './helpers/designDocs.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Each document with the least number of paths it names (a guard against an extraction that finds nothing). */
@@ -84,3 +89,55 @@ test('the path extraction: placeholders, globs, directories and the exemptions',
assert.equal(missing('server/no-such-file.js'), 'server/no-such-file.js: not found');
assert.equal(missing('server/index.js/'), 'server/index.js/: not a directory');
});
/** The `## N.` section headings of a Markdown text. */
const sectionsOf = (md) => [...md.matchAll(/^## (\d+)\./gm)].map((m) => Number(m[1]));
test('DESIGN: every section is a `## N.` heading exactly once in docs/design/ or docs/history/, and the index names its file', () => {
const index = readFileSync(join(ROOT, 'docs/DESIGN.md'), 'utf8');
const where = new Map(); // § → the file that holds it
for (const dir of DESIGN_DIRS) {
for (const f of readdirSync(join(ROOT, dir)).filter((x) => x.endsWith('.md')).sort()) {
const file = `${dir}/${f}`;
const md = readFileSync(join(ROOT, file), 'utf8');
assert.match(md, /^# DESIGN §\d+(?:, §\d+)* — .+\n\nPart of \[DESIGN\.md\]\(\.\.\/DESIGN\.md\)/, `${file}: its title and the pointer to the index`);
for (const n of sectionsOf(md)) {
assert.ok(!where.has(n), `§${n} is in ${where.get(n)} and in ${file}`);
where.set(n, file);
}
}
}
assert.deepEqual(sectionsOf(index), [], 'the index holds no section of its own');
const max = Math.max(...where.keys());
assert.ok(max >= 25, `sections up to §${max}`);
for (let n = 0; n <= max; n++) assert.ok(where.has(n), `§${n} has a file`);
// the index's table: one row per file, naming exactly the sections that file holds, with a link that resolves
const listed = new Map();
for (const [, secs, text, href] of index.matchAll(/^\| ((?:§\d+(?:, )?)+) \| \[([^\]]+)\]\(([^)]+)\) \|/gm)) {
const file = `docs/${href}`;
assert.equal(text, href, `${file}: the link text is its path`);
assert.ok(existsSync(join(ROOT, file)), `${file} exists`);
const ns = secs.split(', ').map((x) => Number(x.slice(1)));
const held = [...where].filter(([, f]) => f === file).map(([n]) => n).sort((a, b) => a - b);
assert.deepEqual(ns, held, `${file}: the index row names the sections it holds`);
for (const n of ns) listed.set(n, file);
}
assert.equal(listed.size, where.size, 'the index names every section');
});
test('DESIGN: every "DESIGN §N" cited in the repository (code comments, tests, docs, data) is a heading of the design document', () => {
const heads = new Set([...designText(ROOT).matchAll(/^#{2,4} (\d+(?:\.\d+)*)[. ]/gm)].map((m) => m[1]));
const files = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd: ROOT, encoding: 'utf8' })
.split('\n').filter((f) => /\.(?:m?js|cjs|md|json|html|css|ya?ml|sh|bat|ps1|py|txt)$/.test(f) && !f.startsWith('test/golden/'));
let cited = 0;
const bad = [];
for (const f of files) {
if (!existsSync(join(ROOT, f))) continue; // deleted in the working tree, not yet in the index
for (const m of readFileSync(join(ROOT, f), 'utf8').matchAll(/DESIGN(?:\.md)?[ ,]*§ ?(\d+(?:\.\d+)*)/g)) {
cited++;
if (!heads.has(m[1])) bad.push(`${f}: §${m[1]}`);
}
}
assert.ok(cited >= 700, `only ${cited} citations found — is the scan broken?`);
assert.deepEqual(bad, [], 'citations of a section or subsection with no heading');
});
+59
View File
@@ -0,0 +1,59 @@
// test/helpers/designDocs.js — the design document as one text, for the tests that pin its sentences.
//
// import { designText } from '../helpers/designDocs.js';
// const DESIGN = designText(ROOT); // the index's preamble, then every `## N.` block in § order
//
// docs/DESIGN.md is the index since 0.2.0: the current rules live in docs/design/*.md, the per-release revisions in
// docs/history/*.md, and every `## N.` block (its heading, text and the `---` after it) sits in one of those files
// verbatim. designText() puts the blocks back in section order (not file order — the tests slice it as `## n.` →
// `## n+1.`), so from `## 0.` on it is the pre-split DESIGN.md byte for byte. A section found twice throws.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const HEAD_RE = /^## (\d+)\./;
/** The folders that hold the `## N.` blocks, after the index. */
export const DESIGN_DIRS = ['docs/design', 'docs/history'];
/** Split a Markdown text at its `## N.` headings: { preamble, blocks: Map<N, text> } (each block ends before the next heading). */
export function splitBlocks(text) {
const lines = text.split('\n');
const blocks = new Map();
let cur = null;
let buf = [];
const preamble = [];
const flush = () => {
if (cur == null) preamble.push(...buf);
else {
if (blocks.has(cur)) throw new Error(`§${cur} appears twice`);
blocks.set(cur, buf.join('\n'));
}
buf = [];
};
for (const line of lines) {
const m = line.match(HEAD_RE);
if (m) { flush(); cur = Number(m[1]); }
buf.push(line);
}
flush();
return { preamble: preamble.join('\n'), blocks };
}
/** The design text in § order: the index's preamble, then every part file's blocks sorted by section number. */
export function designText(root) {
const index = readFileSync(join(root, 'docs/DESIGN.md'), 'utf8');
const all = new Map();
const { preamble, blocks: own } = splitBlocks(index);
for (const [n, t] of own) all.set(n, t);
for (const dir of DESIGN_DIRS) {
if (!existsSync(join(root, dir))) continue;
for (const f of readdirSync(join(root, dir)).filter((x) => x.endsWith('.md')).sort()) {
for (const [n, t] of splitBlocks(readFileSync(join(root, dir, f), 'utf8')).blocks) {
if (all.has(n)) throw new Error(`§${n} appears twice (${dir}/${f})`);
all.set(n, t);
}
}
}
return [preamble, ...[...all].sort((a, b) => a[0] - b[0]).map(([, t]) => t)].join('\n');
}
+3 -1
View File
@@ -9,6 +9,7 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { PHASE } from '../../shared/constants.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds, bondList, bondSnapshot, bondsWithGains, HARMONY_BOND } from '../../server/match/bondsMeta.js';
@@ -17,6 +18,7 @@ import { tileKey } from '../../server/match/board.js';
import { validateClientResult } from '../../server/match/fields.js';
import { bandBondIds as bandBondIdsOf } from '../../shared/bandBonds.js';
import { DATA, makeMatch, give, legalTileFor, checkInvariants } from './harness.js';
import { designText } from '../helpers/designDocs.js';
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (调和)
const members = (bond, n) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && !c.bonds.includes(HARMONY_BOND)).map((c) => c.chessId).sort().slice(0, n);
@@ -256,7 +258,7 @@ describe('§21.26 found on the way — the client-result layer bound reads the l
test('§21.26 docs: DESIGN (the subsection and the normative lines), META, PLAYING and the CHANGELOG say what the code does', () => {
const doc = (p) => readFileSync(new URL(`../../${p}`, import.meta.url), 'utf8');
const DESIGN = doc('docs/DESIGN.md');
const DESIGN = designText(fileURLToPath(new URL('../../', import.meta.url))); // the index + docs/design/ + docs/history/
const at = DESIGN.indexOf('### 21.26 Gaps found while triaging GitHub issues #1 / #8 (v0.1.1)');
assert.ok(at > 0, 'the subsection');
const s = DESIGN.slice(at);
+2 -1
View File
@@ -67,7 +67,8 @@ test('the real tracked tree: runtime in, the rest out; every shipped import, npm
for (const f of ['tools/golden.mjs', 'tools/package.mjs', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'eslint.config.js', 'Dockerfile']) {
assert.ok(!got.has(f), f);
}
for (const f of got) assert.ok(!/^(?:test|handoff|\.github|types|public\/dev|docs\/img)\//.test(f), f);
// the design document (the index docs/DESIGN.md and its parts in docs/design/, docs/history/) stays out too
for (const f of got) assert.ok(!/^(?:test|handoff|\.github|types|public\/dev|docs\/img|docs\/design|docs\/history)\//.test(f), f);
const code = [...got].filter((f) => /\.(?:m?js|py)$/.test(f));
for (const f of code) {
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');