248 KiB
卫戍协议:盟约 — Web Remake · Architecture & Contracts (DESIGN.md)
This is the single source of truth for every implementer. Research lives in docs/research/ (start with 00-INDEX.md; where a research file's body and its "Addendum (critic)" disagree, the addendum wins). When this document and research disagree, this document wins; when this document is silent, follow research; when both are silent, choose the simplest faithful behaviour and write it down in the module's header comment.
Language: all player-facing text is Simplified Chinese (names/descriptions come from official data). Code, comments and identifiers are English.
Versions: the first public release is 0.1.0 (package.json, shared/constants.js APP_VERSION) — the state described by this document, §20.15 included. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
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=CODElink, host picks difficulty, up to 4 players, AI teammates can fill seats, ready/start). Solo (独立模拟) and co-op (同盟模拟, 1–4 humans + optional AI). - Difficulties: 标准 FUNNY, 险境 NORMAL, 绝境 HARD, 终极 ABYSS (solo and multi variants from
modeDataDict). Training/tutorial mode is out of scope. - Pre-game: INFO_CHECK briefing (disabled bonds, banned operators, enemy factions, stage) → BAND_CHECK strategy draft (40 bands, random order, 1 skip in co-op) → BATTLE_CHECK.
- Rounds 1–14 (+ hidden 15): prep (shop, hand, board, items, freeze, refresh, level-up, sell, merge + reward pick, ready), 机变 draft rounds, simultaneous auto-combat on each player's own field, 联防 unite phase, LP loss/elimination, Final Assault boss round with merged LP and shared boss HP pool, Hidden Core, settlement with titles.
- Full content: 112 visible chess (+ elites), their default skills and talents, 23 bonds with layers, all 43 特质 (garrison) effect keys, 56 equipment + Arts, 40 bands, 机变 cards, enemy factions/special enemies, 10 bosses, stage devices/terrain (crates, blowers, mire, smog, deep sea, infection).
- Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker.
- Reconnect, AI take-over of disconnected players, robust validation of every client intent.
Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the pool), trophies/progression persistence, reporting.
1. Tech stack
- Node.js ≥ 18 (tested on 22), ESM (
"type": "module"), single dependencyws@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, globalPIXI),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-coredriving the system Chrome (dev dependency, optional).
Run: npm install && npm run assets && npm start → http://localhost:3000. Friends on LAN use http://<host-ip>:3000. Internet play: a tunnel (e.g. cloudflared tunnel --url http://localhost:3000) or any Node host (Dockerfile provided).
2. Repository layout & ownership
server/
index.js HTTP static server (gzip for .skel/.atlas/.json/.js/.css), WebSocket upgrade at /ws, boot
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
lobby.js rooms (4-letter codes), seats, host, AI seats, ready/start, reconnect tokens, room→Match wiring
data.js loads data/*.json once, builds indexes (getChess, getBond, …); frozen objects
match/
Match.js match state machine, timers, round loop, co-op orchestration, broadcasting views
PlayerState.js per-player economy/shop/hand/board/items/bonds/LP state + all prep-intent handlers
pool.js SharedPool (copies per base chess), banned/disabled bonds, odds & rolls
board.js placement legality, deploy cap, hand/temp management, merge detection & execution
bondsMeta.js bond member counting (BOARD / BOARD_AND_DECK / golden), activation tiers, persistent layers
effectsMeta.js registry + dispatcher of prep-phase ("SERVER_*") effects: garrisons, bands, items, bonds
choices.js 机变 draft: card generation per family, pick order, timeouts, application
waves.js match enemy factions, per-round spawn list generation, stat scaling, bounties
unite.js 联防 helper selection, leaked-enemy aggregation, LP attribution
finalAssault.js R14/R15 pairing, merged LP, shared boss HP pool, overtime drain
results.js settlement stats, titles (评语)
bot.js AI player (plays prep phases; also auto-play for disconnected humans)
sim/
Battle.js one field simulation (normal / unite / boss); public API §5
constants.js TICK, conversions, tuning knobs
rng.js seeded PRNG (mulberry32) + helpers
grid.js field grid, tile queries, passability, pathfinding (8-dir, no corner cutting)
units.js Unit / Operator / Enemy / Token classes, stat aggregation
buffs.js Buff model, status catalogue (stun, freeze, cold, sleep, slow, fragile, stealth, …)
damage.js damage & heal pipeline, element gauges
targeting.js target selection helpers, range tests, priorities
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/tier1.js … tier6.js operator kits (skill + talents) keyed by base chessId
tokens.js summon/token definitions
bonds.js battle side of the 23 bonds
garrisons.js battle side (IN_BATTLE) garrison effect keys
items.js battle side of equipment + Arts
bands.js battle side of band (strategy) effects
enemies.js enemy ability specs + generic special-type behaviours
bosses.js boss scripts (boss_1 … boss_10)
devices.js stage devices & special terrain
shared/
constants.js enums, phases, areas, error codes, UI-relevant constants
protocol.js message catalogue + validators (§8)
format.js rich-text description → HTML/plain; number formatting
rules.js pure helpers shared by UI & server (e.g. price display, sell value, bond tier from count)
data/ generated by tools/build-data.mjs — committed; see docs/DATA.md
public/
index.html single page; loads /js/main.js as module
css/ theme.css (tokens, fonts), components.css, screens/*.css
js/
main.js boot, router between screens, global store
net.js WebSocket client, reconnect, request/response helpers
store.js tiny observable store (state from server + local UI state)
data.js fetches /data/*.json, same indexes as server/data.js
audio.js BGM/SFX manager (Web Audio), volume settings
assets.js asset URLs, image cache, Spine loader with LRU + fallback
screens/ title.js, lobby.js, room.js, briefing.js, bandDraft.js, game.js, result.js
ui/ hud.js, bondStrip.js, bondPopup.js, shopBar.js, teamPanel.js, detailPanel.js,
tooltip.js, choiceOverlay.js, rewardOverlay.js, ticker.js, emotes.js, settings.js, toasts.js
render/ app.js, projection.js, tiles.js, units.js, spine.js, fx.js, interp.js, drag.js, 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.
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
Sat (9,10) and (12,10) (R1–3 only the lower one); protection objectiveEat (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.
- Normal field (own board): rows 9–12, cols 2–10. Enemy gates
- Tile semantics come from
data/stages.jsonlegend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). Melee chess may stand only on LOW tiles with buildable ALL/MELEE; ranged chess on ALL/RANGED and on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their ownposition. - Facing (corrected, see research 09 §1.2): like standard Arknights, every board piece has a direction
dir ∈ UP|RIGHT|DOWN|LEFTchosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); defaultRIGHT; 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);rangeExtendapplies along +dCol before rotating. The right-side Final Assault player is mirrored colc→20−cwith 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 (datahitArea: 巨型单位, 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
Battlereceives a field rect (rows/cols subset) and the full stage grid. Normal battles use rows 9–12 × cols 0–10; unite uses rows 9–12 × cols 0–20; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect. - Speed:
tilesPerSecond = moveSpeed × MOVE_SCALEwithMOVE_SCALE = 0.5(insim/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'smaxPlayTime; alsomodes[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 passes2 × valuegame 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 (bossOvertimeAfter150 /bossOvertimeDrainPerSec1 = officialbossTurnHpReduceTime) 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.pausedthe running battle's field clock, the HUDdeadline/overtimeAtand 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 asdeadline(ms epoch) +serverNowfor clock-offset correction. - Snapshots to clients: every 3 ticks (= 20 Hz real) per watched field.
5. Simulation (server/sim) — engine contract
5.1 Battle API (implemented by the sim-core owner; consumed by match & content)
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:
{ playerId, seat, side: 'L'|'R',
colOffset, // 0 for own field; +8 for the right half in unite/boss
units: [ { uid, kind: 'chess'|'token', chessId /* golden id if elite */, tokenId?, row, col,
items: [itemId...], // equipped items (golden ids when merged)
carryState?: { hpPct, sp, skillActive } | { down: true } } ], // unite: helper units keep end-of-combat state;
// { 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
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:
{ 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 } ] } },
bossHpLeft? }
5.2 Units & stats
Unit fields (minimum): id (int, unique per battle), side ('ally'|'enemy'), kind ('op'|'token'|'enemy'|'device'), defId, ownerId (playerId; enemies: the field owner / source), x, y, tileR, tileC (ops), dir (allies: UP|RIGHT|DOWN|LEFT deploy direction, §3; sim/dir.js), facing (derived horizontal sign +1 / −1: LEFT ⇒ −1), hp, alive, sp, buffs[], statusFlags, blocking[] (ops), blockedBy (enemies).
Base stats (from data): maxHp, atk, def, res (0–100), aspd (100 base), bat (base attack time, s), blockCnt, rangeGrid (ops) / rangeRadius (enemies, 0 = melee), moveSpeed, massLevel, weight, lpr (enemies), dmgType ('phys'|'arts'|'heal'|'true'), motion ('WALK'|'FLY'), cost, respawnTime, spRecovery.
Aggregation (units.js, recomputed lazily when buffs change):
ATK = (base + Σflat) × (1 + Σpct) × Πmul— same shape formaxHp,def; the 卫戍 systems' "+X%" ATK / DEF / max HP (盟约, 策略, 装备, 机变 cards, per-layer 特质) are 直接乘算 and go intoΣpctwith the skills' "+X%" (sim/constants.js DIRECT_BONUS_STACKING 'add', content/supportdirectMods; 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_MIN20: PRTS 数值范围 "ATTACK_SPEED 默认下限 20", §20.3).interval = bat × (1 + ΣbatPct) × 100 / aspd(blackboardbase_attack_timeis treated asbatPct, e.g. −0.3 → 70%).- Changing
maxHpkeeps 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[] }
modskeys (all optional, additive within a key):atkFlat, atkPct, atkMul, defFlat, defPct, defMul, hpFlat, hpPct, hpMul, resFlat, resMul, aspd, batPct, blockCnt, rangeExtend (tiles), moveMul, dmgDealtMul (multiplicative, e.g. 1.2), dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, dodgePhys, dodgeArts, healingDealtMul, healingTakenMul, spRecoveryFlat, spRecoveryMul, maxTargets (+n), shield (absorbs damage; consumed).flags:stun, freeze, sleep, silence (no skills), disarm (no attacks), stealth (untargetable by enemies/ops unless blocked or revealed), camou (an ally's 迷彩: like stealth for enemy targeting and on screen, but not 隐匿 for 隐匿 conditions — 叙拉古, 家族徽章; §20.2), 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: 禁疗 summons and 孤立 units; an HP-regen attribute still applies — PRTS 安洁莉娜 备注), 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/neuralelement gauges (see damage),stealth,camou(迷彩, its own keys: 忍冬 S3vulpis:camouuntil her next cast, 寒芒克洛丝 S1),reveal. - Immunities from enemy data (
stunImmune,silenceImmune,sleepImmune,frozenImmune,levitateImmune) are honoured byapplyStatus. - 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).
5.4 Hook bus (the only way content plugs in)
battle.on(name, handler, { priority, owner }); handlers run in descending priority, then registration order. Handlers receive a mutable ctx. Events:
| name | ctx | notes |
|---|---|---|
battleStart |
{} |
after initial deployment |
deploy |
{ unit, initial } |
ops/tokens/enemies spawn |
tick |
{ dt } |
every tick after movement & attacks |
beforeAttack |
{ attacker, targets } |
may modify targets |
attack |
{ attacker, targets, isSkill } |
an attack was performed |
hit |
{ source, target, dmg } |
before mitigation; mutate dmg (DamageInfo) |
damaged |
{ source, target, amount, type, dmg } |
after application |
heal |
{ source, target, amount } |
before application (mutable amount) |
kill |
{ killer, victim } |
victim hp reached 0 |
death |
{ unit } |
unit removed (ops may redeploy later) |
skillStart / skillEnd |
{ unit, skill } |
|
ammoUsed |
{ unit, left } |
per ammo consumed |
spGain |
{ unit, amount } |
mutable |
statusApplied |
{ source, target, status, duration } |
after immunity check |
blocked |
{ blocker, enemy } |
enemy became blocked |
enemySpawn / enemyLeak |
{ enemy } |
|
battleEnd |
{ result } |
may add layer gains / coins |
DamageInfo: { amount (pre-mitigation), type: 'phys'|'arts'|'true'|'element', element?: 'burn'|'necrosis'|'neural'|'apoptosis', atkScale, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul (multiplier), canDodge, isSkill, isSplash, tags[] }.
Engine helpers (content must use these, never touch internals): battle.dealDamage(source, target, dmgInfo), battle.heal(source, target, amount, {overheal=false}), battle.applyStatus(target, key, { duration, source, value }), battle.addBuff(unit, buff), battle.removeBuff(unit, key), battle.spawnToken(ownerUnit|playerId, tokenId, row, col, opts), battle.spawnEnemy(enemyKey, { routeIndex, pos, mods, tag }), battle.addProjectile({ from, to|target, speed, onHit, visual }), battle.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; 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), 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 }) (流失; 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), battle.after(seconds, fn) (scheduled callbacks), battle.every(seconds, fn).
5.5 Combat rules (engine)
- Initial deployment at t=0, free: operators row-major top→bottom then left→right (right-side boss player: right→left), then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物";
aggroSeqranks 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 (deploywithinitial: true). Then operators carried into 联防 knocked out (carryState.down) are forced out (FORCED_EXIT, HP 0, §19.3). ThenbattleStart, 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 its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). A knocked-out operator stays down on its tile from the knock-out until it redeploys (b.snapdown, §18.3: counting / waiting for DP / its tile taken) — 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 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. 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 usesblockCntcapacity (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 — 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). The huge leaders are 自缚 + unblockable (§7). - Operator attacks: every
intervals if a valid target exists; targets = up tomaxTargets(default 1) fromunitsInGrid(rangeGrid), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers,traittext parsed by build-data intotargetPriority), (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. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact. - Healers (
dmgType heal): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.jsonabnormal,noHeal; §20.3); do nothing otherwise (SP still regenerates). - Enemy AI: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (
rangeRadius > 0) attack ops 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); they pause movement forATTACK_PAUSE = 0.35 sper attack. 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); artsmax(A × (1 − R'/100), 0.05·A)withR' = 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, notdmgTakenMul(脆弱 = "受到的物理、法术、真实伤害提升"). 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 withceil(final) ≥ 300000is cancelled — no HP or pool loss, no credit, no damage number, nodamaged/ kill (shared/constants.js BOSS_HIT_LIMIT,damage.js leaderHitCancelled, after every multiplier and the hit-step blocks, before HP shields;loseHptoo — a part's 传递, a drone's death; the client'sMAX_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), 损伤抵抗 = dataepResistance(PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.jshasHp: 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.jsELEMENT; 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 (
sourcePlayerIdfor unite). LP accounting is done by the match, not the battle. - End: all spawns done and no enemies alive ⇒
cleared;time ≥ timeLimit⇒timeout(remaining enemies = leaked). Boss battles end when the shared boss pool reaches 0 (victory) or the match forces an end (team LP 0). 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.
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), TAKE_DAMAGE (on taking damage — every MANUAL 重装 skill), SP_FULL (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, and AUTO DP skills such as 伺夜 S1, 焰尾 S1, 凛御银灰 S1), CUSTOM_RANGE (enemy inside the custom grid), SEARCH (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills), 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. PASSIVE skills are always on.
SkillSpec (authored per chess in sim/content/kits/tierN.js; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a function of the blackboard):
// kits map: baseChessId -> (bb, chess) => Kit
export default {
chess_char_1_01: (bb, chess) => ({
skill: {
kind: 'duration' | 'ammo' | 'instant' | 'charges' | 'passive' | 'toggle',
duration: bb.duration, // duration kind
ammo: bb['attack@trigger_time'], // ammo kind
mods: { atkPct: bb.atk, batPct: bb.base_attack_time }, // applied while active
targeting: { maxTargets, rangeGrid, priority: 'fly'|'ranged'|'lowestHp'|'highestHp'|... },
attack: { dmgType, atkScale, splashRadius, hits, projectile: 'arrow'|'bolt'|'none'|'beam', onHit(ctx) },
onStart(ctx) {}, onEnd(ctx) {}, // bursts, summons, heals, statuses (use engine helpers only)
},
talents: [ { install(battle, unit) {} } ], // passive/conditional talents via hooks
trait: { ... } // optional overrides of profession trait behaviour
}),
}
The kit loader passes bb = flattened blackboard { key: value } of the chess's skill (normal or elite), and chess = data record (for talents' blackboards). Missing kit ⇒ a generic kit derived from blackboard keys (atk/attack_speed/def/max_hp/base_attack_time/atk_scale/max_target/duration/ammo) so every chess always works.
Profession traits (default behaviours implemented once in the engine, keyed by subProfessionId): e.g. fastshot (fly priority), aoesniper/splashcaster (splash 1.1 tiles), chain caster (bounce ×3, −15%), funnel, blastcaster, physician (single heal), ringhealer (aoe heal), bard (aura heal, no attack), slower (slow on hit), 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).
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;
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
{ playerId, seat, name, isBot, connected,
alive, lp, bandId,
funds, shop: { level, upgradePrice, slots: [ Slot ], frozen: bool, freeRefreshes: n, rewardOffer: null | { slots:[Slot], tier } },
hand: [ Piece|null ×10 ], temp: [ Piece|null ×5 ],
board: Map<'r,c', Piece>, // chess & token pieces on the normal field
bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match
effects: [ EffectRef ], // band/choice/team/garrison persistent effects & counters
counters: { refreshes, buys, sells, … },
stats: { … for results } }
Slot = { kind:'chess'|'item', id, price, sold:false, frozen }
Piece = { uid, kind:'chess'|'item'|'token', id /* chessId (golden id if elite) | itemId | tokenId */,
items:[itemPiece...] (chess only, max 2), count (token stacks), ownerUid (tokens), boughtRound, meta:{} }
Intent handlers (validate → mutate → recompute bonds → push private view): buy(slot), refresh(), freeze(), levelUp(), sell(uid), move(uid, to), equip(itemUid, targetUid, replaceUid?), useArt(itemUid, row, col), destroy(uid), pickReward(idx), pickChoice(idx), setReady(bool). Every handler rejects when the phase forbids it (only PREP allows board/shop mutations; SP_DRAFT allows pickChoice only; reward pick allowed in PREP). Invalid intents return error with a code from shared/constants.js → ERR — the server never throws on bad input.
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.
Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item replaceUid names (the replace dialog's pick, official UseEquipUp.unloadInstId; absent ⇒ the oldest; a uid not equipped on the target ⇒ BAD_TARGET, nothing changes) and the replaced item is destroyed. Equipped items are otherwise locked: destroy (销毁) accepts hand / temp items only (BAD_TARGET 'equipped items are locked'); equipment goes back to the hand only when its carrier merges or is sold (a sale is refused with HAND_FULL when there is no room for it).
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income min(3+r,12); prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite 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); purchase completing a merge allowed with full hand; full hand blocks buy; overflow to temp; temp blocks ready).
Temp overflow (临时整备区, row 8; user playtest #3 item 3). Passive gains that find the hand full (merge elites, grants, battle-result rewards, equipment returned by a merged / sold 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_DECKbonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count, etc. (research 02).tierfrom thresholds;active = tier ≥ 1.layers[bondId]persist all match; gains come from garrisons/bands/items/choices/bond effects; IN_BATTLE gains are collected fromBattleResult.layerGainsat SETTLE (disabled in unite/boss/hidden).- A bond holds at most
BOND_LAYER_CAP= 999 layers (the client'sMAX_GARRISON_STACK, §20.12): every writer —PlayerState.addLayers, the SETTLE merge,Battle.addLayers, the dev tools — addslayerGainRoom(before, n)= min(n, 999 − before); a gain at the cap adds 0 and dispatches nothing (no onLayers, nolayerGainhook, 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.
6.5 Waves (waves.js)
Per match: pick stage (weighted among the mode's config.modes[m].stages, weight 50 each: 标准 FUNNY = 战场#01 act1autochess_m01 only, 险境 NORMAL = 8 stages, 绝境 / 终极 = 7 without 战场#01 — waves.js setupMatchWaves, test/match/stagepick.test.js), 3 of 6 typed enemy factions, disabled bond set (3 core + 4 add-on for NORMAL+, per research), boss (weighted), hidden boss. Per round: wave template (by mode & round) → replace placeholders per research 05 §3.2 algorithm → apply stat multipliers (research 00-INDEX §5 atk ×base×1.1^k, hp ×base×1.2^k, ABYSS speed ×1.15 from R3) → SpawnSpec[] + routes resolved on the stage. (Superseded by the official generator — §14 "Corrections from research 08/09" and the waves.js header.) A player's normal battle (and the bot rehearsal) runs withBounties(gd, round, wave, bounties, playerId): the bounty units inserted among their host action's own units, which are re-timed around them (client order); previewOf keeps one entry per action (wave.spawns + bountySpawns).
6.6 AI players (bot.js)
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy: pick a band; each prep: level up on schedule, buy chess that raise bond counts or complete merges, sell leftovers, place melee on road tiles nearest the enemy path, ranged on highland/others, equip items to highest-tier ops, ready. A bot's whole prep — shop decisions and layout planning (botPrepBeginSteps, 25–70 ms late in a 4-bot match), the layout rehearsal and botPrepEndSteps — runs in wall-clock slices of botSliceMs (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time).
7. Content modules (server/sim/content + match/effectsMeta handlers)
Each domain file server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js exports install(battle) (battle side; called once per Battle by content/index.js → installContent) and registerMeta(registry) (prep side; called once at server boot by match/effectsMeta.js, API documented in docs/META.md). Kits live in content/kits/tier1..6.js (export default { [baseChessId]: (bb, chess) => Kit }). Content must be data-driven from blackboards (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in test/content/*.test.js using the test/helpers/battleHarness.js harness (provided by sim-core).
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens.
- bonds: 23 (battle + meta), exact per-layer formulas from research 02.
- garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry).
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from
data/enemies.json; bosses 1–10 scripted — 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); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (PART_TRANSFER1, 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). 悬赏决策 = cards.bounty
draft(the PRTS 敌人轮选 table: no 战术特训, no 鸭爵 set; 105 cards), drawn uniformly; bounty cards carrydescRaw; a multi-round card lasts two battles (choices.js MULTI_ROUND_BOUNTY_BATTLES, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken).
8. Network protocol (shared/protocol.js is normative)
Transport: one WebSocket per tab at /ws, JSON text frames { t: '<type>', ...payload }. Client requests may carry rid (request id); the server's direct reply echoes rid (ok or error). Server pushes have no rid. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with error RATE).
8.1 Session & lobby
C→S: hello {name, token?, version} · room.create {mode:'solo'|'coop', difficulty} · room.join {code} · room.leave · room.ready {ready} · room.setDifficulty {difficulty} (host) · room.addBot / room.removeBot {seat} (host) · room.start (host) · room.loadout {entries} (§16: any time in the lobby / room and during INFO_CHECK, then WRONG_PHASE; heavy rate-limit bucket; the match exposes it as m.private.loadout) · ping {c}
S→C: welcome {playerId, token, name, serverNow} · room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]} · room.closed {reason} · pong {c, s} · error {rid?, code, msg} · ok {rid}
Platform additions (implemented): welcome.{resumed, version}, room.state.inMatch, error.detail; room.closed.reason ∈ 'timeout'|'shutdown'; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's room.start counts as the host being ready. After any (re)hello the server pushes room.state and, if a match runs, the match resends m.public/m.private (+m.field, or the field's b.start under client-side combat). Reconnect window: a dropped session stays resumable for 10 min (net.js NET_DEFAULTS.reconnectWindowMs; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its solo room's match runs stays resumable for the official config.constants.singleReconnectTime (86 400 s = 24 h; lobby option soloReconnectWindowMs; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become match.onLeave (co-op: 中途退出 = elimination; solo: the run ends 'abandoned'). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, start/handle/onDisconnect/onReconnect/onLeave/dispose) is documented verbatim at the top of server/match/Match.js and is normative. server/data.js provides getData() + index getters (getChess, getBond, getGarrison, getItem, getBand, getEffect, getEnemy, getWave, getStage, getBoss, getToken, getConfig, getMode). Note: the skill trigger rule lives at chess.skill.trigger.rule in data.
8.2 Match
C→S (all carry rid): g.infoReady · g.band {bandId} · g.bandSkip · g.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}], 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] }(the units on the field, knocked-out operators waiting to redeploy included —Battle.fieldMeta, §18.3)- NOTE: game time in
b.snap/b.evis carried asgt(the frame'stis the message type). 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]]— knocked-out operators waiting to redeploy,state0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken (sim/constants.jsDOWN_STATE);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),['layer', playerId, bondId, n],['bounty', playerId, coins],['deploy', id]m.toast {kind, text}·m.ticker {text}·m.emote {playerId, id}·m.result {...}m.unitStats {seq, round, units: [unitStatsEntry]}— the answer tog.unitStats(seqechoed): every own board operator / summon by uid with{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, base: {…same}}(shared/protocol.jsunitStatsEntry) 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? } (dir = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; skillIndex = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (anims.skills[i]) and the audio its own ON_SKILL_START sound; moduleId = an elite ally's equipped module (uniEquipId | 'none'); both also on the prep-scouting m.field units (Match.prepFieldMeta), so a teammate's unit shows its owner's loadout in the detail card).
8.3 Exact view shapes (normative for match ⇄ UI)
m.private:
{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */,
shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes,
frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ],
rewardOffer: null | { tier, slots: [ { kind: 'chess', id, price: 0, sold } ] } },
hand: [ Piece | null ], // length 10, index = hand slot
temp: [ Piece | null ], // length 5
board: [ Piece & { row, col, dir } ], // dir ∈ UP|RIGHT|DOWN|LEFT (§3; bench pieces carry none)
deployCap, deployCount,
bonds: [ { bondId, count, active, tier, layers, thresholds, countsHand } ], // sorted: active first, then layers desc
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
nextEnemies: [ { enemyKey, count, tag } ], // preview of the upcoming round's wave (after waves.js generation)
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { uid, id } ] /* chess only */, count /* token stacks */, ownerUid /* token */ }
m.public.players[].status ∈ 'acting' | 'ready' | 'deciding' | 'combat' | 'done' | 'helping' | 'left' | 'dead'.
m.public.fields[]: { fieldId, kind: 'normal'|'unite'|'boss'|'hidden', players: [playerId…], live: bool } — fieldId is 'n:<playerId>' for normal fields, 'u' for the unite field, 'b1'/'b2' for boss fields. Clients g.watch a fieldId; default = own field (or the unite/boss field they are in).
flags bitmask: 1 blocked, 2 stunned, 4 frozen, 8 stealth (隐匿, or an ally's 迷彩 — §20.2), 16 skillActive, 32 shielded, 64 invulnerable, 128 cold, 256 sleep, 512 flying.
anim small int: 0 idle, 1 move, 2 attack, 3 skill, 4 die, 5 stun, 6 deploy.
9. Client rendering contract (public/js/render)
import { createFieldView } from './render/app.js'
const view = await createFieldView(canvasHost /* HTMLElement */, { data, assets, audio })
view.setStage(stage) // draw tiles (procedural), devices
view.setCamera(kind /* 'prep'|'normal'|'unite'|'boss' */, { rect, side }) // animates camera
view.setPrep(privateState, { editable }) // render hand/temp/board pieces as chibis (spine or fallback); editable enables drag;
// 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); 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. 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), 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); 机变 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; phase banners; ticker; toasts; emote wheel; settings (volume, quality, show damage numbers)), Result (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, 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.
11. Quality bar & testing
- No crash paths: every server handler guarded; a thrown error inside a Battle tick for one field must not kill the match (log, force-end that battle as timeout, continue). Invariants asserted in tests: no NaN/Infinity in any unit field; hp ∈ [0, maxHp]; positions inside rect; battles terminate within
timeLimit + 1 s(boss: terminate by pool/force); pool copy counts never negative and never exceed caps; funds never negative; hand/temp sizes respected. - Unit tests per module; content tests per effect; full-match bot tests: 1/2/4-player matches of every difficulty with fixed seeds run to RESULT headlessly (fast-forward: no real-time pacing) with zero errors; protocol fuzz (random/invalid intents) never crashes and never corrupts state.
- Browser E2E (puppeteer-core + system Chrome): open 2 tabs, create/join room, add AI, play through prep/combat of several rounds, zero console errors, screenshots saved to
test/e2e/out/for visual review. - Performance: a normal battle tick with 60 enemies + 10 ops < 0.5 ms; 4 parallel fields at 2× real time < 15% CPU of one core.
12. Implementation phases
- Foundation: build-data, fetch-assets/vendor, server platform (http/ws/lobby), client shell (screens up to room).
- Core: sim engine + harness; match/meta; render engine; game UI.
- Content: kits, bonds, garrisons, items, bands, enemies/bosses/devices, choices.
- Integration & QA: wire everything, bots, E2E, balance pass, bug-hunt workflows until dry, visual polish.
13. Local-client art (optional, host-side)
tools/local-extract/extract.py (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom LZ4AK bundle compression via aklz4.py) extracts art that no web source has from a locally installed client (CrossOver Windows build or PlayCover iOS build) into public/assets/local/** and writes data/local-assets.json ({ groups: { '<subdir>': { name: { path, w, h, kind } } } }). Everything here is optional: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art).
map/autochess/TX_autochessi_*— the real autochess board atlas (_Ddiffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates;_N/_M/_Enormal/mask/emission;common_Ddevice icons;BGbackdrop).map/autochesssand/*— the sand-theme variant.ui/{common,battle,outer}/*— every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).emoticon/{basic,basic_2}/pic_<id>_battle.png— the 12 official battle emotes (shared/constants.js → EMOTES,emoteArtPath).guide/autochess_{home,shop,handbook}_N.png— the official tutorial pages; stored squashed to 1024² — display at 16:9.projectiles/*— battle projectile sprites.module/<TYPE>— the official module (uniequip) TYPE icons (white glyphs; keys are the client's mixed-case file names, e.g.PRI-X,mar-x,isw-a), matched case-insensitively against a ModuleRecord'stypeNamebyassetUrls.moduleTypeIconUrl(local, typeName)(Greek type letters map to the file's Latin letter:ISW-α→isw-a) — shown on the 干员调配 module cards / module info, the detail panel's 模组 row and the shop card's module tag; lettered tiles / type text without them.
14. Client-side combat (v2 architecture — supersedes the combat-streaming parts of §4, §8.2, §9)
Why: like the official client, each player's browser simulates its own battle locally and uploads the result. The server no longer paces battles in real time nor streams b.snap/b.ev for combat, so a low-power host (e.g. a Windows mini PC) is enough, and combat rendering has no network jitter. Measured (4 human seats, HARD, real sim, in-process): server CPU per combat round ≈ 170–360 ms → ≈ 1 ms; server→client traffic ≈ 2–3.3 MB → ≈ 0.25 MB per round. Browser cost ≈ 0.05–0.1 ms per tick (≈ 3–6 ms per real second at 2×) on the main thread (no worker needed).
Modes. Match option clientCombat (default on; env SP_COMBAT=server → the legacy server-run + streaming mode, kept as a fallback). m.public.combatMode = 'client' | 'server' tells the UI which one runs.
Shared deterministic sim. server/sim/** is pure ESM runnable in both Node and browsers. Node-only code (reading data/*.json from disk, research fallbacks) lives in server/sim/nodeData.js, imported dynamically by simdata.js under Node only; the browser injects its own frozen copies of /data/{chess,enemies,tokens,stages,waves,bonds,items,garrisons,bands,effects}.json via setSimData(data) (getSimData() serves them). The HTTP server serves server/sim/** read-only at /sim/ (.js only, no listings, nodeData.js excluded under any spelling — the on-disk name decides, for case-insensitive hosts) and a generated /data.js stand-in of server/data.js (the content support module imports ../../../data.js; in the browser it returns getSimData()). A battle is fully described by a JSON BattleSpec (server/sim/spec.js buildBattleSpec: { v, battleId, fieldId, kind, seed, modeId, round, stageId, rect, timeLimit|null, players:[PlayerBattleInput], spawns, routes, flags, enemyOverrides, waveId, bossId, content, boss?: { poolHp, poolMax } } — the JSON round trip of the Battle options; ±Infinity → ±1e308, spawns at time Infinity dropped) and constructed with createBattleFromSpec(spec, dataSource, { sharedBoss? }) identically on both sides; the server builds its own battles from the spec too, so both run exactly the same input. No wall-clock or Math.random inside the sim. Verified: spec-built battles give identical result digests (resultDigest) in any construction order; whole client-combat matches are outcome-identical to server-run matches (same seed).
Authority per field.
- Normal field: the owning human's client is authoritative (if connected).
- Unite field (联防) and boss/hidden pair fields: the lowest-seat connected human in that field is authoritative; every other human in (or watching) that field receives the same spec and simulates it locally for display only.
- Bots, departed/disconnected players, fields with no connected human: the server simulates. Normal and 联防 fields headlessly (
fields.js HeadlessJob, with a[gt, killed, total(, left)]progress timeline —left: the 联防 leakers' enemies still standing, §20.6) in wall-clock slices ofheadlessSliceMs(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 / speedreal 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 aCreditPoolthat 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 getsb.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 asopts.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/b2in 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 lateb.progress/b.resultof 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"), ong.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 tofields.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-lessb.progresswith 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;fitResultkeeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks inisBattleResult;server/net.jsroutesb.*frames to the match (lobbyrouteGame). - S→C
b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }(boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile theirLocalBossPool: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (BOSS_POOL_MIN_HP:SharedBossPoolandLocalBossPool; 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 callsbattle.forceEndand reports;'takeover': stop reporting, keep displaying. m.public.fields[]gainsprogress: { killed, total, done };m.public.combatMode;m.public.paused: boolean(always present; solo pause below).b.snap/b.evare not sent for combat in client mode.g.watchrules: 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), none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; coins ≤ Σ bounty coins of the spawns; perfect consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is rebuilt from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is handed over instead (below). SP_VERIFY=all|sample|off (default off): all re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); sample checks ~1 battle in 8 in a later callback and logs mismatches. Match.verifyStats = { checked, mismatches, rejected, takeovers }. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
Boss rounds. The shared pool (SharedBossPool, co-op size = bloodPoint[difficulty], see corrections) lives on the server and is fed by the authorities' b.progress damage deltas (attributed per player through by, BOSS_HIT tickers as before) and by server-run fields; a server clock (250 ms) applies the overtime drain (−bossOvertimeDrainPerSec team LP per whole real second past bossOvertimeAfter real s, measured on the field clock — gamedata.js bossOvertimeDue, §4), the silence watchdog and the end checks: pool 0 → b.end cleared (victory), team LP 0 → b.end forced (defeat) — 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, unchanged). 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → BAD_TARGET 'other group hidden'. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is no view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
Client runner. public/js/battle/runner.js owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most max(8, 4·speed) ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same snapshot()/drainEvents() wire formats every frame (view.setLocalFeed shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + local, battleId, players, sides, speed) into store.match.field and its state into store.match.battle (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; catch-up when visible again; the server deadline + takeover cover extreme cases). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next b.start retries and the server's deadline covers the field meanwhile. public/js/battle/observe.js holds the observing rules and labels.
Corrections from research 08/09 (normative):
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN action becomes
clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (data/tuning.jsonkeeps only titles); co-op boss pool =bloodPoint[difficulty]unscaled. - Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2): route lengths stay the official ones, but among equal-length routes the one with the fewest non-blockable (floor / gate) tiles wins and smoothing never cuts across floor the grid route does not walk — e.g. 战场#01's lower-gate enemies take the col-8 road, not the col-9 floor lane (server/sim/grid.js header; test/sim/pathing-blockable.test.js).
- Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free.
- Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm; 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.
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.jsonlike 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/extendpublic/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 bytools/local-extract) with their textures; animated gate/objective effects. - One camera model drives both layers:
public/js/render/projection.jskeeps its public API (worldToScreen,screenToWorld/tile picking, presets, transitions) but derives it from a three.jsPerspectiveCamerainitialised 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 followviewKind(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|3doverrides. 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→bossPrepin public/js/ui/gameLogic.js; covered by the playtest2 browser tests).
16. Operator loadout: skill & module selection (v2.1, user playtest #1)
Official rule (bwiki 盟约, 更新公告 5114): before a match the player cannot change an operator's tier, but can change the equipped skill and module of each chess; some non-default choices are stronger. Scope in the remake (no accounts ⇒ everything the chess's status unlocks is selectable):
- Skills: any skill unlocked at the chess's status (
evolvePhase: E1 ⇒ S1–S2, E2 ⇒ S1–S3) at the chess's skill level (normal Lv4, elite Lv7). Default =defaultSkillIndex. - Modules: only matter for the elite (golden) chess (
equipLevel1, or 3 at T6): any module of that character (or none); default =defaultUniEquipId. Normal chess have no module (equipLevel 0).
Data (data/chess.json, built by tools/build-data.mjs): every chess keeps skill (the default, unchanged shape) and gains skills: [SkillRecord…] (index, skillId, iconId, name, desc/descRaw, skillType, durationType, duration, spType, spCost, initSp, maxChargeTime, bb (at the chess's skill level), bbStr, rangeGrid, trigger {rule, customRangeGrid} resolved for THAT skill index, isDefault). Golden chess gain modules: [ModuleRecord…] = { uniEquipId, name, typeName, icon, isDefault, attr: {maxHp, atk, def, res, aspd, cost, blockCnt, respawnTime…} (flat adds at the chess's equipLevel), traitOverride: {desc, descRaw, bb} | null, talentChanges: [{talentIndex, name, desc, bb}] } plus statsBase (stats WITHOUT any module); stats stays = stats with the default module (back-compat).
Loadout wire & state: the client keeps a per-browser loadout { [baseChessId]: { skill: index, module: uniEquipId | 'none' } } (localStorage) and sends it with C2S room.loadout { entries } (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start Match receives seats[].loadout; PlayerState keeps it immutable for the match; board pieces are resolved to {chessId, skillIndex, moduleId} when building PlayerBattleInput.units[] (fields skillIndex, moduleId; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
Sim: simdata resolves a unit def for (chessId, skillIndex, moduleId) (cached): skill = the selected SkillRecord; stats = statsBase + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function (bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait } receives chess.skill = the selected skill and bb = its blackboard; the loader uses skills[selectedSkillId] if present, else skill only when the selected skill is the chess's default, else the generic skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
UI: a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows its owner's skill/module from its UnitInfo skillIndex / moduleId, gameLogic.js unitLoadout) — which also shows the stats, 特性, talents and attack range the unit fights with (shared/loadoutRecord.js loadoutRecord / attackRangeGrid, the same composition the sim's getChess(id, loadout) uses; the deploy wheel's range preview too). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (UnitInfo.skillIndex; data/assets.json lists icons, clips and sounds for every skill index).
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). 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):arrowa bright tracer with a glowing head, a muzzle flash aimed at the target and a hit flare;bolt/orb/enemya glowing ball with a particle trail (arts purple with a burst + shockwave, heal green with a sparkle, enemy red);bomb/loba shell on a visible arc with a ground shadow, smoke and embers, exploding on landing (blastcaster shells purple);dronea small cyan dart;boomerang(回环射手) a spinning boomerang with afterimages that flies out to the moving target atPROJECTILE_SPEEDS.boomerangand back to the thrower's current position atBOOMERANG_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 PROJkeeps a test-guarded copy:test/render/fxproj.test.jsfails 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;
heavykinds — 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.idnames 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 inidof many area effects.FX_KINDSentries flaggedpt(bombard, bombardShell) always happen at (x, y) (theiridis 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 byid), 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) aftert(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), orLOCK_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'sarrowalso 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'(resolveProfilekeeps it over the data's generic'arrow') flies out atPROJECTILE_SPEEDS.boomerang= 15 tiles/s and hits on arrival, then flies back to the thrower's current position atBOOMERANG_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_offset0.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 withindist_2once —proj_atk_scale_1withindist_1, elseproj_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 isownLeaks(local, server)= the larger of the local runner'sstate().leaks[own field](§14) andm.public players[].pendingLp(§8.2; the authority'sb.progressleaks, 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 settledm.privatelands (itslporstats.leakschanged;Match.flushsends it before the SETTLEm.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.publicuniteLeft— 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 theirpendingLp(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 throughb.pool). - Temp row (#3, UI): while
m.private tempholds 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 throughview.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. - Busy indicators and texts (#9): every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data,
/data/*.jsonloaded 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 officialeventTypeIcon(detailPanel.js garrisonTypeIconKey: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: nocursor: wait / progressanywhere; a loadingButtonkeeps 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 inm.public(choiceOverlay.js pickBusy); the 作战结束,等待队友完成作战 hourglass is still; the hexagonSpinneris 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 warmGameDatadownloads 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.jsretries 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.
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 withinENEMY_REACH0.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,tileHoverand 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 aftertouchend, 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 撤退 / 出售. - 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 dragdropPointhook,pieceDragOver,ui/gameLogic.js pickPieceAt/equipRetarget.UnitView.bounds()is a plain body rectangle again (tooltips andpieceScreenRectonly). - 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
ChoiceOverlaywraps the pureChoiceView. 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 确认选择, sendsg.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.bandTurnsays the same — the official data only has the whole BAND_CHECK step, 50 s, now informational) andm.public.deadlineIS 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.jschecks 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()listsdown: [[id, respawnAt, respawnTime, state]](§8.2) for operators knocked out (reason'killed', not withdrawn — orFORCED_EXIT, entering 联防 knocked out, §19.3), deployed at least once and with a finite respawn timer;state= sim/constants.jsDOWN_STATE(0 counting, 1 timer done but DP < cost, 2 timer done but the home tile is taken — the official auto-redeploy rule, research 01 §4.3).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 its tile: 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). Thedeployevent 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), 损伤抵抗 = dataepResistance(EP_RESISTANCE; it used to readepDamageResistanceas 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 gainSprefuses 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.snapelem(§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.jsappends it to the unit's tuple;units.jsdraws 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 atlastextures.js hudRingskeeps 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 withoutelem, 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'shit; 纯烬艾雅法拉 (who only heals) had her S3 impact (p_imp_gtshpbrnch_s, bank ON_ABILITY_HIT.attack.2) ashitin 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 banksmaxSoundAllowed2 — 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 plainattack/combatability 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. agoat2hit→p_imp_gtshpbrnch_n, nearl2attack→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 lazyunit.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).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.unitStatsbuilds a Battle from the player's battle input after theonBattleStartmeta (dispatched withev.preview: trueand nospawns— 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]; cached per input (≈ 0.3–0.7 ms a build).screens/game.jsasks 120 ms after anym.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
trapShopChessDatashashideInShopfalse for all 56 normal items, so an explicit sourced list (tools/build-data.mjs SHOP_EXCLUDED_ITEMS→ items.jsonshopExcluded/shopExcludedBy) marks the effect-only items: 战栗 / 坚固 / 加速 / 灼燃维式重锤 (the 维多利亚 bond every 25 layers, 洛洛's 定制品) and 突变细胞 (the 昆图斯 strategy 不稳定要素; user playtest #4 first-hand).sim/simdata.js isShopItemis 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 aspool_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 — one bomb on its target and the 8 tiles around it (100 % ATK, splash ignores camouflage), then its speed ×2 (final); 帝国炮火先兆者 / 中枢先兆者 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; newai.js enemyAttackhooksprofile.deferHit— content resolves the hit in itsattackhandler — andprofile.shot, the'atk'kind:'mortar'draws no projectile; fxbombardShell); 假想敌:黑云 grabs withinrange_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.jsonsp.maxSp(3; newspfrom 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
placeabletotokenDisplayTypeDEFAULT 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 fxyanyouFlame {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 radiusrfollowing the target — which burnsdur+ 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.
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.isFlyingis the one air-unit test:motionFLY, or an enemy flagfloat(近地悬浮) orlevitate(浮空); pathing, displacement tiles and 诱导 readmotion. canHitFly / groundOnly, splash, chain, the kits' "地面敌人" filters, ground terrain and traps skip air units; 失衡免疫 is the flagnoDisplace; 浮空 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
phasecrawlswitches the view to the skeleton's crawl clips (*_02, after itsChangeclip) —render/units.js FORMS,spine.js setForm, kept on the unit info for a view built later (a reload mid-battle replays no events: such a view hovers until then). 吉兆飞鳞 — 晕眩 / 沉睡 → 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 appear in this mode (the bot'sHOVER_KEYS;fieldModelcounts 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, 锏 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.startdeploys operators row-major, then summons;aggroSeqranks every start-of-battle summon after all operators (deploySeqstays the deployment identity). An enemy's own rulee.profile.canTargetfilters 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 flagcamou, not 隐匿 for 隐匿 conditions, §20.2). [ASSUMED] "从上到下>从左到右" = row-major; 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 (
stunreleases 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
positionMELEE: 要塞 / 领主 / 哨戒铁卫 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-1as 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). - [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 inkits_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 uniteBattleOptsdropped every operator thatunitsEndreportedalive: 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 theSP_COMBAT=serverfallback alike. - Official (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,上一阶段为退场状态的干员强制退场)".
- Now: the operator is fielded with
carryState: { down: true }(§5.1); its summons are fielded as the board has them.Battle.startdeploys everyone (the initialdeployhook fires), then beforebattleStartsets its HP to 0 (the end-of-phase ratio, askill()) and withdraws it with removal reasonFORCED_EXIT='forcedExit'.Battle.isDownaccepts that reason, so it is in b.snapdownandfieldMetawith 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: nokillhook, nodeathwith reason'killed', nodeathscount — 深海 / 不屈 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/techper operator (research 09 §3HelpBattleInfo), 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 beforebattleStart; 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]. Not covered: 召唤物仅修改技力 (summons enter 联防 fresh;unitsEndlists operators only — carrying their SP would extend the client-reported result,spec.jsdigests andfields.jsvalidation). - 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.jsis 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 persistentnoMove+unblockablebuff 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_2the prefabKeyenemy_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 intest/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 rateelemTakenMul, so 炎佑's 20 % sped up fills instead of raising bursts); 元素抗性 reduces'elemental'with the 5 % floor (expectedFinalmirrors it); bursts carry DamageInfosourceless— no damage-dealt multiplier or penetration of the filler, and thehit/damaged/fatalhooks seesource: nullpluscredit(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 readsource || 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 —applyElementrefuses a target with no HP left, so no rider in a killing blow'sdamagedhook can burst a corpse (the content copies in tier5 / tier6 / items / tokens now import it);'elemental'damage skipsdmgTakenMul(脆弱 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 anelementHitmultiplier at priority 20 like every operator-side "受到的元素损伤±", before a 损伤屏障 (it was theelemTakenMulmod, 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
damagedhooks run beforebattle.kill, while the target isaliveat 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,applyElementrefuses it (§19.5). - 塑心 S2 (integration): PRTS "受影响的干员即将造成伤害时,因该效果造成的凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" — the rider is a late
hithandler, 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_stageDevicesminus 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'scanPlace/ 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). NowbuildDeployMap(stage, { field })takes'normal' | 'bossL' | 'bossR'(board row = boss-field row +BOARD_ROWS_ABOVE_BOSS7; the right-hand player mirrored col c → 20 − c, research 09 §1.2 ConvertChessPositionInfoToBossMap);Match.bossGroupOfis the one pairing lookup (deployFieldOf,nextEnemiesFor, the bot);Match.startRoundplans the round's enemies beforePlayerState.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'sgameLogic.deployFieldOffeedsplacementContext({ field }). Operators on the fence tiles hit the huge boss (§19.4); ground enemies cannot walk onto those tiles. - Audit (all 11 stages × normal / 联防 / boss L / boss R, server = client = builder deployTiles = the raw level files): 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.jsnow hands it thebossPrepcamera, which it maps throughgameLogic.fieldTileon the own board's layout (integration QA: it drew the normal field's walls under legal highlights). Limitations:Match.unitStatspreviews 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 aremax(.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), andpresetCamera('prep' | 'bossPrep', …, { hud })runsclearHudon 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 keeps the camera (nothing jumps) — the official client moves to its closerleft_prepare_camera_paramthere, so on a phone the remake's collapsed prep board stays at the clearing zoom (×0.844 at 756×366; integration QA, an optional follow-up:shop: !collapsedfor the own prep camera with a 0 bottom band). 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.priceis 2, and the handler set the price tobb.price("blackboards are authoritative"). - Official: two chess carry this 特质 (
effectTypeSERVER_CHESS_PRICE) and both texts say 购买价格为1 — 至简 (Ⅲ, tier price 3,price2) and 红豆 (Ⅰ, tier price 2,price1, hidden in 下半). Only "bb.priceis 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)), andEffectDispatcher.dispatchruns the priced chess's own 特质 first ononPrice(before globals / band / bonds), so 远见's discount (−1, never below 1) and strategy caps act on the lowered price: 至简 stays at 1 with 远见 150 instead of dropping to 0. The shop card keepsbasePrice3, 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 leaves 至简 at 1).
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.
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, andtoken_10041_cathy_catsldthe 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);startRoundtops 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):releaseSkillSummonadds 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: adeployhook 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. The 爬行号·防护单元 kit shields the operator on the tile it faces (range 1-1): the full shield on each new target, invulnerable, keycathy: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 = truedrives the sim and the card hint (PLAYING §4 and SIM.md follow;test/ui/playtest6_summons.test.jschecks). 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: droppingtoken_10041_cathy_catsldfromplaceableintools/build-data.mjsonly 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-intest/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/-1sentinel.SKILL_RANGE: 13 skills (26 records with the elites), 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) checkskill.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. - Integration follow-up (WE review round 2): the AUTO DP skills 焰尾 S1 迅敏直觉 and 凛御银灰 S1 周旋的谋略 ("立即获得…部署费用") are
SP_FULLlike 伺夜 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+hpAtMost0.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), throughbattle.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_siracusa32 + 0.4 × layers s). 忍冬 S3 "进入迷彩状态,直至下一次开启技能" was the genericstealthunder the keystealth, which made 伪装服's timed 隐匿 permanent (or stripped it) and counted as 隐匿 for 叙拉古 / 家族徽章. Now a separatecamouflag 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
skillTypecounts as MANUAL. - Tests:
test/sim/playtest6-skills.test.js, kit tests (incl.kits_alt_t4/kits_alt_t5for 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 | 重装 row |
| 薄绿 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.blockedTargetswithout the melee gate;sortEnemyTargetsblocked-first for every attacker; the skills' DEFAULT trigger follows);targeting.js meleeUnitis 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 and 停止攻击 skills (泡泡 S2, 蛇屠箱 S2, 古米 S2, 凯瑟琳 S2, 菲莱 S2, 宴 S1, 铃兰 S3, 信仰搅拌机 S3) still never hit. Flip: gateBattle.blockedTargetsandsortEnemyTargets' blocked-first on a melee position again (the v2.4meleeUnit). - ASPD floor: 沼泽 −50 + 寒霜 −50 took ASPD to 10 (塞雷娅 swung every 12 s); officially "ATTACK_SPEED 默认下限 20" (PRTS 数值范围; 游戏数据基础 "实际被限制了下限为20") —
ASPD_MIN20, 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 are radial except 推击手 / 朝部署方向; a directional push turns radial at −2 when the target is > 45° off or < 0.25 tile away (圣聆初雪 S1 waives both, 见行者 S2 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,displacethe 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 and pulls hit air units (added to §19.1's 可对空 list), 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) givesnoHeal, 孤立 alsoisolated;Battle.allySelectable / alliesForback 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; 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, 山 S3 (2nd hit) | 中力 radial push | 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, 圣聆初雪 S1 | 小力 / 中力 directional (圣聆初雪 fixed, ground only) | 0.5 / 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 too |
| 薄绿 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 throughdealDamage, 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,PollutedRangedAtkrange_radius 2.2, atk_scale implicit 100 %,Empty.attack@chuang_atk_scale2, 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.jsonmodelScale= product / 0.27 (125 keys);render/units.jsscales 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-intest/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.
_creditBosssubtracts 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'sLocalBossPool(server hp − (cum − acked)) absorbed every later hit (less than half an ulp ofcum), 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 finalb.result, whose per-player damage isMath.rounded — 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) andb.end clearedgoes 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 Hzm.public.bossHpand 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
descbeat 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; bounty cards carrydescRaw; 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 and stays in the draft:
MULTI_ROUND_BOUNTY_BATTLES = 2(bountyBattles;bountyTextrewrites "之后的 / 后续<@ba.vdown>每场</>作战" to "接下来<@ba.vup>两场作战</>" on the card and in the effects column, 还剩 N 场作战); choices.json keeps the officialrounds99 /multiRound. Flip:MULTI_ROUND_BOUNTY_BATTLES = nullrestores 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):uniteLeftper leaker = not spawned yet + alive + counted leaks — it falls on kills and rises on splits / summons (children inheritsourcePlayerId); the server takes it fromb.progress left, the headless timeline[gt, killed, total, left]or the exactuniteSurvivors, clamps live values touniteBillBounds; m.public players[]uniteLeftis uncapped andpendingLp= 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), andpushRewardOfferdrops 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 owndescRaw, else the datadescRawwhile its plain form equals the server'sdesc, 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.uframe12 and.spov20), 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-intest/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:
elementViewrounded 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-intest/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, in the draft | 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' |
| 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), a transformed carrier's tile and the elite's fresh summon stack (§20.11); "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 bossInfoDictbloodPoint/bloodPointNormal/bloodPointHard/bloodPointAbyssof the current data (the cache equals upstream ArknightsGameData master as of 2026-10-01;test/match/bosshp.test.jspins 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 ignoresenemyScale("领袖单位于服务器的生命值加成不受上述加成影响"). 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:
- 直接乘算: "+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/supportdirectMods; 炎佑 ×1.5 and the char_attribute_mul 特质 stay multipliers (§5.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]. - Leader parts: 剑 / 锤 / 碎铳之簧 pass every damage they take 1:1 as 无来源 HP loss, credited to the attacker (PRTS "受到等量的无来源生命流失";
PART_TRANSFER1,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). - 坚守 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).
- 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).
- 直接乘算: "+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).
- 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-inSIM_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.jsonpromotion.eliteGoesTo), so_mergeChessput 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 — row desc, then col asc, the mirrored right boss half included —
board.js mergeTile(tiles, legal)[ASSUMED]. A deployed piece transformed into the completing copy (突变细胞,transformChessfromKey/fromDir) counts as deployed with its own tile [ASSUMED]. 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 itsequipPerChess(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 andstats.mergesunchanged;onMergecarriesarea('board' | 'hand' | 'temp'). Bots plan the elite like any owned unit. - Now (client):
gameLogic.mergeTarget(priv, chessId)mirrorsmergeTile; a shop or reward card armed for a merge-completing buy lights the target tile in gold (ShopBar onArm→screens/game.jshighlight groupmergeTile); the 可晋升 tag says 精锐干员将出现在作战区原位置 or 精锐干员将进入整备区; an elite card never merges (completesChessMergerefusesisGolden,mergeProgressreports 0 copies: no pips, tag or tile);render/promote.js promotionsOfrecognises a merge in setPrep and the elite playsfx.promote(gold pillar, shockwave, hex ring, streaks from the other copies) instead offx.deploy(view.debug.promotions). - Checks:
audit.jschecks every merge — the tile ismergeTileof the deployed copies legal for the elite, with that copy's facing; the deploy count did not grow (a carriertransformChesstook off the board first still counts); otherwise the elite is not on the board, except the no-room fallback.tools/matchrun --checksweeps 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-inSP_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—AddBondCountstoresmin(L + n, 999), per bond.MAX_BATTLE_DAMAGE = 300000— in step mode (every boss battle outside training)AutoChessStepModeManager._OnBossEnemyTakeDamagerunsmodifier.Cancel()whenceil(value) ≥ 300000on anIsBossEnemyunit;IsBossEnemy(disassembled, research 11 §2.3) = enemyId ∈autoChessData.bossInfoDict[*].enemyId= data/bosses.jsonenemyKey. [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 duplicatesim/constants.js BOND_LAYER_CAP / layerRoomwas removed in the merge). Every writer uses it:PlayerState.addLayers(特质, items, bands, 机变 cards, bonds), the SETTLE ofBattleResult.layerGains,Battle.addLayers(the live copy, clamped after thelayerGainhook, 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-bondlayerGains ≤ min(60 + 4·round, 999 − starting layers).invariants.jsflags a bond above 999; the UI shows the server's capped count. - Now — 限伤: kinds
'boss'/'hidden'only; a leader isisBoss(the tag-'boss' units: those 10 keys and their mirrored copies — parts, 刺胄之弹, drones and escorts never count). A hit withceil(final) ≥ 300000is cancelled: checked indealDamageafter DEF/RES and every multiplier, before HP shields, and inloseHp(a part's 传递, a drone's death). A cancelled hit causes no HP or pool loss, no credit or stats, no damage number, nodamaged/fatal/ kill; the attack, its SP, thehithook and separate element 损伤 hits stay. Element bursts and DoT ticks are checked, gauge fills are not. The sim emits fxhitCap {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 = 0and / orBOSS_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'stest/match/layercap.test.js/test/sim/battle.test.jscap 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'smax_hp0.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>_blinkroute, 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>_originroute 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_TRANSFER1 (BLADE_TRANSFERis 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_GS5 game s, §14) had its'cleared'b.resultrejected 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 ab.progresscould 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:
enterBattleemptied the prep state the cue compares with.render/app.jskeeps the last prep pieces across a battle or scouting board (promoBase), so the nextsetPrepplaysfx.promoteon the elite (streaks from the consumed copies' slots). Re-run of the QA case (松桐 grants the third 古米 at R4 ROUND_START):view.debug.promotionsrecords 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'spanelSiderule), 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-inRENDER_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-inSP_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 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 (
-
A shared field (联防
'u', boss / hidden'b1'/'b2') — settled by the user (2026-10-02: "共享阵地按你说的规则就行"; no official source shows the strip there):- If the ‹ › pill points at a half that a player holds → that player. The pill's "你自己" means yours; "👁 name" means theirs.
- Else (全景, an empty half "无人在家", or no pill), if you fight on that field → yours.
- 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)). Theg.watchrequest still names only the field.watchWhocounts 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.watchis sent, because it would be refused withBAD_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.startis still on its way never brings the teammate back. - A loading battle waits until it runs (its state carries no
watchyet). - 联防 leakers and eliminated spectators keep their own rules.
- The screen now adopts that field as the watched one, once per battle (
Labelling:
- A teammate's strip leads with an amber "👁 name" pill (the observing pill's spelling) and gets amber disc rings,
aria-label"name 的盟约" anddata-owner; slots carrydata-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. popupViewbuilds 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 atBOND_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, bothm.private bondsandm.public players[].bondsadd the finished normal battle's IN_BATTLE gains (PlayerState.pendingLayerGains= the result'slayerGains;bondsMeta.bondsWithGains: floored, at most up to the cap, likesettle()). The strip of a player, and of a teammate watching him in the 联防, therefore keeps the layers his battle reached. - This is views only:
ps.bonds/ps.layersare untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too.
Data:
m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView()) : []: every bond with members, layers or an active tier. It is the same list and order as the player's ownm.private bonds, minusthresholds/countsHand(the client reads those from bonds.json).- An eliminated player's list is
[], for these reasons:- nobody can watch them:
g.watchrefuses them and they have no field; - the result screen reads
m.result's own bonds; - their remaining layers would otherwise cost every
m.publicfor the rest of the match.
- nobody can watch them:
- Measured in a 4-AI NORMAL harness match (seed 11, round 13, 3 players eliminated):
players[].bondsis 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.publicstays 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.