196 KiB
DESIGN §27 — 0.2.2
Part of DESIGN.md (the index; section numbers are global).
27. 0.2.2 — after the 0.2.1 release
0.2.2 is built on feedback7, branched from the 0.2.1 release (tag v0.2.1) and marked 0.2.2-dev until it shipped (2026-10-09). Its
workstreams branched off it as fb7-* (some carried out by Grok coding runs on statically reviewed pull requests), each
reviewed with an interaction pass before it was merged with its golden scenarios named; where a merge moved a scenario
neither side moved alone, the merge commit explains the interaction. Each rule was checked against the official data and
PRTS; anything no source backs is marked ASSUMED. Where each is handled: the engine-independent math, the Windows zip
names, the tooling / interface PRs and AGENTS.md → §27.1–§27.5; the community reports the owner relayed (白铁's
铁钳号, 伊内丝, 引星棘刺's alchemy units, 炎佑, 望's 跟子, 突袭 and 隐匿, 香槟炸弹) → §27.6–§27.13; battle rules from GitHub
(take-the-highest chains, REA-Y, the four PR #329 rules, 骨刺, instant skills, 先兆者, 速射手, 余) → §27.14–§27.21; 失衡
→ §27.22; the boss fields (portals, 碎铳之簧, 耀骑士临光, the water map) → §27.23–§27.26; the newest reports (画卷,
spectator bond counts, the facing wheel's Enter, attack ranges, 引星棘刺's DEF, 谢拉格's blizzard) → §27.27–§27.32; 教鞭,
the boss bounty payout and the bots' guards → §27.33–§27.35; the battle view (赤刃明霄陈's wave, attack recovery, push /
pull slides, the champagne's bar, closed eyes, late attack clips, the HP-bar readouts, the renderer abort, held skill
cues, atlas refreshes, 梓兰's frost) → §27.36–§27.46; the interface (device tips, the settings button, the shop fold,
spectators, the top-bar capsule, the empty new shop slot) → §27.47–§27.52; voices → §27.53–§27.54; the statistics page
→ §27.55; potential and training (潜能 / 练度) and the 干员调配 list → §27.56–§27.59; the 自选 picker's Esc → §27.60;
whole-frame attack intervals and SP costs (PR #402) → §27.61; the drag snap → §27.62; items that grant nothing say why → §27.63.
Decisions of the owner, 2026-10-08 (round 22, from community reports, GitHub and research into the official rules): 潜能 and the 自持有 练度 bonus are set per operator in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (official: the account's potential, the four 练度 tiers; §27.56, replacing §26.14's fixed full potential); 香槟炸弹 is unhealable, loses no HP and never leaves because of its HP (a deliberate deviation, §27.12), and neither an unrevealed 隐匿 nor a sleeping enemy sets it off; 余's 【文火慢炖】 skips bonds without layers (overriding the PRTS sentence, §27.21); 突袭 members jump to 隐匿 ground enemies (§27.11); 谢拉格's 寒风 blows bottom-up (§27.32); 教鞭 is the official three-choice (§27.34); only the 速射手 trait ranks a flyer above the attacker's own blocked enemy, every other attacker keeps 「阻挡了就一定要能打到」 (§27.20); PR #392's 失衡 — verify the official rule, then decide: implemented from the sources, not the PR's formula (§27.22); PR #313 follows the official help text (declined: the 3 s auto-operation cooldown is for 手动触发 skills); PR #301 accepted with a new layout (§27.57); PR #323 accepted, a match quit before a round was cleared stays out of the statistics (§27.55); the tap voice in every phase and the full Japanese dub, default 中文 (§27.53–§27.54); accepted: the co-op option 「AI 队友最后选择」, the prep double-click, the diagnostics copy, AGENTS.md; declined: free camera zoom (PR #290), the debug room (PR #366), the 一图流 import (#371 / PR #372), AI presets (#338), the shop purchase caps stay as measured by the community (no official equipment numbers), 不屈's occupied-tile edge stays.
27.1 The battle sim computes with engine-independent math (PR #373 by @Cloudnyco) — server/sim/detmath.js (new), every sim Math.hypot / sin / cos / atan2 / pow call
- Seen: ECMA-262 lets
Math.hypot,sin,cos,atan2andpowreturn implementation-approximated values, and the engines differ in the last bits (V8's hypot is not SpiderMonkey's or JavaScriptCore's; Chrome 154's sin / cos / atan2 are correctly rounded while Node's are fdlibm's; JavaScriptCore computes integer powers by repeated squaring). A battle simulated in Firefox or Safari could therefore drift from the server's verification run. - Now:
detmath.jsbuildshypot(√(x² + y²), rescaled by exact powers of two outside 2^±500),powi(integer powers in double-double, one final rounding),sin/cos(fdlibm 5.3) andatan2(fdlibm / FreeBSD msun) only from correctly rounded IEEE operations, so every conforming engine gives the same bits. sin / cos / atan2 are the algorithms V8 already runs in Node, so the server's results stay; ESLint forbids the approximated Math functions underserver/sim/andtest/sim/detmath.test.jsscans for them. - Golden: two scenarios move, both by
hypot— diy-027 (one distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end). Tests: detmath 7; the whole suite unchanged otherwise.
27.2 Windows CI: UTF-8 zip entry names (PR #311 by @Cloudnyco, merged on master with the owner's OK) — tools/package.mjs zipFolder, tools/package-update.mjs readZip
- Seen: since 0.2.1 one test failed on both Windows CI legs of every PR and of master: the GitHub Windows runners
have no
zip, the fallback bsdtar wrote 卫戍 in the system code page (??) and the update tool's reader decoded only UTF-8. No shipped file has a non-ASCII name, so players and the release zips were never affected. - Now: bsdtar is told
--options zip:hdrcharset=UTF-8; the reader also takes Info-ZIP's Unicode Path extra field (0x7075) when its CRC matches the header name. PR #363 (relax the assertion) was closed as superseded.
27.3 Tooling and server PRs applied (Grok coding run, each statically reviewed first) — see each commit
- PR #299 by @siruimei07: two tabs started apart can no longer claim the same reconnect token.
- PR #304 by @siruimei07: an older language pack that arrives late no longer overwrites the newer choice.
- PR #350 by @siruimei07 (issue #344):
build-data --refreshfails instead of mixing an old cached table into a build. - PR #351 by @siruimei07 (issue #343): the boot build tag includes the sim, shared modules and data, so an open page notices a new version after a restart (it still reloads after the match).
- PR #352 by @siruimei07 (issue #345): SP_VERIFY re-runs a battle in 8 ms slices instead of blocking the event loop.
- PR #367 by @anAtheist987:
npm run ci(tools/ci.mjs) runs the checks of.github/workflows/ci.ymllocally. - PR #198 by @Cloudnyco: the battle performance bench under
public/dev/(not packaged). - PR #300 by @Griseo-bh3rd: the GitHub issue templates (the owner accepted #239), with the review's fixes (the feature
form's field id, no do-not-tick checkbox, one
buglabel). - PR #291 by @LimitlessHPPK — only the dual-stack listen (the owner's #188): HOST unset listens on
::(IPv6 and IPv4 on one socket, falling back to0.0.0.0when IPv6 is unavailable); the banner lists shareable IPv6 addresses. Its /dev/grant endpoint, settings migration and firewall script were not taken. - PR #289 by @weinibuliu, re-done without its dependency: with HTTP(S)_PROXY set, fetch-assets restarts once with
NODE_USE_ENV_PROXY=1(Node ≥ 22.21 / ≥ 24) so the built-in fetch uses the proxy; an older Node warns and downloads directly, as before (a stray proxy variable must not stop a first start).
27.4 Interface PRs applied (Grok coding run, each statically reviewed first) — see each commit
- PR #306 by @Xinglan233 (issue #305): reselecting the same 自选 operator keeps its draft skill and module.
- PR #309 by @siruimei07: selling a summoner counts the bench slots its summon stacks free.
- PR #341 by @Xinglan233 (issue #340): the shared dialogs trap keyboard focus (and give way to the guide overlay).
- PR #349 by @siruimei07 (issue #346): the spectator prep view refreshes effects and the enemy preview after a 机变 pick.
- PR #356 by @Xinglan233 (issue #355): the 调度中心 upgrade confirm clears once the level is reached (D).
- PR #358 by @Xinglan233 (issue #357): Enter on the equip-replace dialog's buttons keeps their own action.
- PR #361 by @Xinglan233 (issue #360): retreating into a full bench counts the own summon stack's slot.
- PR #368 by @anAtheist987 (issue #338): the co-op room option 「AI 队友最后选择」 (host only, off by default — off, the draft order and its randomness are unchanged); the #338 bot presets are not part of it (not official).
- PR #374 by @shadiaojumao-hyb (issue #131): in PHASE.PREP a double-click on a teammate's avatar opens their field; no forced view without a field, no re-open while already watching; 「前往查看」 stays.
- PR #321 by @Cloudnyco: 设置 → 问题反馈 copies local diagnostics (error log, device, optionally the battle); nothing is uploaded; player names are replaced only as whole tokens / exact JSON strings.
27.5 AGENTS.md and CLAUDE.md (GitHub #379 by @anAtheist987; the owner's OK of 2026-10-08) — AGENTS.md, CLAUDE.md
- A short entry point for AI coding assistants: it only indexes the existing documents (ARCHITECTURE, the DESIGN index,
SIM / META / DATA / I18N, the kits README) and their hard rules (official first and
[ASSUMED], sim determinism, golden results, generated data, no game art,t()strings, docs with the code, commit / PR conventions, verification); CONTRIBUTING.md stays the full text. CLAUDE.md is one line pointing to it.
27.6 白铁's 铁钳号·原型机 opens a non-heal skill like an enemy (community report; the owner's rule of 2026-10-08) — sim/skills.js _allyTargetIn, sim/battle/queries.js allyTargetsInKeys, kits/ops/chess_char_3_11-snhunt.js
- Seen: 「圣约送葬人攻击白铁的装置不消耗子弹,无法叠层」. With 铁钳号·原型机 alone in her range, 圣约送葬人 (S2 近身铳斗, MANUAL, DEFAULT, 12 bullets, SP full) attacked it 15 times in 14 s, the skill never opened: 0 bullets, 0 layers. With the skill forced on, the same attacks spent bullets and the 拉特兰 / 远见 garrisons' "每消耗7发子弹" gave layers — the ammo and layer code worked; the start conditions did not see the device.
- Cause: our attacks select the device (Battle.setAllyTarget, 0.2.0 O23; its damage is cancelled), but every skill start condition counted enemies only (enemiesInKeys / anyEnemyInKeys / canTargetEnemy); the device is an ally unit flagged untargetable. 0.2.0 left "the AUTO cast of an attacker of 白铁's 铁钳号" to the owner (§25.10).
- Official: PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方…可被我方干员攻击但不受伤害" (嘲讽等级 −2, 禁疗). No text on skill starts; the owner's recall of the official game: with the device in an operator's attack / skill range the skill opens and spends by itself, "白铁的装置对干员的仇恨有点类似于敌方单位" — the owner's rule of 2026-10-08.
- Now: a registered ally target on the tiles counts like an enemy in every enemy condition of a non-heal skill: DEFAULT (about to attack, the initial range — SEARCH and ACTIVE_RANGE through it), CUSTOM_RANGE, SKILL_RANGE, GDGLOW_SKILL_2 (anywhere on the field) and the content trigger ranges (addTriggerRange); 雪猎's empty-magazine S2 cast too. Heal skills never count it (禁疗); it stays out of enemiesInKeys / canTargetEnemy, so no other selector changes; 白铁™多功能平台 stays no target. Checked over every chess skill (283 with the loadouts) and the 自选 pool (240): 200 / 152 now open on the device alone, none opens less. The ammo skills spend on it: 隐现 S1 / S2, 圣约送葬人 S1–S3, 烛煌 S3, 号角 S2, 蕾缪安 S1 / S2, 新约能天使 S1–S3 (the 自选 pool: 假日威龙陈 S1–S3, 予愿安洁莉娜 S3, 涤火杰西卡 S3, 谬因 S3, 机械师 S1); 流明 S3 (a heal) and 信仰搅拌机 S2 / S3 (TAKE_DAMAGE) are untouched.
- Only a skill that acts on the device is opened by it (skills.js
allyTargetsOk, after Grok's review): SkillSpecallyTargetsfrom the kit, else ASSUMED a skill acting through the unit's own attacks (timed and still attacking, or a cast that is the next attack). Four kits now take the device in their selectors and spend on it (allyTargets: true; hits cancelled, its 受击回复 counts): 蕾缪安 S3 locks it and spends her bullets (ammoUsed → per-bullet layers) and shells it, 艾丽妮 S3 strikes it, 陈 S2 draws on it after every enemy, 引星棘刺 S2 throws onto it (its pulses hit it). Explicitly closed ASSUMED (allyTargets: false): 普罗旺斯 S2, 卡涅利安 S1, 山 S2, 余 S2 / S3, 迷迭香 S3, 荒芜拉普兰德 S1 / S2, 淬羽赫默 S1–S3, 遥 S1–S3, 机械师 S3, 珊比 S2 / S3, 陈 S3, 艾丽妮 S2. By the default the onStart-only casts and no-attack timed skills (德克萨斯 S2, 锡人 S1 / S2, 铃兰 S3, … about 50) wait for an enemy. Audit (survey3.mjs): of 351 skills the device opened, 67 never touched it; after the fix 281 open and all act on it. - Golden: unchanged (no scenario fields the device). Tests: test/sim/feedback7-allytarget-cast.test.js (4; 3 fail on the parent): 圣约送葬人 S2 opens, spends ≥ 7 bullets and the garrison layers come; 隐现 / 烛煌 / 新约能天使 spend on it; the platform opens nothing; DEFAULT / SEARCH / SKILL_RANGE / CUSTOM_RANGE / ACTIVE_RANGE / GDGLOW / a trigger range open on it, a heal skill does not. test/content/feedback7-allytarget-kits.test.js (5; all fail before the review fix): the four kits spend on it, 德克萨斯 S2 waits for an enemy, the engine rule's cases.
27.7 伊内丝 S3 starts again on a 突袭 landing (community report) — sim/battle/deploy.js _deploy (keepSp)
- Seen: 「伊内丝激活突袭瞬移以后3技能被吞」: S3 独影归途 running, 突袭 jumped her at 1.27 s; the skill ended with the retreat and never came back (landing: charges 0, no skillStart).
- Cause: the 突袭 jump is retreat + redeploy with keepSp ("保留技力"). The retreat ends the running skill, so the
snapshot holds 0 charges; reset() gives a free (spCost 0) skill its charge at every deployment, and keepSp wrote the
snapshot's 0 over it before her kit's deploy hook casts S3 (activate('deploy') → not ready). The other free skills
set activateOnDeploy, run already at that point and are skipped by
!sk.active— only 伊内丝 S3 was hit. - Official: activity_table bondeffect_raid "保留技力立即再部署至一名地面敌人周围"; skill_table skchr_ines_3 PASSIVE, spCost 0, "部署后攻击力…立刻收回影哨…" (PRTS 伊内丝 备注: from every deployment but the first). A free skill has no 技力 to keep; the redeploy is a deployment.
- Now: keepSp applies to skills that cost SP only; the landing recasts S3 (a fresh 11 s, the sentry recalled from where she left). 伊内丝 S2 (27 SP) keeps a full charge and a partial bar as before.
- Golden: unchanged. Tests: test/content/feedback7-ines-raid.test.js (2; the S3 one fails on the parent).
27.8 引星棘刺's alchemy units restore HP as 生命回复速度 (community report) — kits/ops/chess_char_5_15-thorn2.js
- Seen: 「炼金师分支干员的炼金单元无法给不屈者等禁疗的干员提供生命恢复」. 折桠 (不屈者) and a 禁疗 角峰 in 引星棘刺's S1 / S2 zone got 0 HP; a plain 角峰 +152 / +166 in 3.2 s. 锡人 S2 (the branch's other operator) already reached them.
- Cause: S1's guard zone and S2's drifting zone called battle.heal; 不屈者 (no heal from others) and 禁疗 refuse a heal.
- Official: skill_table skchr_thorn2_1 "每秒回复相当于攻击力{hp_recovery_per_sec_ratio}的生命值", skchr_thorn2_2 "友方单位 每秒恢复相当于攻击力{hp_recovery_per_sec_ratio_chr}的生命" — 锡人 S2's wording and key, and PRTS 锡人 备注 "生命恢复的提供方式 为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响". 引星棘刺's own PRTS page has no such note [the classification rests on 锡人 and the report].
- Now: both are an hpRegen buff per alchemy unit, set at each 1 s pulse until the next (锡人's pattern): the same amount per second, not scaled by heal bonuses, credited to the target's own regen (no heal numbers); S2 skips 孤立 units like every ally effect. S1's per-zone key lets two zones stack (PRTS "效果均可叠加"); its DEF bonus keeps one key (unchanged, not stacking — left as it was).
- Golden: roster-014, roster-015, roster-033, roster-034, bond-maniShip-high, bond-soloShip-high, boss-boss_2-pair, boss-boss_4-pair, boss-boss_6-pair, hidden-boss_8-pair, hidden-boss_10-pair (引星棘刺 fielded, S2 by default) and coop2-NORMAL-14-standins (h_0 fields her from round 11): the allies' HP now rises over each second and the heal stats move from her to them. Tests: test/content/feedback7-thorn2-regen.test.js (2; both fail on the parent); kits_alt_t5's S1 case asserts the regen buff.
27.9 Enemy effects marked 不可对空 spare the flying 炎佑 (community report) — content/enemies/archetypes.js deathBoom, enemies/special.js, enemies/fly.js kitRoar, enemies/dot.js, content/bosses.js kitBlade
- Seen: 「炎祐好像吃到了不该吃到的地面伤害」: 炎佑 took 840 from 高能源石虫's death blast, 400 + 寒冷 from 冰爆源石虫's, 480 from an ignited 卷心籽's, 1040 from 水遁忍者's vortex, 1680 from 鼎沸's pulses, 200 / 650 from “斩胄之剑” / “破胄之锤”, and 祈祷邀约's ASPD −30.
- Cause: those effects select through areaAllies / fieldAllies / targetsNear, which leave out 起飞 allies only; 炎佑 is a FLY ally (Unit.isFlying).
- Official: PRTS, read for every enemy of data/enemies.json: 高能源石虫 / 冰爆源石虫 / 卷心籽 死亡爆炸 "…无视迷彩,不可对空"; 水遁忍者 漩涡形态 "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空)"; 鼎沸 "攻击范围内的所有我方单位每秒受到…法术 持续伤害与…灼燃损伤(不可对空)"; “萨科塔昂首” 祈祷邀约 "令全场我方单位(不可对空,无视迷彩)"; “斩胄之剑” / “破胄之锤” "普通攻击 对攻击范围内的所有我方单位造成…物理普通伤害,不可对空". Already right: 烹泉 / 沏虹, 庞贝, 集团军重型火炮 (shell, 燃烧区域), the terrain. The other 不可对空 notes act on enemies (咸鳞汁推荐者, 灼藤, 温顺的武装大驮兽, 愧悔魂灵圣杯) or on an unmodelled pickup (王权号令's equipment). “裂管之音”/“断弦之音” has no PRTS page.
- Now: every one of them skips a flying ally (deathBoom
noAir, on by default, 寒冷 included; the blades' target rule too). A ground enemy's normal attack and the effects without the note still reach 炎佑 (PRTS: 可被能打空中的敌人选中). - Golden: unchanged. Tests: test/content/feedback7-yanyou-noair.test.js (8; all fail on the parent).
27.10 望's placed 棋子 drop their 铸子 跟子 at the battle start (community report) — kits/ops/op-wang.js
- Seen: 「望的天赋以及3技能增强的天赋提供的额外棋子是可以出现(官方好像有出现的前后左右先后顺序的规则)在不可部署地块的」: the 棋子 the player placed came in with no 跟子 at all — a lone 棋子 never connected, never set off; a 跟子 appeared only when a spent 棋子 came back.
- Cause: 铸子's drop was wired to the return (scheduleReturn) only, under the ASSUMED "the battle-start deployment does not count". The placement order (dropFollowers) was already PRTS's.
- Official: PRTS 望 铸子 "手动部署棋子时,望在相邻位置额外部署一枚棋子"; 备注: 跟子 may go on deployable tiles and on undeployable ground with no character unit; "按照有敌人的>没有敌人的;不可部署地块>可部署地面地块>可部署高台地块的优先级 生成;相同优先级时按棋子上>右>下>左"; "跟子所在格进行部署(或进行类似部署的行为)时,该跟子会随之消失"; S3 备注 "技能开启时自动 “部署”的为第一天赋的跟子…采用相同的位置选择优先顺序". PRTS 卫戍协议/帮助: a placement in 休整 fires no deployment effect; the piece deploys when the combat starts — that is the 手动部署 of this mode.
- Now: the battle-start deployment of a placed 棋子 drops one 跟子 (enemy tile, then undeployable ground > ground > 高台, then 上 > 右 > 下 > 左; 上 = row +1, the mirrored field swaps 右 / 左 as before); a return still drops its own. Any ally deploying on a 跟子's tile removes it — the battle start deploys one piece after another, so a 跟子 may land on a piece still to come. S3's overflow orders its tiles enemy first, then by class (an enemy on undeployable floor before one on the road her target order puts first). Her stock rule (2026-10-06) is unchanged; a 跟子 takes none.
- ASSUMED (kept) each later return counts as a 手动部署; S3's ties: her target order among enemy tiles, then nearest to her, then by tile (no anchor for 上>右>下>左 away from a 棋子).
- Golden: unchanged (no scenario fields 望). Tests: test/content/op_wang.test.js — the four tests that encoded "no 跟子 at the battle start" updated, a removal case and an S3 order test added (10; 5 fail on the parent).
27.11 突袭 members jump to 隐匿 ground enemies, and one in range counts as 「范围内有敌人」 (GitHub #316; the owner's decision of 2026-10-08) — content/bonds/addon/battle.js raidGroundEnemy / raidStealthInRange
(First written as an open question — the sources below do not settle it; the owner then decided from their official play.)
- Seen: reproduced: a ready 突袭 member jumps to a visible ground enemy within 1 s, never to the same enemy under 隐匿 (12 s). raidTargets and the "范围内没有敌人" check use canTargetEnemy, which rejects 隐匿 (迷彩 passes).
- Sources: the bond text says only "若范围内没有敌人…再部署至一名地面敌人周围" (activity_table; no blackboard key on
selection). gamedata_const ba.invisible "不阻挡时不成为敌方攻击的目标" (EN "Invisible") speaks of attacks; PRTS 异常效果
隐匿 "无法被不同阵营选中(属于无法选择类效果)" and §无法选择 "常见的…选择行为将无视这些单位" — but only for selectors with
enableAdvancedOptions; the "无视可选性" ones skip it. The 突袭 entry of PRTS 盟约记录 has no note either way. The reporter attached no footage. - Now (the owner's decision of 2026-10-08): a 隐匿 (unrevealed) ground enemy is a landing target, and one on a member's range counts for 「若范围内没有敌人」 (so a ready member beside one does not hop every poll). Asleep, untargetable and flying enemies stay out (skipEnemy too); the landing rules are unchanged.
- Golden: solo-HARD-1, solo-ABYSS-1, coop2-NORMAL-3, coop4-ABYSS-7, coop2-NORMAL-70-diy (their 斯卡蒂 / 瑕光 / 宴 / 伊内丝 jump to 潜伏者, 隐形弩手组长, 山海众秘使, 深池逐火战士, 业余竞演者, 架桥船工 under 隐匿, or stay with one in range). Tests: test/sim/feedback7-raid-stealth.test.js (4; 2 fail on the parent — the jump to the only landing spot beside a 隐匿 enemy, and staying with one in range while a visible enemy waits elsewhere). Docs: research/02-bonds.md §3.18.
27.12 琳琅诗怀雅's 香槟炸弹 holds 禁疗, loses no HP, never leaves because of its HP (the owner's decision D2 of 2026-10-08) — content/tokens.js champagneHold, kits/ops/chess_char_3_04-swire2.js bombKit
- Seen: a bomb on 活性源石 lost 70 HP/s; a medic healed a hurt one (400 → 1000); a lethal 流失 knocked it out. In the golden bond-yanShip-high a healer of the team spent its whole fight (27 heals, 5040 HP) on a bomb standing on 活性源石.
- Official: PRTS 香槟炸弹: 1000 HP, 实体类型 装置, 备注 "即使自身生命值未满,模型下方也不会显示生命值槽" — it can lose HP; no 无敌 / 禁疗. The owner's decision D2 (a deliberate deviation): 禁疗, no HP loss, no leaving because of its HP; its 见面礼 as before.
- Now: champagneHold (installed by the token kit and by 琳琅诗怀雅's own bomb): flags noHeal + healFree (no heal, no
heal pick, no status icon); every damage cancelled in its
hit/elementHithooks; a 流失 given back indamagedbefore the knock-out check. No 无敌 flag (it would draw a badge — the render stream hides its HP bar,dd23572b). - Golden: bond-yanShip-high, coop2-HARD-4 (ai_1's bombs on 活性源石 / 污染秽蚀 from round 6). Tests: test/content/feedback7-champagne-hold.test.js (3; 2 fail on the parent, the 见面礼 guard passes on both).
27.13 香槟炸弹 does not go off on an unrevealed 隐匿 enemy (community report) — content/tokens.js CHAMPAGNE_TRIGGER, kits/ops/chess_char_3_04-swire2.js bombKit
- Seen: 「有人反馈香槟炸到没有破隐的隐匿敌人了」: a 隐匿 walker crossing a row of bombs set them off (both kits checked hidden / flying / untargetable only).
- Official: PRTS 作战机制 §隐匿 "隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中…要选中隐匿的目标,则该单位自身需拥有 无视隐匿的效果"; PRTS 异常效果: 隐匿 and 无敌 are 无法选择 effects (沉睡 = "无敌且无法行动", gamedata_const ba.sleep). No 无视隐匿 / 无视可选性 note on 香槟炸弹 or 琳琅诗怀雅 S2 (PRTS marks those exceptions). The trap pages (夹子, 共振装置, 棋子) say nothing either way; the sim's other contact traps (多萝西's 共振装置, 望's 棋子) already select via canTargetEnemy. [The general rule settles it; no trap-specific source.]
- Now: both kits go off on a ground enemy canTargetEnemy accepts: a 隐匿 one once revealed (反隐) or blocked (its 隐匿 lifts — PRTS 作战机制), a sleeping one once awake (the bomb used to be spent on it for no damage). Placement was already on selectable enemies only.
- Golden: bond-yanShip-high, coop2-HARD-4 (bombs under 隐形弩手组长 / 访问团强攻冠军 / 重弩突袭者 in rounds 10–11 and under the sleeping 宿主士兵 / 粉碎攻坚手 in round 7 now wait). Tests: test/content/feedback7-champagne-stealth.test.js (2; both fail on the parent).
27.14 Three overlapping 同名效果取最高 applications resume strongest first (GitHub #342, PR #348 by @siruimei07) — sim/battle/status.js _applyValuedStatus
- Seen:
buff.data.tailheld ONE waiting application, the later-ending one. A third application, weaker than the running one but longer than the waiting one, replaced the waiter although it was still running, so when the strongest ended the effect jumped to the weakest. #342's case — 30 % / 25 % / 20 % 减防 (精锐多萝西 S1 共振装置, 鸿雪's typewriter, 黑 S3), 5 s each, applied at 0 / 1 / 2 s on DEF 1000 — read 700 → 800 at 5.1 s instead of 750 (repro: a repro script.mjs). The same path carries every strongest status (减防, 脆弱, 虚弱, 庇护 …) andapplyStrongest. - Official: PRTS 作战机制 (already cited in docs/SIM.md): same-name buffs show one instance, the strongest value wins, and a weaker application that outlasts it resumes when it ends. Three applications are the same rule twice; no source drops the middle one.
- Now: the tail is a chain ordered by strength (decreasing) and end (increasing). A weaker application is inserted
where it belongs (dropped if a stronger waiter outlasts it; it drops the weaker waiters it outlasts); a stronger one
takes over with the old value and the old chain behind it, minus what ends before it; an equal refresh trims waiters
that end before the new end; an expiry resumes the next live waiter with the rest of the chain.
removeStatus/removeBuffstill drop the whole chain; equal-priority waiters keep the longer application's value (with itsstackAs); the two-application data shapes are unchanged (test/content/standin_acsupo.test.js). The PR's code is applied as written (commit authored by @siruimei07); a randomised check against a reference model (every application keeps its own end; the effect is the strongest live one) matched 400 / 400 trials (the parent: 30 mismatching) — a repro script.mjs. - ASSUMED: none new.
- Golden: 6 moved, all 自选 battles stacking three same-name applications on one unit — diy-059, diy-122, diy-123,
diy-124 (庇护 from several operators), diy-133 (Mon3tr's ASPD talent buff
talent:monstr:aspd), diy-138 (脆弱 on enemies): the middle application now resumes. - Tests:
test/sim/strongest-status.test.js(19: all six application orders for applyStatus 脆弱 and applyStrongest, a waiter that ends under the winner never resumes, equal refresh, refreshing a waiter, stackAs on every fallback, equal-priority waiters, removeStatus / removeBuff drop the chain — 15 fail on the parent);test/content/strongest-status-overlap.test.js(1: the real 多萝西 / 鸿雪 / 黑 kits, DEF 700 → 750 → 800 → 1000 — fails on the parent: 800 at 5.1 s). Docs: docs/SIM.md §3 (statuses) gains the chain sentence.
27.15 The REA-Y module line is applied by the engine, not by two kits (PR #293 by @LimitlessHPPK) — sim/content/traitMods.js (new), sim/battle/players.js _setupUnit, kits/shared/tier5.js (crowdAspd removed), ops/chess_char_5_01-excu2.js, ops/chess_char_5_06-etlchi.js
- Seen: a module's conditional trait line is data no engine code reads: build-data folds the module blackboard
(
attack_speed) intotrait.bb/ a hidden talent, the condition lives only in the sentence, andresolveProfilespreadstraitBbinto a profile nothing consumes. 圣约送葬人 and 隐德来希 each re-implemented REA-Y in their kit (crowdAspd: a 0.2 s poll that gave a 0.3 s buff) — measured, the +12 came 0.167 s after the second enemy entered the range and stayed 0.267 s after it died (a repro script). - Official: battle_equip_table
uniequip_003_excu2(display trait 「攻击范围内存在2名及以上敌人时攻击速度+12」, the number on the hidden module talent: attack_speed 12, trigger_cnt[equip] 2) anduniequip_003_etlchi(the same sentence on the trait: attack_speed 12, cnt 2). 仇白'suniequip_003_qiubaicarries the same sentence on her NAMED talent 0 (cnt 2, attack_speed 12) — her kit keeps reading it; the engine does not see it. - Now:
installTraitAttackSpeed(afterkit.install, every operator) readsattack_speedfromtrait.bbor a hidden module talent and the clause before 「攻击速度」 in the module / trait sentence; it applies exactly one shape — 「攻击范围内(存在|有)N名…敌人时」, N from the text (default 2), counted bybattle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)(the kits' call) every tick: onetrait:attack_speedbuff (+12 points, refresh 'keep') on while it holds, removed the tick it stops. Every other shape installs nothing and is reported: 'kit' (DRE-Y, AFT-X, FOR-Y, FGT-Y, MAR-Y, WDM-Y, and — a maintainer addition — MSC-Y 「拥有已储存的攻击能量时」 of the 自选 黑键 / 维伊, owned by their kits), 'mode' (水月 ISW-A, 集成战略 only), 'unknown', 'no-text'. The kits' copies are deleted (no +24). - Scan (the coverage lock): 716 chess defs (every record × every module choice), 266 补位 stand-ins and 3 348 自选
combinations (every pool operator × slot form × skill × legal module) — the rule applies to exactly
chess_char_5_01_b|uniequip_003_excu2andchess_char_5_06_b|uniequip_003_etlchi; no stand-in or 自选 pick gains it (a repro script.mjs; the test asserts the same). - ASSUMED: none new (the condition is re-checked every tick — the official check frequency is not documented; the old 0.2 s poll was no source either).
- Golden: unchanged (roster-019 / roster-033 field the two modules; no sampled snapshot or count moves).
- Tests:
test/sim/trait_attack_speed.test.js(9: the two operators — 1 enemy → base, 2 → +12 within one tick with the measured cadence ratio 112 / 100, back to base the tick the second dies, on again on re-entry; both blackboard routes; operators without the line unchanged; the kit-owned lines refused; 史尔特尔 / 维娜·维多利亚 / 山 / 空弦 / 斯卡蒂 values unchanged; rule parsing; 仇白's named talent invisible; the chess × module lock (unknown 0); the 补位 / 自选 lock — the file fails on the parent, whose kits lag by 0.167 s). Related: kits_t5 / kits_alt_t5 / kits_layout and four sim files (160 pass). Docs: docs/ARCHITECTURE.md (content files), kits/README.md (a kit must not add the line again).
27.16 Four PRTS-confirmed rules from PR #329 (by @IceCodeNew; issues #322 / #324 / #325 / #328, parent #315), re-implemented one commit each
PR #329 is a 14-commit branch on another base with regenerated golden JSON; only the four claims below were re-verified and re-implemented on feedback7 (its golden files were not taken). Not ported: the 信标 refusal for 自选 pieces (#326 — no public sentence says 信标 cannot be equipped on a 自选 piece; the PR also deleted the existing diyFor gift skip, which stays ASSUMED as PLAYING.md / META.md say) and the comment-only removal of ASSUMED marks (空弦, 银灰, 号角, 夕, 自在, 扎罗, 教鞭, the multi-round bounty cap, the gift after elimination) — not re-checked here. #315 stays open.
(a) 异客 S3's 雷暴 is range x-1 around the target's tile (#322) — ops/chess_char_6_05-pasngr.js, shared/tier6.js (notes)
- Seen: the storm struck enemies within a 1.5-tile circle around the target's exact position ASSUMED. With the target at (5.4, 10.4), a probe two tiles straight off its tile (7, 10) was never struck, one at (6.55, 11.05) — tile (+2, +1) — was (a repro script).
- Official: PRTS 异客 S3 备注 「开启技能时选择技能范围内的目标,以其判定中心所在格为中心生成一个x-1的雷暴区域;每个雷暴区域单独 进行额外攻击…」 「撤退异客后所有的雷暴区域会消失」; range_table x-1 = 13 cells, |Δrow| + |Δcol| ≤ 2 (x-3, the search range, is unchanged).
- Now: the storm's tile = the rounding of the target's centre (body.js posKey), fixed at the cast; a strike picks a
random enemy she can select whose body is on those 13 tiles (
enemiesInKeys, profile ANY as before); each strike still chains on its own bounce radius. The fx ring is drawn with radius 2 (visual only). - ASSUMED: none left on the zone.
- Golden: 12 moved, every one fields 异客 casting S3 (other enemies in the zone; the random pick draws from another list): roster-016, roster-018, roster-048; fields boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, hidden-boss_9-pair; matches solo-HARD-1, solo-ABYSS-1, coop2-HARD-4, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy.
- Tests:
test/content/kits_t6.test.js«异客: the S3 storm is range x-1 …» (the storm centre is the tile centre; six probes inside the diamond struck by all 8 strikes, three outside never — fails on the parent); the two older 异客 tests pass.
(b) 歌蕾蒂娅 S3's skill-end 捕网 has radius 1 (#324) — ops/chess_char_4_12-glady.js
- Seen: the final pull reused the tornado's 1.5: an enemy 1.25 from the centre was dragged to her front at the end (a repro script).
- Official: PRTS 歌蕾蒂娅 S3 备注 「龙卷风半径1.5,持续9秒,共进行7次持续0.5s的拖拽,其中第7次拖拽不会造成伤害」 and 「技能结束时的拖拽为捕网,捕网半径为1,无伤害」.
- Now: slow and pulses within 1.5 (now sourced, the ASSUMED mark goes); the skill-end pull (to her front, unchanged)
takes the enemies within 1. Not examined here: the note's 7 pulls (the kit pulses every
interval). - Golden: fields boss-boss_4-pair (fields 歌蕾蒂娅; fewer enemies dragged at the end — 11 kills instead of 14, one leak).
- Tests:
test/content/kits_t4.test.js«歌蕾蒂娅 S3: the skill-end 捕网 has radius 1 …» (enemies 0.9 and 1.25 from the centre both slowed; only the inner one pulled — fails on the parent).
(c) 信仰搅拌机 S1 reloads the latest-deployed operator first (#325) — ops/chess_char_4_01-rmixer.js
- Seen: the reload went to the nearest candidate ASSUMED (repro-325.mjs: the orthogonal 隐现 deployed first was reloaded instead of the diagonal one deployed later).
- Official: PRTS 信仰搅拌机 S1 备注 「补弹范围为周围8格,仅选择可补弹的干员为目标,优先选择最后部署的干员」.
- Now: candidates as before (his own 拉特兰 operators within 1.5 — the 8 surrounding tiles — whose ammo skill runs),
sorted by the battle-wide
deploySeq, latest first (a redeploy is a new deployment), ties by id. - Golden: unchanged.
- Tests:
test/content/kits_alt_t4.test.js«信仰搅拌机 S1 reloads the latest-deployed …» (initial order → the later diagonal one; after the near one redeploys → it — fails on the parent).
(d) 大帝 加急调派 has no stack cap (#328) — content/bands/battle.js act1autochess_band16_buff
- Seen: the per-deployment ×0.5 redeploy stack stopped at 20 (since the initial import): the 21st redeploy time was the 20th's (repro-328.mjs).
- Official: PRTS 卫戍协议:盟约 下半 and 下半/PRTS盟约记录: 「我方干员每次部署后再部署时间减少50%」, 备注 「※该策略效果可无限叠加」; the data buff (respawn_time −0.5) has no cap.
- Now:
maxStacks: Infinity; the multiplicative stack per deployment is unchanged (its "cumulative" reading, ASSUMED since 0.1.3, is what 无限叠加 describes). - Golden: unchanged.
- Tests:
test/content/bands.test.js«大帝 加急调派 stacks without a cap …» (22 deployments: 20 / 2^n each time, 22 stacks — fails on the parent at n = 21).
27.17 假想敌:骨刺: its model follows its 隐匿, and it targets as PRTS writes (GitHub #296 by FrogThai, PR #365 by @Sukvii) — render/units.js FORMS / STEALTH_FORMS / UnitView.sync, render/spine.js syncFormPose, content/enemies/invisible.js kitBoneSpike
(a) the A / B model switch (render; commit authored by @Sukvii)
- Seen (#296; the screenshot was not opened): a blocked 骨刺 stays the three-headed snake. The view had no clip set for it (no FORMS entry; the sim sends no form fx for it) — a repro script.mjs: bit off → Idle_A.
- Official: the skeleton holds two clip sets (Idle / Move / Die / Attack
_Aand_B; the manifest's roles are the_Aset); PRTS ability 「隐匿状态下同时攻击数个目标」 ties its special behaviour to the 隐匿. Which set is the hidden one rests on the report and the PR author's Chrome check (snake slots on A); no PRTS sentence describes the models. - Now: the snapshot's UF.STEALTH (set only while its 隐匿 is effective: not blocked, not revealed) drives the form at
once —
STEALTH_FORMS { enemy_9008_acbunn: 'revealed' }→ FORMSrevealed= the_Bset; bit on → the manifest's_A. Switched in UnitView.sync before a DIE of the same snapshot (it dies in the form it had); no change clip; a running attack continues in the other attack clip at the same point relative to its strike frame (Attack_A hit 0.533 s, Attack_B 0.733 s; a pending wind-up still strikes at its deadline), a stun / idle restarts on the new set without a crossfade, a frozen skeleton is posed with update(0); a model loaded later is posed the same way. ASSUMED: a 迷彩 enemy also carries the stealth bit (snapshot.js), so a camouflaged 骨刺 would draw A — no source gives it camouflage. - Golden: unchanged (render only).
- Tests:
test/render/forms.test.js«骨刺 effective stealth drives A/B clips» (14, from the PR: real-sim block / blocker stun / reveal / expiry A → B → A → B → A, overlapping causes, attack remap both ways, wind-up deadline kept, frozen / stunned / asleep poses, first snapshot, late model load, A / B idle-move-death — 13 fail on the parent); related render suites 60 pass. Not ported: the PR's Chrome suite (forms.browser.test.js; not run here). Docs: docs/ASSETS.md (forms).
(b) targeting (sim; commit authored by @Sukvii — the PR's lines, checked rule by rule)
- Seen: blocked (so revealed), it hit its blocker even with a taunt-5 ally in reach; it attacked a flying 炎佑 (one of its 3 hidden targets, or the only target when revealed); its 3 hidden targets were picked within the bare 2.0 radius while its normal attack reaches 2.25 (repro-365-targets.mjs).
- Official: PRTS 假想敌:骨刺 级别0 天赋 — written by PRTS editors in 2025-11 (page history), not by the PR author — 「自身普通攻击索敌不受阻挡影响」 「不会攻击飞行单位,隐匿(解除阻挡0秒后恢复)」 「隐匿期间可同时攻击3个目标」; enemy_database level 0 description 「隐匿;隐匿状态下同时攻击3个目标。」 (the handbook line: 「数个目标」). The Grok triage read only the page header and the handbook line; the level section settles all three rules.
- Now: profile
blockFree(as 自制投石机: blocked, it picks by priority among the allies in reach; a 隐匿 / 迷彩 blocker is no target), profilecanTarget= not flying, the hidden selection = the first 3 by priority among the allies its normal attack could hit (rangeRadius + ALLY_COLLIDER_RADIUS, the same rule) — the engine's single target stays if that list were empty. The 3 is no longer ASSUMED; stealth back 0 s after a block was already so (helpers.js). - Golden: roster-018 (its 骨刺 lands one more attack, on 异客: dmg 2434 → 2771 — the same move the PR's roster.json had).
- Tests:
test/content/enemies_bosses.test.js(3 new: 3 → 1 → 3 through block and reveal with a taunt-5 ally winning over the blocker; never the flying 炎佑, hidden or revealed; the ally 2.1 away among the three, 2.3 away not — all fail on the parent); the file: 268 pass.
27.18 An instant skill with no attack of its own is cast at most once per attack interval (GitHub #298 by FrogThai) — sim/skills.js SkillRuntime.tick / activate (nextCastAt)
- Seen: 引星棘刺 S1 度算浪波 (AUTO, instant, SP_FULL, cost 6, no attack) with 迅捷 (refund 12 + 15 at ≥ 40 layers) cast 60 times in 2 s — every tick — at ASPD 100 and at ASPD 600 alike (a repro script): skill.tick checks the tick rules before the attack loop and nothing paced a cast that refilled its own bar.
- Official: PRTS 引星棘刺 S1: 自动回复 / 自动触发, an instant throw, no duration, no attack; PRTS prints no 前后摇 for such casts. The pacing rule is the maintainer's (relayed 2026-10-08).
- Now: a cast of an instant / charges skill with no spec.attack (not the DEFAULT cast right before an attack) sets
skill.nextCastAt= now + the unit's attack interval, and the casts checked in skill.tick (tick rules, the DEFAULT of units that never attack, content trigger ranges) wait for it; reset on deployment. The cast is no attack: the unit's attack cooldown is not touched. (First version4128965awrote the attack cooldown, so each cast replaced the attack / heal that was due — the branch review's high finding; reworked in db29acf9.) Measured, 20 s, 迅捷 229 layers: 12 casts (the first at 4 s as SP fills — #124 — then one per 1.36 s) and 14 attacks, as many as silenced; without 迅捷 3 casts, 14 attacks; an idle cast does not delay the next attack. ASSUMED: one attack interval. Not taken: PR #313's 3 s operation cooldown for AUTO skills (conflicts with #124). - Golden: against the code before #298, 2 moved — roster-033, roster-034 (引星棘刺 S1 no longer recast within an interval).
(
4128965ahad moved 84 through the attack cooldown;db29acf9moves those back.) - Tests:
test/content/bonds_addon.test.js«迅捷 refills 引星棘刺 S1 at once: it is cast at most once per attack interval, and her attacks go on untouched» (4–5 casts in 6 s, attacks = the silenced run's; 23–25 at ASPD +500; no enemy 4–5 — fails before #298: 180 casts, and on4128965a: 0 attacks vs 4); op_wang waits skill.nextCastAt. test/content + test/sim: 2835 pass. Docs: SIM.md §7 (trigger rules), PLAYING.md 技能自动释放.
27.19 The 先兆者 shells hit every ally whose collider touches the 1.2 circle (GitHub #364 by FrogThai) — content/enemies/fly.js kitShell
- Seen: the shell (中枢先兆者 ATK 1200; 先兆者 1000) hit the impact and its orthogonal neighbours, not a diagonal one (√2 ≈ 1.414 away) nor a tile two away (repro-364.mjs); the reporter saw the 3×3 in the official game.
- Official: PRTS 天赋 of both 先兆者: 「弹道对半径1.2范围内的所有我方单位造成攻击力100%的无来源物理伤害(此弹道不会强制击中 主目标,碰撞无视迷彩)」. The landing is a 碰撞; PRTS 作战机制 §碰撞体积 (docs/SIM.md, constants.js ALLY_COLLIDER_RADIUS): collider tests are the common way, an ally's collider a 0.25 circle (already used for ranged enemies' reach).
- Now: hit when centre distance ≤ 1.2 + 0.25 = 1.45 (SHELL_REACH): the impact tile and the 8 around it, never two tiles away; telegraph and blast drawn at that reach. ASSUMED for this shell (its text names the 碰撞); other content zones keep their point radius. Follow-up candidate, not changed: 集团军重型火炮's 燃烧区域 also says 「碰撞不受迷彩制约」.
- Golden: roster-001 (中枢先兆者), roster-019, diy-020 (先兆者).
- Tests:
test/content/enemies_bosses.test.js«… the impact tile and the 8 around it …» (both enemies: impact, orthogonal, diagonal hit; two away not; drawn r 1.45 — fails on the parent); the file 269 pass.
27.20 A 速射手 blocking a ground enemy shoots a flyer in its range first (GitHub #220, PR #220 by TsangAsuna for #205; the owner's decision of 2026-10-08) — sim/targeting.js sortEnemyTargets
- Seen: 能天使 on a melee tile blocking a walker kept shooting it with a flyer in range (repro-220.mjs).
- Official / decision: PRTS 索敌的概念 「阻挡(近战限定)→特殊优先级→…」; the owner keeps the playtest #6 rule 「阻挡了就一定要能打到」 (blocked first) for everyone except the 速射手 air priority.
- Now: priority 'fly' (the 速射手 trait, and a skill's targeting priority 'fly' — 新约能天使) sorts the flying enemies before the attacker's own blocked enemy; every other priority keeps blocked first (远牙, 神射手 lowest DEF, still hits the enemy it blocks over a softer or a flying one). PR #220 moved every special priority — not taken beyond 'fly'.
- Golden: roster-026 (流星), roster-037 (新约能天使).
- Tests:
test/sim/playtest5-targeting.test.js«#220 …» (fails on the parent); playtest5/6 targeting + blocking files 55 pass. Docs: SIM.md (blocking), design/engine.md, PLAYING.md 阻挡.
27.21 余 【文火慢炖】 adds no layers to a bond without layers (the owner's decision of 2026-10-08) — content/bands/meta.js round_start_bond_check_gain_layer
- Seen: at round 8, 独行 alone got +36, 绝技 alone +36, 独行 + 炎 got +12 each (repro-yu.mjs).
- Official / decision: 「第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层」; PRTS 下半/PRTS盟约记录 says layers still apply to the bonds that show none — overridden by the owner for this strategy.
- Now: every bonds.json
noStackbond (绝技, 独行, 调和, 协防干员) is skipped; the 「仅激活了1个盟约」 count stays the sentence's (every active bond) — one such bond alone → nothing; with a layered bond beside it, that one gets count2. Only 余: 协防 (bonds/addon/meta.js) and 华法琳 【重点监护】 pick their own bonds and share no helper with 余 beyond the generic Player.addLayers sink (every layer gain), left unchanged. - Golden: unchanged.
- Tests:
test/content/bands.test.js«余 文火慢炖 adds no layers to a bond without layers …» (the four noStack bonds; 独行 alone / 绝技 alone → nothing; 独行 + 炎 → 炎 +12 — fails on the parent); the file 37 pass. Docs: PLAYING.md 层数.
27.22 A pushed / pulled enemy is held in the 失衡 (UNBALANCE) state (the idea of PR #392 by @xcdoge, re-implemented from the sources) — sim/battle/displacement.js push / pull / _unbalance / _staticForce, sim/ai.js updateEnemy, sim/constants.js PUSH_UNBALANCE / PULL_UNBALANCE(_WEAK) / UNBALANCE_MIN, render/units.js slideTo, render/app.js
- Seen: the sim moved a displaced enemy at once (displace() walks its 0.1-tile steps inside one call) and the enemy walked and swung again on the next tick — a 小力 push on a weight-0 walker: walking again after 0.033 s (a repro script). PR #392 reported the official pause 「被推/拉完的敌人会先站住一小会儿再走」.
- Official (the research of 2026-10-08; PRTS revisions as quoted):
- 失衡位移机制: a force > 0 puts an enemy in the UNBALANCE state machine, 「立刻拥有 0.1 s 的“失衡硬直”」; it ends when its speed is ≤ 0.1 m/s (not during the 硬直), on 浮空, or on a forced state change (复活, 触发技能).
- 异常效果图鉴/失衡免疫: 「失衡期间无法自主移动、发动攻击、使用技能」; 异常效果: 失衡 「不属于异常效果,它是一种状态机」 — a check on the 阻止攻击 statuses (水遁忍者) still runs.
- 游戏数据基础 推力-位移近似对应表 位移时间 by 受力等级: −2 0.2, −1 0.4, 0 0.8, 1 0.9, 2 1.0667, ≥ 3 1.1667 s — whole frames at 30 fps (6, 12, 24, 27, 32, 35); a 30 fps integration of the page's μ = 0.5 单位假设 (stop at ≤ 0.1 m/s) gives exactly that column.
- 推与拉 §拉力: 「作用时间默认为 1 s;若本次受力等级 < −1,作用时间改为 0.5 s」; 急停 zeroes the speed, 「目标将仍保持失衡状态 至拉力作用时间结束为止」.
- 特殊机制 静态刚体: enters the state, cannot move, 「失衡状态拥有 0.1 秒保底持续时间」 — unlike 失衡免疫 (never enters).
- 术语释义 浮空: 「触发浮空时清除受到的推/拉力…无法陷入失衡」.
- Now:
- The move stays instant (the unbalanced() talents keyed off the position jump — 弧光锋卫's bleed, 冒失的小弟 … — are
unchanged); after it,
enemy.unbalanceUntil: no walking (恐惧 / 诱导 included), no normal attack started, a swing short of its frame cut, the attack clip's stand ended. Not a stun: no status, no immunity check, SP and the attack cooldown run; checks that ignore the state go on. - Push: the row of the level push() computes (the −2 angle / distance correction included); the row time is kept when the 特效 column (见行者) or a wall shortens the slide; a slide a wall stops at the first step gets the 0.1 s floor.
- Pull: the force window (1 s; 0.5 s at level −2), whatever the travel — the 急停 and a pull from inside the stop circle included; ≤ −3: no state; an enemy the puller blocks: no pull, no state (unchanged).
- 静态刚体 (data
staticBody— every air unit but “炎佑”; not 失衡免疫noDisplace, not leaders): a force at level ≥ −2, no movement, the state while the force acts — a push 0.1 s (its force is instant), a pull its window (1 s / 0.5 s). - No skill either (失衡免疫 「…使用技能」): content skills (
canCast(e, sil, battle)), blinks (blinkForward), scripted moves (枯朽之种's dive, the 铳 / 碎铳之簧 charges) wait; 乌顶巨角卢鲁's clash waits for the end. Left running on purpose: 水遁忍者's damage check (异常效果 「仍然能正常造成伤害」), passive auras and terrain / periodic effects, death / damage-taken triggers, skill cooldowns. - 弧光锋卫 (天赋 「处于失衡状态时,每0.066s受到400点无来源真实持续伤害」): 400 every 0.066 s while the state lasts (a 0.8 s push → 4800, a 急停 pull with no movement → 6000); the old per-tile one-shot ASSUMED is gone.
- Ends: its time (compared with 1e-9 — every push row holds its exact frames 6 / 12 / 24 / 27 / 32 / 35); 浮空 (cleared in updateEnemy); a 重生 (reborn, and the 逐火 余烬 husk); a route APPEAR relocation (forced state switches). A new force keeps the longer state.
- Client: the
displacefx carriesdur(game s); slideTo slides fordur / ctx.animRate(2× default: a 0.8 s push slides 0.4 real s), landing as the state ends; DISPLACE_SLIDE·√D is only the fallback for an fx withoutdur. - Not taken from PR #392: UNBALANCE_TRAVEL 0.6387·√tiles and the client's 0.3194 (derived from the μ = 0.5 assumption — no page prints them; one frame high on every push row; the wrong shape for pulls), and its exemption of 恐惧.
- The move stays instant (the unbalanced() talents keyed off the position jump — 弧光锋卫's bleed, 冒失的小弟 … — are
unchanged); after it,
- ASSUMED: the row time kept when a wall shortens the slide (碰撞、停止 is 待补充 on PRTS) and the 0.1 s floor when a
wall stops it at the first step; a 强制触发技能 cutting the state is not modelled;
unbalanceProtectDueTime(a client name, no duration) and wall bounces are not modelled. - Golden (cdf2bd5c; the review fixes
1db84d79move 32 more, 8 in outcomes: roster-010 / 040 — 妖怪 drones pulled now held 1 s —, roster-043 / 044, hidden-boss_9-pair, coop4-ABYSS-7, diy-115 / 116 — the 1e-9 end, a frame shorter where the float sum overshot): 69 scenarios move, 35 of them in kills / leaks / deaths / boss HP — every one has pushes / pulls (a probe of the callers): roster-002 / 003 (野鬃), roster-007 / 008 (薄绿), roster-010 (歌蕾蒂娅), roster-011 (莫斯提马), roster-031 / 041 (钩索师 pulls), roster-043 / 044 (山); bond-deputShip-low (薄绿: 18 → 21 killed, perfect), bond-kazimierzShip-high (野鬃); boss-boss_3-pair (锏), boss-boss_4-pair / boss_6-pair / hidden-boss_8-pair (莫斯提马, 歌蕾蒂娅: boss HP lower by ~67 000 on 4 / 6, +28 on 8), hidden-boss_9-pair (锏: boss HP +6 379); standin-05 / 08 / 10 (预备干员-特种 pulls); diy-101 (傀影), diy-114 / 115 / 116 (黑键), diy-137 (预备干员-特种); matches coop2-HARD-4, coop2-NORMAL-14-standins, coop2-NORMAL-3, coop2-NORMAL-70-diy, coop3-ABYSS-5, coop4-ABYSS-7, solo-ABYSS-2, solo-HARD-2 (its bot now survives round 13), solo-HARD-9-boosted, solo-NORMAL-1. Mostly more kills / fewer leaks; some the reverse (a held enemy shifts when the wave reaches the line). Full list with numbers: the commit message. - Tests:
test/sim/unbalance.test.js(14; the review's six fail oncdf2bd5c— exact frames, static pull, 弧光锋卫's bleed, skills / blink / 枯朽之种, the elk's clash, APPEAR and 逐火 重生 — and the file fails on the parent: push rows and walking after them; no attack in the hold, a feared enemy held; the −2 correction; 特效 / wall rows and the first-step floor; pull windows, 急停, ≤ −3; 静态刚体 0.1 s, 失衡免疫 never; 浮空 clears; the fxdur);test/render/unitview.test.js(+1, fails on the parent: the slide at 2× for a push and a pull, the fallback);test/sim/feedback1b-displacement.test.jsB5 updated (the drone held 0.1 s per force, never moved). test/sim + test/content + test/render: 3433 pass. - Docs: docs/SIM.md (push / pull, displace, the assumptions list), docs/PLAYING.md 推开 / 拉拽, docs/research/13 §0 / §5.
27.23 Boss-field portals teleport: a route ending on an entrance comes out of the far exit and leaks at that side's blue door; the four appear-less crossings reappear (GitHub #336, PR #337 by @2321Robin) — sim/ai.js advanceRoute / portalPickup, sim/simdata.js normalizeRoute / portalAppear
-
Seen: GitHub #336 (2321Robin, with a recording of the official game): in a Final Assault / Hidden Core fight an escort that walks into a corner portal vanishes and counts as a leak on the spot; four routes walk the rest of their run hidden after a portal. Reproduced (notes
repro-337.mjs): h07_02 #4, h08_02 #5 / #9 and act2 h07_05 #6 vanish and never reappear, leaking hidden at [1,3] / [1,17]; act1autochess_h07_02_s route 0 leaked at the entrance [1,3] (157 s), a synthetic route ending on [1,17] leaked there. The tile tips (ui/gameLogic/terrain.js) already said an entrance makes the enemy vanish and an exit brings it back; the engine had no teleport —advanceRouteleaked at the end of the last leg whatever the tile. -
Official: level files (.cache/gamedata/levels/activities/act1autochess / act2autochess): the four routes write DISAPPEAR → WAIT_FOR_SECONDS → MOVE (5,10) with no APPEAR_AT_POS while their twins write APPEAR_AT_POS (5,10). data/waves.json: 137 boss / hidden routes end on the entrances [1,3] / [1,17] (
tile_telin); every MOVE onto an entrance mid-route is followed by an explicit DISAPPEAR; all 95 explicit DISAPPEAR / APPEAR pairs (routes and extra routes) read [1,3] / [1,17] → [5,10] (WAIT 3 s in 85, 5 s in 9, 1 s in 1). The battle stages carry telouts at [5,10] and [2,10], blue doors (tile_end) at [2,2] / [2,18]; no tile has a link or blackboard. PRTS 卫戍协议:盟约 (战场一览 and the 下半 page) says nothing about the portals. The behaviour at a route's end follows the contributor's recording. -
Now:
normalizeRouteinserts an APPEAR at the next MOVE's target after a DISAPPEAR (WAITs skipped) when the data left it out — idempotent, an existing APPEAR is kept.advanceRoute, at the end of the route on atile_telin, callsportalPickup: it appends DISAPPEAR, APPEAR at the telout farthest from the entrance ([5,10]) and a final MOVE to the blue door nearest the entrance ([2,2] for [1,3], [2,18] for [1,17]); the leak happens there. A route ending anywhere else, a mid-route crossing and a push onto an entrance are unchanged. The inserted APPEAR also stops the portal hop counting as ~8 tiles of walking inremainingDistance(the targeting order). -
ASSUMED: the pairing (farthest telout, nearest door — it matches every explicit pair; no tile carries a link) and the 3 s the enemy stays hidden inside the end-of-route portal (
PORTAL_WAIT: the modal wait of the explicit crossings, 85 of 95; added after the review of fb7-fields — the first version reappeared in the same tick). -
Golden: only boss / Hidden Core fields and the matches' final rounds move (no match round row moves). Fields: boss-boss_2-solo (the 2 lrrook leak at the door [2,2] instead of the portal and take 120 652 damage instead of 66 943), boss-boss_4-solo (the 2 dspred leaks at the door; one more operator death), boss-boss_6-pair (the 2 hllevi_2 that leaked at the portals are killed on the way back: killed 4 → 6, both players perfect), boss-boss_6-solo (killed 5 → 8, leaks 5 → 2, leader damage 105 194 → 75 209), hidden-boss_8-pair (p2's vtsk leak is gone: it is still walking to the door at the 200 s cap), hidden-boss_8-solo (killed 3 → 8, total 13 → 19 as more sfshu die and split into msfshu, all 10 leaks now at [2,2], leader damage 402 606 → 191 576), hidden-boss_9-pair (from the APPEAR insertion alone: h08_02's routes #5 / #9 no longer count the hop as walking; leader left 3 570 058 → 3 511 007), hidden-boss_10-solo (the yokai_3 escort crosses back before leaking at the door; 4 more operator deaths). Matches: solo-FUNNY-2 and coop2-NORMAL-14-standins (Final Assaults lost as before; pool left 94 860 → 94 412 and 107 915 → 97 588), solo-HARD-1 (Hidden Core failed as before; pool left 3 046 117 → 2 839 368), solo-HARD-9-boosted (cleared; team LP 383 → 361: the 管 echoes leaked 7 times at the portal before and once at the door now, but the longer fight pays 37 overtime LP instead of 9), coop2-ABYSS-10-boosted (APPEAR insertion alone; cleared at 800 LP, the seats' leader damage and titles swap), and solo-NORMAL-1, whose Hidden Core goes from cleared to failed: hiddenCleared true → false, team LP 26 → 0, roundsPassed 15 → 14, pool left 0 → 99 894 (victory stays true — the Final Assault was won). Its Hidden Core (act1autochess_h08_01_s, 假想敌:胄) is identical until 105 s, when the third vtsk escort reaches the corner portal [1,3]: before, it leaked there (27 → 26 LP) and the leader died at 214 s; now it comes out of [5,10], walks through the team's line to the blue door and leaks at 170 s. Meanwhile only 0–2 of the 8 operators stand (2–4 before), the leader stalls near 120 000 HP, the fight passes the 150 real-second mark (300 game s), and the overtime drain (1 LP per real second) plus two 死亡集群 drone leaks at the door take the last 25 LP at 348 s with 99 894 HP left (notes
trace-hidden.mjs,trace-hidden2.mjs). -
Tests: test/sim/portal-appear.test.js (new, 9 — 7 fail on the parent): the APPEAR insertion, an existing APPEAR kept, idempotence; the four real routes reappear at [5,10] and leak visible, once; a solo route ending on [1,3] comes out of [5,10] and leaks at [2,2], a route ending on [1,17] leaks at [2,18], the entrance itself no leak.
-
Addendum (after review):
- With the wait, solo-NORMAL-1's Hidden Core still fails (team LP 0, 14 rounds), the leader left at 63 897 instead of 99 894: the third vtsk comes out of [5,10] 3 s later and leaks at the door at 185.5 s (170.2 s with 0 s), the same operator wipe-out and overtime drain follow. Other moves against the 0 s version: solo-HARD-1 pool left 2 839 368 → 2 982 555, solo-HARD-9-boosted team LP 361 → 376, coop2-NORMAL-14-standins pool left 97 588 → 108 473; fields boss-boss_2-solo, boss-boss_4-solo, boss-boss_6-pair (killed 6 → 5, both still perfect), boss-boss_6-solo, hidden-boss_8-pair (snapshot / fx only), hidden-boss_8-solo (killed 8 → 6, leaks 10 → 12), hidden-boss_10-solo (killed 11 → 10). solo-FUNNY-2 and coop2-ABYSS-10-boosted do not move with it.
27.24 The Hidden Core 铳's 碎铳之簧 chase for the whole 5 s of 末日布道 (PR #347 by @CXUtk; its pair stagger and lock not taken) — content/bosses.js kitSpring / kitGun
- Seen: PR #347 (CXUtk): on the two-player Hidden Core 铳 field the springs ended their chase as soon as they
reached the gun and stayed on one half. Measured (notes
repro-347.mjs): a spring 0.2 tile from the gun chased 0.03 s, one 5 tiles away 2.97 s — kitSpring ended 追逐模式 on arrival or within 1 tile of the gun, and at once when no gun was left. - Official: PRTS 假想敌:铳, 隐秘核心 skill 末日布道: usable while another 铳 or a spring is on the field; every spring gets a 5 s gain (move speed +800%, 无敌) and 「增益期间切换为追逐模式」; PRTS “碎铳之簧” 追逐模式: no ordinary attacks, it keeps following the gun that put it in that mode, a 0.35-radius trample — no arrival clause (the 铳's own 冲锋模式 does end on reaching its target). Handbook: 「持续召唤场上所有“碎铳之簧”向自身移动」. data/enemies.json enemy_9017_achunt_2 skill 2 (and the h08_02 override): cooldown 45, initCooldown 20, dog_duration 5, move_speed 8. Nothing staggers the two guns or locks a call while a chase runs.
- Now: the chase lasts until the gain ends: the spring keeps following the casting gun after reaching it (a dead caster: the nearest other gun; no gun on the field: it holds still), invulnerable, unblockable and disarmed, then returns to its patrol. Each gun casts on its own data timer (20 s after its spawn, then every 45 s); a call replaces every spring's chase (target and a new 5 s), so on a pair field the mirrored gun's call, a tick after the first, takes the springs. The PR's half-cooldown first-call delay for the second gun and its no-call-while-chasing lock are not taken (the maintainer's decision: not in the data, and the lock contradicts 「向自身」); a fairness rule between the two guns would be a separate owner decision.
- Golden: fields hidden-boss_9-pair (the springs spend 5 s invulnerable per call and take less — actrpa 855 522 → 722 451, actrpb 798 033 → 660 498, actrpc 1 384 683 → 1 168 947 — so they pass less on; leader left 3 511 007 → 3 747 749; p2 deaths 7 → 12; still 8 / 8 kills), hidden-boss_9-solo (leader left 1 932 856 → 2 011 732; 隐德来希's candles 6 → 8); matches coop2-ABYSS-10-boosted (cleared at 800 LP as before; the seats' leader damage 7 523 075 / 6 369 365 → 6 992 346 / 6 900 614).
- Tests: test/content/hidden-gun-pursuit.test.js (new, 5; the arena harness and two tests are the PR's): the full 5 s beside a moving gun and the no-gun hold fail on the parent; the pair timing (each gun 20 s after its spawn, then every 45 s, the two calls a tick apart), a mid-chase call retargeting the springs for a new 5 s, and the solo timing guard against the stagger / lock. test/sim/feedback1e-healers.test.js E2 audit: the fields where a healer must block a leader / part run 200 s (the solo 铳 field's springs now first meet a healer at 145 s, at 21 s until now).
27.25 耀骑士临光's 卡西米尔 redeploy reduction has no 5 % floor (GitHub #370, reported by FrogThai) — content/garrisons/battle.js applyReader (act1autochess_gar_eff_respawnTimeByBond)
- Seen: GitHub #370 「耀骑士临光特性赋予身前干员减再部署有上限,最多减至5秒」. Measured on a 100 s base (notes
repro-370.mjs): garrison_144_a at 192 and 300 卡西米尔 layers, 144_b at 96, 192 and 300 → a 5.00 s timer each — the multiplier wasmax(0.05, 1 + respawn_time × steps), a floor at 5 % of the base time (so 3.3 s on 耀骑士临光's own 66 s, 4.0 s on the longest 80 s chess), binding from 192 layers (normal, raw 0.04) and 96 (elite). - Official: data/garrisons.json garrison_144_a bb { divide_num 3, respawn_time −0.015 } 「每叠加3层,本干员再部署时间 -1.5%」, 144_b −0.03 「-3%」, granted to herself and the operator one tile in front by 145_a / 145_b; no minimum key. PRTS 耀骑士临光 has no 卫戍 note on a minimum, and no PRTS page found names a redeploy-time minimum.
- Now:
max(0, …), like every other redeploy multiplier (items, bands, the 协防 bond, svrash); the knock-out timer (deploy.js) already clamps at 0: 144_a 192 / 300 layers → 4 s / 0 s, 144_b 96 / 192 / 300 → 4 s / 0 s / 0 s on a 100 s base. A 0 s timer brings the operator back on the next tick. The ASPD half (garrison 159 / 160) is untouched. - ASSUMED: no engine minimum below the data (none documented).
- Golden: unchanged (no corpus battle reaches 96 / 192 卡西米尔 layers with a knock-out of a 144 holder).
- Tests: test/content/garrisons_battle.test.js (26/26): the new #370 test — 144_a / 144_b at 9, 96, 192 and 300 layers: the multiplier, the knock-out timer, the 0 s return — fails on the parent.
27.26 The path tie-break never wades into more 深水区 than the official route: 战场#08(下半)'s patrolling leaders take the official upper road (GitHub #375, reported by FrogThai) — sim/grid.js Grid._buildField / deepWater
- Seen: GitHub #375 「水图boss会从下面这条路往蓝门走,实际应该从上路走」. On 战场#08(下半) 涨潮控制 (act2autochess_m04) the
walking leaders — 铳 (level_act1autochess_h07_02 route 6; the Hidden Core 铳's patrol routes of act1autochess_h08_02)
and 卢西恩 (act2autochess_h07_05 route 8), PATROL_MOVE (3,9) → (2,3) → (5,9) — walked (3,9) → (2,3) along the lower
road through the 深水区 (3,6) / (4,6): 「3,9 2,9 2,6 4,6 4,4 2,4 2,3」; the mirrored (3,11) → (2,17) likewise (notes
repro-375.mjs; the 自缚 leaders do not walk). - Official: the plain 4-neighbour SPFA (research 08 §3.1): 「3,9 5,9 5,4 2,4 2,3」, the upper road along row 5 with one 深水区 (5,6).
- Cause: the blockable-ground preference (user playtest #2) replaces the official pointer when its route crosses strictly fewer non-blockable tiles; 深水区 is non-blockable itself (it refuses deployment), so the lower road counted 2 (two water tiles) against the upper road's 3 (two floor tiles, one water tile).
- Now: the preference pointer is taken only when its route also crosses no more 深水区 than the official pointer's
(a per-tile
deepcount besidecost;Grid.deepWater= walkable tiles of terrain 'deepsea', tile_deepsea and tile_deepwater). Scope, every stage × normal / 联防 / boss rect × every walkable destination: 48 fields change, all on the two water maps (act2 m04's boss field, the inactive act1 m05), 149 pointers, each back to the official one; the 21 accepted deviations of test/sim/pathing-official.test.js (战场#01 / #02 floor, #07, the inactive #05's water-avoiding routes) and all 154 audited gate routes are unchanged, and tools/build-data.mjs regenerates identical data (the stages.json groundPaths). Other m04 boss-field preference routes that cross fewer 深水区 than the official one stay. - Golden: fields boss-boss_2-pair (铳 pair on act2 m04: the two 铳 patrol the upper roads; leader left 5 311 560 → 5 301 010; still 8 / 8 kills); matches coop2-ABYSS-10-boosted (act2 m04, Final Assault 卢西恩, Hidden Core 铳): still cleared, team LP 800 → 615 — the bots' operators stand on the lower rows, so with the leaders on the upper road the fights last 335 game s (247 before) and 638 (190), both past the 150 real-second mark, and the overtime drain takes 17 + 168 LP; no leak.
- Tests: test/sim/pathing-official.test.js — the four patrol legs equal the pure official waypoints, and in a real battle the 铳 climbs to row 5 and steps on (5,6) only (both fail on the parent); test/sim/pathing-crosscheck.test.js — the independent reference port has the same rule; test/sim/pathing-blockable.test.js — the WALK-leg audit compares with equal-length routes that cross no more 深水区 than ours (its 14 accepted arena exceptions unchanged).
(GitHub #312, Final Assault leader HP vs dead players: already fixed in 0.2.0 by PR #209 — the pool is bloodPoint × players alive when the fight starts; no change.)
27.27 A 画卷 copy that completes a three-copy merge copies the target's equipment (GitHub #389, PR #390 by @Xinglan233) — content/items/meta.js (画卷 onArt)
- Seen: two base 刺玫 (one deployed wearing a base 维式重锤), 画卷 on the deployed one: the elite formed on the
tile (facing kept), the hand held only the original +15 % hammer — the copy was never created, so the two base
hammers never became the +25 % one (repro-389.mjs:
itemMerges 0). A copy that does not promote already worked. - Cause: onArt called
grantChessfirst; the third copy promotes at once (acquire.js _mergeChess), which consumes the target and empties itsitems(stowing the originals); the copy loop then read the empty list. - Official: PRTS 幻画为真 「复制该法术范围内的1名干员(包括精锐状态和携带的装备)」; PRTS 画卷 备注 「使用后销毁,获得 的装备为未装备状态」; PRTS 帮助 equipment comes off on 合并 and two identical base items auto-merge into the advanced one in the hand (mode table: 维式重锤 +15 % / +25 %).
- Now: the item ids are recorded before the gain (the PR). After a promotion (the gained piece is the elite, not the
target's id) no copied item is equipped onto the elite — an already-advanced copy used to go straight back on it
through
equipDirect; all copies wait in the hand, a copied base item merging with the returned original. The non-promotion path is unchanged (it still equips a non-merging copy onto the copy in the hand — see Open questions). - Golden: unchanged. Tests: test/match/scroll-copy-merge.test.js (8, the PR's file with 刺玫 and the advanced-item expectation changed; 4 fail on the parent, the advanced case also fails with the PR's snapshot alone).
27.28 A watched teammate's bond popup counts their bench like their own (GitHub #385, PR #387 by @zasx77771) — match/match/views.js prepFieldMeta / _prepBoardUnits, ui/watchBonds.js ownerBoard
- Seen: watching a teammate in prep, 炎 (BOARD) showed ring 1 and a 成员 header of 3, every bench copy highlighted 在场; 远见 (BOARD_AND_DECK) ring 2, header 3 with the 临时整备区 copy (repro-385.mjs).
- Cause: the prep scout sends the hand and the temp row as units with no area; ownerBoard put every op unit on the popup's synthetic board.
- Official: data/bonds.json — 远见 / 奇迹 / 投资人 count the 整备区 (「整备区的…干员也可用于激活盟约」), the 5 temporary slots never count (server bondsMeta.js membership); the others count the board only.
- Now: the scout's units carry
area'board' | 'hand' | 'temp' (the PR's server tags; the tag survives the boss-half remap). ownerBoard sorts them into board / hand / temp — not the PR's drop of the bench, which would have made a hand-only 远见 member ring 1 / header 0 — so bondMembers / memberHeadCount count exactly as the player's own popup: hand = owned and counted for the three hand bonds, temp = owned only. Battle units (no area) stay on the board. PLAYING.md 观看谁 paragraph updated. - Golden: unchanged. Tests: test/ui/watch-bonds.test.js (36; 2 fail on the parent), test/match/prep-bench.test.js and feedback5-boss-pair-prep.test.js (area tags; 4 fail on the parent).
27.29 While a facing is chosen, Enter follows the focus (GitHub #394, PR #395 by @Xinglan233) — ui/facingWheel.js, ui/gameLogic/shortcuts.js facingEnter
- Seen: arrow → Tab to 准备 / 设置 / the exit button / ✕ → Enter deployed the previewed facing (the capture listener committed on Enter whatever had the focus). The PR only let a focused ✕ keep its native Enter.
- Official: not an official rule (the remake's keyboard: arrows preview, Enter commits, Esc cancels; Space and the shortcuts are swallowed while choosing).
- Now: facingEnter: on ✕ Enter cancels; on any other control (button, link, form field, ARIA control) it does nothing; elsewhere it commits the previewed direction (nothing while none is previewed). The wheel (role="dialog", tabIndex -1, no focus ring) takes the focus when it opens, so a button focused before the drop cannot swallow the confirming Enter and the first Tab lands on ✕; the old focus is not restored on close. ASSUMED the focus move (a dialog convention, not in the issue). PLAYING.md key paragraph updated.
- Golden: unchanged. Tests: test/ui/feedback5-hotkeys.test.js (+2: facingEnter cases and wiring); test/ui/facing.e2e.test.js (SP_E2E; the PR's drag / Tab flow, 4 cases — run in headless Chrome: 4/4, all 4 fail on the parent; Enter on the focused exit button deployed).
27.30 An open detail card draws its field unit's current attack range (GitHub PR #281 by @IceCodeNew; idea after @BBleae's fork) — screens/game/range.js inspectRange, screens/game.js cardRange, shared/protocol.js unitStatsEntry, render/app/info.js
- Seen: in battle, tapping a unit showed its card but not where it reaches; the prep overlay covered only a selected own piece. The PR's first unofficial skill-range preview was withdrawn (the owner's reply of 2026-10-07).
- Now: the range follows the open card: an own board piece in prep (loadout / stand-in record, its facing — this
replaces the selection's range effect); in battle or on a scouted board any ally op / summon on the field, at the
snapshot's tile, with the live stats' range and the new optional
dir(the unit's facing now; snapshot tuples carry none, UnitInfo.dir is the send-time facing); never an enemy, a dead / undeployed unit, or a scouted bench unit (the #385area, kept by renderInfo). Drag, the wheel and the pen clear it. The PR's Co-authored-by trailers are dropped (credit in the message / CHANGELOG). Docs: PLAYING.md, design/client.md, design/network.md (dir?after the pinned unitStats shape). - Golden: unchanged (m.unitStats only). Tests: test/ui/inspect-range.test.js (6: the PR's 5 + a server-sourced scout board — board op ranged, hand / temp none), playtest6_summons, unitStats; inspect-range.e2e.test.js (SP_E2E + CHROME_PATH: run, 1/1).
27.31 引星棘刺 S1 度算浪波: alchemy units add their DEF (GitHub #388, reported by @ChengCC0101) — content/kits/ops/chess_char_5_15-thorn2.js
- Seen: two S1 units over one ally: DEF +60 (normal + elite: +75), while both heals landed (repro-388.mjs).
- Cause: every unit's DEF buff used one key ('thorn2:bastion'); addBuff replaces a same-key buff.
- Official: PRTS 引星棘刺 技能1 备注 「※优先选择生命比例最低>最晚部署的我方单位(不含装置职业单位)※效果均可叠加」; the mode text 防御力+60(精锐 +75), no 不叠加 (S3's text does say 不叠加 — untouched).
- Now: one DEF buff key per alchemy unit (assigned at its first tick; a separate counter, so S2's fx keys do not shift): two units add (+120, +135), each lapses with its own unit.
- Golden: roster-033 (a normal 引星棘刺 S1) and roster-034 (an elite one) move — one operator's consecutive S1 units overlap ~2–4 s (6 / 7 s + 3 s 心相 against SP 7 / 6) and now add on an ally both cover (033: killed 46 → 64, leaks 121 → 103; 034: 5_13_b damage taken 41498 → 14903). Tests: test/content/kits_alt_t5.test.js (+1; fails on the parent).
- 塞雷娅 S1 claim (「塞雷亚一技能不给队友治疗」): no defect. 急救 is 自动触发 at her attack (attack range 0-1: an enemy on her tile) when an ally in the 3×3 around her is at ≤ half HP (mode text 「下一次攻击会为周围血量不足一半的一名友方单位…」, PRTS 备注 「仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击」). repro-388-saria.mjs: own field 30 % ally + enemy → healed 504; no enemy → no heal; ally at 60 % → no heal; on a 联防 field another player's operator at 30 % → healed 504 (elite: twice, 866). On shared fields this matches the official joint field as far as sources go (PRTS 帮助 「两处阵地将前后拼接为一处阵地,玩家在同一阵地作战」; the text says 友方单位; no source limits heals to the caster's own units).
- 天猫: no operator, token or stand-in in the data is named 天猫 (no name contains 猫). Ask the reporter for the in-game name, the skill, and whether it is 手动 or 自动触发.
27.32 谢拉格's 盟约寒风 blows a blizzard up the field (GitHub PR #386 by @leiming2333) — render/fx/zones.js snowfall, render/fx/simfx.js ('chill')
- Seen: the bond's cold wind was 26 small flakes drifting down and a faint flash — barely noticeable.
- Official: the blizzard blows bottom-up (the owner's confirmation of 2026-10-08; PRTS 盟约寒风 / 盟约记录 give no direction).
- Now: the PR's gust — 48 flakes (low 22) rising off the bottom edge on a crosswind, 10 streaks (low 4), flash 0.45 /
1 s — for the bond's field-wide wind only (the device emits coldWind with no anchor unit). The same 'chill' is the
coldWind of 灵知 S3 (失温症, at its start) and of 麦哲伦 S1's pulse (every 3 s; 248 in golden diy-117): with the PR as
written each of those became the full-screen blizzard; they (fx with the unit's
id) keep the light downward flurry and the 0.35 / 0.8 s flash. ASSUMED counts, speeds, lives and the flash strength (visual choices). (The coordinator's 「真理」 is 灵知, chess_char_4_13 — Grok's triage named it 真理.) - Golden: unchanged (display). Tests: test/render/fxchill.test.js (2; the gust case fails on the parent). No visual browser check was made.
Open questions / follow-ups (not done)
- 画卷 without a promotion still equips a non-merging copied item onto the copy in the hand, while PRTS 画卷 备注 says the obtained equipment is unequipped — the owner may want that path to leave the copies in the hand too.
- 度算浪波 target tie-break: PRTS 「生命比例最低>最晚部署…(不含装置职业单位)」; the kit breaks HP-ratio ties by block count, distance, id, and does not exclude device-class units.
- inspectRange draws nothing for an own piece's card left open into the battle (target kind 'piece' outside prep), nor for the boss partner's units shown in the own boss prep — both minor, unchanged from the PR.
- feedback7 moved 30 commits past this branch's base meanwhile; R22-OSIM rewrote the same thorn2 guard block (heal →
hpRegen, its own per-zone key for the regen). My change touches only the DEF addBuff line (two unchanged lines from
theirs) — expect a clean merge, then re-run golden (roster-033 / -034 may move again once both are in).
Checked:
git merge-tree feedback7 fb7-lateconflicts only in test/golden/roster.json; on an exported copy of that merge the three 引星棘刺 S1 tests pass (the #388 test checks the ally's HP rises, not 'heal' hooks, since R22-OSIM's recovery is a 生命回复速度 buff).
Follow-ups done after the coordinator's OK (commits on top, 2026-10-08)
ac423184— 画卷 without a promotion no longer equips the copied items on the copy: every copied item is only gained (hand, overflow temp, destroyed with 「整备区已满,获得的装备已销毁」 when both are full), PRTS 画卷 备注 「使用后销毁,获得的装备为未装备状态」. Full hand: 3 free temp slots hold the copy and both copied advanced items; with 2 the second item is destroyed with the toast (it used to ride on the copy). test/content/items.test.js's 变形同构体 case updated (it pinned the old equip); META.md / research 04 sentences fixed. Golden unchanged. Tests +3 (all fail on the parent).03688c82— 度算浪波's target: an HP-ratio tie goes to the latest deployment (highest deploySeq), PRTS 「※优先选择生命 比例最低>最晚部署的我方单位(不含装置职业单位)」 (devices were already outside alliesInGrid). Golden: roster-033 and roster-034 move again (all allies start at full HP, so the first units land on the latest deployed ally). Test +1 (fails on the parent).- Both checked on an exported merge with the current feedback7 (which already holds the first six commits): the kit auto-merges, only test/golden/roster.json conflicts (regenerate), 18 related tests pass there.
GitHub #397 「塞雷亚一技能不触发(有时)」 — not a defect (no commit)
- Input: the diagnostics dump's b.start (spec v 1 = this tree's SPEC_VERSION 1), co-op ABYSS round 8, act2autochess_m01, copied at 116.57 s. Rebuilt with createBattleFromSpec and run to 116.57 s on an exported v0.2.1 tree (the reporter's version) and on fb7-late: identical timeline (replay-397.mjs, replay-397-who.mjs in this folder).
- 塞雷娅 (uid 72, (9,4) facing RIGHT, S1 急救) has her charge ready from the start (SP 5/5, 1 charge). From 47.2 s 幽灵鲨 at (9,5) is at ≤ 50 % (34 % → 19 %), from 59 s 深巡 at (10,5) too (down to 3 %) — both in her 3×3. She attacks 0 times by 116.57 s: no enemy ever stands on her tile (attack range 0-1: her own tile; she blocks nothing — 幽灵鲨 in front of her on row 9 and 深巡 take the melee 业余竞演者), and all the damage to those two comes from ranged enemies shooting from afar: 假想敌:骨刺 (幽灵鲨 1739, 深巡 5218) and 隐形弩手组长 (1316 / 574). At 116.57 s only 骨刺 is left, at (11.7, 5.0).
- Missing condition: the attack. S1 is 自动触发 and replaces her next attack (mode text 「下一次攻击会为周围血量不足一半的 一名友方单位恢复…」, PRTS 备注 「此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击」); a 守护者 attacks only an enemy on her tile / one she blocks. For the reply: put her where ground enemies reach her, or take S2 药物配置 (heals every ally in its area as soon as one is injured, no attack needed).
27.33 The Final Assault and the Hidden Core pay a 战术特训 card's perfect-battle coins (PR #353) — match/bossRounds.js _finishFinal / _settleBossBounties
- Seen: found in the review of PR #353 and reproduced. A bounty with a perfect payout (a 战术特训 card, from the 杜宾 Art
教鞭) has its enemies spawned in the boss battle on its owner's half like every bounty with battles left, and the battle
uses up one of its battles — but
_settleBossBountiesbanked onlypp.coins(the kill payouts the Battle pays). R14 prep, a perfect card (法术大师A2·多轮战术特训, 2 coins), the Final Assault won with the player's own half clean: pending funds 0 → 3 (the boss field's kill coins) — a normal round pays 3 + 2. The comment of the function said "exactly like SETTLE does for normal rounds";settle.jsaddsb.card.coinof every perfect bounty whencounted === 0 && r.perfect !== false. - Official: PRTS 卫戍协议:盟约 下半 §杜宾 "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" and every card's
"为自身下场战斗添加…,若各自行动阶段就达成完美作战,获得N资金";
add_enemy_selfbattle_win_gain_coin {count, enemy_id, coin, round}(round 1 / 99). Nothing excludes the boss battle, and the enemies of the same card do come in it. The official help (§回合结束) calls a battle perfect when every enemy was beaten. Re-checked on the live PRTS pages (read 2026-10-08, the same sizes as the cached ones). - Now:
_finishFinalreads the victory (the first end the server registered) before the per-field loop and passesperfect = victory && !res.synthetic && pp.perfect === true && no counted leakto_settleBossBounties, which adds the card amount of every perfect-payout bounty to the kill coins (pending funds,stats.fundsGained; spent in the Hidden Core's prep; the Hidden Core pays by the same rule); every bounty still loses one battle afterwards. - ASSUMED: the boss battle's "own battle perfect" = the team won, the field reported for real (not the stand-in of a field that never reported) and the player's own half let no counted enemy through; enemies still alive when the leader falls do not spoil it (the sim ends the battle at the pool's 0); once per player and card, whatever the card's enemy count.
- Follow-up (review of the branch): the boss path trusts a client's
b.result(no leak multiset check, no re-simulation), and the team LP a field costs reaches the server throughb.progress.leaks, so the two could disagree. A modified authority reportingleaks: 1and then two empty leaked lists (or pinning the leak on the partner's list) was paid the card by both seats (repros: LP 58 → 57 charged, +2 / +2; pinned on the partner, +2 / 0). Nowb.progresscarriesleaksBy(spec.js battleProgress: per player the LP of the leak entries the result will show;leaksminus its sum is the leader's own "扣除目标生命" effects, which must not cost a perfect seat its card), the server keeps the highest value per player (f.leaksBy), andMatch._bossLeaksAgreepays a client-reported field's seat only when (a) the LP it reported was split at all (leaksByseen wheneverlpReported > 0) and (b) the seat's leaked list shows at least its reported leak LP. A server-run field (no client, a takeover, server-run combat) is the truth itself and skips it. Limit: an authority that lies consistently (a split that blames the partner, the leak labelled as the leader's effect) is not caught — the boss path has no second source; what is caught is a result contradicting its own progress. Tests:clientCombat.test.js(forge: charged leak + two empty lists → 0 / 0; reassignment → 0 / 0; honest split → clean seat paid, leaker not, leader-effect LP costs nobody; two honest SimClients; the protocol field),runner.test.js(the browser runner sendsleaksBy). - Golden: unchanged (checked:
node tools/golden.mjs --family matches). - Tests:
test/match/clientCombat.test.js(the held 'cleared' result pays once, an accepted duplicate never twice; six eligibility cases: team win / synthetic / own leak / uncounted leak / forced team defeat),test/match/followups.test.js(R14 → R15 income and the Hidden Core with a perfect card; a boss win with an own leak keeps the kill coins only); the first two fail on the parent.
27.34 教鞭: the official personal choice of three 战术特训 cards (PR #353, the owner's decision of 2026-10-08) — match/spDraft.js, content/items/meta.js, player/prep.js, match/prep.js, player/views.js, bot.js, ui/choiceOverlay.js, screens/game.js
- Seen: 教鞭 (杜宾's strategy, one every 2 rounds) drew three 战术特训 cards and added a random one — recorded in docs/META.md
§2.5 as a deliberate simplification: a personal choice would need its own phase, a protocol message outside SP_DRAFT, a
timer and an AI takeover for a rarely used Art. PR #353 shows those costs are small (the offer lives in PREP,
g.choicecarries the offer's id, the prep's deadline is the timer, the bots' bounty scorer picks) and the owner decided to restore the official choice. - Official: act2autochess
effectChoiceInfoDict.hunter_band_1ischoiceType PERSONAL_CHOOSE,effectType ENEMY_GAIN("选定悬赏目标,获取额外奖励。"), opened bytrap_create_self_choice {choice_event: hunter_band_1}of 教鞭 (and 神秘顾客); PRTS 下半 记录 §法术 教鞭: "拖拽至场上使用后,选择一项悬赏任务进行挑战 … 使用后销毁,于3个战术特训的悬赏任务中选择一项"; §机变阶段 "※以下悬赏任务仅由法术教鞭生成". The three cards the official server offers are not published. - Now:
ctx.offerBountyChoice→Match.offerBountyChoiceshuffles the cards the mode can field once with the meta rng (15 of the 20 in 标准 / 险境, all 20 in 绝境 / 终极 —inactiveEnemies) and keeps up to three different ones inPlayerState.personalChoice { id, round, sourceItemId, cards }; the Art is used up (and counts for the two per round) at once, nothing is applied.m.private.personalChoicecarries it to its owner alone (the same cards and id after a reconnect or reload); neverm.public, a scout's view or a spectator.g.choice { idx, choiceId }→Match.pickPersonalChoice(owner alive, not Ready, id, round, index →addBounty); without achoiceIdthe message is the public draft as before, and a stale or foreign id never falls through to it (BAD_TARGET). Refused before an rng draw or an id is spent (the Art stays): not the player's PREP, Ready, an offer already open, no card left. No new phase or timer:setReady(true)is refused while the offer is open (m.private.canReadyfalse); the prep's deadline (prepDeadline) takes a random card (meta rng) before the temp pieces and Ready; a solo or single-human prep is untimed and has no timer for it. A seat the engine plays picks with the bounty scorer (botPickCard→bountyScore; the bots' seeded rng breaks ties; at the start of the bot's prep, right after the cast, before Ready); when a step of the bot's prep fails the scheduler's fallback readies the seat with the random pick. A seat that left is eliminated (onLeave→eliminate()), which drops its open offer — nobody picks for it;invariants.jsflags one outside its owner's own open PREP. Client:ChoiceOverlaywithpersonal(the 机变 draft's overlay and two taps, no order, no turns, the prep's countdown or 「无时间限制」) over a locked prep (editablefalse, shortcuts swallowed, Escape only drops the highlight), the Ready button / key say 「请先完成教鞭选择」,normalizePersonalChoice, six strings in en / ja / ko / zh-TW (the official 战术特训 names: "Tactical Training" / 戦術訓練 / 전술 특훈 / 戰術特訓), the mock harness. “神秘顾客” (granted by nothing in act2, no pool in PRTS) stays a random bounty ASSUMED. - Not taken from the PR: its new §25.24 inside the released docs/history/0.2.0.md (this is the 0.2.2 section); its wider edit of the Ready toggle (only the personal-choice case changes the line under the button); the i18n keys appended at the tail of the four packs (placed beside the draft's countdown string instead, to spare the integration merge); the PR's docs prose (rewritten into META §2.5 and the design files, the PLAYING row, BALANCE).
- ASSUMED (no source): the three cards are distinct cards drawn uniformly from the eligible ones; the deadline's default is a random card; Ready is refused server-side while the offer is open; the client locks the prep behind the overlay (the server still lets the other prep intents through); a disconnected player waits for the deadline like for any prep action.
- Golden:
coop2-NORMAL-8-serverrun(ai_0 杜宾; first differs in R2; teamLp 39 → 34, virtualMs 121400 → 118400, rng shop 511 → 483 / bots 18063 → 18070 / meta 112 → 103) andcoop2-ABYSS-10-boosted(ai_1 杜宾; first differs in R2; virtualMs 121650 → 124650, rng shop 590 → 589 / bots 22075 → 22096 / meta 141 → 154) — the 杜宾 bot casts from R2 on and now scores the three cards instead of a random draw; every other scenario and family unchanged;node tools/golden.mjs --twiceidentical (the bot's pick is the seeded scorer's). - Tests:
test/content/items.test.js(three distinct 战术特训 cards, the tapped one applied once, nothing before the confirm, a refused cast keeps the Art / Arts count / rng / uid, short offers, an eliminated player's offer),test/match/economy.test.js(Ready refused, the deadline picks before endPrep builds the battle, solo prep untimed),connection.test.js(two players' offers, reconnect, watcher, foreign / duplicate / old ids, an offline player's pick at the deadline),bot.test.js(托管 takeover incl. the failing-step fallback),bot-smarter.test.js(the scorer's pick, one seed picks the same card, no rng draw when nothing is pending),followups.test.js(R14 / R15),draft.test.js,unitStats.test.js(protocol),hardening.test.js(the invariant),test/ui/gameLogic.test.js/playtest3.test.js/playtest4.test.js(view model, Ready reason, ChoiceView,actions.choice), browser:test/ui/mock.e2e.test.js(two tests, SP_E2E=1; the PR's Escape step waits 200 ms for the overlay's effect — it raced in headless Chrome),playtest2.real.e2e.test.js(co-op boss prep with two humans, reload, language, foreign id; SP_E2E=1 SP_P2_REAL=coop). All fail on the parent.
27.35 The bots plan a zero-range heavy guard on a free enemy-road tile first (PR #339, that part only) — bot.js planLayoutSteps
- Seen: 蛇屠箱 and the seven other operators with the official range
0-1(角峰, 古米, 泡泡, 折桠, 菲莱, 塞雷娅, 余 — 16 chess records, melee blockers) stand on a tile the enemies never cross and neither block nor hit: the exposure model's kill estimate saturates in a full layout and a 1e-6 rng tie-break decides. Reproduced (planLayout, six guards, act2autochess_m04, seed 11; 40 seeds × 8 stages × 8 guards: 16 of 320 off the road, 11 of them because act1autochess_m03 / act2autochess_m02 have only 7 free road tiles for 8 guards, 5 avoidable). - Official: excel
range_table["0-1"]= the single grid (0,0); the operators are 重装 withblockCnt3 (4 elite 角峰). - Now: a blocker whose deployed range grid (
rangeRec, the loadout's) is exactly one (0,0) grid keeps the best road tile (model.ground) by the same value and is placed there before the overall best; the high-ground 「可以放置于远程位」 preference still comes first; with no free road tile the best legal tile as before. Result: 8 guards fill min(8, free road tiles) road tiles on every stage and seed of the test. - Not taken from the PR (AI heuristics no official text backs, left out by the maintainers' decision): the scarce-card penalty of
buyScore(18 per missing copy of a V / VI card a teammate's strategy needs),itemScore(the 人事部文档 only with nine 炎 on the board — official 「部署上限变为 9」, any lineup;equip_destory_deployment_cnt_change), the morph fit and the draft yield (s −= 8 + max − s). The PR's golden swing (a solo match that cleared the Hidden Core at team LP 26 ended at 0) came from them: with the placement alonesolo-NORMAL-1still wins, clears the core and ends at 26. - Golden (matches only):
solo-FUNNY-1(from R7; boss pool left 74740 → 71550, a loss in R8 as before),solo-NORMAL-1(from R4; same end),solo-HARD-1(from R4; 3046117 → 2940542),solo-ABYSS-1(from R4; 2303334 → 2281356),coop2-NORMAL-3(R4),coop2-HARD-4(R4),coop4-FUNNY-6(R3; virtualMs 128500 → 127000),coop2-NORMAL-8-serverrun(R3),coop2-NORMAL-70-diy(R6) — rows, stats, lineups and rng draws differ; no win / loss / rounds passed flipped;--twiceidentical. - botbench (12 seeds each,
tools/botbench.mjs --configs solo:HARD,solo:ABYSS,coop4:NORMAL,coop4:HARD --seeds 12 --jobs 4, A/B with--compare): wins 1 / 0 / 11 / 3 before and after; rounds passed 13.00 / 11.92 / 14.00 / 13.00 → 12.92 / 11.92 / 14.00 / 13.17; LP left 18.7 / 6.5 / 80.1 / 27.9 → 18.8 / 7.4 / 83.7 / 28.0 — no difference beyond noise. - Tests:
test/match/bot.test.js— the PR's saturated-layout case (a real Battle: 蛇屠箱 blocks and hits only on the road tile, not on '12,5'; the road full: a legal fallback) and the 40-seed all-stages fill test; both fail on the parent.
27.36 赤刃明霄陈 S3's sword wave is drawn along its path (owner item 12, PR #382 by @Convey123) — kits/ops/op-chen3.js (WAVE_FX_EVERY), render/fx/kinds.js chen3Wave, render/fx/simfx.js 'qi'
- Seen: 「赤刃明霄陈3技能的剑气没有特效」. Repro: elite tier-6 slot, S3 cast, 1.5 s later the probe is at x 7.3 moving
right, and the fx tagged chen3:wave are one
dashand oneslash— no event along the path, nochen3Wavekind. - Official: skchr_chen3_3 赤霄·天喟 "向前释放一道可转向的剑气" — a visible travelling, turning slash; its look is a particle prefab (battle/prefabs/effects) this client does not extract.
- Now: the kit emits
fx('chen3Wave', { x, y, fromX, fromY, dr, dc, id, skill })every WAVE_FX_EVERY 0.12 s (4 ticks = 0.133 s), the trail restarting at a corner; the client registers chen3Wave → archetype 'qi' (pt: drawn at the event) — a streak from the previous point plus a crescent turned the way it travels and a glow. Changed on review: streak() reuses the effect system's projection scratch (this._p / this._q), so the PR drew the crescent on the trail's start, one event (~0.2 tile) behind the wave; the head is now projected after it, and trail, head and heading share one height. - ASSUMED the look (procedural streak + crescent, not the official prefab); the 0.12 s cadence is the renderer's.
- Golden: diy-073 events.fx 508 → 1558, fx.chen3Wave 1050; diy-074 events.fx 272 → 872, fx.chen3Wave 600 (both cast chen3 S3). HP, kills, damage, hooks, snapshot hashes unchanged.
- Tests: op_chen3 +1 (12/12); render/fxchen3wave (new, 6/6 — incl. the head on the wave, fails on the PR's code); boardart 15/15.
27.37 An enemy standing out its attack recovery is drawn standing (PR #381 by @ZiyueW1116; supersedes #307 / #314) — sim/battle/events.js snapshot / _cutAttackStand, ai.js, displacement.js, deploy.js _remove, spawns.js, render/interp.js movementStart
- Seen: since 0.2.0 the sim stands an unblocked enemy for the rest of its attack clip (atkStandUntil), but the interpolator lerped x/y over the whole snapshot gap. Repro (real 隐形弩手): snapshots 4.9 / 5.0, stand until 4.934 — sampled at 4.917 the view slid at vx −0.2.
- Official: PRTS enemy state machine (accepted in 0.2.0): ATTACK returns to MOVE when the attack ends; a displacement cuts it. No official interpolation curve.
- Now: optional snapshot lists
stand: [[id, until]](enemies alive, deployed, visible, not feared / stunned) andstandCut: [[id, at]](latest cut: displace, fear, stun, hiding, DISAPPEAR, removal — display metadata only). sample() holds the older position untiluntil, interpolates the rest (HP / SP keep the whole interval), stays linear when the newer snapshot records a cut inside the interval, and extrapolates nothing for a newest snapshot showing a stand, DIE, STUN or BLOCKED. Teleports / redeploys still snap first; old snapshots interpolate as before. Changed on review: docs and comments in English (SIM §9, design/network.md, interp / events / spawns / types), the listed-id set built lazily. A ≤ 2.5-tile push of a living enemy inside one gap can still get a high vx (the PR's stated non-goal; #380 covers displacements). - Golden: unchanged (the digest canon hashes neither list; sim timing unchanged). All 283 scenarios match.
- Tests: render/attack-recovery-interp (new, 25/25 — real 隐形弩手 battles, a Weedy push-kill, stun / freeze / sleep / levitate / block / fear); render/interp 16/16; sim battle / perf / robustness / downelem 69/69.
27.38 Push / pull as a slide, a dying flyer's lift, the death-clip cap (PR #380 by @xcdoge) — render/units.js DISPLACE_SLIDE / slideTo / DIE_CLIP_MAX / DIE_FADE_TAIL, render/app.js syncBattle look-ahead, render/interp.js nextSnapT, research 13 / 14
- Seen: the sim displaces in one call (battle/displacement.js), the snapshots only show the destination: frame-loop emulation over a real battle (SnapshotBuffer + UnitView in app.js's order) — a 1.7-tile push is lerped over 3 frames, 0.58 tiles in one; longer pulls snap (> teleport 2.5). Flyers dropped to the ground as soon as they died; the 1.6 s death cap cut long Die clips.
- Official: client metadata (research 13, contributor's reading): Knockback / KnockBackWithDirection / DragTowardSource carry an impulse (m_force) and a velocity decaying under friction (m_friction, the tile's additionalFriction); no per-skill duration in the data; this mode's maps are all normal land. The fly offset leaves in CharacterAnimator.OnFinish (research 14). data/assets.json: 234 enemy Die clips, the longest 7.967 s (enemy_1521_dslily, 盐风主教昆图斯), 25 over 1.6 s — re-checked here.
- Now: syncBattle starts UnitView.slideTo for a queued
displacefx as soon as the sampled interval ends on the snapshot that carries the destination (SnapshotBuffer.nextSnapT); the view holds where it stood until that snapshot is shown, then eases into the sampled position (destination + the walk resumed from it) under a constant deceleration (v0 = 2D/T, a = v0/T, each step at its mean velocity); a unit that dies on the way finishes the slide and lies where it was pushed. DIE_CLIP_MAX 8 s; a flyer holds FLY_HOVER until the 0.55 s fade tail. Changed on review: (1) the PR moved the corpse back to the slide start (its test asserted it) — the sim's body is at the destination; (2) the PR's pre-pass scanned (renderT − 0.5, renderT] after processEvents had already taken those events (dead code: the slide began one frame before landing, the first ⅔ of a short push still lerped in 2 frames); (3) the PR's fixed-destination end jumped back onto an enemy that walked on, and its end-of-step velocity ran ~9 % short (a 0.15-tile jump at landing); (4) the backslash-escaped backticks / the JSDoc placement in units.js, the research-14 citations of a sweep script and a data guard that were not in the PR. Follow-up7a33bc7e(Grok's review of fb7-render, HIGH): a unit killed on the step of its push died before its slide anchored, and syncBattle gives a dying view only its position — the slide held its start until the 0.5 s timeout, past the 0.8 s DIE window, and the body lay on its pre-push tile (route enemy, summoned enemy, flyer alike). The per-view step is now units.js syncView; its dying branch calls UnitView.followSample (sync()'s position part), so the sample anchors the slide; a never-shown slide now goes to the fx's destination after the hold. The frame-order test drives syncView with the battle's own steps (the harness's step() had drained the events). - ASSUMED T = 0.14·√D in 0.12–0.45 s (ours, no source gives the official timing); the hold of ≤ 1 snapshot interval (≈ 3 frames) before the slide; research 14's root-displacement sweep is the contributor's (script not in the repo).
- Golden: unchanged (client only).
- Tests: render/unitview 38/38 (+16: the slide, corpse at the destination, hold-then-ease without a landing jump, a never-shown slide going to the destination, the app.js frame order through syncView over real battles — a 1.7-tile push over ≥ 8 frames, each step < 0.35 tile; kills on the push's step for a route enemy, a summoned one and a flyer, and 6 ticks later, the body ending where the sim's lies —, a dying flyer's lift, every Die clip ≤ DIE_CLIP_MAX and a 7.97 s clip scheduled whole); sim/displace-fx 1/1.
27.39 香槟炸弹 never shows an HP bar (owner item 11) — render/units.js NO_HP_BAR / _updateHud
- Seen: 「琳琅诗怀雅的2技能的香槟会掉血,会被治疗」. Allies always draw an HP bar, so the bomb's HP changes showed (repro: bar visible at 1000 and 860 HP).
- Official: PRTS 香槟炸弹 (实体类型 装置, 1000 HP, 0 DEF/RES) 备注 "※即使自身生命值未满,模型下方也不会显示生命值槽". Nothing makes it invulnerable or unhealable; 活性源石 hurts what stands on it (its HP can drop, as the 备注 implies).
- Now: the bar is hidden at any HP. HP, damage and healing unchanged — no invulnerability added.
- Open (owner): whether it should be unhealable — no written source says so (Grok's run: 白面鸮 healed it 400 → 930).
- Golden: unchanged. Tests: render/champagne-hpbar (new, 2/2).
27.40 Closed eyes stay closed without the stencil masks (GitHub #177; re-implemented from PR #384 by @Convey123) — render/spine.js _eyeMaskFallback / pointInPolygon / slotCentre, render/app.js comment
- Seen: eyeballs show through closed lids (blinks, knock-downs) on 佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …: their eyelids are clipping attachments and app.js pickClipping keeps the masks only for a lone clipped skeleton at high quality.
- Found: 417 local operator skeletons, 73 with clips, no clip on a non-default skin; covered attachments 313 meshes,
54 regions, 1 path. pixi-spine draws a mesh slot through
slot.currentMesh(currentSprite null) — PR #384 hidslot.currentSpriteand measured only VertexAttachments (worldVerticesLength), and its test stubbed a currentSprite on every slot: with the real pixi-spine Spine nothing was hidden (5 of the new tests fail on its code). - Now: while the masks are off, after each update, the actor walks the draw order like pixi-spine (clip slot with its attachment in force → endSlot, any skin) and hides the slot container of a covered slot whose drawn centre (mesh mean vertex, region quad centre through the bone) is outside the clip polygon; each pass undoes the last; masks on → shown again. pickClipping unchanged.
- ASSUMED all-or-nothing per slot (an approximation of the mask's cut).
- Golden: unchanged. Tests: render/eyemask (new, 7/7, real runtime objects: official skeletons in @pixi-spine/runtime-3.8's real Spine with @pixi/core display objects and a 16 px canvas stand-in, the real SpineActor; the five skeleton tests skip without public/assets — not committed).
27.41 An attack seen late plays its whole clip when the rhythm allows (#246 by @TsangAsuna, the owner's decision of 2026-10-07) — render/spine.js attack
- Seen: an unwound attack (no look-ahead) starts on the strike frame: 重犯 (enemy_1121_lifbos, Attack_grey 2.333 s, strike 1.0 s, every 3 s) showed the slam without the ball rising, 1.33 s of the clip.
- Owner (thread #246): re-implement so that when the attack interval leaves room for the whole clip it plays once
completely from the start — the PR's
!singlecondition changed operators' looping attacks and left out 重犯-type clip-per-attack enemies. - Now: a clip-per-attack actor with interval ≥ clip plays it from 0 at its own speed for the whole clip (attack mode covers it); quicker rhythms keep the strike-frame start; operators' loops unchanged.
- ASSUMED the late strike is acceptable visually; an unblocked enemy may walk during the last
hits of that clip (the sim's stand endsrestafter the strike). - Golden: unchanged. Tests: render/feedback2-enemy-attack-clip +1 (5/5).
27.42 The HP bar's three readouts — the ammo magazine, 伺夜's 狼影, 斩业星熊's 我执 pool (PR #303 + #378 by @IceCodeNew, PR #383 by @Convey123 — one change) — sim/snapshot.js ammoView / wolfView / negView, sim/battle/events.js snapshot, render/interp.js, render/units.js, render/fx/rings.js
- Seen: 隐现's S2 drained the SP bar as one continuous fraction (0.2.1:
ammoLeft / ammoMax) under a standing gold hex and rising motes like any timed skill — the rounds could not be counted and the skill looked like a buff that never changes; 伺夜's 狼群 showed no count (only the fxwolfShadow/wolfShadowLost, a wave and a vanish); 斩业星熊 in 我执 sat on the 1-HP floor, her green bar empty, and the pool she was working off (unit.mem.hsEgo.pool, since 0.2.0) was drawn nowhere — the community report quoted in PR #383: 「绿条被打空以后受到伤害就会涨红条,这里看不到红条」. Reproduced withnotes/repro-hud.mjs: 隐现 S2ammoMax14 andammoLeft13 on the activation tick withsp22.3 of 24 and no list in the snapshot; the pack'smem.shadows2 with no capacity; the pool 300 athp1 of 3228 and noneg. - Official: the counts and the pool are data.
skill_table skchr_inside_2「解决麻烦」 isdurationType AMMOwith 「攻击装有 {attack@trigger_time}发弹药,打完后结束」 andattack@trigger_time14 at all ten ranks (the chess's backup skillIndex 1, skillLevel 4);character_table char_427_vigiltalent 狼群领袖 「初始两只 … 至多3只」, one 狼影 more every 25 s, a lethal hit while more than one remains spends one (token_10028_vigil_wolf);char_1044_hsgma2talent 业火: HP 0 → 「我执」, the damage after it accumulates, she falls atmax_minus_hp_ratio× max HP (2 at the elite line). What no table says is the widget: the segmented yellow bar and the red bar are the official client's drawing as the three PRs' authors and the report describe it (their screenshots were not opened here); no wolf-head counter icon exists in the manifests (data/assets.json,data/local-assets.json) or the game data. - Now: three optional, display-only lists on
b.snapbeside the unchanged nine-field tuples (golden hashes units / down / elem only):ammo [[id, left, magazine]](an ally's running ammo skill; whole rounds — a fractionalammoLeftrounds up —; the magazine is the activation's real total, the one the tuple's SP fraction runs on: extra bullets and refills included),wolves [[id, left, max]](mem.shadowsof the newmem.wolfCapacitythattokens.js wolfPackand the managedchess_char_3_19-vigil.jspack set from the talent text; the placed and the automatic pack alike; nothing in the 战术点形态, where the pack is not alive),neg [[id, fill]](unit.negFill(), a functionop-hsgma2.jssets on the unit — the pool is read when the snapshot is taken, no per-tick sync —, share of the cap 0.01–1).UnitInfo.ammoSkill(an ally whose skill is an ammo magazine) is known from the unit's first appearance, because the frame runsprocessEventsbeforesyncBattle: the skill-on fx cannot wait for the firstammorow.render/interp.jsvalidates the rows (whole numbers, 0 ≤ left ≤ max, max ≥ 1, ids the snapshot lists), keeps them per snapshot andsample()hands them out from the older snapshot likeflags;render/units.js: the magazine replaces the SP bar — a yellow cell per round (COLORS.ammo), the fill the exact share, a 1 px cut at every boundary only while a cell is at least 2 px (AMMO_BAR), the bar shown even with no SP cost —, the wolves are a row of diamonds from the bars' left edge (WOLF_PIPS: lit = left, 25 % alpha = spent, on a dark backing; the element gauge row moves below it), the pool isnegFill— a red bar (COLORS.hpNeg, the enemy HP red) over the drained HP bar from its left edge, the width the pool's share; none of them in the prep view or for a knocked-out unit. An ammo skill draws no standing aura (rings.js _aura: the activation burst stays). Sim, rules, damage, RNG and the 1-HP floor are untouched. - ASSUMED: the look — the diamonds (PR #378 drew the token portrait as a wolf head; not taken: no official counter art is in any source), the cell height 0.85 × the HP bar, the pips' size and the colours; and that an ammo skill has no standing aura (PR #303's reading of the screenshot; Grok: the removed hex is this project's own generic skill aura, not an official effect).
- Golden: unchanged (283 scenarios; the canon never hashed these lists). Tests:
test/sim/hud-readouts.test.js(4: 隐现's 14 rounds 13 → 12, the refill's new magazine, the list gone with the skill, allies only and whole numbers; both wolf packs 2 → 3 → 2 → 1 → none → back with 1 → gone with the owner),test/content/op_hsgma2.test.js(the pool's share of the cap grows with the damage and leaves with 我执),test/render/hud-readouts.test.js(14: interp stepping / malformed rows / absent list, the cells, the pips, the red bar, the aura, and the chain sim → snapshot → buffer → view) — all fail on the parent; a headless-Chrome look at the real field view (notes/shot-hud.mjs, not committed) shows the three readouts as intended. Docs: design/network.md, SIM.md §9, design/client.md, PLAYING.md,types/core.js.
27.43 A renderer that loses the 12 s race no longer mounts its canvas late — the attempt is aborted (PR #302 by @siruimei07) — ui/fieldHost.js mountFieldView / useFieldView, render/app.js createFieldView, render/app/host.js withTimeout
- Seen:
mountFieldViewracedcreateFieldViewagainst a 12 s timeout and on failure removed every child of the host, but the factory went on: Pixi finished later and appended its canvas over the simplified view (taking its clicks, anddestroy()of the simplified view never removed it), and the host wipe of an unmounting hook could delete the view a newer mount had put there. Reproduced with the PR's own real-browser test on the parent: 5 of its 6 scenarios fail (a timed-out Pixi load covers the fallback — with a cancellable and a signal-ignoring factory —, an art timeout leaves the renderer running, a setup exception leaks its parts, an unmount races a newer mount) and all 5 node cases of the lifecycle test. - Official: none — client lifecycle only.
- Now: the PR applied unchanged after review.
mountFieldView(host, { signal })owns anAbortController: the timeout, a failed import, an invalid view or an abort of the caller (useFieldView's cleanup) aborts it, a view a factory returns afterwards is destroyed, and nothing clears the host's other children.createFieldViewtakes the signal: the Pixi load, manifest and font waits end on abort; the build up to the view object is synchronous and registers its disposables (setupCleanup: app, tiles, fx, impostors, 3D board, listeners, observers) so a throw releases exactly what the attempt made; the board-art and 3D-board waits moved to after the view exists (aborting them isview.destroy(), idempotent);destroy()also removes the canvas. The body ofcreateFieldViewstays unindented inside its newtry(the PR's choice, to keep the diff reviewable). - ASSUMED: none. Golden: unchanged. Tests:
test/ui/fieldHost-lifecycle.test.js(5, node vm with fake timers), the opt-intest/render/initialization.browser.test.js(6 scenarios in headless Chrome,RENDER_E2E=1),test/ui/playtest2.test.js's source pin takes the new indentation;fieldview/browsersuites unchanged (their two 4× throttled fps tests flake when two Chrome files run at once and pass alone, on the parent too).
27.44 A cast that reaches the audio before its unit is held and sounds once (PR #292 by @LimitlessHPPK, without the whole early buffer) — audio.js pendingSkill, screens/game/early.js audioEarly
- Seen: the sim's deploy emits a deployment-activated skill's
['skill', id, 1]before the unit's['spawn', unitInfo](宴: three events earlier) andhandleBattleEventsdropped a skill event of an unknown unit: that first cast was silent, every later one sounded (the report 「技能音效有时候不触发」). - Official: none — the cue is the equipped skill's
ON_SKILL_STARTfile (audio_data.json); the order is this project's deploy path. - Now: a skill-on for an unknown unit is held (one per unit, 64 at most) and
_trackplays it once when the unit's spawn arrives; a skill-off is no cast;setFieldUnitsdrops the holds before it tracks the new field's units (the PR cleared after, so a hold left from the field just left would have sounded for a unit of the new field that shares its id — ids are per battle). Not taken: the PR's change ofscreens/game.jsthat hands the whole pre-entry buffer to the audio — that buffer is every spawn, die, deploy, status and skill since the field'sm.field(up to 1500), so entering would play every old death, deploy line and cast at once;onEvalready forwards the full list, so the hold alone fixes the live silent first cast. The sound gets the buffer'sspawntuples only (audioEarly), as before. A deploy-tick cast that falls into the short gap betweenm.fieldand entering stays unheard — deliberately: replaying only "the casts before their spawn" would still sound a minute-old cast when a field was buffered while another one was on screen. - ASSUMED: none. Golden: unchanged. Tests:
test/ui/audio.test.js(3: the held cast in one batch and across batches, once, no hold for a skill off; a hold does not outlive its field and is bounded; 宴's real deploy — the sim's cast comes before the spawn and sounds exactly once),test/match/feedback1-runner-forms.test.js(the audio gets the spawn tuples only: a silent replay that still teaches the units, the next live cast sounds; the whole list would have sounded old cues) — fail on the parent.
27.45 The cached-atlas refreshes of a crowded battle take their turns from this frame's update order (GitHub #327 step 1, PR #334 by @Cloudnyco) — render/app.js impostorSlot, render/units.js _updateImpostor
- Seen: each impostor (cached-atlas) unit refreshed when
(frame + phase) % interval === 0with a random per-unit phase, so a frame's refreshes were binomial: with 7 pen figures at interval 3 the parent refreshes 5 1 1 5 1 1 … or 2 4 1 … per frame instead of 2 / 3 (the author's simulation: the busiest frame 20–66 % above even for 16–48 units at intervals 2–6 — the frame-time peaks of a crowded battle). - Official: none — a client refresh schedule. Not measured here on a device (the author's numbers are one Chrome CPU-throttle run on one machine).
- Now: the PR applied unchanged after review. Every impostor unit takes the next slot of the frame (
ctx.impostorSlot(), a counter reset inframeBody) and refreshes when(frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units; a unit not refreshed for 2 intervals is refreshed anyway (imp.last: its slot can move with the frame when the units before it are culled on and off); a context without slots keeps the random phase. Every unit's rate and the total work are unchanged. Step 2 of #327 (a time budget by priority) is not done and still needs a decision. - ASSUMED: none. Golden: unchanged. Tests:
test/render/unitview.test.js'impostor refresh slots' (3: slots in update order — fails on the parent —, the starvation cap, no slots).
27.46 The card 梓兰's 猎头顾问 freezes shows its frost (GitHub #354 by @DuolaCCCCat) — ui/shopBar.js slotFrozen
- Seen:
band_orchid's active refresh copies the top-tier operator into another slot and freezes the copy (bands/meta.jsband_shop_refresh_copy_max_lv_char,slot.frozen) while the 冻结 toggleshop.frozenstays false; the server keeps that card to the next prep. m.private sends both, the shop bar passed the toggle to every card: no frost on the frozen card (notes/repro-354.mjs). - Official: the band text 「每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名」; the frost on the official card is shown in the issue's video (not opened here). The manual-refresh rule is unchanged: a second refresh re-rolls everything, the frozen card too, like the toggle.
- Now:
slotFrozen(slot, toggle)= the slot's own flag, or the toggle (the old behaviour; a frame without per-slot flags — the dev mock); both card kinds use it; the bar's frame and the 冻结 button stay on the toggle. Nothing server-side changes. - ASSUMED: none. Golden: unchanged. Tests:
test/ui/feedback7-orchid-freeze.test.js(4: the wire and the cards drawn from it, the toggle, the rule unchanged across two refreshes and a round, the ShopBar source pin) — fails on the parent.
27.47 A stage device explains itself on a tap — 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 (GitHub #228, PR #229 by @rickylxw; the terrain tip of #184 for the devices) — ui/gameLogic/terrain.js (deviceInfo, deviceTipAt, noteDeviceUnits), ui/detailPanel.js (DeviceDetail, resolveDetail), screens/game.js (tileClick, deviceUnitsRef), public/dev/game-mock.js
- Seen: #184 gave the special terrain a tip on a tap (§24.5 of 0.1.4, now
terrainInfo); a stage DEVICE had none: the renderer's pick skipskind === 'device'(render/app.js battleUnitAt), so a tap on a crate landed intileClickandterrainInfosaid nothing about the road under it. The owner accepted the idea on 2026-10-07 (0.2.1 plan: 「点一下场上的阻隔工事、“双眼皮”、射击台、气流鼓风机会弹出说明,和点特殊地形的提示一样」; PR #229's own diff was written for 0.1.4's single-file UI and its text did not go throught()), and 0.2.1 did not get to it. - Official: the four roles are the ones the issue lists and the season fields (
data/stages.jsondevices[].role:crate70 entries,turret44,platform36,blower8; the other roles — 盟约寒风, the 涨潮 / 沼泽 controllers, the weight-0 act1 m05–m07 devices — have no tip). A device's NAME is its data text (devices[].name, localized by the data overlay: Barricade / 'Double Lids' / Ranged Tile / Originium Flow Generator, バリケード, 차단 공사 …), so it is not a msgid. Every number on a card is the stage's own, the one the sim runs, checked againstsim/content/devices.jsandserver/match/board.js(not copied from the PR): the crate is an obstacle-like tile of cost 1000 that ground enemies route around, an enemy that must cross it is blocked by it and breaks it (grid.js,devices.js'blocked'), 100 HP (stats.maxHp), and its tile leaves the deploy map; the turret is a ranged arts shooter on its data range (TURRET_PROFILE), ASPD+attack_speed_per_stack × L(≤max_attack_speed, 300) and a fragile1 + damage_scale_per_stack × L(≤max_damage_scale, 1.3) for the持续N秒of its skill text, L = the highest bond layers of the half's owner; the platform makes its tile a 'ranged' deploy tile and its operator is notground(blocks nothing), ground movement is blocked there; the blower blows alongrangeTiles(its own tile + 3), the operator whosedirequals the blower's getsblower_s_character[equal].atk, the opposite one[opposite], a moving enemy1 + blower_s_enemy[equal|opposite].move_speed, the device's blackboard elsestage.special.blower.bb. - Now:
gameLogic.deviceInfo(stage, row, col)finds the device at the tapped tile of the stage on screen (the player'seffectiveStage— a crate a 机变 card removed, a turret still dormant (active: false) say nothing, the renderers' own visibility rule) and builds the card: name, 「场地装置」 chip, lines (t(), numbers from the entry), the turret's stat grid (生命上限 / 攻击 / 防御 / 攻击间隔), the tile fact (不可部署 / 仅远程位可部署). In a BATTLE the crate and the turret are device UNITS (kind 'device',defId= the device key), sodeviceTipAtfinds them by the unit standing on the tile and reads the card from the stage entry of that key: this is where the port departs from the PR, which matched the stagepos— (1) a turret outside the battle's rect (act2 m01's row 8, most stages' row 8 / col 17) stands on another tile than itspos(devices.js deviceTile), (2) the crates and the turrets come into being at the battle's first step (battleStart), after the field meta of a battle shown from its start was taken (fieldMeta()holds no device unit then: checked), so the screen folds the meta'sunitsand thespawnevents intodeviceUnitsRef(noteDeviceUnits), (3) a destroyed crate keeps its tuple (HP 0) for the die animation and then leaves the snapshots — the PR showed the stage numbers for it from then on; here it is gone (null → the tap falls through to the tile under it), and before the first snapshot a unit counts as standing. The platforms and the blowers are no units and answer byposin a battle too. A battle card carries the unit id: the HP bar (live,hpOf(live, snapHp)like a unit card) updates with the snapshots, and the card closes with its device (destroyed) and with the field it belongs to.closesOnFieldPresslistsdevice. The dev mock boards another stage on?stage=and streams crate units on?variant=devices(__MOCK__.hurtDevice(i, dmg)). - ASSUMED: none (no rule is added; the card words are ours, the numbers the stage's).
- Golden: unchanged (client only).
- Tests:
test/ui/device-tip.test.js(12: the four cards against the real stages and the sim's blackboard keys, in every shipped language; the dormant / removed / tip-less cases; against a REAL battle — the field meta before the first step holds no device unit, the relocated turret, the destroyed crate, a watcher's plain stage —resolveDetail,closesOnFieldPress; every drawn device of every active stage),test/ui/device-tip.e2e.test.js(4, opt-inSP_E2E=1: the tap in prep on the DOM fallback and the engine canvas; a battle's crate with its live HP, closing when destroyed, the wreck opens nothing). 16 new UI strings in all four packs (node tools/i18n.mjs check --all --strictclean). Docs: docs/PLAYING.md.
27.48 A 设置 button in the lobby and the room (GitHub #238 by RushFTK; the owner's decisions of 2026-10-07) — ui/settings.js SettingsButton, screens/lobby.js, screens/room.js, css/screens/game.css .settings-btn
- Seen: the settings (language, BGM / voice / SFX, mute, damage numbers, 画面质量, 快捷键) opened from the title screen's gear and the match's corner gear only; a player waiting in the lobby or the room could not change them. The issue asked for four things: (1) the title screen's 设置 button moved to the top-left corner, (2) a 设置 button left of 玩法说明 in the lobby, (3) the same in the room — with the room's 「当前延迟」 label dropped so the top bar's elements share one row —, (4) options 「允许后台播放音乐」 and a 「棋盘渲染风格」 (自动 / 3D拟真 / 3D简图 / 2D).
- Official: a screen layout, not a rule — nothing to check against the data. The owner's replies on #238 (2026-10-07): 「设置入口会在 0.2.1 加到大厅和房间里」; the background-music option and the three board styles are NOT done (官方没有; the music stops in a hidden tab because the page itself suspends the audio — not a missing browser permission); the owner's replies name only the lobby and room entries, so the title screen's button stays where it is (point 1) and the room's 「当前延迟」 label stays (point 3's second half).
- Now:
SettingsButton(ui/settings.js) — the 玩法说明 button's twin: aButtonwith the settings gear (GIcon 'gear', the one the title screen and the match draw) and the label 设置, which opens the sameSettingsModal(mounted while open; Esc and 完成 close it) — sits left ofGuideButtonin the lobby's top bar (topbar__right) and in the room's (topbar__left, after the ping pill). Nothing in the modal is match-only, so it is the same dialog everywhere. No new UI string (设置 exists in all four packs). - Golden: unchanged (client only).
- Tests:
test/ui/settings-entry.e2e.test.js(opt-inSP_E2E=1: 开始 → the lobby's 设置 sits right before 玩法说明, opens the modal (语言 / 背景音乐 / 快捷键, no 棋盘 / 后台 option), Esc closes it; 创建同盟 → the room's button likewise, closed with 完成; no console error; it fails on the parent). Docs: docs/PLAYING.md §11 (where to rebind the shortcuts).
27.49 准备就绪 folds the shop bar, cancelling unfolds it (GitHub #138 by blackyang40-cmd; the owner's decision of 2026-10-07) — ui/gameLogic/shop.js readyShopFold, screens/game.js (the effect on priv.ready)
- Seen: #138 asked for two things: dragging an operator by its body and not only its tile (the pointer's tile decides a drag, the top row is hard to grab on a phone) and, in the reporter's words, 「点击准备的时候立刻跟着收起商店,而不是重新点击一遍…画面重点就会立刻来到战场上」. The shop bar already folded by hand (收起 next to the funds, issue #5's board camera follows the fold) but stayed open after 准备就绪, covering the board the player has just finished.
- Official: not in the data; the owner's decision on the thread (2026-10-07): 「点准备后商店自动收起,取消准备时再展开。拖动干员身体这条还没做。」 — so the drag-by-body request is NOT part of this change (comment 1 of the owner: the drag follows the pointer's tile, 手感问题, noted) and nothing else on the thread was accepted.
- Now:
readyShopFold(prev, cur)turns the own prep state{ round, ready }into 'fold' / 'unfold' / null; the screen's effect applies it to the shop bar'scollapsedstate (the board camera follows through the existing fold camera, so the board opens up to the full-board framing exactly as after a hand fold). Read from the CONFIRMED state (m.private.ready: a refused 准备 — the temp row, the funds prompt answered 继续整备 — folds nothing) and only a change inside one prep round counts: the first state seen (a reconnect in the middle of a prep), the new round's reset to not ready, a phase outside the prep and a spectator's / eliminated player's view are no press. The player still folds / unfolds by hand in between: unfolding while ready stays open; 取消准备 always unfolds (the owner's wording), also after a hand fold. In the enemy pen (which folds the bar for itself) the state it will return to changes, so the fold is not lost on leaving it. The same path serves the Space key, the button and an AI 托管 seat's automatic ready. - ASSUMED: 取消准备 unfolds even when the player had folded the bar by hand before pressing 准备 (the owner's sentence has no exception; restoring the earlier state would be the other reading).
- Golden: unchanged (client only).
- Tests:
test/ui/ready-fold.test.js(2: the transitions — ready / cancel / unchanged, truthy flags, the first state, a new round, outside a prep),test/ui/ready-fold.e2e.test.js(1, opt-inSP_E2E=1on the mock: ready (through the funds prompt) folds, the tab unfolds by hand, cancel with the bar open leaves it open, fold by hand → ready → cancel unfolds; fails on the parent). Docs: docs/PLAYING.md (the 休整期 row).
27.50 The in-match 观战席 capsule: the host sees and removes spectators while the match runs (GitHub #120 — PR #120 by @salt-fishes, the part the owner accepted; the owner's decisions of 2026-10-05 and 2026-10-07) — ui/hud.js SpectatorPill / TopBar, ui/gameActions.js removeSpectator, screens/game.js, css/screens/game.css .specpill / .spec__*, public/dev/game-mock.js
- Seen: the room screen lists the spectator seats (GitHub #26, §23.19 of 0.1.3) with the host's ✕ (
room.removeSpectator), but the route after 开始 is the game screen, which showed nothing of them: during a running match nobody saw who watched and the host could not free a seat, although the server takesroom.removeSpectatorat any time (server/lobby.js removeSpectator, "host, any time"). The PR also made the seat count a per-room option (0–8 / the 「允许观战」 checkbox in the lobby) and asked the full room onroom.joinwhether to spectate instead. - Official: the spectator seat is a remake feature (the official room has none), so there is nothing to compare. The owner's decisions on the PR: 2026-10-05 「每房观战上限改成 0–8 和现在定下的两席不一致,观战人数越多,战斗广播的流量也越大。请保持上限 2,把对局中的名单和移除单独留在这个 PR 里」, and after the author cut the PR down (2026-10-07) 「观战上限保持 2 席;对局中查看和移除观战者这部分我们采纳,会在 0.2.1 按新的顶栏重做(文字走翻译函数)并补房主移除的测试」. So the per-room cap (and the lobby checkbox, the 0 = no spectating setting, the full-room dialog) is NOT taken:
MAX_SPECTATORSstays 2 androom.createtakes nospectatorsfield. - Now:
SpectatorPill(ui/hud.js) sits beside the latency pill in the top bar (gtop__net): an eye and the count ofroom.state.spectators(the store keeps updatingroomwhile the match runs; the screen reads the list andhostId === mewithshallowEqual), nothing at all while no seat is taken; a tap opens a roster modal — each spectator's name (the seat's own mark 你, a dimmed name and the wifiOff glyph for an offline one, as in the room's strip) — with the host's ✕ on every row (actions.removeSpectator→room.removeSpectator { playerId }, the row's own loading state; the room.state that comes back takes the row and the count away) and one line saying that spectators take no seat and the host can remove one. The roster closes with the last seat (it does not pop up for the next one). A removed seat getsroom.closed {kicked}and goes back to the lobby (as in the lobby phase) and no match frame reaches it any more. One new UI string in all four packs. The dev mock seats two spectators on?variant=spectators(?variant=notHostmakes another player the host) and answersroom.removeSpectator. - ASSUMED: none (a UI over a rule the server already had).
- Golden: unchanged (client only).
- Tests:
test/ui/spectator-roster.e2e.test.js(3, opt-inSP_E2E=1on the mock: no capsule without a spectator; the host's capsule count, roster rows, offline mark, ✕ → the request, the roster / count following the room.state, the last seat takes the capsule and an open roster with it; a non-host sees the roster without ✕, 关闭 / Esc close it),test/match/lobby-integration.test.js(+1, the real Match over sockets: the host removes a spectator WHILE the match runs —room.closed {kicked}, the other spectator and the host get the newroom.statewithinMatch, a second removal / a player / a stranger id → BAD_TARGET, a spectator removing → NOT_HOST, no frame at all reaches the removed socket while the match goes on, the other spectator keeps getting it, the freed seat is taken again — the host-removal test the owner asked for; the server already did all of this). Docs: docs/PLAYING.md §8, docs/design/network.md.
27.51 The top-bar capsule counts like the official: resolved = killed + leaked, split / summoned / boss-summoned enemies in neither number (PR #308 by @xcdoge — the capsule half of PR #157; the owner's review of #157 split the bounty / kill-credit half off) — sim/battle/spawns.js (inTotal), sim/battle/deploy.js (killedInTotal / leakedInTotal), sim/Battle.js (resolved), sim/battle/events.js, sim/battle/lifecycle.js, sim/battle/players.js, sim/units.js, sim/snapshot.js, sim/spec.js (battleProgress, compactResult), match/fields.js (timelineSample, validateClientResult), match/match/{clientCombat,reports,views,unitePhase}.js, shared/protocol.js, battle/{runner,observe}.js, ui/{hud,combatHud}.js, ui/gameLogic/format.js
- Seen: the capsule in the top bar showed
killed / total: the numerator counted only knock-outs (a leaked enemy was no step), andtotalwas raised by every counted enemy the battle spawned at run time (spawnEnemy:if (e.counted && !opts._precounted) this.total++) — a 磨砻's two 木制瑞印, a summoner's seeds, a boss's 余音 / 妖怪 — so after a split the denominator grew and a round with a summoning leader never filled its capsule. Reproduced on the parent (a repro script, the snapshot's killed / total): the official example reads 0/3 → 0/3 (a leak is no step) → 1/3 → 2/5 (the splitter's death raised the denominator by its two children); 4 磨砻 killed read 4/12 once their 8 children had spawned, and still 4/12 after the children leaked. - Official: the player report relayed in PR #157 / #308 (first-hand, 2026-10-05, the same reading the PR's author re-stated on 2026-10-07): the capsule is 「已解决 / 本回合敌人数」 — 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3 —, and a split child, a summon (a boss's branch summons, tentacles, shells included) and a part are in neither number while each of them still costs its own LP when it leaks (4 磨砻 killed, their 8 children all leaked: the capsule reads 4/4 and the round charges 8 LP). Neither the data tables nor PRTS (卫戍协议 帮助 / 作战界面, searched) state the counting rule; the research screenshots (docs/research/06 / 09) only show
n/m. The numbers the PR keeps untouched are the ones the owner has already settled:counted(LP chargemin(lpCapPerRound, counted leaks), 完美作战, the kill statisticskilled, the bounties) is exactly 0.2.1's. - Now: two counter sets that never cross.
countedis as before.inTotalis the capsule's own set — set on a spawn the battle's spec scheduled (_queueSpawn, only when it counts:countInTotal: false,tagboss / part and datanotCountInTotalstay out), false for everything a runtimespawnEnemymakes (an explicitinTotal: trueis the content back door); a form change / revive (setForm) is the same unit and keeps its flag.Battle.totalcounts theinTotalenemies,killedInTotal/leakedInTotal(battle and per player) count those knocked down / leaked,resolved = min(total, killedInTotal + leakedInTotal).resolvedridesb.snap,BattleResult(battle and per player),b.progress(a new OPTIONAL field of the C→S frame),m.public.fields[].progress(null, never 0, while unknown; a bot / server-run field reads the battle's own counter through the headless timeline sample[gt, killed, total, resolved(, left)]—leftmoved to index 4,unitePhase.jsfollows) and the teammates' rows. The HUD readsresolved ?? killed, so a server that does not send it shows 0.2.1's numbers.killedmay now exceedtotal(4 磨砻 killed plus one child reads killed 5, total 4, capsule 4/4):compactResultno longer clamps it, the result validation boundskilledand the counted-leak count by the spec's content-spawn allowance (4·spawns + 100) instead of bytotal,isPerPlayerdropskilled ≤ total. A 联防 field's live counter (uniteLeft, the ×N tag) is unchanged; because a spawn no longer movestotal, the browser runner's change mark for re-reading it (uniteLeftMark) adds the length of the enemy list. Removed from the PR: the design-doc linecf9b19d (…)in docs/design/network.md — it was a commit subject pasted OVER theS→C b.endbullet (the PR's diff deletes that bullet), so the bullet is back. - Follow-up (the review of this branch, 2026-10-08): the per-player counters split an enemy that crosses the midline —
totaland a knock-out go to the spawn owner, a leak to the player whose half it reached (_recordLeak) — so in a two-half field (the 联防 lane col 18 → col 2, the boss pair's crossing routes) an enemy that entered one helper's half and leaked on the other's left 0 and 0 in the players' ownmin(total, killedInTotal + leakedInTotal)while the field read 1/1. The finished field'sm.publicprogress, which summed those clamps (validateClientResultand_fieldProgress), then dropped the capsule from 1/1 to 0/1 for the teammates. Reproduced with the real sim (a repro script: the unite walk leak, the boss-pair walk; and a 3-human match with four escaped enemies and unarmed helpers — the authority's capsule 4/4,m.public0/4). NowvalidateClientResultkeeps the result's field-levelresolved(the client'scompactResultalready carriedBattle.resolved; clamped to the field'stotal) and_fieldProgresspublishes it, for client-run and server-run fields alike (Battle.result()has the same field-level number); the players' own numbers are only the fallback of a result with none (an older client, a hand-made result) and with none at all the value staysnull. The per-playerresolvedof the result is as the PR defined it (clamped to the player's owntotal); no sim change. - ASSUMED: that a boss's / summoner's runtime spawns follow the same reading as a split child (the report names the splits and the boss summons together); that a 变身 / 重生 of the same unit stays one enemy.
- Golden: only
total,players.p1.totaland the snapshot hashessnaps(a snapshot carriestotal) move, in exactly the scenarios whose battle spawns an enemy at run time; every battle outcome (killed, kills, leaks, coins, units, enemies, hooks, events, time, rng draws) is bit-identical — roster 23 of 49 (roster-002 003 007 008 009 010 011 012 013 015 017 018 020 023 024 025 026 027 037 045 047 048 049), bonds 2 (bond-maniShip-low, bond-soloShip-high), fields 11 (boss-boss_1-pair / -solo, boss-boss_3-pair / -solo, boss-boss_5-pair / -solo, boss-boss_6-solo, hidden-boss_8-pair / -solo, hidden-boss_10-pair / -solo), diy 11 (diy-036 040 056 062 068 073 083 091 095 114 115), matches 12 of 18 (solo-FUNNY-1, solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1, solo-ABYSS-2, coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-NORMAL-70-diy: 36 player-round rows, thetotalcolumn only — a round that read 53 / 53 now reads 53 / 16 —; the end results, LP, shop and bot RNG draws are identical), standins unchanged. The follow-up above moves nothing (it reads a number the result already carries). - Tests:
test/sim/hud-capsule-resolved.test.js(5: the official 0/3 → 3/3 sequence; 4 磨砻 + their 8 leaked children — 4/4, 8 counted leaks, charge min(10, 8), 完美作战 broken; a killed / leaked child moves nothing,killed+1; 枯朽萃聚使徒's seeds; a real boss round — 假想敌:管's 余音 summons — fills to resolved === total;inTotalflags),test/match/hud-capsule-resolved.test.js(10: a bot field reads the battle's own counter, a leak-only bot field,resolvednull vs a reported 0, a finished field's reported number, the 联防 change mark; and the five of the follow-up, all failing before it — a 3-human match whose 联防 field runs the real sim with unarmed helpers: four escaped enemies cross the midline, the authority's capsule 4/4 andm.public4/4 not 0/4; the boss pair's crossing route; a server-run 联防 field and its timeline;validateClientResult's field-level rule — clamp, junk, fallback, absent;_fieldProgress's precedence incl. a synthetic result), and the PR's updates ofclientCombat/observe/playtest6-matchflow/fuzz/fidelity/gameLogic/docs-consistency/ the battle harness invariants. Docs: docs/SIM.md §1.3 / §9, docs/design/network.md (顶栏胶囊), docs/PLAYING.md.
27.52 Upgrading the 调度中心 opens the new slot EMPTY — the 0.2.0 fill reverted (GitHub #332 and PR #333 by @2321Robin; official recording) — match/player/economy.js _openLevelSlots, ui/shopBar.js EmptyCard, css/screens/game-shop.css .scard--empty
- Seen: 0.2.0 (§25.19.2, commit
464a823, the community report of 2026-10-06 item 19 「升级商店获得新的商店位时用新卡补上,而不是空着」) drew a card into each extra slot at the moment of the upgrade (险境及以上 level 2: 4th operator slot, level 4: 5th; 标准 level 2 and 6) and froze it with the shop's toggle (ASSUMED). @2321Robin reported (#332) that the official game shows the new slot empty and that the card comes with the next refresh; the 0.1.x behaviour (the slot waits for the next roll) was the right one. - Official: the recording the reporter cites — bilibili BV1AXwuzdEys 1:39 (《【明日方舟】新版卫戍协议倒转变得更简单了?》, the streamer whose footage settled #244): a 1→2 upgrade, the new slot appears and stays empty — the video exists (bilibili's metadata API: that title, that uploader, 41 min) but I did not watch it, so the frame at 1:39 rests on the reporter (the same footing as #244's precedent: official footage over a spoken recollection). The texts only name the slots, never a card: the tutorial's 休整期 page 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」, PRTS 帮助 「增加刷新栏位」 / 「进入此阶段时……刷新调度中心未被冻结的位置」; the season's act2autochess table has nothing on it. Item 19 was one remark, its freeze detail an assumption.
- Now:
_openLevelSlotspushesnullfor each new slot (chess slots keep their index, the item slot stays after them; nothing drawn, so nothing frozen): the next roll — a manual refresh (rollShop, which rerolls every slot) or the round start (rollShop({ keepFrozen: true }), which keeps frozen unsold cards and rolls the rest, the null slots included) — fills it at the new level. The flag and "LOCAL RULING" comment of the PR are not kept: this is the rule. The client: a null slot used to be drawn like a bought one (the SOLD OUT frame, 「已招募」), which on the new slot says somebody was recruited where nothing was;ShopBarnow draws a null operator slot asEmptyCard— a bare dashed frame with a tooltip 「空栏位:刷新或下回合开始时补满」 and the inertscard--soldclass (no hover lift, skipped by the:not(.scard--sold)rules); a sold slot keeps 已招募 / 已购买. The item slot is unchanged (every mode has exactly one item slot at every level, so the upgrade never opens an item slot; a null there keeps the old frame). One side effect to know: the shop effects that grant a card straight from the shop (紧急调度券, the strategy that recruits one every n rounds, …) clear its slot tonulltoo — that slot now shows the bare frame instead of 「已招募」 (nothing is for sale there either way). Docs: META.md (§3 shop and §7 assumptions), PLAYING.md; the 0.2.0 history (§25.19.2) stays as the record of the old rule. The dev mock's refresh handles null slots and its level-up opens the slot empty like the server. - ASSUMED: none new (the item-19 assumption is gone).
- Golden: only
matchesmoves — all 18 scenarios (the bots' shops roll from a different RNG stream once an upgrade stops drawing cards): roster, bonds, fields, standins and diy are bit-identical. End results that changed: solo-FUNNY-1 defeat → victory (round 9, teamLp 27), solo-NORMAL-1 victory → defeat (round 13), solo-HARD-1 victory → defeat (13), solo-HARD-2 and solo-ABYSS-1 defeat → eliminated (rounds 12 / 11), coop2-NORMAL-14-standins defeat → victory (round 14: this match's human seat now fields 2 distinct stand-ins, 3_12_a→char_608_acpion and 5_20_a→char_614_acsupo —test/golden-standins.test.jsasks for ≥ 2 instead of ≥ 3, the PR's change), ten more keep their result with different LP / time / boss-pool numbers (solo-FUNNY-2, solo-NORMAL-2, coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-ABYSS-10-boosted), and solo-ABYSS-2 and coop2-NORMAL-70-diy keep the same end block (their rounds differ). - Tests:
test/match/feedback5-shop-levelslots.test.jsrewritten (3: the new slot is null, the cards shown are the same objects, a refresh fills it at the new level; a frozen shop has nothing to freeze there and the round start fills it; every mode's slot table — all 3 fail on the parent),test/ui/shop-empty-slot.e2e.test.js(2, opt-inSP_E2E=1on the mock: a null slot is a bare frame without text, a bought one still says 已招募, a tap opens nothing, a refresh fills it; the real two-tap upgrade opens the fifth slot empty with the four cards unchanged). The shop audit (server/match/audit.js) andinvariantsalready took null slots.
27.53 「干员点击上去的语气一样的语音」: a tap on an operator says 选中干员 in every phase (the owner's request of 2026-10-08) — screens/game.js (DetailPanel voice), ui/detailPanel.js, public/js/audio.js (VOICE_TAP_SLOTS, VoiceGate.request, VOICE_COOLDOWN_MS.select)
- Seen: the official 选中干员1 / 2 line (charword BATTLE_SELECT, our
selectslot) played only while a battle ran — the game screen passed the detail panelvoice=${combat}(§21.30, the owner's 2026-10-03 「整备阶段不需要干员语音」), so tapping a piece in the prep, the hand or a shop card was silent. On an idle channel the gate could still drop a tap: the 1.2 s global gap after any line and a 1.5 s select cooldown (reproduced:repro-select.mjs— a tap 800 ms after the previous one → drop; a newer tap while a select line plays → drop). - Official:
audio_data.json battleVoice.voiceTypeOptionsFOCUS_CHAR — priority 10,cooldown: 0,overlapIfSamePriority: true(BATTLE_START 100, ENCOUNTER_ENEMY 90, SKILL_ACTIVE 70, PLACE_CHAR 20). charword: 斯卡蒂 CN_021 / CN_022 = 选中干员1 「听你的。」 / 选中干员2. No source documents what the official 卫戍协议 plays on a prep / shop tap (PRTS 卫戍协议/帮助 and 卫戍协议:盟约 have no voice rule). - Now: the game screen passes
voice=${true}, so the panel's existingaudio.voice(charId, 'select')runs whenever it opens on an operator the player tapped: a piece on the field or in the hand, a shop / 晋升奖励 card (first tap), a bond member, in every phase.VOICE_TAP_SLOTS = ['select']: on an idle channel a tap always plays (no global gap;VOICE_COOLDOWN_MS.select1500 → 0), a newer tap replaces the 选中 line still on air (overlapIfSamePriority), and it never interrupts a higher-priority line (部署, 作战中, 开战, 结算 …), and it starts no gap of its own (review fix: a tap had moved the gap's start, dropping a 部署 / 技能 / 接敌 the battle asks for once). Every shop / reward card opened is a new tap (tapin the detail target,selectVoiceKey): two cards of one operator each speak (review fix: the second had the same key and stayed silent). 部署 / 技能 / 接敌 / 结算 stay battle-only. Stand-ins (the 17 预备干员 / 原型干员), 盟约·辅助干员 (char_616_pithst) and summons stay silent — no voice entry (verified: 120 of the 121 chess charIds and all 71 自选 picks have one; pithst and the 17 stand-ins none). - ASSUMED: the official prep / shop tap says 选中干员 like the battle's FOCUS_CHAR (官方休整点击 = 选中干员). A newer tap
replacing the select line on air follows FOCUS_CHAR
overlapIfSamePriority: true; the other equal-priority lines keep §21.30's "wait for its turn" reading. A detail panel already open on a piece whose chess record changes (an elite merge) re-says the line (the panel keys on chessId + uid, as in battle). - Golden: unchanged (client only).
- Tests:
test/ui/audio.test.js「选中干员 on every tap」 (the gate at the real 1.2 s gap: idle tap, no cooldown, a tap replacing a tap, 部署 over 选中, a tap under 部署 / 作战中 dropped; two taps through the manager; game.js passesvoice=${true}) — fails on the parent;test/docs-consistency.test.jspins (VOICE_COOLDOWN_MS.select0,voice=${true}). Headless check in the mock match (check-ui.mjs): a PREP tap requested/media/voice/cn/char_1020_reed2/cn_021.
27.54 「全套的日配语音」: the Japanese dub as a second tree, audio.voiceJp, and 设置 → 语音语言 中文 / 日本語 (the owner's request of 2026-10-08; GitHub #256 by xcdoge asked for JP / EN / KR / special dubs — JP only) — tools/assets/plan.mjs (VOICE_JP_LANG, voiceTree), tools/fetch-assets.mjs (stats.voiceJpChars), data/assets.json, public/js/audio.js (voiceLine, setVoiceLang, _playVoice fallback), ui/gameLogic/settings.js (VOICE_LANGS, voiceLang), ui/settings.js, tools/package.mjs (FULL_ZIP_JP_VOICE), tools/setup.mjs
- Seen: every operator spoke Chinese only;
--voice-lang=jprewrote the one tree (audio.voice) to another dub at fetch time — no runtime choice, and an installed client's Chinese paths would have changed. - Official: charword_table.json (zh_CN) has one numbering for every dub (
voiceIdCN_nnn,voiceLangDict: CN_MANDARIN, JP, KR, EN with the samewordkey); the dumps keep the same file names per folder — ArknightsAssets2voice_cn/(CN),voice/(JP),voice_en/,voice_kr/(VOICE_DIRS). The official account sets the dub per operator; this mode gets one global switch (the owner's scope). - Now: the plan builds one tree per dub from the same charword slots:
audio.voice(the--voice-langdub, cn by default) andaudio.voiceJp(always JP:RAW.aa2voice + 'voice/<charId>/cn_nnn.mp3'→/assets/audio/voice/jp/…). Both are downloaded by fetch-assets and so by setup (checkAssets walks every listed URL). Measured withnode tools/fetch-assets.mjs --add-only: 2674 JP files for the 191 voiced operators (14 each, the same file names as the Chinese 2674), 89,388,041 bytes (85.2 MiB; the Chinese set is 68,801,427), 107 s at ~1 MB/s from raw.githubusercontent.com, 0 misses; every file starts with an MP3 frame / ID3. data/assets.json changed only byaudio.voiceJp(the Chinese tree with the folder swapped),stats(+2674 files, +89,388,041 bytes → 574,449,900;voiceJpChars191) andhash. The client:voiceLine(audio, charId, slot, lang)— 'jp' draws fromvoiceJpand falls back to the Chinese line per slot (an entry the manifest lacks) and per line at play time (the Chinese file of the same name when the host lacks the JP one; same token, same gate —_playVoice(url, token, volume, fallback)); operators without voice stay silent. Settings:voiceLang'cn' | 'jp' (default 'cn', anything else → 'cn'; not tied to the interface language), the 语音语言 row under 干员语音 with the dubs named in their own language (中文 / 日本語, like the language switch); 语音语言 in en / ja / ko / zh-TW. Packaging:FULL_ZIP_JP_VOICE = trueships both dubs in the full zip; false holds the JP files back (artPlanheld: neither shipped nor missing nor orphans; an update zip never lists them inremoved) and setup downloads them on the first start. The lite zip carries no art either way. Sizes (README, DEPLOY, ASSETS, setup's fallback): art download ≈ 550 MB (548 MiB, was 463), full zip ≈ 505 MB (0.2.1's 428 MiB + the JP folder zipped, 76 MiB), unzipped ≈ 710 MB (package --dry-run: 711.0 MB; with the switch off 625.8 MB). - ASSUMED: none on the rules side (the slots and their timing are the Chinese tree's). The labels 中文 / 日本語 are endonyms by the owner's wording (not translated).
- Golden: unchanged (client and asset tooling only).
- Tests:
test/feedback7-voices.test.js(new, 4: the plan's two trees from a charword fixture — rel / URL / slots,--voice-lang=enkeeps voiceJp JP,voiceJp: false; the committed manifest — 191 operators, 2674 JP lines mirroring the Chinese ones, no stand-in / pithst / summon entry, files on disk when present; settings + the four packs; the package switch on a temp root),test/ui/audio.test.js(voiceLine; the manager's dub choice and the 404 → Chinese fallback holding the channel),test/ui/gameLogic.test.js(sanitizeSettings voiceLang),test/docs-consistency.test.js(voiceJp, DEPLOY switch, PLAYING sentences) — all fail on the parent. Headless check (check-ui.mjs): the row renders (settings-voice-lang.png), 日本語 is stored, the next tap requested/media/voice/jp/char_4100_caper/cn_022.
Notes for the integrator
- The JP files are in the shared
public/assets/audio/voice/jp/(the worktree's public/assets is the main checkout's, written with--add-only: only new files, nothing rewritten); git-ignored, never committed. - The 0.2.2 update zip will carry the 2674 JP files (new vs 0.2.1/0.2.0): about +76 MB — README / DEPLOY say an update is "usually a few MB"; mention it in the release notes (or the owner flips FULL_ZIP_JP_VOICE, and setup fetches them).
- Possible overlaps: PR #291's stream changes
DEFAULT_SETTINGS(bgm / sfx / quality) — this branch addsvoiceLangto the same line; PR #292's stream touches audio.js event handling (this branch: VoiceGate, voice(), _playVoice, voiceLine, the header comment). - docs/WINDOWS.md "素材约 330 MB" was already stale before this round (not touched).
- The worktree's
.cache/gamedatawas a symlink into the main checkout's cache; it is now a local folder of links plus local copies ofcharword_table.jsonandark-models/models_data.json(taken from w6-r21f, the inputs of the committed manifest: an--offline --add-onlyrebuild reproduced data/assets.json byte for byte before the change), so nothing was written into the main checkout's.cache. - Files here: repro-select.mjs (+ .parent.txt), official-battleVoice.txt, fetch-jp.log, check-ui.mjs (+ check-ui.out.txt, settings-voice-lang.png), assets.committed.json (the manifest before the change).
- Review (Grok, MERGE AFTER FIXES) addressed in
bcee737f: the tap gap, the duplicate shop card, the package comment's size. check-shop.mjs (+ check-shop.out.txt): the second card of one operator now calls voice(); none on the parent.
27.55 The local statistics page (PR #323 by @2321Robin, accepted by the owner on 2026-10-08) — ui/stats.js, screens/stats.js, screens/result.js ResultView, ui/matchChrome.js quitMatch, css/screens/stats.css, main.js
- Seen: the PR adds a page every player can open from the title screen, the lobby and the room: this browser's record of
the matches the player took part in (总览 / 策略 / 称号 / 战斗累计 / 最近对局), kept in
localStorage(sp.pref.stats, up to 1000 matches), 导出 / 导入 / 清空, and a row that re-views the match's settlement. The server is untouched and stores nothing (§ #84: no server-side history). The owner accepted it ("可做,具体 UI 要不要优化你来决定,看下最终数据不要误统计我前面 说的那种进去就退的对局,但是历史记录可以看到") and left the layout to the maintainer: a match entered and left right away must not count in the totals but stay visible in the history. - Official: the official game has no match-history page and no notion of a 「统计」 outside the settlement screen, so
nothing here is checked against the game. The one official rule the counting uses is the 中途退出 rule the server already
implements (
match/platform.js _quit, research 00-INDEX §3 / 01 §9 / 06 §7): leaving counts as elimination, and a player eliminated in round k has passed k − 1 rounds (leaving in the settlement of round k: k). - Counting rule (
ui/stats.js countsTowardStats): every aggregate — games, wins, win rate, hidden-core reaches / clears, best round, time played, the strategy table, the titles, the combat totals — is taken over the records that count. A settled record (the match reached its settlement:m.resultarrived) always counts. A quit record (the player gave up with 放弃模拟) counts once at least one of the player's own battles had finished: a round cleared (roundsPassed ≥ 1) or the player already eliminated. A match left earlier — in the briefing, the strategy draft or round 1 — is history only: the 对局记录 row says 中途退出 and 不计入统计 (dimmed), 总览 says how many were left out. Combat totals read the settled records only (a quit record has no stat block); "rounds" is the player's ownroundsPassed. ASSUMED: the threshold is one cleared round — the owner named only "进去就退"; a match given up after clearing a round counts as a loss for its strategy (the official 中途退出 is an elimination), a match the server ended (restart, room closed) and a closed tab are not recorded at all, a spectator seat records nothing. - Why the page records a quit itself: the server sends a leaver nothing (
match/match/settle.js finishsendsm.resultto_viewers(), which excludesleftseats;lobby.js buildReplayskips them too), so the PR — which records onm.result— never saw a match given up.quitMatch(放弃模拟, the only way out of a match) now callsrecordQuitafterg.leave/room.leavewent through (and no settlement arrived meanwhile);buildQuitRecordbuilds the record from the app state at that moment (m.public: frame and own row) and a small tracker (observeMatch, fed byinstallStatsRecorderinmain.js) that remembers when the page first saw the match (a duration is kept only when that was the start) and the last round the player was seen alive in — the only way to know the round of an elimination, which the leaver is never told. The rounds cleared are the server's formula (clearedRounds). - Defects of the PR as submitted (each reproduced against its module / tree: a repro script,
loads-check-pr.mjs): (1)<StatsHost/>calleduseGameData()at mount, so every visitor's title screen downloaded all 16 game data files (5.3 MB; the lobby alone is meant never to,main.js warmGameData) — the host renders nothing until opened now, the overview readsconfig/bands/assetsonly and the re-view loads the rest when opened; (2)mergeStatsreturned the list oldest first while every reader treats index 0 as the newest — after an import the page's 最近对局 showed the oldest 20 and the cap dropped the newest; (3)recordResultwrote over an envelope of a newer build (a downgrade, against the module's own rule); (4) 1000 four-player records are ~7.2 M characters (a record is ~7 kB; the PR's 1.5 kB is a solo match) against a ~2.5 M-character quota; (5) a 自选 pick and a 补位 stand-in were dropped from the stored lineup, so a re-viewed settlement drew the plain operator; (6) the re-view put a synthetic result intostore.match(the route switched to the game screen under the overlay, the music followed, a live match refused it); (7)DIFFICULTY_NAMESwere shown untranslated; adisplay: flexon a<td>; text sizes in bare rem (6 px on a phone in landscape); the overlay stayed open over a co-op briefing the host started. - Now: storage —
MAX_RECORDS1000 andMAX_CHARS800 000 characters of JSON (the oldest go first; a refused write retries with half the history); only the newestFULL_KEEP30 records keep their rows (lineup, bonds, teammates' names — what the re-view needs), older ones are compacted to the player's own summary row (~620 characters; measured with real results: 1000 solo records ≈ 0.66 M characters, 1000 of the heaviest real co-op record (7 kB) ≈ 0.80 M — 974 fit);normalizeRecordis a strict, bounded schema (unknown fields dropped, strings and lists cut, ≤ 6 players, ≤ 12 lineup entries, ≤ 24 stat keys; an import file ≤ 8 MB). Corrupt / old data — unreadable storage (not JSON, not an envelope) is moved tosp.pref.stats.damagedand the page says so; damaged rows are dropped and counted (a note on the page); a repeated record, an oversized or unsorted list are repaired on load; a PR-era envelope (noend) reads as settled; a newer envelope is returned untouched and nothing records or writes over it (the page says "upgrade", the buttons are off). Privacy — the page and PLAYING.md §12 say the data stays in this browser and that the teammates' names of the newest records are stored and exported. UI — the overlay is built like 干员调配 (top bar, four tabs 总览 / 策略 / 战斗累计 / 对局记录, a note line, a scrolling body) withmax(.xxrem, N px)sizes for phones in landscape; the history pages 50 rows at a time (显示更多); 清空 uses the shared confirm dialog; the entries are a newcharticon (the ping bars were confusable), the title screen's beside the language menu (the connection row squeezed the gear and wrapped the status text), the lobby / room buttons icon-only below 700 px (the room title ran under the label at 640 px).ResultScreenis split intoResultScreen(reads the store) andResultView(props:res,myId,backLabel,onBack,quiet), which the stats page renders for a stored match itself —store.matchis never touched. 41 + 25 UI strings in en / ja / ko / zh-TW (ui/stats.jsholds none: the page translates). - Golden: unchanged (client only;
server/andshared/untouched). Tests:test/ui/stats.test.js44 — the counting rule per phase (INFO_CHECK 0 … SETTLE 1 counts, PREP 2 counts, eliminated counts, unknown rounds), quit records (own row only, no teammates, no replay, spectator / settled / unseen matches build none,installStatsRecorderover a real store), the aggregates over counted records only, the budget (1000 real-size co-op records fit, oldest first, quota retry, no storage), compaction, corrupt / old / newer storage (six unreadable shapes → backup + fresh start, damaged rows, duplicates, over the cap, the newer envelope byte for byte), import order / cap / refusals, the replay payload (自选 / 补位 kept); the file fails on the PR's module (nothing of the above exists there).test/ui/stats.e2e.test.js6 (opt-inSP_E2E=1): seeded history on 1920×1080 and 844×390, the re-view and its Esc, the title entry (English), export → import / corrupt / newer / clear with its dialog, unreadable and newer storage, and the real flows against a fast real server — a solo match given up in the briefing (history only), one given up after round 1 (counts, 1 round: the server's own count), a settled match recorded once also after a reload.
27.56 潜能 / 练度 per operator, set in 干员调配 (GitHub #252 follow-up; the owner's decision of 2026-10-08: 「D7D8调配干员里自己设置吧,默认满潜满加成」) — shared/potential.js, tools/build-data.mjs (per-rank passes, potDown, chained talents), shared/loadoutRecord.js, server/sim/simdata.js, server/sim/battle/players.js, server/sim/units.js, server/sim/spec.js, shared/protocol.js (room.loadout.ops), server/lobby.js, server/match/player/{basics,round,diy,views}.js, public/js/ui/{loadoutModel,loadoutSync}.js, public/js/ui/gameLogic/{loadout,diy}.js, public/js/screens/cultivation.js
- Seen: 0.2.1 baked full potential (潜能 6) into every record and gave no 自持有 bonus (§26.14 — 「全体满潜能」, the owner's decision of 2026-10-07: no setting). In #252 players described the official rule (the account's own potential, an unowned operator at potential 1); a 1–6 host slider (PR #255) had not been taken. On 2026-10-08 the owner chose a setting per operator in 干员调配, defaulting to full potential and the full bonus (「默认满潜满加成」).
- Official: 调度手册 (activity_table
autochess_shop_3/autochess_shop_6): 「卫戍协议中干员潜能由自身已持有干员潜 能决定」 and 「提升已持有的干员,将为卫戍协议中同名的干员提供加成」. The bonus table isautoChessData.cultivateEffectList→ effects.json CHAR_MAPaceffect_char_1…4,char_attribute_mul: 未精英化 +0 %; 精英阶段1 ATK / DEF +5 %; 精英阶段2 ATK +10 %, DEF / HP +5 %; 精英阶段2-60级 ATK / DEF / HP +10 % — the highest tier the player's copy reached, for owned operators of the same name, initial and elite alike, a multiplier of its own (PRTS 盟约记录: 「…与自持有干员的属性加成独 立」), not one of the 直接乘算 percentages. An unowned 特许 (PRESET) operator fights as itself at potential 1 with no bonus (RushFTK in #252; its shop row'sbackupCharPotRankis 0); a 补位 prototype and a prototype 自选 pick have no potential ranks and no bonus; trust is 0 everywhere. (Research: round 22 report, scratch-r1; the table checked against the excel in test/potential.test.js.) - Now:
- Data (DATA.md §2.3): the builder runs the chess / backups / tokens builders once per potential rank (
ctx.potRank0–5) and keeps the full-potential build annotated with what each lower rank changes —potDown{ rank: { "dotted.path": value } }on chess records, backups.json forms and token variants (stats, statsBase, 望's 棋子 deployLimit / deckStack, 多萝西's 共振装置 count), and chained talent entries (potMin/potBelow: the candidates byrequiredPotentialRank, partial nodes) in talents / talentsBase / module talentChanges / variant talents. A build self-check provesatRankrebuilds every rank exactly; the unit-form parity runs at every rank. The files minus the annotations equal 0.2.1's byte for byte; data 5.21 → 5.51 MB. - Sim (SIM.md §2, §9, §12): a PlayerBattleInput operator entry may carry
potential(1–6) andcultivate(练度 0–3). The def is composed at the potential (shared/loadoutRecord.js→atPotential; an owned 自选 pick's form too; a summon's owner variant at the owner's potential — talents, 望's / 多萝西's counts);unit.cultMulmultiplies ATK / DEF / max HP inside the unit's Πmul. A 补位 stand-in, a prototype 自选 pick and a summon take no 练度; spec.js keeps both only when well-formed; UnitInfo carriespotential(below 6) andcultivate. - Server (design/match.md §16, design/network.md):
room.loadout { entries, ops? },ops = { [charId]: { potential?, cultivate? } }checked strictly with the entries (checkLoadoutOps: an operator of the roster or of the 自选 owned pool, ≤ 256, defaults dropped; either refused ⇒ nothing stored); session / seat /match.setLoadout(INFO_CHECK only);PlayerState.ops; every owned operator's battle entry states both (none set, an old seat and every bot: 潜能 6 / 精英2 Lv.60);m.private.ops; scouting units carry them. - Browser:
sp.pref.loadout { v, entries, ops }(an older stored loadout = the defaults), synced with the entries (sanitised against chess.json + backups.json); 导出 / 导入 carryops(an older export leaves the settings alone); the shop / hand / board / detail cards and 局内数值 compose the record at the operator's potential and multiply its stats by the tier (chessLoadout(…, { ops | cultivation, effects })); a teammate's unit shows its own (UnitInfo); a 自选 card its pick's potential. UI: the row selects and the detail's 潜能与练度 section (§27.57), an owned 自选 pick's selects on its slot card. The section's rule line states the default first (满潜能 / 精英2 Lv.60) and that the official 潜能1 / 未精英化 of an unowned 特许 operator is the player's own setting (Grok's review of the branch: it read as automatic); the module card (ModuleInfo) reads the module's talent changes at the operator's 潜能, as 局内数值 and the battle do (余's 闲云隐市 2 %(+0.5 %) → 1.5 % at 潜能1). - Docs: PLAYING.md (干员调配 bullet; the detail card's base values — no longer 「满潜能 for everyone」), DATA.md §2, §2.3, §7, §14, §16, §18, SIM.md §2 / §9 / §12, design/match.md, design/network.md, research/03-operators.md, kits/README.md. This replaces §26.14's 「every operator fights at FULL potential」: full potential is now only the default.
- Data (DATA.md §2.3): the builder runs the chess / backups / tokens builders once per potential rank (
- ASSUMED: summons take the owner's potential for their talents (the candidates mirror the owner's, §26.14) but
no 练度 (the bonus names 「同名的干员」); a teammate's bond-popup member card (no unit to read) shows the defaults,
never the viewer's settings; tokens.json's top-level token defaults stay at full potential (only owner variants are
composed); 宴 / 野鬃's
backupCharPotRank1 is not applied (not traced in the client, the report's own caution); an unowned 特许 operator is set to 潜能1 / 未精英化 by hand (the 干员持有 tab lists only the NORMAL chess); an import whoseopssurvive but whose entries do not keeps the current entries and reports the settings. - Golden: every battle with an owned operator moves — ATK / DEF / max HP ×1.1 from the first second (roster-001:
角峰 ends at 3527 HP instead of 3115; the first differing snapshot window is 0–10 s in every moved battle): roster
49/49, bonds 46/46, fields 22/22, matches 18/18 (they part at rounds 1–8: battle results, then the bots' boards),
diy 134/138. Unchanged: the 10 stand-in battles and diy-135…138 (the prototype 自选 picks). No engine error.
tools/golden.mjsfields the default player in the direct-spec scenarios (owned operators at 6 / 3), as PlayerState does. - Tests: test/potential.test.js (new, 7: every chess and 自选 form at every potential = the raw keyframes plus the steps up to it, every talent = the last candidate with requiredPotentialRank ≤ the rank, 刺玫 413 / 17 at 潜能1, summons at the owner's potential, the tier table = cultivateEffectList); test/sim/potential-battle.test.js (new, 6: a unit at each potential, the four tiers and their product with a +20 % ATK bonus, HP at deployment, stand-in / prototype neither and an owned 自选 pick both, summons, the spec round trip, two players with the same chess at different settings); test/match/potential.test.js (new, 4: the wire / data check, battle input of a human / an old seat / a bot, scouting, 自选 owned vs prototype and 望's hand count, setLoadout in INFO_CHECK only); test/lobby-loadout.test.js (+2: strict validation, storage beside the loadout, an older client, the briefing's m.private.ops and the lock); test/ui/potential-ui.test.js (new, 7: storage, 导出 / 导入, the 已调整 count, the sync, the cards at the settings, a teammate's unit, a stand-in, 自选 cards); test/ui/loadout-rows.test.js (the rule line, the module card at 潜能 none / 6 / 1). Updated: data, sim/loadout, match/runner, match/diy-shop, ui/loadout.
27.57 干员调配 as one list with the quick choices (PR #301 by @farridge — the owner's decision of 2026-10-08: 「D12 [PR #301] 可以做,具体布局怎么优化你来决定,他那样确实方便,但是三个竖排有点乱影响观感」) — public/js/screens/loadout.js RosterHead / RosterRow / LoadoutGarrisons, public/js/screens/cultivation.js, public/js/screens/diy.js, public/js/ui/loadoutModel.js quickSkillTags, public/css/screens/loadout.css
- Seen: choosing a skill or a module meant opening each operator's detail; PR #301 put the choices on the roster cards (three stacked columns per card: skills, modules, a trait preview) and the operator's 特质 in the detail. The owner liked the convenience but found three vertical stacks cluttered.
- Now: the roster is one list — a row per operator, its columns aligned under a sticky head (干员 | 技能 | 模组 精锐 |
潜能 · 练度): portrait / name / bonds (selects the row, opens the detail), three skill slots (an empty one for a
two-skill chess; the label gives slot, name, SP recovery, initial / cost SP, duration —
quickSkillTags, from the SkillRecord, never from localized text), the elite's modules (不装备 first; the type letter in the corner), and the 潜能 / 练度 selects (quiet at the defaults, mint when changed). A quick choice is the same setChoice / setEntries edit as the detail's and keeps a phone on the list. The detail opens with 潜能与练度 (潜能 1–6, the four tiers by their official names with the effect text, the rule in one line, the stand-in note), then the operator's 特质 (the in-match card'sGarrisonBlockof the form the 普通 / 精锐 toggle shows, triggers kept — PR #301's part, committed under @farridge's name), skills, 局内数值, modules. Below 1000 px wide (was 760 px) the detail slides over the list (「干员列 表」 returns); below 700 px the row's bonds give way to the name; 40 px quick targets on a coarse pointer. 全部恢复默认 / 恢复默认 reset the settings with the skills and modules (全部: the roster's operators; a 自选 pick's stay); ←/→ stay inside a focused row's choices. Headless check (own script, not the e2e suite): 1920 × 1080, 1366 × 768, 844 × 390, 667 × 375 — no sideways overflow, the head's columns equal every row's, rows 64 / 50 px, the slide-over and back. - Tests: test/ui/loadout-rows.test.js (new, 7: a row's pick, skill slots, modules, aria-pressed, callbacks and the shared 精锐 preview; its selects — values, options, a change, the changed mark, a stand-in; the head; the detail's section; a 自选 owned pick vs a prototype; 局内数值 at 潜能1 / 未精英化 and at the defaults with its caption; the css grid and breakpoints); test/ui/loadout-quick.test.js (from the PR, 4: quick tags, the 特质 blocks). e2e updated (not run in this stream — their new steps were replayed with a headless page): loadout.e2e (the row pick, the phone test rewritten, 局内数值 ×1.1, a new 潜能 / 练度 flow), loadout-battle.e2e and teammate-loadout.e2e (the row pick selector).
27.58 The selected skill's frame is square around its icon again (GitHub #398 / PR #399 by shadiaojumao-hyb) — public/css/screens/loadout.css .lo-sicon .lo-sicon__outline
- Seen: in 干员调配 the blue frame of the selected skill was taller than wide, shifted left, its side lines thin, the icon poking out on one side (#398; PR #399 measured 36.67 × 41.38 px for the 30 × 30 sprite).
- Now: the cause, measured headless on the 0.2.2 list (detail rows and quick buttons, 667–2560 px wide, DPR 1–3):
theme.css
img { max-width: 100% }held the frame's width at the icon's padding box whileheight: calc(100% + .1rem)grew it (36 × 40 px on a phone, 70 × 80 at 1920 px; left edge at −.05rem, so the right edge fell inside the icon), and.lo-sicon img { object-fit: cover }(more specific) beat the frame'sfill, cropping its side lines. The rule is.lo-sicon .lo-sicon__outlinewithmax-width: none: square (40 × 40 / 80 × 80), .05rem outside the icon on every side. PR #399's own change (inset 0, 100 %) also squared it but laid the 3 px ring over the icon's edge pixels; its diagnosis is what the fix follows. - Tests: test/ui/loadout-rows.test.js (the frame rule: selector, size, max-width, fit; the global clamp).
27.59 圣约送葬人 REA-Y's trait shows the heal it fights with, 50 per enemy hit (GitHub #400 by PCL1234) — tools/build-data.mjs applyModuleTraitParts / traitRecord / tokenVariant, data/chess.json, data/i18n/*
- Seen: 干员调配 (and 局内数值, the shop / detail card) read 「每攻击到一个敌人回复自身12生命」 for 圣约送葬人 with REA-Y; in battle 50.
- Official: battle_equip_table uniequip_003_excu2 — a part with target
DISPLAY(display only) and no rewrite, blackboardvalue12 for its own line 「攻击范围内存在2名及以上敌人时攻击速度+{value}」; the class trait keeps its blackboard (50; REA-X'sTRAIT_DATA_ONLYpart rewrites it: 60). - Now: the build merged every module part's blackboard into the trait's before writing the class trait line, so
{value}read 12 (the text template — not potential, not the module level). The trait line is now written with the blackboards of the parts that carry trait data or rewrite the line; a DISPLAY part that only adds a line fills that line.trait.bb(what the kits read) is unchanged — the kit already healed 50 from traitBase. JSON compare: only chess_char_5_01_b REA-Y traitOverride desc / descRaw moved; data/i18n rebuilt (that text + the record hash). - Golden: unchanged (GOLDEN_FULL).
- Tests: test/data.test.js (REA-Y line = the class trait's, added line +12, REA-X 60; no module line differs from its class trait only by its own added line's numbers); test/content/kits_alt_t5.test.js (reaper heal 50 / 60 / 50 for REA-Y / REA-X / none); test/ui/loadout-rows.test.js (the module card: 特性 50, 特性追加 +12).
27.60 An Esc pressed the moment the 自选 picker opens closes it (found by the 0.2.2 full browser pass) — public/js/screens/diy.js DiyPicker
- Seen: with §26.15's Esc, an Esc pressed right after the picker appeared (before the next animation frame) was
swallowed: the 干员调配 overlay skips Esc as soon as
.diy-pickexists, while the picker attached its own keydown listener in a plain effect, a frame later. The picker stayed open; diy.e2e's Esc test hit it on a busy machine. - Now: the listener is attached in a layout effect, in the picker's own commit.
- Golden: unchanged.
- Tests: test/ui/diy.e2e.test.js adds the immediate Esc (it fails without the fix). The loadout-battle / teammate-loadout / loadout browser tests follow §27.57's 干员调配: the row's quick skill / module choices, the default 练度 in the detail card's base ATK (×1.1), the detail back at the top before a click.
27.61 An attack interval or a time-SP cost of a whole number of frames takes exactly that many — 1 s is 30 frames, not 31 (PR #402 by @Cloudnyco; the owner's approval of 2026-10-09) — sim/ai.js attackCountdown / updateAlly / updateEnemy, sim/skills.js SkillRuntime.gainSp / _normalize, sim/content/devices.js tickTurret, sim/content/kits/ops/chess_char_5_21-sntlla.js
- Seen: PR #402: an attack cooldown counted down by TICK = 1/30 s (not a binary fraction) stops at 2.1e-16 after
thirty steps from 1 s, and the fire check
atkCd > 0waited a 31st frame; 300 time gains of 1/30 SP add up to 9.999999999999975, sosp >= costcharged a 10-SP skill a frame late. Measured on 0.2.2 before the fix, over the 265 golden battles: 30.85 % of the consecutive attacks on a whole-frame interval came a frame late (the other intervals, the control: 0.09 %) — 1 s gaps were 31 frames in 3 545 of 3 801, 3 s 91 in all 6 677, 4 s 121 in all 2 223 —, and 4 157 end-of-tick states sat within 1e-9 under a skill's cost (229 battles). The engine already took a timer within 1e-9 as done (skilltimeLeft, buff intervals,every()/after(), enemy abilities, nextCastAt, the 失衡 end); these two did not. - Official: PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」: the time unit is one frame, 1/30 s; since the 2019-12-24 update a non-integer frame count is rounded (四舍五入; before, 进一取整) — 能天使's 0.696 s is 21 frames, 0.678 s 20. An exact 1 s is 30 frames under either rule; PRTS 游戏数据基础 攻速公式 (T = T₀ / (clamp(S, 10, 600) / 100)) adds no frame.
- Now:
ai.js attackCountdown(cd, dt)— what is left within 1e-9 of 0 is 0 — counts down the operators' cooldown (still only whilecanAct) and the enemies' (not while stunned or hidden; the 失衡 swing cut as it was). An enemy's strike frame is that countdown's 0, so a whole-frame wind-up is exact too (a 0.4 s clip hit: 12 frames, 13 before). The “双眼皮” turrets (content/devices.js) and 寒檀's S2 icicles (0.5 s: 15 frames, 16 before) use it — the samecd -= dt; if (cd > 0)shape; the drones of 澄闪 / 荒芜拉普兰德 and 电弧's 赛柯 volleys compared with 1e-9 already.gainSptakes SP within 1e-9 of the cost as full, every charge of a multi-charge skill included (a tiny negative remainder is 0), and_normalize(an SP cost changed mid-battle) compares the same way. Attack / hurt SP (+1 on an integer cost) compare as before: a cost-N attack-SP skill still fires every N + 1 attacks, its attacks now on time. 1e-9 is far above any float residue and far below a frame (PR #402's bounds: ≤ 7.5e-13 on the ASPD 20–600 grid; an interval off the frame grid is ≥ 1 / (30 × 600) ≈ 5.6e-5 s from a boundary). Unchanged on purpose: a non-integer count still ends on the frame that crosses 0, ⌈30 × interval⌉ (1.25 s = 38; 0.678 s = 21, officially 20) — the official 四舍五入 is an older, separate difference; content clocks that count up and restart from 0 (the boss self-timers ofcontent/bosses.js: 管 / 弦's strikes and summons, “萨米的意志”'s 冰凌, 阿利斯泰尔's 王权号令 SP; kit auras and pulses — PR #402 lists them) are not touched and may still take a frame more. - Golden: 281 of 283 scenarios move — roster 49 / 49, bonds 46 / 46, fields 22 / 22, matches 18 / 18, standins
10 / 10, diy 136 / 138: both sides attack on time (more enemy kinds than operator forms sit on whole-frame intervals —
3 s, 4 s, 1 s …) and time SP charges a frame sooner. After it: 0.04 % of the whole-frame consecutive attacks a frame
late (control 0.13 %), no SP state short of its cost. The moved battles' totals: kills 10 647 → 10 625, leaks 12 398 →
12 445, deaths 2 913 → 2 944. End results that flip (one frame cascades; the causes as measured):
- solo-NORMAL-1: won → lost in round 14 (team LP 8 → 0) — the Final Assault on 卢西恩 (pool 390 000) runs as before for two minutes (his 0.4 s wind-up a frame shorter), then the team's damage on the pool trails: 884 are left when the overtime drain (from 300 game s) takes the last of the 17 LP at 334 s; before, the pool emptied at 318 s with 8 left.
- solo-HARD-1: LP 10 → 12 — round 8 lets one more through (26 → 25; rounds 11–12 even it out) and the Final Assault ends at 300.6 s, before the overtime drain (304.8 s and 2 LP before).
- solo-NORMAL-2: LP 9 → 25 — round 5 lets one more through (26 → 25, a coin less), the bot's shop path parts from round 6, and its later board (突袭, 232 layers at round 13 against 94) clears rounds 12–13 with no leak (7 + 18 before).
- coop2-NORMAL-3: team LP 63 → 68 — ai_1's round-13 battle leaks 1 instead of 6 (its round-12 leaker now goes to 联防).
- coop2-ABYSS-10-boosted: team LP 583 → 630 — the Final Assault ends at 606.6 s (639.6) and the Hidden Core at 335.9 s (397.8): 47 LP less of overtime drain.
- coop2-NORMAL-14-standins: team LP 41 → 40 — ai_0's round-13 battle lets one 潜伏者 through (23 → 22).
- Battles: roster-016 cleared at 220.3 s → a time-out at 230 s (主角阵营角色 leaks instead of falling; kills 101 → 105, leaks 26 → 22); roster-041 a time-out → cleared at 189.0 s, perfect (the last 粉碎攻坚组长 falls inside the 190 s); bond-suntShip-high cleared at 164.2 s → a time-out at 190 s (kills 11 → 13, deaths 4 → 2); standin-04 perfect → 10 leaks (deaths 8 → 10); diy-005 / diy-008 cleared at 225.3 s → a time-out at 230 s (a last 宿主重装士兵 / 深池方阵指挥官 still walking); diy-127 (白铁 / 酒神) a time-out → cleared at 225.8 s (deaths 7 → 13, leaks 57 → 72); diy-131 (死芒) perfect → 1 leak (a 掠海漂移体).
- Tests:
test/sim/tick-tolerance.test.js(new, 7 — the PR's four adapted, plus three): the residue itself; attackCountdown over bat 0.1–10 s × 14 ASPD values = ⌈30 × interval⌉; a 1 s operator every 30 frames and a 3 s ranged enemy every 90; the control 1.2 s = 36, 1.25 s = 38; time-SP costs 4 / 10 / 16 / 20 / 30 / 50 after exactly 30 × N gains, one and two charges; the “双眼皮” turret at 0 layers (BAT 4 s) every 120; 寒檀's icicles every 15 — the four engine ones fail on the parent (31 / 91, cost 4 at 121 gains, 121, 16). The PR's seven test edits, re-run here: the same 8 assertions failed on the new timing, and each edit passes on the parent and on the fix — feedback2-surtr-stealth (counts from when no arrow is in flight: a shot keeps its target), kits_alt_t2 拉普兰德 S1 (the dummy's ATK 300 → 250: at 15 frames a hit, 120 × 15 = her 1 791 HP by 59.5 s), kits_alt_t6 (the medic smoke starts the team at half HP; 余 S1 compares up to the attacker's first 燃烧 burst, which refuses the next fill), op_jesca2 (the T6 shield, 60 + 30 = the 90 s cost, returns a tick after the cast), op_lmlee (his hits before the burst), op_logos (counts after the clear), op_narant (the first attack thrown after the new enemies came). node --test: 5 703 tests, 5 701 pass, 0 fail, 2 skipped (the browser suites off); GOLDEN_FULL golden.test 9 / 9; eslint 0 errors (135 warnings, as on the parent). - Docs: docs/SIM.md §2 (the countdown, the turrets / icicles, the official 帧对齐 note) and §7.1 (SP); docs/design/geometry-time.md §4.
27.62 A dragged operator stands on the tile it will drop on, as in the official deploy drag (the report in PR #403 by @bubububaoshe — not merged: the pick stays the pointer's tile; the owner's recording of the official game, 2026-10-09) — render/app.js moveDragVisual, render/drag.js dragStandTile
- Seen: while an operator was dragged, the model was held under the pointer — its drawn (lifted) feet
DRAG_HOLD_TILES(0.45 tile, §18.1) of its own px per tile below the pointer, solved on the lifted plane (ground + the 0.3 drag lift) — while the drop target, its highlight,tileHoverand the direction wheel are the tile under the pointer. On the prep camera the feet and the shadow landed about 0.7 tile toward the camera: on the tile below the highlighted one (PR #403's screenshot). Measured on the parent in the mock: the pointer on (9,4) held the model at y 8.46–8.50 (mouse at 1920×1080, finger on the 844×390 phone), on (11,5) at y 9.98–10.50 (1600×900); in the Final Assault prep about 0.7 tile off its target. PR #403 moved the pick to the feet instead. That breaks the standing rule (§18.1: the tile under the pointer decides; restated by the owner on #138, §27.49), still misses by about 0.2 tile (it samples the ground ray, the model is lifted) and fails models.browser #4.1, so the pick stays and only the drawing changes. - Official: the owner's recording of the official game (2026-10-09; a deploy drag, the gesture 卫戍协议 shares, research 09 §1.2): while an operator is dragged onto the field its model is drawn standing on the tile under the touch point. It does not float with the finger: the finger moving inside the tile leaves the model where it is, the touch point on that tile at the model's feet or lower body; that tile is lit (with the attack-range stripes), takes the drop, and the direction wheel opens on it. Over no deployable tile no model is shown on the field.
- Now:
moveDragVisualasksdragStandTile(pieceDragMove), which returns the drop target while it is LEGAL and on the board or the bench ({ row, col, bench }, board space); the unit's world x / y / z are then set to that tile at once (no tween between tiles) throughboardWorld— the point the drop tween goes to, with the Final Assault / Hidden Core prep transform — and it keeps its drag lift. Otherwise — no target, an illegal tile, a temp slot, its own bench slot,'outside'over the shop bar — it is held under the pointer as before, so the player still sees what they carry (our pieces start on the bench, unlike the official hand card, so "no model off the field" is not taken). Item plates are unchanged (centred on the pointer). The pick (pickBoardTile), the highlight,tileHover, the wheel and the drop are untouched, and mouse and touch share the path (the phone uses the same tile pick). A drop from the snapped place has nothing left to slide: the lift drops and the wheel opens on the tile. The DOM fallback field (ui/fallbackField.js: a flat grid, its ghost centred on the pointer, so already on its tile) is unchanged. - ASSUMED: a legal bench slot is stood on like a board tile (the recording shows the field only); the 0.3 drag lift stays while the unit stands (the recording does not settle a lift that small).
- Golden: unchanged (presentation only).
- Tests: test/render/drag.test.js (+2:
dragStandTile's decisions; a drag through the controller over its stubbed tile pick, mouse and touch — one tile while the pointer moves inside it, the next legal tile at once, none on an illegal tile, a temp slot, the own bench slot, the shop bar or off the grid, the drop on the tile stood on, a board piece on its own tile). test/render/models.browser.test.js #4.1 drag (mouse) and touch (phone): over a legal tile the unit stands on the pointer's tile (its x / y = the tile, on its top, lifted), at four spots of the tile without moving on screen, at every step of a second leg (standing on the legal tiles, held under the pointer elsewhere), held under the pointer / finger over the shop bar, standing on a free bench slot; the wheel on the pointer's tile as before. test/render/fieldview.browser.test.js: in the Final Assault prep (both halves) the dragged unit stands on its target's boss-field tile, mirrored on the right. Both browser extensions fail on the parent. test/docs-consistency.test.js (the code callsdragStandTile, DESIGN and PLAYING describe it). Docs: docs/PLAYING.md §4, README.md, docs/design/client.md §9 / §10, the app.js / drag.js / tune.js comments.
27.63 拟态物质 / 简易通讯机 / 寻呼模块 that grant nothing say why (GitHub #401 by FrogThai; the owner's OK of 2026-10-09) — match/builtinMeta.js toastNothing, sim/content/items/meta.js (寻呼模块)
- Seen: #401 — two players, the field holds an elite and two normal 溯光星源, 拟态物质 on a normal one 「无事发生」. Correct by the rules: the Ⅵ阶 pool holds 5 copies of an operator for the whole team (community-measured, §25.13.1; an elite takes 3), so 1 elite + 2 normal own all 5 and the item's third copy cannot come (GitHub #207: never another operator instead) — but the item was destroyed without a word. The same silence for 简易通讯机 / 寻呼模块 when no operator of the bond qualifies (缪尔赛思's 调和 below shop level 6, community report 45 of feedback #5).
- Official: the items' text — 「装备时销毁,若已拥有至少2名该初始干员,则再获得1名该初始干员;否则随机获得1名同盟约初始干员」 — has no fallback; the item is destroyed on equip either way. The owner noted the confusion: OPERATORS have the shared pool; EQUIPMENT has no per-item cap.
- Now: when such an item grants nothing, a warn toast: 「{who}:卡池中已没有{name}」 (拟态物质 with 2 copies owned and none left) or 「{who}:没有可获得的同盟约干员」; the item is still destroyed. en / ja / ko / zh-TW.
- Golden: unchanged. Tests: test/content/items.test.js (the #207 cases assert the toast; a new case: one 缪尔赛思, her pool taken → the bond toast), test/content/feedback5-cb1-pins.test.js 45 (the toast below level 6, none at 6).
27.64 Confirm-page setup reroll (GitHub #77, PR #408; convenience proposal) — match/setupVote.js
The proposal keeps the room and its connections while redrawing the complete random starting setup. It references @qiongqi-empty's plugin in #77, which rerolled bans on 0.1.2; this implementation uses the current method modules, adds unanimous human consent and redraws the stage, both leaders, factions and wave schedule, and bans. It is a remake convenience feature for review, not an official button or a claim that the maintainer has already adopted the proposal.
Voting suspends the confirmation clock and any queued all-ready transition. Refusal, cancellation, disconnect or exit preserves the original setup and resumes its remaining time; success rebuilds shared and DIY stock, clears confirmation (bot-controlled seats auto-ready), and restarts the full clock. Single-human matches remain untimed. Potential / cultivation, loadout, ownership, DIY picks and AI-picks-last remain unchanged. Setup revisions and vote IDs refuse stale requests; the separate RNG stream leaves the existing streams unchanged.
Tests: test/match/setup-reroll.test.js, test/match/setup-reroll-ws.test.js (four real sockets and reconnects,
including preserved 0.2.2 潜能 / 练度), the protocol fuzz generator, strict en / ja / ko / zh-TW coverage. Normative
docs: DESIGN §6.1 / §8.1, META §1 and PLAYING §1. Golden results unchanged.