feedback5: merge WE2 — fidelity (data / match / UI): summon hand counts from the tokens' hidden talents (麦哲伦 / 令 3, 白铁 2), 望's 棋子 on any tile, the 一会儿见! marker no hand card, TRY_SEARCH_ALLY_SKILL → SKILL_RANGE + allies, 斩业星熊's normal attack phys, IS / RA-only modules refused (SO-A / SO-B / RA-A), 黑键 S2 at full SP with a free tile, 重构体 / 战术锚点 placement, 塑心's cap per trait, random grants draw 自选 stock, diyBanned shown, AI 托管 buys 自选, 谬因 S2 / 薇薇安娜 S3 area triggers, #148 regression test, tier*.js / diyRecord shims removed; golden: coop2-NORMAL-70-diy, diy-045 / 078 / 114 / 115 / 116 moved (WE2's own), the W4B moves kept

This commit is contained in:
sganggs committed 2026-10-06 16:15:52 +08:00
commit 723ee72d38
79 files changed
+1389 -405

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
+13 -11
View File
@@ -193,10 +193,10 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `bb`, `bbStr` | `{"atk":1,"base_attack_time":-0.3,"attack@trigger_time":14}` | kit input (DESIGN §5.6) |
| `rangeId`, `rangeGrid` | `null` | skill range override |
| `prefabId`, `overrideTokenKey` | | |
| `trigger` | `{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null}` | auto-cast rule (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §2.2). `rule` ∈ `DEFAULT` (basic strategy), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own: `customRangeGrid` = its `rangeGrid`, `rawRule` `DEFAULT` — no official row), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05: a MANUAL skill on the basic strategy — 深巡 S2's deviation included — or on the `SEARCH` row whose attack range while it runs strictly contains the operator's own range: `customRangeGrid` = that running range, `rawRule` the official row, `DEFAULT` / `SEARCH` / 深巡's `TAKE_DAMAGE`), `TAKE_DAMAGE` (every MANUAL 重装 skill but seven: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT` with `rawRule` `TAKE_DAMAGE` — a deliberate deviation, `tools/build-data.mjs TRIGGER_DEVIATIONS`, DESIGN §21.29; 深巡 S2 then `ACTIVE_RANGE` on its 3-2 — and 余 S2 is `SKILL_RANGE` with `rawRule` `TAKE_DAMAGE` and `customRangeGrid` its x-1, DESIGN §22.10), `SP_FULL` (official `ALWAYS`: 执旗手 / 战术家 / 吟游者 MANUAL skills), `CUSTOM_RANGE` (official `CUSTOM_RANGE_SEARCH_ENEMY`, uses `customRangeGrid`), `SEARCH` (解放者 / 阵法术师 MANUAL skills, 安洁莉娜 S2/S3 — but those whose running range is larger, `ACTIVE_RANGE`: 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2, 安洁莉娜 S3), `MLYSS_WTRMAN` (缪尔赛思), `GDGLOW_SKILL_2` (荒芜拉普兰德, 纯烬艾雅法拉 S3: "全场存在可选目标时释放技能"). |
| `trigger` | `{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null}` | auto-cast rule (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §2.2). `rule` ∈ `DEFAULT` (basic strategy), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own: `customRangeGrid` = its `rangeGrid`, `rawRule` `DEFAULT` — no official row), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05: a MANUAL skill on the basic strategy — 深巡 S2's deviation included — or on the `SEARCH` row whose attack range while it runs strictly contains the operator's own range: `customRangeGrid` = that running range, `rawRule` the official row, `DEFAULT` / `SEARCH` / 深巡's `TAKE_DAMAGE`), `TAKE_DAMAGE` (every MANUAL 重装 skill but seven: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT` with `rawRule` `TAKE_DAMAGE` — a deliberate deviation, `tools/build-data.mjs TRIGGER_DEVIATIONS`, DESIGN §21.29; 深巡 S2 then `ACTIVE_RANGE` on its 3-2 — and 余 S2 is `SKILL_RANGE` with `rawRule` `TAKE_DAMAGE` and `customRangeGrid` its x-1, DESIGN §22.10), `SP_FULL` (official `ALWAYS`: 执旗手 / 战术家 / 吟游者 MANUAL skills), `CUSTOM_RANGE` (official `CUSTOM_RANGE_SEARCH_ENEMY`, uses `customRangeGrid`), `SEARCH` (解放者 / 阵法术师 MANUAL skills, 安洁莉娜 S2/S3 — but those whose running range is larger, `ACTIVE_RANGE`: 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2, 安洁莉娜 S3), `MLYSS_WTRMAN` (缪尔赛思), `GDGLOW_SKILL_2` (荒芜拉普兰德, 纯烬艾雅法拉 S3: "全场存在可选目标时释放技能"). `allies: true` (only then present): an ally row the engine plays as one of its rules on an injured, healable ally of `customRangeGrid` — 黍 S3 (data/backups.json): official `TRY_SEARCH_ALLY_SKILL` ("技能范围内存在可治疗的我方单位时释放技能") → `SKILL_RANGE` on its x-2 (`tools/build-data.mjs TRIGGER_ALLY_RULES`). |
### 2.1 Combat classification heuristic (`dmgType` / `attackKind` / `projectile` / `canHitFly`)
- `dmgType`: MEDIC (except `incantationmedic`) and `bard` → `heal`; trait text containing 法术伤害 or profession CASTER → `arts`; else `phys`.
- `dmgType`: MEDIC (except `incantationmedic`) and `bard` → `heal`; trait text containing 法术伤害 or profession CASTER → `arts` — but a trait whose 法术伤害 comes only with a running skill ("技能开启时普通攻击会造成法术伤害": 驭法铁卫, 斩业星熊) keeps the normal attack's `phys` (display / the bot; the kit switches to arts while a skill runs; 0.2.0); else `phys`.
- `attackKind`: `bard`, `phalanx`, `librator` → `none` (no normal attack); heal → `heal`; position RANGED or melee sub-professions with a ranged normal attack (`lord`, `fortress`, `shotprotector`, `agent`, `hookmaster`) → `ranged`; else `melee`.
- `canHitFly`: ranged attackers unless the trait says 地面敌人 (投掷手) or the sub-profession is `fortress` (要塞: 灰毫, 号角 — DESIGN §22.13); `skywalker` (蒂比) true.
- These are defaults for the generic engine; kits may override.
@@ -480,15 +480,16 @@ Glyph legend (`rows`):
| Field | Example (`token_10028_vigil_wolf`) | Meaning |
|---|---|---|
| `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | |
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | |
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only) / `null` (not listed). `placeable` (a prep hand piece) = a manually deployable summon (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"; user playtest #6): not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 海嗣, 狼群, 流形 and 爬行号·防护单元 (凯瑟琳's talent device — the only pool summon missing from `shopStateTokenDict`, read as shown: placed by hand in the base game, by the friend's report, and confirmed by the user after playtest #6, DESIGN §20); 投递坐标 (HIDDEN) is not. In battle a skill's summon deploys once at the battle start, then on its tile each time the skill gives one (SIM.md, token pieces) |
| `ownerRange` | `true` | the token text reads "只能部署在召唤者攻击范围内" (`desc`; the tacticians' 援军 — 狼群, 流形; PRTS 狼群 特性): its hand piece may only stand on a tile of its owner's attack range (server/match/board.js `ownerRangeKeys`, `PlayerState._legal`, the client's `gameLogic.summonRange`; player report #9 after 0.1.0). `false` for every other token |
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | `position` = the token row's, except where PRTS records the client's row as wrong (`tools/build-data.mjs TOKEN_POSITION_CORRECTIONS`): 望's 棋子 `ALL` (PRTS 棋子 部署位置 "全部位", 备注 "游戏内召唤物信息与实际不符(显示为仅部署在近战位)") — the prep's placement class (board.js `positionClass`) |
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only) / `null` (not listed). `placeable` (a prep hand piece) = a manually deployable summon (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"; user playtest #6): not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 海嗣, 狼群, 流形 and 爬行号·防护单元 (凯瑟琳's talent device — the only pool summon missing from `shopStateTokenDict`, read as shown: placed by hand in the base game, by the friend's report, and confirmed by the user after playtest #6, DESIGN §20); 投递坐标 (HIDDEN) is not; nor is a summon no owner shows (its `display` source, the owner's displayTokenDict — a skill's own object: every such summon is HIDDEN but 予愿安洁莉娜 S3's “一会儿见!”, which the shop state does not list; PRTS 予愿安洁莉娜 S3 备注, 0.2.0). In battle a skill's summon deploys once at the battle start, then on its tile each time the skill gives one (SIM.md, token pieces) |
| `ownerRange` | `true` | the token text reads "只能部署在召唤者攻击范围内" (`desc`; the tacticians' 援军 — 狼群, 流形; PRTS 狼群 特性) — or, data/backups.json, an owner's talent naming the token reads "可以在攻击范围内(的地面)部署 / 使用…" (Mon3tr's 重构体; 莱伊's 沙地兽 says both): its hand piece may only stand on a tile of its owner's attack range (server/match/board.js `ownerRangeKeys`, `PlayerState._legal`, the client's `gameLogic.summonRange`; player report #9 after 0.1.0). `false` for every other token |
| `ownerRangeOutside`, `rangedTilesOnly` | `true` (present only then) | data/backups.json: the token text reads "部署在…攻击范围外" / "仅可以部署在…远程位" — 凯尔希·思衡托's 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位" (PRTS 战术锚点 特性): its hand piece may only stand outside its owner's attack range (`PlayerState.summonExcluded`, the client's `gameLogic.summonExcluded`) and on a ranged (高台) tile (board.js placement class `high`; the mode's "所有行动内远程干员可部署在近战位" is an operators' rule [ASSUMED]) (0.2.0) |
| `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | |
| `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults |
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
| `deployLimit`, `count` | `1`, `1` | `count` = copies sent to the hand / spawned (talent/skill `cnt`); `null` ⇒ use `deployLimit` |
| `deployLimit`, `count` | `1`, `1` | `deployLimit` = the first owner's `stats.deployLimit`. `count` = copies sent to the hand / spawned (talent/skill `cnt`); `null` ⇒ use `deployLimit` |
| `abnormal[]` | `["healFree"]` | abnormal effects the summon holds from the start, no official table carries them — `tools/build-data.mjs TOKEN_ABNORMAL` from the PRTS summon pages (user playtest #6 item 18): `healFree` = 禁疗 (“小自在”, “耀阳”, 斯卡蒂的海嗣, 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态)), `isolated` = 孤立 "无法被同阵营选中" (“炎佑”, 从不混淆的方向); `[]` otherwise. The sim sets `noHeal` / `isolated` (docs/SIM.md §3) |
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) + golden module `tokenAttributeBlackboard`; the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) + golden module `tokenAttributeBlackboard` + the token's own talent additions to `deployLimit` / `deckStack` (blackboard `max_deploy_count` / `max_deck_stack_cnt` — the hidden "TOKEN数" talent of 麦哲伦's / 令's summons and 白铁's devices, 夜莺's 幻影; a module token part of the same talent replaces it: `tools/build-data.mjs tokenTalentDeckBonus`, 0.2.0 — no tokens.json summon has one; PRTS 幻影 备注 "最大可部署数量为3", the owners' "最多同时部署3个"); the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
| `variants[o].bySkill[i]` | `{"skill":{…},"count":1,"sources":["talent","display"]}` | owner loadout with the non-default skill index `i` (one entry per other selectable owner skill): the token skill of that slot (伺夜's wolves, 缪尔赛思's 流形, 凛御银灰's eagles…), the count and how the chess then produces it (`sources` may be `[]`: 风丸 S1 makes no 纸偶; 赫默 / 巫恋 S1 only `display` ⇒ no hand piece). The sim resolves them for an owner loadout: `simdata getToken(id, ownerChessId, loadout)` → `def.sources` / `def.count` |
| `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) |
| `assets` | `{"avatar":"token_10028_vigil_wolf","spine":"token_10028_vigil_wolf"}` | |
@@ -669,11 +670,12 @@ derived bonds, the operator's form at the slot's status with that skill and modu
`diyFor` = the slot's base id —, `diyPool(tier, { data, kitted })` the legal picks of a tier (prototypes, then the owned
pool; with `kitted`, only operators with a kit: server/sim/content/kits/index.js `KITTED_CHARS`) and
`validateDiyPicks(picks, { data, kitted })` a roster (a prototype may fill a tier-5 and a tier-6 slot, an owned operator
one slot, the picks of a tier differ, no 集成战略 module — `isDiyModule`: the ISW-A modules of 凯尔希, 傀影, 菲亚梅塔, 提丰,
艾丽妮 and 霍尔海雅 are never a player's choice [ASSUMED], the owner's decision of 2026-10-05, while the record and the sim
still compose them for the kits' tests). **In battle** a PlayerBattleInput entry of a DIY slot carries `diy` (the pick) and
one slot, the picks of a tier differ, no module of another game mode — `isDiyModule` / `DIY_EXCLUDED_MODULE_TYPE`: the
集成战略 modules ISW-A (凯尔希, 傀影, 菲亚梅塔, 提丰, 艾丽妮, 霍尔海雅) and SO-A / SO-B (电弧, 机械师 — "在【岁的界园志异】中",
"在【沉沦者的黑流树海】中") and the 生息演算 module RA-A (森蚺) are never a player's choice [ASSUMED], the owner's decision of
2026-10-05 for ISW-A and the same reason for SO / RA, while the record and the sim still compose them for the kits' tests). **In battle** a PlayerBattleInput entry of a DIY slot carries `diy` (the pick) and
is fielded as `getChess(slotId, { diy })` (docs/SIM.md §12); its kit is `KITS[charId]` (kits/README.md "How to add an
operator (自选)"). (`shared/standIn.js diyRecord(slot, charId, backups, sel)` is the older prototype-only form.)
operator (自选)").
`diy`:
+4 -4
View File
@@ -155,7 +155,7 @@ Ownership rule for parallel agents: **only edit files assigned to you**; if you
- **Partner display** cols 11–18 of rows 9–12 (the unite field uses them).
- **Boss field**: rows 1–5; left half cols 2–10, right half cols 10–18.
- Enemy preview pen rows 14–18 × cols 7–13 (row 16 empty): during prep the next round's enemies idle there (upper-gate enemies rows 17–18, lower-gate rows 14–15, ≤ 50 models, spawn-time order) — research 08 §4 / 09 §2.
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE — the **effective** deploy type: the 深水区 `tile_deepsea` refuses deployment, NONE with the level's `buildableType` ALL kept, PRTS 深水区 地形信息 "拒绝部署(待补充)"; an active 特制水上平台 makes its tile deployable in the prep deploy map only, §21.3), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). The fenced tiles (围墙 `tile_fence_bound` / 围栏 `tile_fence`, legend `b`: LOW, buildable ALL, passable to flyers only) take units, which attack normally from there but block no ground enemy (PRTS 地形机制, §21.23). Melee chess may stand only on LOW tiles with buildable ALL/MELEE — except a melee chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, with any module or none), which may use the ranged tiles too (`shared/highGround.js`; the owner's decision of 2026-10-05, following PRTS — it reverses the 2026-10-04 one that allowed only elite 歌蕾蒂娅 with HOK-Y; on a 高台 it attacks and blocks nothing); ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`; a summon marked `ownerRange` (狼群, 流形: "只能部署在召唤者攻击范围内") goes only on a tile of its owner's attack range — the loadout grid rotated by the owner's facing around its tile (`PlayerState.summonRange`, §21.3).
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE — the **effective** deploy type: the 深水区 `tile_deepsea` refuses deployment, NONE with the level's `buildableType` ALL kept, PRTS 深水区 地形信息 "拒绝部署(待补充)"; an active 特制水上平台 makes its tile deployable in the prep deploy map only, §21.3), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). The fenced tiles (围墙 `tile_fence_bound` / 围栏 `tile_fence`, legend `b`: LOW, buildable ALL, passable to flyers only) take units, which attack normally from there but block no ground enemy (PRTS 地形机制, §21.23). Melee chess may stand only on LOW tiles with buildable ALL/MELEE — except a melee chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, with any module or none), which may use the ranged tiles too (`shared/highGround.js`; the owner's decision of 2026-10-05, following PRTS — it reverses the 2026-10-04 one that allowed only elite 歌蕾蒂娅 with HOK-Y; on a 高台 it attacks and blocks nothing); ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`; a summon marked `ownerRange` (狼群, 流形: "只能部署在召唤者攻击范围内"; Mon3tr's 重构体 by her talent) goes only on a tile of its owner's attack range — the loadout grid rotated by the owner's facing around its tile (`PlayerState.summonRange`, §21.3) —, one marked `ownerRangeOutside` / `rangedTilesOnly` (凯尔希·思衡托's 战术锚点: "仅可以部署在凯尔希·思衡托攻击范围外的远程位") only on a 高台 outside it (`summonExcluded`, placement class `high`; 0.2.0).
- **Facing (corrected, see research 09 §1.2):** like standard Arknights, every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT` chosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); default `RIGHT`; persists across rounds. Range grid entries are `[dRow, dCol]` relative to facing RIGHT and are rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)` (row 0 = bottom); `rangeExtend` applies along +dCol before rotating — to a running skill's range too, unless that skill ignores 攻击距离 (`targeting.noRangeExtend`, 信仰搅拌机 S3, §21.16). The right-side Final Assault player is mirrored col `c→20−c` with RIGHT↔LEFT (UP/DOWN unchanged).
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles; a huge enemy (data `hitArea`: 巨型单位, a 4.95 × 2.95 rectangle moved 1.0 up) if any tile its hit rectangle overlaps is one (PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"; `sim/body.js`, §19.4). Radius ranges (enemies, auras, AoE) use Euclidean distance in tiles — to a huge enemy's rectangle [ASSUMED] — except splash around a struck target, a 中点判定 on positions (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"). A ranged enemy's normal attack takes an ally whose collider (radius 0.25, PRTS 作战机制 §碰撞体积 "我方干员碰撞体积基本均为以0.25格为半径的圆形") touches its range circle: centre distance ≤ `rangeRadius` + 0.25 (`constants.js ALLY_COLLIDER_RADIUS`, `ai.js enemyAttack`; §20.4).
- **Fields at runtime:** a `Battle` receives a **field rect** (rows/cols subset) and the full stage grid. Normal battles use rows 9–12 × cols 0–10; unite uses rows 9–12 × cols 0–20 — on its own map, the escaped template's (stages.json `kind: 'unite'`, `unite.js uniteStageId`; GitHub #41), not the round's stage; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect.
@@ -312,7 +312,7 @@ Duration end cleans the window and emits `skillEnd`; it leaves zero charges and
Redeployment opens a new full window. Permanent passives retain `passive`; first-kill recasts of 缄默德克萨斯 explicitly
restart a full window. Special timed kits and their effect rules are described in SIM §7.2.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 waits for it to come back [ASSUMED]), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 waits for it to come back [ASSUMED]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above.
**SkillSpec** (authored per chess in `sim/content/kits/tierN.js`; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a *function of the blackboard*):
```js
@@ -418,7 +418,7 @@ Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weigh
### 6.6 AI players (bot.js)
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time).
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time). Under AI 托管 a human's own 自选 pieces score `DIY_PIECE_BONUS` (12) more in the shop / reward choice and the 调度中心 is levelled one round earlier for the step that opens a slotted tier (`levelUp`, `diyOpensAt`; 0.2.0 — bots field no 自选 piece).
---
@@ -441,7 +441,7 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
### 8.1 Session & lobby
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a 集成战略 module, an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
+7 -3
View File
@@ -520,8 +520,10 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
picks, merges, promotions, sells, temp, elimination; `invariants.js` checks `left + held = cap` per player). The
shop's chess slots and the reward offers' temporary refreshes roll it with the shared pool (`pool.roll({ extra })`,
copy-weighted like any chess of its tier) once the 调度中心 reaches the slot's `shopLevel` (5 / 6) [ASSUMED for the
reward offers]; effects that draw a random operator from the shared pool never draw it, and 信标 never sends one to a
teammate [ASSUMED]. `battleInput` carries `diy` (the pick); `m.private.diy` / `diyBanned`; `prepFieldMeta` and
reward offers]; an effect, reward pool or 机变 card that grants the player a random operator draws its stock too
(effectsMeta `rollChess` / `rollPool`, the 驰援 fallback in choices.js: `ps.diyStockEntries()` — the roll's own tier
rules, no 调度中心 gate, bonds through the player's view; 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」, 0.2.0), and
信标 never sends one to a teammate [ASSUMED]. `battleInput` carries `diy` (the pick); `m.private.diy` / `diyBanned`; `prepFieldMeta` and
`m.result` lineups carry the pick (`diy`) so other players' cards compose the operator.
* **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment);
a full hand refuses every buy and reward pick — also one whose copy would complete a merge at once (PRTS
@@ -576,7 +578,9 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
the tacticians' 援军, 伺夜 狼群 / 缪尔赛思 流形 — choosing the tactical point; player report #9 after 0.1.0) only goes on
a tile of its owner's attack range: the owner's loadout grid (`attackRangeGrid`) rotated by its facing around its tile
(`PlayerState._legal` / `summonRange`, `board.js ownerRangeKeys`; a summon dragged onto its own owner is checked from
the owner's new tile, an operator dragged onto the summon from the summon's new tile). When the owner is re-oriented in
the owner's new tile, an operator dragged onto the summon from the summon's new tile); Mon3tr's 重构体 likewise (her
talent), while 凯尔希·思衡托's 战术锚点 (`ownerRangeOutside` / `rangedTilesOnly`) goes only on a 高台 outside that range
(`summonExcluded`, board.js class `high`). When the owner is re-oriented in
place (or promoted) a summon its new range leaves out goes back onto its stack with a toast (`_liftOutOfRange` in
`recompute`) — one still inside stays [ASSUMED]. A re-orientation that would leave such a summon with no stack and no
free hand / temp slot is refused (HAND_FULL, like withdrawing a summon into a full hand); in the other cases (a
+1 -1
View File
@@ -61,7 +61,7 @@
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略里生效的 ISW-A 模组不能选)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用)。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
- 每回合商店会自动刷新未冻结的格子。
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;同一回合内用已经生效过的拉普兰德合成的精锐,本回合不再生效。
+7 -4
View File
@@ -852,9 +852,11 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
`fn(battle, unit)` → list of ally units (their current range) or tile-key arrays; returns an unregister fn; not for
heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形); `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any
living enemy (stealthed, untargetable, flying included) on `trigger.customRangeGrid` (= the skill's rangeGrid), every
tick, no attack needed; a kit may set `trigger.allies` (+ `hpAtMost`, default 1) for an injured, healable ally of the
grid at or below that HP ratio instead (the AUTO heal skill 古米 S1: PRTS 备注 "此技能在存在生命值不满的可治疗角色时可
触发…直至古米完成一次普通攻击的治疗" — her heal mode waits for its heal); `ACTIVE_RANGE` — the owner's rule of 2026-10-05
tick, no attack needed; `trigger.allies` (+ `hpAtMost`, default 1) — set by a kit, or by the data for an ally row (黍 S3's
official `TRY_SEARCH_ALLY_SKILL`, "技能范围内存在可治疗的我方单位时释放技能": tools/build-data.mjs `TRIGGER_ALLY_RULES`,
simdata passes `allies` on) — asks for an injured, healable ally of the grid at or below that HP ratio instead (the AUTO
heal skill 古米 S1: PRTS 备注 "此技能在存在生命值不满的可治疗角色时可触发…直至古米完成一次普通攻击的治疗" — her heal mode
waits for its heal); `ACTIVE_RANGE` — the owner's rule of 2026-10-05
(a deliberate deviation): a MANUAL skill on the basic strategy (深巡 S2's DEFAULT deviation included) or on the SEARCH
row (薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2, 安洁莉娜 S3) whose attack range while it runs strictly contains the
unit's own range checks the DEFAULT condition — a targetable enemy (or one it blocks), a heal skill an injured ally —
@@ -893,7 +895,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
`skillEnd` fires. `onAttack` ctx carries `noAmmo` (set it to true: this attack spends no bullet, no `ammoUsed` — 流明's
free heals).
- Runtime helpers on `unit.skill`: `activate(reason, {free})`, `end(reason)`, `stop()`, `addAmmo(n)`, `extend(s)`,
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn)`; fields `sp, spCost (= floor(base×spCostMul + spCostFlat)), spCostMul, charges,
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn)`, `setTrigger(rule, grid)` (a kit's own rule change mid-battle —
薇薇安娜 S3's ACTIVE_RANGE on 3-2 after its first cast; 0.2.0); fields `sp, spCost (= floor(base×spCostMul + spCostFlat)), spCostMul, charges,
maxCharges, active, timeLeft, ammoLeft, ammoMax, activations, kind, rule, bb` (`ammoMax`: the most bullets the running ammo skill
has held — set at activation, raised by skillStart additions and `addAmmo` above it; 0 when inactive).
+4
View File
@@ -89,6 +89,10 @@
.brief-banned__n { margin-left: auto; font-size: .22rem; color: var(--red-premium); }
.brief-banned__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(.6rem, 1fr)); gap: .08rem; }
.brief-banned__grid .uthumb { --ut: .6rem; }
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine; m.private.diyBanned) */
.brief-banned__diy { margin: .1rem 0 0; font-size: .14rem; line-height: 1.5; color: var(--text-dim, #b8c2bf); }
.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: .12rem; font-weight: 700;
letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); vertical-align: .01rem; }
.brief__foot { position: relative; z-index: 2; flex: none; display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .18rem .5rem .3rem; }
.brief-ready { display: flex; align-items: center; gap: .16rem; }
+1
View File
@@ -130,4 +130,5 @@
.minfo-dlg .brief-banned__n { font-size: max(.22rem, 13px); }
.minfo-dlg .brief-banned__grid { grid-template-columns: repeat(auto-fill, minmax(max(.6rem, 34px), 1fr)); gap: max(.08rem, 3px); }
.minfo-dlg .brief-banned__grid .uthumb { --ut: max(.6rem, 34px); }
.minfo-dlg .brief-banned__diy { font-size: max(.14rem, 12px); }
.minfo-dlg .t-dim { font-size: max(.16rem, 10px); }
+4
View File
@@ -370,6 +370,10 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.ibond__ban .icon { width: .12rem; height: .12rem; }
.ibanned { display: flex; flex-wrap: wrap; gap: .06rem; }
.ibanned__one { padding: 0; background: none; border: 0; }
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine) */
.idiybanned { margin: .08rem 0 0; font-size: .13rem; line-height: 1.5; }
.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: .11rem; font-weight: 700;
background: var(--mint-500); color: var(--text-on-mint); }
/* ---- 机变 overlay ---- */
.spov { position: absolute; inset: 0 0 0 2.8rem; z-index: 20; }
+5
View File
@@ -577,6 +577,8 @@
"请将装备拖拽至干员身上": "Drag Equipment onto an Operator",
"近战单位只能部署在地面": "Melee units can only be deployed on ground tiles",
"只能部署在召唤者攻击范围内": "Can only be deployed within the summoner's Attack Range",
"只能部署在召唤者攻击范围外": "Can only be deployed outside the summoner's Attack Range",
"只能部署在远程位": "Can only be deployed on a Ranged tile",
"该位置已有单位": "This tile is already occupied",
"召唤者尚未部署": "The summoner is not deployed yet",
"装备只能配发给干员": "Equipment can only be equipped on Operators",
@@ -842,6 +844,8 @@
"地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench",
"{names}只能部署在召唤者攻击范围内,已退回整备区": "{names} can only be deployed within the summoner's attack range and returned to the Bench",
"{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还": "{names} can only be deployed within the summoner's attack range; the Bench is full, so they come back next round",
"{names}只能部署在召唤者攻击范围外,已退回整备区": "{names} can only be deployed outside the summoner's attack range and returned to the Bench",
"{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还": "{names} can only be deployed outside the summoner's attack range; the Bench is full, so they come back next round",
"【{label}】层数达成,下回合开始时获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds at the start of the next round",
"【{label}】层数达成,获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds",
"【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
@@ -874,6 +878,7 @@
"阶": "Tier",
"自选编队:{name}(只在你的商店出现)": "Custom Squad: {name} (only in your shop)",
"自选": "Custom",
"{names}的盟约本局全部禁用,不会出现在你的商店": "Every Alliance of {names} is disabled this match: not in your shop",
"自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions",
"自选编队:{name}": "Custom Squad: {name}",
"替补": "Stand-in",
+2 -1
View File
@@ -26,6 +26,7 @@ import { MatchInfoDialog, matchInfoModel } from '../ui/matchInfo.js';
import { actions, act } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js';
import { useStore } from '../store.js';
import { data } from '../data.js';
import { audio } from '../audio.js';
import { modeOffBonds, bandOffBonds, bandOffLine } from '../ui/gameLogic.js';
@@ -242,7 +243,7 @@ export function BandDraftScreen() {
const turnSecs = clock ? secondsLeft(clock.deadline) : null;
const turnLen = Number(pub?.draft?.turnSeconds) > 0 ? Math.round(pub.draft.turnSeconds) : null;
// 本局信息: the briefing's blocks (built only while the dialog is open) and the draft's state under it
const info = infoOpen ? matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode }) : null;
const info = infoOpen ? matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode, priv, diyData: { chess: data.get('chess'), backups: data.get('backups') } }) : null;
const infoStatus = infoOpen ? draftInfoStatus({ myPick, pickName: myPick ? gd.band(myPick)?.name : null, myTurn, turnName, secs: turnSecs,
waiting: !solo && !draft.done }) : null;
+6 -2
View File
@@ -2,7 +2,8 @@
// abilities), stage (+ its pool: 战场固定 / 战场随机(共N张)), 特训敌人 factions (icon, name, description), the difficulty
// tag, the ready count x/N with person pips, the 准备就绪 button (g.infoReady) and the countdown. The right column — 核心盟约
// / 附加盟约 rows of bond discs (disabled bonds greyed with the banned-member badge), the legend and the 本局禁用干员
// avatars — is ui/matchInfo.js MatchInfo, the same blocks the strategy draft's 本局信息 dialog shows (screens/bandDraft.js).
// avatars, with the player's own 自选 pieces out of the shop (m.private.diyBanned) — is ui/matchInfo.js MatchInfo, the same
// blocks the strategy draft's 本局信息 dialog shows (screens/bandDraft.js).
import { useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel } from '../ui/components.js';
@@ -14,12 +15,14 @@ import { actions } from '../ui/gameActions.js';
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
import { useStore } from '../store.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** INFO_CHECK screen. */
export function BriefingScreen() {
const pub = useStore((s) => s.match.public);
const priv = useStore((s) => s.match.private);
const myId = useStore((s) => s.me.playerId);
const solo = useStore((s) => s.room?.mode === 'solo') || String(pub?.modeId || '').includes('single');
const gd = useGameData();
@@ -31,7 +34,8 @@ export function BriefingScreen() {
const me = players.find((p) => p.playerId === myId);
const readyN = players.filter((p) => p.ready).length;
const mode = gd.config?.modes?.[pub.modeId];
const info = matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode });
// (+ the own 自选 pieces this match leaves out of the shop: m.private.diyBanned)
const info = matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode, priv, diyData: { chess: data.get('chess'), backups: data.get('backups') } });
const boss = pub.bossId ? gd.boss(pub.bossId) : null;
const bossEnemy = boss ? gd.enemy(boss.enemyKey) : null;
const stage = pub.stageId ? gd.stage(pub.stageId) : null;
+2 -1
View File
@@ -2,7 +2,8 @@
// 干员调配 overlay (screens/diy.js), shared with the sync (ui/loadoutSync.js), the match UI (ui/gameLogic/diy.js) and
// the tests. Four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6, sold from 调度中心 level 5 / 6);
// each takes an operator: a 6★ the player owns outside the chess pool (`diy.ownedPool`) — any of its three skills and
// any module of its elite form but a 集成战略 one (shared/diy.js isDiyModule) — or a prototype (原型干员) with its locked
// any module of its elite form but one of another game mode (集成战略 / 生息演算: shared/diy.js isDiyModule) — or a
// prototype (原型干员) with its locked
// skill / module; only operators with a kit are offered (`kitted`: the server's `welcome.diyKitted`). A prototype may
// fill a tier-5 and a tier-6 slot, an owned operator one slot; the picks of a tier differ (shared/diy.js
// validateDiyPicks).
+6 -2
View File
@@ -10,7 +10,7 @@
import { html, Icon, Tabs, MicroLabel } from './components.js';
import { Img, UnitThumb, BondGlyph, BandIcon, RichText, GIcon } from './gameComponents.js';
import { groupEnemies, factionTypes, briefingBondTip } from './gameLogic.js';
import { matchInfoModel } from './matchInfo.js';
import { matchInfoModel, DiyBannedLine } from './matchInfo.js';
import { factionIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -60,9 +60,12 @@ function EnemiesTab({ pub, priv, onEnemy }) {
}
function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) {
const { bonds, banned, perBond, stateOf } = matchInfoModel(pub, {
const model = matchInfoModel(pub, {
bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: data.get('config')?.modes?.[pub?.modeId],
// the own 自选 pieces this match leaves out of the shop (m.private.diyBanned) — not while scouting a teammate
priv: bandOwner ? null : priv, diyData: { chess: data.get('chess'), backups: data.get('backups') },
});
const { bonds, banned, perBond, stateOf } = model;
const disabled = new Set(bonds.filter((b) => stateOf(b.bondId)).map((b) => b.bondId));
// while scouting a teammate's prep board the drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2)
const band = (bandId || priv?.bandId) ? data.lookup('bands', bandId || priv.bandId) : null;
@@ -85,6 +88,7 @@ function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) {
</div>
${banned.length ? html`<div class="ibanned">${banned.map((id) => html`<button key=${id} type="button" class="ibanned__one" onClick=${() => onChess(id)}>
<${UnitThumb} kind="chess" id=${id} size="sm" dim=${true} /></button>`)}</div>` : null}
<${DiyBannedLine} model=${model} class="idiybanned" />
</div>`;
}
+2 -2
View File
@@ -33,7 +33,7 @@ export { sortedPlayers } from './gameLogic/shared.js';
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, canPlace, itemAttaches, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, itemAttaches, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
@@ -43,5 +43,5 @@ export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord } from './gameLogic/diy.js';
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js';
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
+20
View File
@@ -92,3 +92,23 @@ export function pickGetter(getChess, pick, data) {
/** Whether a record is a composed 自选 record. */
export const isDiyRecord = (rec) => isObj(rec) && typeof rec.diyFor === 'string';
/**
* The player's slotted 自选 pieces this match leaves out of the shop because every bond of the operator is switched off
* (m.private.diyBanned — the server's initDiyStock: no stock), in slot order: [{ slotId, charId, name }] (the operator's
* name from its composed record; the charId when the data lacks it). [] without any.
* @param {any} priv m.private @param {(id: string) => any} getChess @param {{ chess?: any, backups?: any }} data
*/
export function diyBannedPieces(priv, getChess, data) {
const ids = Array.isArray(priv?.diyBanned) ? priv.diyBanned.filter((id) => typeof id === 'string') : [];
const picks = diyPicks(priv);
const out = [];
for (const slotId of ids) {
const pick = picks[slotId];
if (!isObj(pick) || typeof pick.charId !== 'string') continue;
const chess = getChess(slotId);
const rec = chess ? diyRecordFor(chess, pick, data) : null;
out.push({ slotId, charId: pick.charId, name: rec?.name || pick.charId });
}
return out;
}
+35 -6
View File
@@ -227,8 +227,13 @@ export function piecePosition(ctx, piece) {
if (meleeOnHighGround(rec)) return 'ALL';
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
}
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile
if (piece.kind === 'token') return ctx.getToken(piece.id)?.position === 'MELEE' ? 'MELEE' : 'RANGED';
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile; `rangedTilesOnly` (战术锚点 "仅可以部署在…远程位")
// → 'HIGH', the ranged tiles only (server/match/board.js positionClass 'high')
if (piece.kind === 'token') {
const t = ctx.getToken(piece.id);
if (t?.rangedTilesOnly === true) return 'HIGH';
return t?.position === 'MELEE' ? 'MELEE' : 'RANGED';
}
return null;
}
@@ -237,6 +242,7 @@ export function tileAllows(ctx, piece, row, col) {
const pos = piecePosition(ctx, piece);
if (!pos) return false;
const k = tileKey(row, col);
if (pos === 'HIGH') return ctx.deploy.ranged.has(k) && !ctx.deploy.melee.has(k);
return pos === 'MELEE' ? ctx.deploy.melee.has(k) : ctx.deploy.ranged.has(k);
}
@@ -251,6 +257,23 @@ export function tileAllows(ctx, piece, row, col) {
*/
export function summonRange(ctx, piece, owner = null) {
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
return ownerRangeOf(ctx, piece, owner);
}
/**
* Board tiles ('r,c') an outside-bound summon may NOT stand on — its owner's attack range (tokens.json
* `ownerRangeOutside`: 凯尔希·思衡托's 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位") — or null. Mirror of
* server/match/player/placement.js summonExcluded.
* @param {ReturnType<typeof placementContext>} ctx
* @returns {Set<string> | null}
*/
export function summonExcluded(ctx, piece, owner = null) {
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRangeOutside !== true) return null;
return ownerRangeOf(ctx, piece, owner);
}
/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */
function ownerRangeOf(ctx, piece, owner = null) {
let at = owner;
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
const rec = at && ctx.getChess(at.piece.id);
@@ -260,11 +283,14 @@ export function summonRange(ctx, piece, owner = null) {
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
}
/** tileAllows plus the owner-range rule of a range-bound summon (summonRange). */
/** tileAllows plus the owner-range rules of a range-bound summon (summonRange, summonExcluded). */
function unitAllowed(ctx, piece, row, col, owner = null) {
if (!tileAllows(ctx, piece, row, col)) return false;
const k = tileKey(row, col);
const range = summonRange(ctx, piece, owner);
return !range || range.has(tileKey(row, col));
if (range && !range.has(k)) return false;
const out = summonExcluded(ctx, piece, owner);
return !out || !out.has(k);
}
/**
@@ -329,13 +355,16 @@ export function canPlace(ctx, uid, target) {
if (src.area === 'board' && src.row === row && src.col === col) return { ok: true, action: 'orient' };
if (!tileAllows(ctx, piece, row, col)) {
const deployable = ctx.deploy.ranged.has(tileKey(row, col));
return no('BAD_TILE', deployable && piecePosition(ctx, piece) === 'MELEE' ? '近战单位只能部署在地面' : '无法部署在该位置');
const pos = piecePosition(ctx, piece);
return no('BAD_TILE', deployable && pos === 'MELEE' ? '近战单位只能部署在地面' : deployable && pos === 'HIGH' ? '只能部署在远程位' : '无法部署在该位置');
}
// a range-bound summon (战术点): inside its owner's attack range — seen from the summon's old tile when it is
// dropped onto its own owner (the two swap)
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
const range = summonRange(ctx, piece, ownerSwap);
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围内');
const out = summonExcluded(ctx, piece, ownerSwap);
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围外');
if (src.area === 'board') {
// board → board: move or swap (the occupant must be legal on the source tile — the mover's own summon excepted:
// a moved operator's summons go back to the hand anyway)
+26 -7
View File
@@ -8,8 +8,10 @@
// (gameLogic disabledBondSets over config.modes[modeId].inactiveBondIds — the drawn set D "部分盟约所含干员阵容不完整",
// still activatable through other bonds' operators or items, and the mode's static inactive bonds "本局禁用", e.g.
// 标准模拟's 10), every bond in the briefing order (bondOrder, then identifier) split into 核心 / 附加, the banned
// operators (m.public bannedChess, known chess only) sorted by tier, and the banned-member count per bond (gameLogic
// bannedPerBond). The blocks are hookless (unit-tested by calling them): the 核心盟约 / 附加盟约 rows of bond discs —
// operators (m.public bannedChess, known chess only) sorted by tier, the banned-member count per bond (gameLogic
// bannedPerBond) and — from the viewer's own m.private — its slotted 自选 pieces left out of the shop because all their
// bonds are off (gameLogic diyBannedPieces; 0.2.0). The blocks are hookless (unit-tested by calling them): the 核心盟约 /
// 附加盟约 rows of bond discs —
// greyed with the ✕, the red banned-member badge, the briefingBondTip tooltip — the legend and the 本局禁用干员 grid.
// MatchInfoDialog shows the same blocks read-only in a components.js Modal (关闭, a tap outside or Esc close it) with a
// status line from its caller (the draft's turn and countdown, so the running clock stays in view).
@@ -17,27 +19,31 @@
import { html, Button, Icon, MicroLabel, BondDisc, Tooltip, Modal } from './components.js';
import { UnitThumb } from './gameComponents.js';
import { bannedPerBond, disabledBondSets, briefingBondTip } from './gameLogic.js';
import { bannedPerBond, disabledBondSets, briefingBondTip, diyBannedPieces } from './gameLogic.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { t, dn } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* @typedef {{ sets: { drawn: Set<string>, off: Set<string> }, stateOf: (bondId: string) => 'off'|'drawn'|null,
* bonds: any[], core: any[], addon: any[], banned: string[], perBond: Map<string, number> }} MatchInfoModel
* bonds: any[], core: any[], addon: any[], banned: string[], perBond: Map<string, number>,
* diyBanned: Array<{ slotId: string, charId: string, name: string }> }} MatchInfoModel
*/
/**
* Everything the match-info blocks show.
* @param {any} pub m.public (drawnDisabledBonds / disabledBonds, bannedChess)
* @param {{ bonds?: any[], chess?: (id: string) => any, mode?: any }} [src]
* bonds: bonds.json records (data.list('bonds')); chess: the chess lookup; mode: config.json modes[pub.modeId]
* @param {{ bonds?: any[], chess?: (id: string) => any, mode?: any, priv?: any, diyData?: any }} [src]
* bonds: bonds.json records (data.list('bonds')); chess: the chess lookup; mode: config.json modes[pub.modeId]; priv: the
* viewer's m.private (its 自选 pieces out of the shop, diyBanned) with diyData (`{ chess, backups }`: data.get('chess') /
* data.get('backups')) to name them
* @returns {MatchInfoModel}
* stateOf: 'off' = the mode never activates the bond (本局禁用), 'drawn' = in the drawn set D (阵容不完整), null = normal;
* banned: the banned chess ids the data knows, by tier (ties keep the server's order); perBond: bondId → banned members
*/
export function matchInfoModel(pub, { bonds = [], chess = () => null, mode = null } = {}) {
export function matchInfoModel(pub, { bonds = [], chess = () => null, mode = null, priv = null, diyData = null } = {}) {
const sets = disabledBondSets(pub, mode?.inactiveBondIds);
const stateOf = (id) => (sets.off.has(id) ? 'off' : sets.drawn.has(id) ? 'drawn' : null);
const list = (Array.isArray(bonds) ? bonds : []).filter((b) => !!b && typeof b === 'object' && typeof b.bondId === 'string')
@@ -48,9 +54,21 @@ export function matchInfoModel(pub, { bonds = [], chess = () => null, mode = nul
return {
sets, stateOf, bonds: list, core: list.filter((b) => b.isCore), addon: list.filter((b) => !b.isCore),
banned, perBond: bannedPerBond(list, banned),
diyBanned: priv && diyData ? diyBannedPieces(priv, chess, diyData) : [],
};
}
/**
* The viewer's 自选 pieces out of the shop this match (MatchInfoModel `diyBanned`): one line under 本局禁用干员, or nothing.
* @param {{ model: MatchInfoModel, class?: string }} props
*/
export function DiyBannedLine({ model, class: cls = 'brief-banned__diy' }) {
const list = model?.diyBanned || [];
if (!list.length) return null;
return html`<p class=${cls} data-testid="diy-banned"><span class="diybanned__tag">${t('自选')}</span>
${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}</p>`;
}
/**
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
@@ -97,6 +115,7 @@ export function BannedOperators({ model }) {
${banned.length ? html`<div class="brief-banned__grid">
${banned.map((id) => html`<${UnitThumb} key=${id} kind="chess" id=${id} size="sm" dim=${true} />`)}
</div>` : html`<p class="t-dim">本局没有禁用干员</p>`}
<${DiyBannedLine} model=${model} />
</div>`;
}
+11 -6
View File
@@ -27,11 +27,13 @@
// 「可以放置于远程位」 — the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, any module or none
// (shared/highGround.js ⇒ any deployable tile; the owner's decision of 2026-10-05, following PRTS). The loadout does
// not change the class.
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except
// a token marked `rangedTilesOnly` ("仅可以部署在…远程位": 凯尔希·思衡托's 战术锚点) — class 'high', the ranged tiles only.
// A token whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: the tacticians' 援军 — 伺夜's 狼群,
// 缪尔赛思's 流形; PRTS 狼群 特性) also needs a tile of its owner's attack range: `ownerRangeKeys` = the owner's range
// grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its facing around its board tile
// (PlayerState._legal; player report #9 after 0.1.0).
// 缪尔赛思's 流形; PRTS 狼群 特性; Mon3tr's 重构体 by her talent) also needs a tile of its owner's attack range:
// `ownerRangeKeys` = the owner's range grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its
// facing around its board tile (PlayerState._legal; player report #9 after 0.1.0); one marked `ownerRangeOutside`
// ("…攻击范围外": the 战术锚点) a tile outside that range.
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
// `parseDir` validates an intent's optional direction.
@@ -135,12 +137,14 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
}
/**
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. A MELEE chess whose trait reads
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all' | 'high'. A MELEE chess whose trait reads
* 「可以放置于远程位」 (shared/highGround.js: 歌蕾蒂娅, 崖心, 见行者, normal and elite) is widened to 'all': it may also
* stand on the ranged (高台) tiles. Every other MELEE record stays 'melee'. `chess.json` has no `placement` field.
* stand on the ranged (高台) tiles. Every other MELEE record stays 'melee'. A token marked `rangedTilesOnly` (tokens
* "仅可以部署在…远程位": 战术锚点) is 'high': the ranged tiles only. `chess.json` has no `placement` field.
* @param {object|null} rec
*/
export function positionClass(rec) {
if (rec && rec.rangedTilesOnly === true) return 'high';
return meleeOnHighGround(rec) ? 'all' : basePositionClass(rec);
}
@@ -183,6 +187,7 @@ export function canPlace(map, pos, r, c) {
const cls = map.get(tileKey(r, c));
if (!cls) return false;
if (pos === 'melee') return cls === 'melee';
if (pos === 'high') return cls === 'ranged';
return true; // ranged / all: melee tiles and ranged tiles
}
+29 -3
View File
@@ -119,6 +119,12 @@ const MAX_REFRESHES = 16;
const BENCH_BUDGET = 6;
/** Shop level the bot aims for at the start of round r (index = round). */
const LEVEL_TARGET = [1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 6];
/**
* AI 托管 of a human with 自选 picks (0.2.0; bots field none): the buy score of one of the player's own slotted pieces —
* the operator the player chose to field (follow-up #13: under AI 托管 they were rarely bought) — and the 调度中心 is
* levelled one round earlier for the step that opens a slotted tier (levelUp).
*/
export const DIY_PIECE_BONUS = 12;
const TIER_POWER = [0, 10, 12.5, 15, 18, 21.5, 25];
/** Prep-side 特质 that keep adding bond layers every round / every refresh (a player's main layer engine). */
const RECURRING_TRAIT_EVENTS = new Set(['SERVER_PREP_START', 'SERVER_PREP_FIN', 'SERVER_REFRESH_SHOP']);
@@ -563,6 +569,11 @@ function chooseLineup(m, ps, ctx) {
return { set, bench, score };
}
/** buyScore with a fresh context (tests). */
export function buyScoreOf(m, ps, id) {
return buyScore(m, ps, id, context(m, ps));
}
/** Shop / reward score of acquiring one copy of chess `id` (0 = not worth it). */
function buyScore(m, ps, id, ctx) {
const gd = ps?.gd || m.gd;
@@ -589,6 +600,8 @@ function buyScore(m, ps, id, ctx) {
if (isHealer(c)) s += ctx.roles.healers === 0 && m.round >= 3 ? 6 : ctx.roles.healers >= 2 ? -14 : -3;
if (c.attackKind === 'none' && !isHealer(c)) s -= 4;
if (ctx.model && !isHealer(c)) s += ARMOR_WEIGHT * 0.6 * (armorFit(ctx.model, c) - 0.6);
// the player's own 自选 piece (a slotted slot: its composed record carries diyFor) — AI 托管 of a human with picks
if (c.diyFor) s += DIY_PIECE_BONUS;
return s;
}
@@ -956,16 +969,18 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
let bestV = -Infinity;
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
// and an outside-bound one (凯尔希·思衡托's 战术锚点): never a tile of its owner's range
const without = p.kind === 'token' && typeof ps.summonExcluded === 'function' ? ps.summonExcluded(p) : null;
// ground tiles for a MELEE blocker. placeClass 'all' on a MELEE record is a chess whose trait reads 「可以放置于远程位」
// (歌蕾蒂娅, 崖心, 见行者, any module): it may stand on a 高台, and when one of those tiles covers the enemy road it is
// planned there even with the ground free (owner 2026-10-04: the bot uses the 高台; 2026-10-05: every such chess).
const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
const cls = p.kind === 'token' ? positionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null;
let bestHighV = -Infinity;
for (const [r, c] of legalTiles(map, cls)) {
const k = tileKey(r, c);
if (taken.has(k) || (within && !within.has(k))) continue;
if (taken.has(k) || (within && !within.has(k)) || (without && without.has(k))) continue;
const noise = m.rngBots() * 1e-6;
const seen = new Set();
for (const dir of PLAN_DIRS) {
@@ -1383,7 +1398,16 @@ function maybeFreeze(m, ps) {
if (ps.shop.slots.some((s) => s && !s.sold && s.kind === 'chess' && ps.priceOf(s) > ps.funds && ps.completesChessMerge(s.id))) tryDo(() => ps.freeze());
}
function levelUp(m, ps, { spare = false } = {}) {
/** Whether level `lv` of the 调度中心 opens one of the player's slotted 自选 pieces with copies left (player/diy.js). */
export function diyOpensAt(ps, lv) {
const st = ps.diyStock;
if (!st || !st.entries || !st.entries.size) return false;
for (const e of st.entries.values()) if (e.shopLevel === lv && e.left > 0) return true;
return false;
}
/** The 调度中心 level-ups of a bot's prep (exported for tests). */
export function levelUp(m, ps, { spare = false } = {}) {
const gd = ps?.gd || m.gd;
for (let guard = 0; guard < 6; guard++) {
if (ps.shop.level >= gd.maxShopLevel) return;
@@ -1396,6 +1420,8 @@ function levelUp(m, ps, { spare = false } = {}) {
const boardReady = ps.allChess().length >= (r <= 4 ? ps.deployCap : Math.min(ps.deployCap, 6));
let want = price === 0;
if (!want && ps.shop.level < target && (boardReady || r >= 6)) want = true;
// a human's 自选 slots of the next level (AI 托管): that step one round earlier (DIY_PIECE_BONUS)
if (!want && boardReady && ps.shop.level < nextTarget && diyOpensAt(ps, ps.shop.level + 1)) want = true;
if (!want && ps.shop.level < nextTarget && price <= 2 && boardReady) want = true;
if (!want && spare && ps.funds >= price + 1 + fundsReserve(m, ps) && ps.shop.level < nextTarget + 1 && boardReady) want = true;
if (!want && ps.funds >= price + 14) want = true;
+6 -2
View File
@@ -482,9 +482,13 @@ function applyDefault(m, ps, card) {
handled = true;
break;
case 'single_special_choice_gain_bond_chess': {
// the player's 自选 stock joins the draw, its bonds read through the player's view (player/diy.js diyStockEntries)
const pgd = ps.gd || gd;
const hasBond = (cid) => { const c = pgd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); };
for (let i = 0; i < count; i++) {
const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } })
|| m.pool.roll(m.rngMeta, { maxTier: 6, filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } });
const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null;
const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: hasBond, extra })
|| m.pool.roll(m.rngMeta, { maxTier: 6, filter: hasBond, extra });
if (id) ps.acquireChess(id, { source: 'choice' });
}
handled = true;
+13 -3
View File
@@ -544,20 +544,30 @@ export function makeCtx(m, ps, source, hook, ev = null) {
});
return p ? view(p) : null;
},
/** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */
/**
* Random chess id from the shared pool (copy-weighted) — and the player's own 自选 stock (player/diy.js
* diyStockEntries, 0.2.0: 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」). opts: { maxTier, tier, bond, filter(id) };
* bonds are read through the player's data view (a slotted slot: its operator's).
*/
rollChess: (opts = {}) => {
const f = (id) => {
if (opts.bond) { const c = gd.chess(id); if (!c || !Array.isArray(c.bonds) || !c.bonds.includes(opts.bond)) return false; }
return typeof opts.filter === 'function' ? !!opts.filter(id) : true;
};
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f });
const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null;
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f, extra });
},
rollItem: (opts = {}) => m.rollItemId(opts),
/**
* Roll a choices.json pool: equip pools → { kind: 'item', id }; chess pools (items / weighted / shopEligible with
* tier, minTier, bond, golden) → { kind: 'chess', id, golden } (a pool chess needs a free copy). null when empty.
*/
rollPool: (poolId, opts = {}) => m.rollPool(poolId, { shopLevel: ps.shop.level, ...opts }),
rollPool: (poolId, opts = {}) => m.rollPool(poolId, {
shopLevel: ps.shop.level,
// a shared-pool draw also takes the player's 自选 stock, its bonds read through the player's view (rollChess)
extra: typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null, chessOf: (id) => gd.chess(id),
...opts,
}),
/**
* Run another owned chess's 特质 of `eventType` now (SERVER_GAIN / SERVER_PREP_START / SERVER_PREP_FIN /
* SERVER_CHESS_SOLD / SERVER_REFRESH_SHOP). opts.asUid: run them as if they belonged to that piece. Returns the
+3
View File
@@ -128,6 +128,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
const range = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
if (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);
// an outside-bound summon (战术锚点, PlayerState.summonExcluded) outside it
const out = p.kind === 'token' && typeof ps.summonExcluded === 'function' ? ps.summonExcluded(p) : null;
if (out && out.has(k)) fail(`${id}: ${p.id} on ${k}, inside its owner's attack range`);
if (p.kind === 'chess') deployed++;
}
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
+6 -2
View File
@@ -152,9 +152,11 @@ export class MatchSpDraft {
* Roll a choices.json pool (ctx.rollPool). Equip pools → rollItemId. Chess pools: an `items` (uniform) or `weighted`
* list — only chess with a free pool copy (or outside the pool) qualify — else a copy-weighted draw from the shared
* pool filtered by `tier` / `minTier` / `maxTier` (number or 'shopLevel') / `bond`; `golden: true` yields the elite id.
* `extra` (the drawing player's 自选 stock, player/diy.js diyStockEntries) joins that shared-pool draw, `chessOf` reads the
* bonds (the player's data view: a slotted slot's operator) — 0.2.0 WE2.
* @returns {{ kind: 'item'|'chess', id: string, golden?: boolean } | null}
*/
rollPool(poolId, { shopLevel = 6 } = {}) {
rollPool(poolId, { shopLevel = 6, extra = null, chessOf = null } = {}) {
const pools = this.gd.choices.pools && typeof this.gd.choices.pools === 'object' ? this.gd.choices.pools : {};
const p = typeof poolId === 'string' && Object.hasOwn(pools, poolId) ? pools[poolId] : null;
if (!p || typeof p !== 'object') return null;
@@ -181,10 +183,12 @@ export class MatchSpDraft {
const maxTier = p.maxTier === 'shopLevel' ? lvl : Number.isInteger(p.maxTier) ? p.maxTier : 6;
const minTier = Number.isInteger(p.minTier) ? p.minTier : 1;
const bond = typeof p.bond === 'string' ? p.bond : null;
const recOf = typeof chessOf === 'function' ? chessOf : (cid) => this.gd.chess(cid);
id = this.pool.roll(rng, {
tier: Number.isInteger(p.tier) ? p.tier : null,
maxTier,
filter: (cid, e) => e.tier >= minTier && (!bond || (Array.isArray(this.gd.chess(cid)?.bonds) && this.gd.chess(cid).bonds.includes(bond))),
filter: (cid, e) => e.tier >= minTier && (!bond || (Array.isArray(recOf(cid)?.bonds) && recOf(cid).bonds.includes(bond))),
extra,
});
}
if (!id) return null;
+20 -3
View File
@@ -25,7 +25,11 @@
// shop's chess slots and the reward offers' temporary refreshes [ASSUMED] — weighted like any chess of its tier, once
// the 调度中心 has reached the slot's shopLevel (activity_table shopLevelDisplayDataDict lists the tier-5 slots at
// level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心等级 ≥ 干员所在等阶"). The price is the slot's (any
// chess of its tier: 4). Effects that grant a random operator from the shared pool never draw it.
// chess of its tier: 4).
// * random grants (diyStockEntries, 0.2.0 WE2 #9): an effect, reward or 机变 card that grants this player a random
// operator from the pool draws its stock too — 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」 (bilibili), "调度中心
// 随机资源的范围将被扩大" (PRTS 新手教程) — under the roll's own tier rules (the effect's tier / maxTier: no 调度中心 gate,
// as for a preset chess) and filters (bonds read through the player's data view).
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state).
@@ -95,8 +99,10 @@ export function diyGameData(gd, records) {
token: { value: (id) => gd.token(id) || tokenOf(id) },
/**
* GameData.placeableTokens for a slotted slot: the summons its record lists (the pick's skill and talents) that are
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count, the
* active module's own when its variant has one (`byModule`: 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子).
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count (PRTS
* 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量)"), the active module's own when its variant has one (`byModule`:
* 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子; SUM-Y stage 2+ 4 drones / summons). The data's deploy limit holds the
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影.
*/
placeableTokens: {
value: (chessId, loadout = null) => {
@@ -202,6 +208,17 @@ export class PlayerDiy {
return out.length ? out : null;
}
/**
* The stock entries a random grant of this player draws besides the shared pool (see the header): every slotted piece
* with copies left, whatever the 调度中心 level (the roll's own tier rules apply), or null (none).
* @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null}
*/
diyStockEntries() {
if (!this.diyStock || !this.diyStock.entries.size) return null;
const out = [...this.diyStock.entries].filter(([, e]) => e.left > 0);
return out.length ? out : null;
}
/**
* The 自选 pick a chess id (normal or elite) of this player fields — its slot's — or null (not a slotted slot).
* @param {string} id
+38 -9
View File
@@ -44,14 +44,18 @@ export class PlayerPlacement {
/**
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形), a tile of its owner's
* attack range (summonRange). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a
* summon swapped with its own owner).
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
* (summonExcluded). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
* with its own owner).
*/
_legal(piece, r, c, owner = null) {
if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false;
const k = tileKey(r, c);
const range = this.summonRange(piece, owner);
return !range || range.has(tileKey(r, c));
if (range && !range.has(k)) return false;
const out = this.summonExcluded(piece, owner);
return !out || !out.has(k);
}
/**
@@ -66,6 +70,24 @@ export class PlayerPlacement {
*/
summonRange(piece, owner = null) {
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
return this._ownerRangeOf(piece, owner);
}
/**
* The 'r,c' keys an outside-bound summon may NOT stand on — its owner's attack range (tokens `ownerRangeOutside`: PRTS
* 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位"; 0.2.0 WE2) — or null (not outside-bound, or the owner is off the
* board). Same range as summonRange.
* @param {any} piece
* @param {{ key: string, piece: any, dir: string } | null} [owner]
* @returns {Set<string> | null}
*/
summonExcluded(piece, owner = null) {
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRangeOutside !== true) return null;
return this._ownerRangeOf(piece, owner);
}
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */
_ownerRangeOf(piece, owner = null) {
let at = owner;
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
const chess = at && this.gd.chess(at.piece.id);
@@ -86,17 +108,23 @@ export class PlayerPlacement {
* battle. Returns the number taken off the board; a toast names them.
*/
_liftOutOfRange() {
const back = [], gone = [];
const back = [], gone = [], backOut = [], goneOut = [];
for (const [k, p] of [...this.board]) {
if (p.kind !== 'token') continue;
const range = this.summonRange(p);
if (!range || range.has(k)) continue;
const out = this.summonExcluded(p);
const inside = !!(out && out.has(k));
if (!inside && (!range || range.has(k))) continue;
this.board.delete(k);
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
const ok = this._returnToken(p, null, { allowTemp: true });
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id);
}
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
return back.length + gone.length;
// an outside-bound summon (战术锚点) its owner's new range now covers
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
return back.length + gone.length + backOut.length + goneOut.length;
}
/**
@@ -111,7 +139,8 @@ export class PlayerPlacement {
for (const [k, p] of this.board) {
if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue;
const range = this.summonRange(p, at);
if (range && !range.has(k)) needSlot.add(p.id);
const out = this.summonExcluded(p, at);
if ((range && !range.has(k)) || (out && out.has(k))) needSlot.add(p.id);
}
return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length;
}
+14 -3
View File
@@ -19,7 +19,8 @@
// Targets: bond_by_id / bond_self (own active bonds) / bond_actived_maxstack;
// amounts: by_count / by_charcount_samerow / by_charlevel; conditions character_same_row /
// character_same_col (≥ check_count incl. self). Gains go through support.gainLayers with
// reason 'garrison', source = the trait's owner, cap = max_add_count_per_battle per (instance, bond).
// reason 'garrison', source = the trait's owner, cap = max_add_count_per_battle per (instance, bond)
// — per instance for bond_actived_maxstack (塑心: the highest bond may change, the cap does not).
// act1autochess_gar_event_addition_cnt (魔王) layerGain: a 'garrison' gain whose source stands on the tile in front
// of 魔王 (range_id 1-1) — or was knocked out there this instant (幽灵鲨 "被击倒时"; engine ctx.tile)
// — gets +extra_cnt per bond (the extra does not count toward the source's per-battle cap).
@@ -191,10 +192,20 @@ export function fireGain(battle, it) {
if (!conditionMet(battle, it)) return 0;
const bonds = targetBonds(battle, it);
if (!bonds.length) return 0;
const n = amountOf(battle, it);
let n = amountOf(battle, it);
// bond_actived_maxstack (塑心 garrison_90 "当前已激活且层数最多的盟约层数+1(每场作战至多10层)"): the cap is the instance's
// own over the battle, whichever bond is the highest at each gain — per (instance, bond) a change of the highest bond
// granted a fresh 10 (PR #178 review). The base amount counts toward it (魔王's extra does not, as in gainLayers).
const whole = it.bbStr.bond_type === 'bond_actived_maxstack' && Number.isFinite(it.cap);
if (whole) {
n = Math.min(Math.floor(n), Math.max(0, Math.floor(it.cap) - (it.wholeUsed ?? 0)));
if (!(n > 0)) return 0;
}
const added = S.gainLayers(battle, {
playerId: it.unit.ownerId, bonds, n, requireActive: true, source: it.unit, reason: 'garrison', cap: it.cap, capKey: it.capKey,
playerId: it.unit.ownerId, bonds, n, requireActive: true, source: it.unit, reason: 'garrison',
cap: whole ? Infinity : it.cap, capKey: whole ? null : it.capKey,
});
if (whole && added > 0) it.wholeUsed = (it.wholeUsed ?? 0) + n;
if (added > 0) S.fxOn(battle, 'garrison', it.unit, `gar:${it.gid}`, it.key, { n: added });
return added;
}
+4 -3
View File
@@ -16,7 +16,6 @@ operators, the self-select 6★ operators, contributions from GitHub issue #136
| `shared/tier1.js` | the general kit helpers (blackboard readers, unit predicates, hit hooks, area queries, buffs, zones, free tiles, skill records) and the notes of the tier-1 kits |
| `shared/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) |
| `shared/summoner.js` | the 召唤师 summon deck the 自选 kits of 麦哲伦 / 令 / 电弧 share (`summonDeck`: the holding, a placed piece's return, recalls, the summons leaving with their owner; `holdBuff`, `tokenStat`) |
| `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor |
`content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less
id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). A 补位 stand-in's
@@ -184,8 +183,10 @@ pick chooses any of the three, so write every skill under `skills: { [skillId]:
both tiers, both forms and both module stages. A 自选 piece has no 特质 and its bonds come from its factions: neither is
the kit's business. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnToken(unit, …)` resolve the variant of the
pick. A **placeable** summon (data/backups.json `tokens[id].placeable`, made by the pick's skill or a talent — the
variant's `sources`) is a hand piece like any operator's: when the 自选 piece is deployed in prep its player gets one
stack of the variant's `deployLimit` — the picked module's own when its variant has one (望's TRP-X: 7 棋子) —
variant's `sources` — and shown by its owner: a skill's own object such as 予愿安洁莉娜's “一会儿见!” is not) is a hand piece like any operator's: when the 自选 piece is deployed in prep its player gets one
stack of the variant's `deployLimit` — the picked module's own when its variant has one (望's TRP-X: 7 棋子); it holds
the token's own talent additions (max_deploy_count / max_deck_stack_cnt, tools/build-data.mjs `tokenTalentDeckBonus`:
麦哲伦 / 令 3, SUM-Y stage 2+ 4, 白铁 2, 夜莺 3), so a deck reads `deployLimit` / `deckStack` as they are —
(server/match/player/diy.js `placeableTokens`, PlayerState `grantTokensFor`), places
it on a legal tile (the token's position / `ownerRange`), loses it with its owner (sold, merged, moved back), and the
match hands every placed piece to the battle as a PlayerBattleInput token unit `{ kind: 'token', tokenId, ownerUid }` —
+4 -13
View File
@@ -53,9 +53,9 @@
// abilities). Off her deployment tile, “一会儿见!” marks it (备注 "每次移动后若不位于初始位置且初始位置不存在标志物…部署一个").
// When the skill ends she goes back there by a 【移动】 (the marker leaves) unless she is knocked out.
// - “一会儿见!” (its kit below): 无敌, 孤立, 不可选中 by enemies, blocks nothing, no attack (PRTS 备注; token_aglna2_passive).
// It is the skill's placeholder (skchr_aglna2_3 `_placeholderTokenKey`), not a player's summon: the data marks it
// placeable (the prep hands a piece out with S3 — open question), so a placed piece is used as that marker and never
// deploys by itself (`deferDeploy`; its tile stays reserved); without one the skill summons it.
// It is the skill's placeholder (skchr_aglna2_3 `_placeholderTokenKey`), not a player's summon: no owner shows it, so the
// data does not mark it placeable (tools/build-data.mjs summonRecord, 0.2.0 WE2 — until then the prep handed a piece out
// with S3 and this kit used it as the marker) and the skill summons it each time.
import { num, talentBb, skillRec, batMod, up, installAura, toggleBuff } from '../shared/tier1.js';
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
@@ -174,13 +174,11 @@ export default {
}
function placeMarker(battle, unit) {
if (!st || (st.marker && st.marker.alive)) return;
const piece = battle.allyUnits.find((t) => t.kind === 'token' && t.defId === MARKER && t.ownerUnit === unit && t.uid != null && !t.alive && !t.removed);
if (piece && battle.redeploy(piece, { free: true, tile: [st.r0, st.c0] })) { st.marker = piece; return; }
st.marker = battle.spawnToken(unit, MARKER, st.r0, st.c0, { kit: markerKit() });
}
function dropMarker(battle) {
const m = st && st.marker;
if (m && m.alive) battle.retreat(m, { reason: 'expired', permanent: m.uid == null });
if (m && m.alive) battle.retreat(m, { reason: 'expired', permanent: true });
if (st) st.marker = null;
}
/** Her S3 range for where she stands: the 3-9 ∪ x-4 at home; away, the x-4 here + the anchored 3-9 (extra keys). */
@@ -297,13 +295,6 @@ export default {
if (ctx.target !== unit || !RESIST_STATUSES.has(ctx.status) || (ctx.source && ctx.source.side === 'ally')) return;
if (unit.findBuff(CHANT_KEY) || unit.findBuff(MOVE_KEY)) ctx.cancel = true;
}, { owner: unit });
// a placed “一会儿见!” piece is S3's marker: it never deploys by itself
for (const t of battle.allyUnits) {
if (t.kind !== 'token' || t.defId !== MARKER || t.ownerUnit !== unit || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, markerKit());
if (sid === S3) t.deferDeploy = true;
}
if (sid !== S3) return;
const dr = num(b3.damage_resistance), slow = -num(b3.move_speed);
const aspdB = num(b3['aglna2_s_3[blocked].attack_speed']), aspdU = num(b3['aglna2_s_3[unblocked].attack_speed']);
+6 -2
View File
@@ -47,8 +47,10 @@
// every `interval` (0.5) s, every selectable enemy on her path takes atk_scale × ATK arts (16 hits in the 8 s). The path
// meanwhile has no length limit (`_infinite`) and turns at her 中继器 and at every allied
// operator it reaches (validators: the piece, allies of profession mask 639 — every operator profession; a 孤立 one too),
// to that unit's facing. The data resolves DEFAULT: the unbounded range is no data range (no rangeId), so build-data's
// ACTIVE_RANGE rule cannot see it (reported, not hand-coded).
// to that unit's facing. Trigger: the data's DEFAULT, plus — the owner's ACTIVE_RANGE rule (2026-10-05: a MANUAL skill
// whose running range strictly contains her own casts with an enemy inside that range), which no data range can carry
// for an unbounded path that bends — the beam's path as a content trigger range (skill.addTriggerRange: a targetable enemy
// on the path she would fire along now, air units too as the beam hits them; 0.2.0 WE2, follow-up #16, O1's report).
// - S3 混沌的本质 (MANUAL, data DEFAULT, 20 发弹药 — `ammo`, attack@trigger_time): for charge_time (3) s from the cast 强制缴械
// (aphris_sk3[sheltering], abnormal flag disarmed) and aphris_sk3[token-withdraw]: every 中继器 of hers withdrawn and its
// redeploy time cleared (ResetTokenRespawnTime — the placed piece is back on its tile at once; the two extra pieces the
@@ -285,6 +287,8 @@ export default {
battle.on('skillStart', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('tick', () => refreshPath(battle, unit), { owner: unit });
// S2: the beam's path is its trigger range too (the owner's larger-range rule, see the header)
if (unit.skill?.id === S2) unit.skill.addTriggerRange(() => (up(unit) ? [aphrisPath(battle, unit, true).tiles.map(([r, c]) => r * COLS + c)] : []));
// S3's 强制缴械 from the cast on: the attack loop goes on to the attack the cast was made for (ai.js updateAlly
// checks `disarm` before the cast only) — no target, no attack, no bullet spent
battle.on('beforeAttack', (c) => { if (c.attacker === unit && unit.findBuff(CHARGE_KEY)) c.targets = []; }, { owner: unit });
+23 -3
View File
@@ -39,7 +39,10 @@
// (PRTS "大幅度缩短(*0.2)": batMod's ratio reading), every attack attack@atk_scale × ATK; the energies released meanwhile
// use that scale too (备注 "该技能的“每次攻击的攻击倍率”会实时应用在特性积攒的“攻击能量”抛射物上") × the energy scale.
// `attack@cnt` (1) has no text [unused].
// - S2 荒芜回响 (AUTO, instant, data DEFAULT — an AUTO skill whose effect acts on enemies keeps the data rule): spends every
// - S2 荒芜回响 (AUTO, instant): cast at full SP as soon as one placeable tile of his attack range is free — no enemy needed
// (PRTS 备注 "攻击范围内不存在可部署位时,技能不会被触发"; the skill prefab's `_trigger._minTileNum` 1 — O20's report; 0.2.0 WE2:
// until then the data's DEFAULT waited for an enemy in his range; `trigger: 'NEVER'` + this kit's cast, the pattern of
// 伺夜's conditional casts): spends every
// stored energy (elite ones first: 备注 "可以消耗并优先消耗第一天赋储存的额外能量") and places energies + 1 旧日残影 on free
// placeable tiles of his attack range (部署位置 全部位), in the 备注's order: tiles holding a selectable ground enemy (the
// best of them first by his target order — 仇恨值 [ASSUMED: the engine's default operator order]), then the tiles nearest
@@ -88,6 +91,15 @@ export const eliteOrLeader = (e) => !!e && (!!e.isBoss || e.def?.rank === 'ELITE
/** His stored energies (per deployment): `n` normal, `e` elite, `q` the volleys in flight per target id. */
const stateOf = (unit) => unit.trait.ebn ?? (unit.trait.ebn = { n: 0, e: 0, q: new Map() });
/** A tile 荒芜回响 may place a 旧日残影 on: inside the field, deployable (全部位) and free (Battle.isReservedTile). */
const remnantTileOk = (battle, r, c) => battle.grid.inRect(r, c) && battle.grid.canStand(r, c, { ranged: true }) && !battle.isReservedTile(r, c);
/** Whether `unit`'s attack range holds one tile 荒芜回响 may use (its cast condition; no rng, unlike remnantTiles). */
function hasRemnantTile(battle, unit) {
for (const k of unit.rangeKeys || []) if (remnantTileOk(battle, Math.floor(k / COLS), k % COLS)) return true;
return false;
}
/**
* The tiles 荒芜回响 may place 旧日残影 on (PRTS 备注 order): free deployable tiles of `unit`'s attack range — first those
* holding a selectable ground enemy, by the best of them in his target order; then by the distance to the nearest
@@ -97,8 +109,7 @@ function remnantTiles(battle, unit) {
const tiles = [];
for (const k of unit.rangeKeys || []) {
const r = Math.floor(k / COLS), c = k % COLS;
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
tiles.push([r, c]);
if (remnantTileOk(battle, r, c)) tiles.push([r, c]);
}
if (!tiles.length) return tiles;
const foes = battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND));
@@ -231,6 +242,7 @@ export default {
},
[S2]: {
kind: 'instant',
trigger: 'NEVER', // the kit's cast (install): full SP and one free placeable tile in his range
onStart({ battle, unit, skill }) {
const tiles = remnantTiles(battle, unit);
if (!tiles.length) { skill.addCharge(1); return; } // "攻击范围内不存在可部署位时,技能不会被触发"
@@ -260,6 +272,14 @@ export default {
},
},
install(battle, unit) {
// S2 荒芜回响: cast at full SP with one free placeable tile in his range, no enemy needed (see the header)
if (unit.skill?.id === S2) {
battle.on('tick', () => {
const sk = unit.skill;
if (!sk || !sk.ready || sk.active || !up(unit) || !unit.canAct || unit.s.flags.silence) return;
if (hasRemnantTile(battle, unit)) sk.activate('SP_FULL');
}, { owner: unit });
}
// MSC-Y: ASPD while any energy is stored
const aspd = num(hidden.attack_speed);
if (aspd) toggleBuff(battle, unit, 'trait:ebnhlz:stored', () => { const v = stateOf(unit); return v.n + v.e > 0; }, { aspd });
+1 -1
View File
@@ -16,7 +16,7 @@
// 单位反弹伤害,伤害为法术普通伤害"), PRTS 卫戍协议/帮助 技能策略 (her S3 row "于技能持续时间过半后…关闭技能(进入其技能效果的
// 临死模式)" added in 上半, REMOVED with 下半; "通常不会自动关闭技能").
// - Trait (驭法铁卫) "技能开启时普通攻击会造成法术伤害": physical attacks while no skill runs (the kit's trait dmgType — the
// data's 'arts' is the skill-on type) and arts while one runs (every skill's attack override); block 3, ground-only
// data's 'phys' too since 0.2.0 WE2 — build-data classifyAttack) and arts while one runs (every skill's attack override); block 3, ground-only
// melee on her 1-1. Module AST-X 无迹 adds "且攻击和受到攻击时对目标额外造成10%攻击力的法术伤害" (trait bb atk_scale): every
// damage instance of her normal attacks (skill attacks included; not the shield, a counter or the module's own damage)
// is followed by atk_scale × ATK arts on that enemy (附加伤害: no dodge), and every enemy attack she takes returns
+2 -2
View File
@@ -68,8 +68,8 @@
// - S3 铁钳号·原型机 (MANUAL, DEFAULT): 30 s, ATK +atk, ASPD +attack_speed; +1 stock at the start.
// [ASSUMED] the stock refilled by +cnt on each deployment, capped at cnt + the CRA-X +1; a RechargeToken beyond the cap is lost;
// the crab charges only from the attacks that select it (an ally's area skill that picks enemies does not see it); summons
// attack it too. Open (data, report): the hand count of his devices is the data's deployLimit 1 — tools/build-data.mjs takes
// the attribute frame's maxDeployCount and leaves out the token's hidden talent max_deploy_count +1 (E2 "最多可部署2个").
// attack it too. The hand count of his devices is the data's deployLimit 2 (the attribute frame's 1 + the token's hidden talent
// max_deploy_count 1 — E2 "最多可部署2个"; tools/build-data.mjs tokenTalentDeckBonus, 0.2.0).
import { num, talentBb, talentGrid, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
+2 -2
View File
@@ -68,8 +68,8 @@
// the move be refused, she comes back on her own tile [ASSUMED; officially she is knocked out].
// At the skill's end any anchor of hers on the field is withdrawn and the stock emptied (token_ctrl). The flight's begin
// clip and its 生命回复速度 ×0 are not modelled [ASSUMED].
// - 战术锚点 (token_10068_kalts2_mtship, placed in prep — "仅可以部署在凯尔希·思衡托攻击范围外的远程位" is the match's
// placement rule): 无敌 and never targeted ("不会受到攻击"), no attack, blocks nothing; its range (hers) is only shown.
// - 战术锚点 (token_10068_kalts2_mtship, placed in prep on a ranged tile outside her attack range — "仅可以部署在凯尔希·思衡托
// 攻击范围外的远程位": the data's `ownerRangeOutside` / `rangedTilesOnly`, enforced by the match, 0.2.0 WE2): 无敌 and never targeted ("不会受到攻击"), no attack, blocks nothing; its range (hers) is only shown.
// A placed piece is docked by content/tokens.js (a skill's summon: deployed for free at the battle start, then only when
// her S3 gives one and its 70 s redeploy time has passed, on its own tile).
+5 -8
View File
@@ -23,8 +23,9 @@
// target her; her summons leave the field with her (shared/summoner.js).
// - T1 挑灯问梦 "可以使用5个召唤物(最多同时部署3个),功能随技能选择而改变": the summon of her pick's skill (“清平” / “逍遥” /
// “弦惊” — the skills' overrideTokenKey) is the hand piece the player places; the deck of ../shared/summoner.js with charge =
// cnt (5), cap = the summon deck (max_deck_stack_cnt 5, +3 with SUM-Y), at most 1 + max_deploy_count (2 ⇒ 3; SUM-Y stage 3:
// 4) standing. Every summon holds 禁疗 (PRTS; [ASSUMED: given here — the data's `abnormal` lacks it]) and fights with its
// cnt (5), cap = the summon record's deckStack (5: its hidden talent's max_deck_stack_cnt, tools/build-data.mjs
// tokenTalentDeckBonus; +3 with SUM-Y), at most its deployLimit standing (3 = 1 + the talent's max_deploy_count 2; SUM-Y
// stage 2+: 4) — the hand count too. Every summon holds 禁疗 (PRTS; [ASSUMED: given here — the data's `abnormal` lacks it]) and fights with its
// own data: “清平” blocks 1 and strikes in melee (physical) on its 1-1, “逍遥” shoots arts on its 3-1 (air units too), “弦惊”
// blocks 2 and strikes every enemy it blocks (physical; its trait "攻击阻挡的所有敌人").
// - T2 随付笺咏醉屠苏 "召唤物被击倒/吸收/回收时令额外获得3点技力、攻击力+3%(攻击力加成最多叠加5层)": a summon knocked out,
@@ -74,7 +75,6 @@ const TAG_S3 = 'ling:s3';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const skillOn = (u, id) => !!(u && u.skill && u.skill.active && u.skill.id === id);
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
const tileOf = (u) => u.tileR * COLS + u.tileC;
/** Her S1 / S3 acting on a summon (held from each skill start / end and each summon deployment). */
@@ -217,16 +217,13 @@ export default {
talents: [
{ install(battle, unit) { // 挑灯问梦: the deck of her summon pieces and their kit
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
const deckBase = num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, cnt);
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
const sp = num(t1.sp), atk = num(t1.atk), stacks = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
summonDeck(battle, unit, {
tokenIds: SOULS,
charge: cnt,
start: sumY,
cap: deckBase + num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : 0, 0),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1),
kit: (t) => soulKit(t, unit, opts),
// 随付笺咏醉屠苏: knocked out / absorbed / recalled ⇒ +sp SP and a stack of ATK +atk (not when they leave with her)
onLeave(t, kind) {
+5 -7
View File
@@ -23,8 +23,9 @@
// PRTS "可对空"), blocks 1, ground enemies target her. Her drones leave the field with her (shared/summoner.js).
// - T1 支援无人机·龙腾 "可以使用5个不阻挡敌人的无人机(最多同时部署3个),功能随技能选择而改变": the drone of her pick's skill
// (龙腾.F / .L / .A — the skills' overrideTokenKey) is the hand piece the player places; the deck of ../shared/summoner.js
// with charge = cnt (5) at each of her deployments, cap = the drone deck (its hidden talent's max_deck_stack_cnt 5, +the
// SUM-Y deckStack 3), at most 1 + its max_deploy_count (2 ⇒ 3; SUM-Y stage 3: 3 ⇒ 4) standing. Every drone holds 禁疗
// with charge = cnt (5) at each of her deployments, cap = the drone record's deckStack (5: its hidden talent's
// max_deck_stack_cnt, tools/build-data.mjs tokenTalentDeckBonus; SUM-Y +3), at most its deployLimit standing (3 = 1 + the
// talent's max_deploy_count 2; SUM-Y stage 2+: 4) — the hand count too. Every drone holds 禁疗
// (PRTS; the data's `abnormal` lacks it [ASSUMED: given here]), blocks nothing, has no skill of its own here (its 回收 is
// her skills' end, below). 龙腾.F (ATK 0) makes no attack (the engine's rule for a 0-ATK summon; a commented-out PRTS
// note "缴械,普通攻击不造成伤害" agrees [ASSUMED]); 龙腾.L strikes one ground enemy on its own tile for arts damage; 龙腾.A
@@ -135,15 +136,12 @@ export default {
talents: [
{ install(battle, unit) { // 支援无人机·龙腾: the deck of her drone pieces and their kit
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
const deckBase = num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, cnt);
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
summonDeck(battle, unit, {
tokenIds: DRONES,
charge: cnt,
start: sumY,
cap: deckBase + num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : 0, 0),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1),
kit: (t) => droneKit(t, unit, opts),
});
// her skills on the standing drones: at each start / end (a drone deployed meanwhile syncs itself)
+3 -3
View File
@@ -34,9 +34,9 @@
// stage 3: the next attack@chain.extra_cnt jumps after that neither). Her heals pass 禁疗 ("治疗效果无视禁疗"); her selection
// takes her own 重构体 through its 禁疗 (`healThrough`), no other 禁疗 unit [ASSUMED: the jumps too].
// - Module XAH-X 记忆存档: the trait's 0.85 per jump (traitOverride); stage 3 changes T1 (below); ATK / DEF from the attributes.
// - T1 自我修复 (cnt 1): her 重构体 is a hand piece the player places (the talent's summon). "可以在攻击范围内的地面使用" — a
// ground tile of her attack range — is not in the token data (`ownerRange` false), so the match does not hold the piece to
// it and the battle takes it where it stands [open question]. Its kit: 禁疗 (only her heals reach it), blocks nothing, taunt
// - T1 自我修复 (cnt 1): her 重构体 is a hand piece the player places (the talent's summon) on a ground tile of her attack
// range — "可以在攻击范围内的地面使用": the data's `ownerRange` from that talent text (tools/build-data.mjs summonRecord,
// 0.2.0 WE2) and its MELEE position, enforced by the match. Its kit: 禁疗 (only her heals reach it), blocks nothing, taunt
// −1, loses 8 HP every 0.1 s from 1 s after it lands (流失, Battle.loseHp — PRTS "每0.1s流失8生命值"; trait damage_per_second
// 80); every ally of its
// 3×3 but itself (no 孤立 one) ATK +atk (0.15; XAH-X stage 3 0.25; 同名效果取最高). Gone (destroyed or withdrawn) ⇒ back on its
+4 -5
View File
@@ -50,7 +50,8 @@
// - Modules SO-A 电弧特勤证章 / SO-B 新起点: their trait and talent parts act "在【岁的界园志异】中" only (the client gates them to
// the 集成战略 theme rogue_yan / validInGameTag roguelike): N/A here — the module talents the composed record carries
// (SO-A's hidden respawn_time / prob / sp, SO-B's attack@max_target and x-5 recall) are ignored; their attributes are in the
// stats. (DIY_EXCLUDED_MODULE_TYPE refuses ISW-* only, so a roster may carry them — see the report.)
// stats. A player's roster never carries them (shared/diy.js DIY_EXCLUDED_MODULE_TYPE, 0.2.0 WE2); the kit fields them for
// its tests.
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js';
@@ -231,8 +232,6 @@ export default {
talents: [
{ install(battle, unit) { // 卡带里的灵感: the deck of her summon pieces and their kit
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
const ratios = { atk: num(t1.atk), def: num(t1.def), hp: num(t1.max_hp) };
const inspireOne = (s) => {
if (!up(unit)) return;
@@ -250,8 +249,8 @@ export default {
summonDeck(battle, unit, {
tokenIds: TOWERS,
charge: cnt,
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt) + num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, 0),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1),
kit: (t) => towerKit(t, unit, opts),
});
// her skills on the standing summons at each start / end (a summon deployed meanwhile syncs itself); 鼓舞 again
+3 -4
View File
@@ -50,9 +50,9 @@
// a tile at a Manhattan distance ≥ max_distance from it, it is put back on that tile's centre (unblocked, its route
// re-planned from there; waypoints already passed stay passed), stealth or 无敌 notwithstanding; a 消失 (hidden) enemy loses
// its mark and every mark ends with the skill (the client attaches it to shu_s_3). No enemy of the mode holds
// 免疫传送 (data otherImmunities). Trigger: the data rule is TRY_SEARCH_ALLY_SKILL (her own row, PRTS "技能范围内存在可治疗的
// 我方单位时释放技能"), which the engine does not know (an unknown rule acts as DEFAULT): this kit maps it onto the engine's
// SKILL_RANGE + `allies` on x-2 (an injured, healable allied unit of the skill range, herself included; 古米 S1's option).
// 免疫传送 (data otherImmunities). Trigger: the data's — her own row TRY_SEARCH_ALLY_SKILL (PRTS "技能范围内存在可治疗的我方
// 单位时释放技能"), which tools/build-data.mjs (TRIGGER_ALLY_RULES) writes as the engine's SKILL_RANGE + `allies` on the
// skill range x-2 (an injured, healable allied unit there, herself included; 古米 S1's option).
import { num, traitBb, skillRec, batMod, up, giveSp } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
@@ -164,7 +164,6 @@ export default {
},
[S3]: {
kind: 'duration',
trigger: { rule: 'SKILL_RANGE', grid: g3, allies: true },
mods: { atkPct: num(b3.atk) },
targeting: { rangeGrid: g3 },
onStart({ unit }) { unit.mem.shuMarks = new Map(); },
+8 -3
View File
@@ -54,8 +54,10 @@
// 备注 "每次部署薇薇安娜时,重新计算该技能的使用次数" / "首次技能结束后,本技能的技能范围永久扩大至3-2"; vvana_s_3[check]: a
// skill triggered before ⇒ the enhance mark and duration_plus = enhance_duration, vvana_s_3: mode 4 instead of 3): from her
// second cast of a deployment the skill runs enhance_duration s with S3_LATER_HITS hits on the 3-2 range (her own grid while
// it runs: the card shows it). The trigger stays the data's DEFAULT on her own range for every cast — the later casts'
// larger running range is no data range (reported, not changed: kits/README.md checklist 5).
// it runs: the card shows it). Trigger: the data's DEFAULT on her own range for the first cast of a deployment; once it
// has ended the owner's ACTIVE_RANGE rule (2026-10-05: a MANUAL skill whose running range strictly contains her own casts
// with an enemy inside that range) on the 3-2 — no data range carries it, so the kit sets it (skill.setTrigger, 0.2.0 WE2,
// follow-up #16; O11's report) — and DEFAULT again at her next deployment.
import { num, talentBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js';
import { isElite, elementDmg, enemiesIn } from '../shared/tier6.js';
@@ -168,9 +170,11 @@ export default {
skill.timeLeft = Math.max(0.01, num(b3.enhance_duration, skill.duration));
setGrid(battle, unit, RANGE_3_2);
},
onEnd({ battle, unit }) {
onEnd({ battle, unit, skill }) {
if (unit.mem.vvanaS3Later) setGrid(battle, unit, unit.def?.rangeGrid);
unit.mem.vvanaS3Later = false;
// its later casts run on the 3-2: from now the owner's ACTIVE_RANGE rule there (see the header)
if ((unit.mem.vvanaS3Casts ?? 0) >= 1) skill.setTrigger('ACTIVE_RANGE', RANGE_3_2);
},
},
},
@@ -258,6 +262,7 @@ export default {
unit.mem.vvanaS3Casts = 0;
unit.mem.vvanaS3Later = false;
setGrid(battle, unit, own);
if (unit.skill?.id === S3) unit.skill.setTrigger('DEFAULT', null); // the first cast of a deployment: the data's rule
}, { owner: unit });
}
// AFT-D: 元素伤害 at the output of every damage of hers on an enemy in its 灼燃 爆发冷却 (not on this rider's own)
+3 -2
View File
@@ -5,8 +5,9 @@
// Sources: the client's battle data (charpack char_248_mgllan / char_2023_ling / char_4195_radian: Talents/1
// charge_token[born] — RechargeToken `cnt` at every deployment of the owner; CommonAbilities die_to_kill_token — KillTokens
// when the owner leaves; the SUM-Y equips mgllan_equip_2_* / ling_equip_1_*: trigger_charge_token — RechargeToken the
// trait's `cnt` once, on the undeployed card (`_attachPassiveBuffsOnDummy`); the token decks: max_deck_stack_cnt /
// max_deploy_count of the tokens' hidden talent, deckStack / deployLimit of the token records); PRTS 分支特性信息 召唤师
// trait's `cnt` once, on the undeployed card (`_attachPassiveBuffsOnDummy`); the token decks: deckStack / deployLimit of
// the token records, which carry the tokens' hidden talent max_deck_stack_cnt / max_deploy_count — tools/build-data.mjs
// tokenTalentDeckBonus); PRTS 分支特性信息 召唤师
// ("干员离场后,附属的召唤物随之消失"); PRTS 卫戍协议/帮助 §作战阶段 (placed summons: "所有手动部署的召唤物,无视所属干员的持有
// 状态,不消耗持有数量,作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个").
//
-8
View File
@@ -1,8 +0,0 @@
// server/sim/content/kits/tier1.js — re-export shim (0.2.0 refactor, R4): the tier-1 kits live one per file in ops/,
// registered by index.js; their shared helpers and notes are in shared/tier1.js (README.md). This module keeps the
// old imports working until the shims are removed after the 0.2.0 refactor wave.
import { TIER_KITS } from './index.js';
export * from './shared/tier1.js';
export { tinmanKit } from './ops/chess_char_1_16-tinman.js';
export default TIER_KITS[0];
-6
View File
@@ -1,6 +0,0 @@
// server/sim/content/kits/tier2.js — re-export shim (0.2.0 refactor, R4): the tier-2 kits live one per file in ops/,
// registered by index.js; their shared helpers and notes are in shared/tier2.js (README.md). This module keeps the
// old imports working until the shims are removed after the 0.2.0 refactor wave.
import { TIER_KITS } from './index.js';
export default TIER_KITS[1];
-6
View File
@@ -1,6 +0,0 @@
// server/sim/content/kits/tier3.js — re-export shim (0.2.0 refactor, R4): the tier-3 kits live one per file in ops/,
// registered by index.js; their shared helpers and notes are in shared/tier3.js (README.md). This module keeps the
// old imports working until the shims are removed after the 0.2.0 refactor wave.
import { TIER_KITS } from './index.js';
export default TIER_KITS[2];
-6
View File
@@ -1,6 +0,0 @@
// server/sim/content/kits/tier4.js — re-export shim (0.2.0 refactor, R4): the tier-4 kits live one per file in ops/,
// registered by index.js; their shared helpers and notes are in shared/tier4.js (README.md). This module keeps the
// old imports working until the shims are removed after the 0.2.0 refactor wave.
import { TIER_KITS } from './index.js';
export default Object.freeze({ ...TIER_KITS[3] });
-6
View File
@@ -1,6 +0,0 @@
// server/sim/content/kits/tier5.js — re-export shim (0.2.0 refactor, R4): the tier-5 kits live one per file in ops/,
// registered by index.js; their shared helpers and notes are in shared/tier5.js (README.md). This module keeps the
// old imports working until the shims are removed after the 0.2.0 refactor wave.
import { TIER_KITS } from './index.js';
export default TIER_KITS[4];
-7
View File
@@ -1,7 +0,0 @@
// server/sim/content/kits/tier6.js — re-export shim (0.2.0 refactor, R4): the tier-6 kits live one per file in ops/,
// registered by index.js; their shared helpers and notes are in shared/tier6.js (README.md). This module keeps the
// old imports working until the shims are removed after the 0.2.0 refactor wave.
import { TIER_KITS } from './index.js';
export { PITHST_ELEMENTS } from './ops/chess_char_1_15-pithst.js';
export default TIER_KITS[5];
+2 -1
View File
@@ -136,7 +136,8 @@ export function normalizeSkill(rec) {
initSp: Math.max(0, num(s.initSp, 0)),
maxCharges: Math.max(1, num(s.maxChargeTime ?? s.maxCharges ?? s.charges, 1)),
rangeGrid: toArrayOfPairs(s.rangeGrid),
trigger: { rule, grid: trigGrid },
// `allies` (data: an ally row, tools/build-data.mjs TRIGGER_ALLY_RULES — 黍 S3) — skills.js `trigger.allies`
trigger: typeof trig === 'object' && trig.allies === true ? { rule, grid: trigGrid, allies: true } : { rule, grid: trigGrid },
kind: s.kind ?? null,
bb,
description: s.description ?? s.desc ?? '',
+13
View File
@@ -120,6 +120,19 @@ export class SkillRuntime {
this.noSkill = !spec; // unit without any skill spec
}
/**
* Change the trigger rule and grid mid-battle — a kit whose skill's running range changes with its own use (薇薇安娜 S3:
* "首次技能结束后,本技能的技能范围永久扩大至3-2" — ACTIVE_RANGE on 3-2 from then, DEFAULT again at her next deployment).
* `grid`: a facing-RIGHT [dRow, dCol] grid (ACTIVE_RANGE / SKILL_RANGE / CUSTOM_RANGE), or null. (0.2.0 WE2, additive.)
* @param {string} rule @param {number[][]|null} [grid]
*/
setTrigger(rule, grid = null) {
this.rule = String(rule ?? 'DEFAULT').toUpperCase();
this.triggerGrid = Array.isArray(grid) && grid.length ? grid : null;
this._trigKeys = null;
this._trigSet = null;
}
/**
* Extra DEFAULT-trigger range (海嗣 "攻击范围视为自身攻击范围的延伸", 流形): `fn(battle, unit)` returns a list whose
* entries are ally units (their current `rangeKeys` count while they are on the field) or arrays of absolute tile
+14 -10
View File
@@ -12,10 +12,11 @@
// - no 特质 (PRTS "甄选加入的干员不会拥有任何特质"); bonds from the operator's factions (`diy.operators[id].bonds`);
// - the normal form is E2 Lv1, skill rank 4, no module; the elite E2 Lv60, rank 7, its module at stage 1 (tier 5) or 3
// (tier 6) — the slot record's `status` (activity_table diyChessDict) picks the unit form;
// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none — a roster never carries a
// 集成战略-only one: ISW-A, "在集成战略中…" [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05],
// validateDiyPicks / isDiyModule; the composed record and the sim still field any module, as the kits test them); a
// prototype carries the skill
// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none — a roster never carries a module
// whose own effect works in another game mode only: ISW-A "在集成战略中…", SO-A / SO-B "在【岁的界园志异】中…" /
// "在【沉沦者的黑流树海】中…", RA-A "在生息演算中…" [ASSUMED: not usable outside that mode, the owner's decision of
// 2026-10-05 for ISW-A, the same reason for SO / RA], validateDiyPicks / isDiyModule; the composed record and the sim
// still field any module, as the kits test them); a prototype carries the skill
// and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the
// owner's decision of 2026-10-05);
// - potential 0 for everyone [ASSUMED: no account].
@@ -28,11 +29,14 @@ import { composeUnitRecord, unitForm, statusKey } from './standIn.js';
export const DIY_TIERS = Object.freeze([5, 6]);
/**
* Module types a player's 自选 roster never carries: the 集成战略 modules (ISW-A — their own effect applies only "在集成
* 战略中") [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05]. A roster rule (validateDiyPicks,
* the client's picker), like the kit list: checkDiyPick / diyRecordOf still compose any module of the form.
* Module types a player's 自选 roster never carries: the modules whose own effect works in one other game mode only — the
* text "在…中" and battle_equip_table's gate on those parts (`validInGameTag`): the 集成战略 modules ISW-A ("在集成战略中")
* and the 集成战略 themes' 特勤证章 SO-A / SO-B (电弧 "在【岁的界园志异】中", 机械师 "在【沉沦者的黑流树海】中"; roguelike), the
* 生息演算 modules RA-A (森蚺 "在生息演算中"; sandbox) [ASSUMED: not usable outside that mode — the owner's decision of
* 2026-10-05 for ISW-A, the same reason for SO / RA (follow-ups #10, #25)]. A roster rule (validateDiyPicks, the client's
* picker), like the kit list: checkDiyPick / diyRecordOf still compose any module of the form.
*/
export const DIY_EXCLUDED_MODULE_TYPE = /^ISW-/;
export const DIY_EXCLUDED_MODULE_TYPE = /^(?:ISW|SO|RA)-/;
/**
* Whether a module of a unit form (`forms[k].modules[]` entry) may be picked for a 自选 piece (DIY_EXCLUDED_MODULE_TYPE).
@@ -235,7 +239,7 @@ export function diyModuleOf(slotId, charId, uniEquipId, data) {
/**
* Check a whole 自选 roster: `picks` maps slot base ids to a pick (absent / null = an empty slot). Strict: an unknown
* slot, an illegal pick (checkDiyPick), a 集成战略 module (isDiyModule), an operator without a kit (when `kitted` is
* slot, an illegal pick (checkDiyPick), a module of another game mode (isDiyModule: 集成战略 / 生息演算), an operator without a kit (when `kitted` is
* given), the same operator twice in one tier, or an owned operator in two slots rejects the roster; a prototype may fill
* a tier-5 and a tier-6 slot.
* @param {any} picks
@@ -259,7 +263,7 @@ export function validateDiyPicks(picks, { data, kitted = null } = { data: null }
const { charId } = c.pick;
if (!ok(charId)) return { error: 'BAD_TARGET', detail: `${charId} has no kit yet` };
const mod = c.pick.uniEquipId ? diyModuleOf(slotId, charId, c.pick.uniEquipId, data) : null;
if (mod && !isDiyModule(mod)) return { error: 'BAD_TARGET', detail: `${charId}: ${c.pick.uniEquipId} is a 集成战略 module (${mod.typeName})` };
if (mod && !isDiyModule(mod)) return { error: 'BAD_TARGET', detail: `${charId}: ${c.pick.uniEquipId} is a module of another game mode (${mod.typeName})` };
const tier = diy.slots[slotId].tier;
const inTier = byTier.get(tier) ?? new Set();
if (inTier.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills two tier-${tier} slots` };
+2 -19
View File
@@ -1,5 +1,6 @@
// shared/standIn.js — a chess fielded with another character's body: the 补位 stand-in (原型干员) of a NORMAL chess
// whose operator the player does not own, and a 自选 (DIY) slot filled with a prototype. Pure ESM shared by the build
// whose operator the player does not own, and the composition a 自选 (DIY) slot uses (shared/diy.js diyRecordOf calls
// composeUnitRecord with the pick). Pure ESM shared by the build
// (tools/build-data.mjs proves composeUnitRecord rebuilds every PRESET chess from its own unit form), the simulation and
// the client. Data: data/backups.json (docs/DATA.md §18).
//
@@ -130,21 +131,3 @@ export function standInRecord(chess, backups) {
return composeUnitRecord(chess, unit, unitForm(backups, b.charId, chess.status),
{ skillIndex: b.skillIndex, moduleId: b.uniEquipId ?? null, standInFor: chess.charId });
}
/**
* A 自选 slot (DIY chess record, normal or elite) filled with a prototype `charId` (backups.diy.prototypes[tier]):
* the slot's identity (tier, price, no 特质) with the pick's derived bonds (backups.diy.operators[charId].bonds). The
* skill and module are the caller's — which skill a prototype carries in a slot is not in the data (DESIGN 0.2.0
* draft, open question). Null when the pick is not a legal prototype of the slot's tier or the data lacks it.
* @param {object} slot a DIY chess record (data/chess.json, `isDiy`)
* @param {string} charId the picked prototype
* @param {object} backups data/backups.json
* @param {{ skillIndex?: number, moduleId?: string|null }} [opts]
* @returns {object|null}
*/
export function diyRecord(slot, charId, backups, { skillIndex, moduleId = null } = {}) {
if (!slot?.isDiy || !(backups?.diy?.prototypes?.[slot.tier] || []).includes(charId)) return null;
const unit = backups.units?.[charId] ?? null;
return composeUnitRecord(slot, unit, unitForm(backups, charId, slot.status),
{ skillIndex, moduleId, bonds: backups.diy.operators?.[charId]?.bonds ?? null });
}
+14 -13
View File
@@ -12,7 +12,8 @@ import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { standInRecord, diyRecord, composeUnitRecord, unitForm, statusKey, IDENTITY_FIELDS } from '../shared/standIn.js';
import { standInRecord, composeUnitRecord, unitForm, statusKey, IDENTITY_FIELDS } from '../shared/standIn.js';
import { diyRecordOf, lockedSelection } from '../shared/diy.js';
import { composeStats, composeTalents, resolveLoadout, loadoutRecord, normalizeChess } from '../server/sim/simdata.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -212,26 +213,26 @@ test('DIY: prototype picks — the 9 elites at tiers 5 and 6, six 4★ reserves
assert.deepEqual(prototypes['6'], ELITES);
assert.deepEqual(prototypes['5'], [...RESERVES.filter((id) => !['char_600_cpione', 'char_607_cspec'].includes(id)), ...ELITES]);
assert.equal(prototypes['5'].length, 15);
// every pick composes in every slot form, with any of its skills (which skill a prototype carries in a slot is an open
// question — the data supports any answer), with the derived bonds (协防干员: no prototype has a faction) and no 特质
// every pick composes in every slot form — at its locked selection (diy.locked: "技能携带规则与系统补位时一致"; shared/diy.js
// diyRecordOf, which replaced standIn.js's prototype-only diyRecord in 0.2.0) — with the derived bonds (协防干员: no
// prototype has a faction) and no 特质
const data = { chess, backups };
for (const [id, s] of Object.entries(backups.diy.slots)) {
for (const slot of [chess[id], chess[s.goldenId]]) {
for (const p of prototypes[s.tier]) {
const form = unitForm(backups, p, slot.status);
assert.ok(form, `${slot.chessId} ${p}: form`);
for (const sk of form.skills) {
const mod = slot.status.equipLevel > 0 ? form.modules[0]?.uniEquipId ?? null : null;
const r = diyRecord(slot, p, backups, { skillIndex: sk.index, moduleId: mod });
assert.ok(r && r.skill.index === sk.index && r.charId === p, `${slot.chessId} ${p} S${sk.index + 1}`);
assert.deepEqual([r.bonds, r.garrisonIds, r.price, r.tier], [['emptyShip'], [], 4, s.tier]);
if (mod) assert.equal(r.module.active, true, `${slot.chessId} ${p}: module`);
}
const lk = lockedSelection(data, s.tier, p);
const r = diyRecordOf(slot, { charId: p }, data);
assert.ok(r && r.skill.index === lk.skillIndex && r.charId === p, `${slot.chessId} ${p}`);
assert.deepEqual([r.bonds, r.garrisonIds, r.price, r.tier], [['emptyShip'], [], 4, s.tier]);
if (slot.status.equipLevel > 0 && lk.uniEquipId) assert.equal(r.module.active, true, `${slot.chessId} ${p}: module`);
}
}
}
assert.equal(diyRecord(chess.chess_char_6_diy1_a, 'char_601_cguard', backups, { skillIndex: 2 }), null, 'a reserve is not a tier-6 pick');
assert.equal(diyRecord(chess.chess_char_5_diy1_a, 'char_600_cpione', backups, { skillIndex: 2 }), null, '预备干员-先锋 is not a pick');
assert.equal(diyRecord(chess.chess_char_5_01_a, 'char_608_acpion', backups, { skillIndex: 2 }), null, 'not a DIY slot');
assert.equal(diyRecordOf(chess.chess_char_6_diy1_a, { charId: 'char_601_cguard' }, data), null, 'a reserve is not a tier-6 pick');
assert.equal(diyRecordOf(chess.chess_char_5_diy1_a, { charId: 'char_600_cpione' }, data), null, '预备干员-先锋 is not a pick');
assert.equal(diyRecordOf(chess.chess_char_5_01_a, { charId: 'char_608_acpion' }, data), null, 'not a DIY slot');
});
test('DIY: the 71 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
@@ -0,0 +1,87 @@
// test/content/feedback5-we2-area-triggers.test.js — 0.2.0 WE2 #16: kit-side larger-range triggers where no data range can
// carry the owner's ACTIVE_RANGE rule (2026-10-05: a MANUAL skill whose running range strictly contains the operator's own
// casts as soon as an enemy is inside that larger range):
// * 谬因 S2 临界瞬爆 — the beam's unbounded path (O1's report): content trigger range = the path she would fire along now;
// * 薇薇安娜 S3 “明灭” — from its second cast of a deployment on 3-2 (O11's report): ACTIVE_RANGE after the first cast ends
// (skills.js setTrigger), DEFAULT again at her next deployment.
// Run: node --test test/content/feedback5-we2-area-triggers.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) };
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** `charId` as uid 1 at (row, col) facing RIGHT with skill `skill`. */
function field(charId, { skill, row, col, tier = 5, seed = 5 }) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpInit: 99, dpPerSec: 0, dpMax: 999, startOpCooldown: 0 }, hooks: ['skillStart', 'skillEnd'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId, skillIndex: skill, uniEquipId: null }, elite: false, row, col }],
});
h.step();
return { h, u: h.unit(1) };
}
test('#16 谬因 S2: an enemy on the beam\'s line beyond her own 6 tiles casts it (data DEFAULT alone would wait); one off the line does not', () => {
// her own range (9, 2) … (9, 7); the beam reaches the end of the row
{
const { h, u } = field('char_4229_aphris', { skill: 1, row: 9, col: 2 });
assert.equal(u.skill.rule, 'DEFAULT');
assert.equal(u.skill.triggerRanges.length, 1, 'the beam path is a content trigger range');
h.spawn('enemy_dummy', { pos: [10, 12] }); // off the line
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, 'off the line: no cast');
h.spawn('enemy_dummy', { pos: [9, 13] }); // on the line, far beyond (9, 7)
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'on the beam\'s line: cast');
done(h);
}
// a flyer on the line too (the beam hits air units)
{
const { h, u } = field('char_4229_aphris', { skill: 1, row: 9, col: 2 });
h.spawn('enemy_fly', { pos: [9, 14] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
done(h);
}
// S1 / S3 get no such range
for (const skill of [0, 2]) {
const { h, u } = field('char_4229_aphris', { skill, row: 9, col: 2 });
assert.equal(u.skill.triggerRanges.length, 0, `S${skill + 1}`);
done(h);
}
});
test('#16 薇薇安娜 S3: the first cast of a deployment by the data\'s DEFAULT; once it ended, an enemy on the 3-2 beyond her own reach casts the next one (ACTIVE_RANGE); a redeploy goes back to DEFAULT', () => {
const { h, u } = field('char_4098_vvana', { skill: 2, row: 10, col: 3 });
assert.equal(u.skill.rule, 'DEFAULT');
// (10, 6): on her 3-2 (cols 3–6), outside her own 1-1 (cols 3–4)
const far = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, 'first cast: DEFAULT on her own range — the far enemy does not cast it');
// an enemy in her own range: the first cast; then let it run out
const near = h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'first cast');
assert.ok(h.runUntil(() => !u.skill.active, 30), 'it ends');
assert.deepEqual([u.skill.rule, u.skill.triggerGrid], ['ACTIVE_RANGE', [[0, 0], [0, 1], [0, 2], [0, 3]]], 'from now on the 3-2');
h.b.kill(near, null);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 2, 4), 'the far enemy alone casts the second one');
void far;
// a redeploy: the count and the rule start again
assert.ok(h.runUntil(() => !u.skill.active, 40));
h.b.retreat(u, { reason: 'retreat' });
h.step();
assert.ok(h.b.redeploy(u, { free: true }));
h.step();
assert.deepEqual([u.skill.rule, u.skill.triggerGrid], ['DEFAULT', null], 'the next deployment: the data\'s rule');
done(h);
});
@@ -0,0 +1,40 @@
// test/content/feedback5-we2-garrison90.test.js — 0.2.0 WE2 #30: 塑心's 特质 garrison_90 (bond_actived_maxstack) "<战斗中>
// 开启技能时,同一行每有1名干员,当前已激活且层数最多的盟约层数+1(每场作战至多10层)" (elite +2, at most 20): the per-battle cap is
// the trait's own, whichever bond is the highest at each gain — a change of the highest bond grants no fresh cap
// (server/sim/content/garrisons/battle.js fireGain; PR #178 review).
// Run: node --test test/content/feedback5-we2-garrison90.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const B = (layers = 0) => ({ count: 3, active: true, tier: 1, layers });
/** 塑心's trait on 'op' with one row mate (2 in the row: +2 per skill, elite +4). */
function field(gid, bonds) {
const chess = { op: { ...chessRec({ id: 'op' }), garrisonIds: [gid] }, m1: { ...chessRec({ id: 'm1' }), garrisonIds: [] } };
const h = makeBattle({
seed: 3, bonds, defs: { chess, enemies: {} }, autoFinish: false, timeLimit: 400,
units: [{ chessId: 'op', row: 10, col: 4 }, { chessId: 'm1', row: 10, col: 6 }],
});
h.step(1);
return h;
}
const skill = (h) => h.b.emit('skillStart', { unit: h.unit('op'), skill: h.unit('op').skill, reason: 'test' });
const gains = (h) => ({ ...h.result().perPlayer.p1.layerGains });
test('#30 塑心 garrison_90: at most 10 (elite 20) layers per battle in all, even when the highest bond changes', () => {
for (const [gid, per, cap] of [['garrison_90_a', 2, 10], ['garrison_90_b', 4, 20]]) {
const h = field(gid, { yanShip: B(20), steadShip: B(15) });
const n1 = Math.floor(cap / per / 2); // half the cap on 炎 (the highest)
for (let i = 0; i < n1; i++) skill(h);
assert.deepEqual(gains(h), { yanShip: n1 * per }, `${gid}: 炎 is the highest`);
// 坚守 becomes the highest (an outside gain of 30), then many more casts
assert.equal(h.b.addLayers('p1', 'steadShip', 30, 'test'), 30);
for (let i = 0; i < 20; i++) skill(h);
const g = gains(h);
assert.equal(g.yanShip, n1 * per, `${gid}: 炎 kept its gain`);
assert.equal(g.steadShip - 30, cap - n1 * per, `${gid}: 坚守 got only what was left of the one cap`);
checkInvariants(h.b);
}
});
@@ -0,0 +1,48 @@
// test/content/feedback5-we2-raid-148.test.js — 0.2.0 WE2 #3 (GitHub #148 「突袭近战干员部署在高台」, a battle screenshot on the
// 深水区 map): reproduced on every battle stage — a melee 突袭 member's jump (content/bonds/addon/battle.js raidTile:
// grid.canStand for its position) lands on low ground only, never on a 高台; on 战场#08 (act2 m04) it may land on a 围墙 tile
// (tile_fence_bound: LOW, buildable ALL — deployable, it attacks from there and blocks no ground enemy, DESIGN §21.23), which
// the board draws as a raised concrete area inside an orange railing: what the screenshot most likely shows.
// Run: node --test test/content/feedback5-we2-raid-148.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { getData } from '../../server/data.js';
const D = getData({ log: { warn() {}, error() {}, info() {} } });
const STAGES = Object.keys(D.stages).filter((id) => D.stages[id].kind !== 'unite');
const defs = {
chess: { m: chessRec({ id: 'm', bonds: ['raidShip'], position: 'MELEE', range: [[0, 0], [0, 1]] }) },
enemies: { e: enemyRec({ key: 'e', hp: 1e9, speed: 0, mass: 0 }) },
};
const RAID = { raidShip: { count: 3, active: true, tier: 1, layers: 1 } };
test('#3 / GitHub #148: a melee 突袭 member never lands on a 高台 (any stage, any enemy tile of the field); 战场#08 lands it on 围墙 tiles', () => {
let jumps = 0, fence = 0;
for (const stageId of STAGES) {
const st = D.stages[stageId];
const tileAt = (r, c) => st.tiles[st.rows[r]?.[c]];
// the member's start: the first deployable melee tile of the field
let home = null;
for (let r = 12; r >= 9 && !home; r--) for (let c = 2; c <= 10 && !home; c++) {
const t = tileAt(r, c);
if (t && t.height === 'LOW' && (t.buildable === 'ALL' || t.buildable === 'MELEE') && t.tileKey === 'tile_road') home = [r, c];
}
if (!home) continue;
for (let er = 9; er <= 12; er++) for (let ec = 2; ec <= 10; ec++) {
const t = tileAt(er, ec);
if (!t || t.height !== 'LOW' || (er === home[0] && ec === home[1])) continue;
const h = makeBattle({ stageId, defs, bonds: RAID, enemies: [{ key: 'e', pos: [er, ec] }], units: [{ chessId: 'm', row: home[0], col: home[1] }], autoFinish: false, timeLimit: 60 });
const u = h.unit('m');
h.run(12);
if (u.tileR === home[0] && u.tileC === home[1]) continue;
jumps++;
const lt = tileAt(u.tileR, u.tileC);
assert.ok(lt && lt.height === 'LOW' && (lt.buildable === 'ALL' || lt.buildable === 'MELEE'), `${stageId}: enemy ${er},${ec} → ${u.tileR},${u.tileC} ${lt?.tileKey}/${lt?.height}`);
if (lt.tileKey === 'tile_fence_bound') fence++;
}
}
assert.ok(jumps > 50, `${jumps} jumps checked`);
assert.ok(fence > 0, 'some land on a 围墙 tile (the #148 look)');
});
@@ -0,0 +1,121 @@
// test/content/feedback5-we2-summon-hand.test.js — 0.2.0 WE2 #21: the hand count of every 自选 summoner's placeable summon
// is its deploy limit (PRTS 卫戍协议/帮助 §战斗部署 "根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区"), and the
// data's deploy limit / holding carry the tokens' own talent additions (tools/build-data.mjs tokenTalentDeckBonus: the
// hidden "TOKEN数" talent max_deploy_count / max_deck_stack_cnt on top of the attribute frame). Official counts: the owners'
// talents (麦哲伦 / 令 / 电弧 "最多同时部署3个", SUM-Y stage 2+ "最多同时部署4个", 白铁 / 娜斯提 "最多可部署2个", 望 "可以使用6枚
// 棋子", TRP-X +1), PRTS 幻影 备注 "最大可部署数量为3" (夜莺).
// #24: a summon no owner shows (displayTokenDict: the variants' `display` source) is its skill's own object, never a hand
// card — 予愿安洁莉娜 S3's “一会儿见!” (PRTS 予愿安洁莉娜 S3 备注: the skill deploys it at her initial tile).
// Run: node --test test/content/feedback5-we2-summon-hand.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getData } from '../../server/data.js';
import { GameData } from '../../server/match/gamedata.js';
import { diyGameData } from '../../server/match/player/diy.js';
import { diyRecord, diySlot } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const DATA = { chess: CHESS, backups: BACKUPS };
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const STAGE = { 5: 1, 6: 3 };
const statusOf = (tier, elite) => (elite ? `2/60/7/${STAGE[tier]}` : '2/1/4/0');
/** The official hand count of `charId`'s summon under a module at its stage (null module = none). */
function official(charId, mod, stage) {
switch (charId) {
case 'char_248_mgllan': return mod === 'uniequip_003_mgllan' && stage >= 2 ? 4 : 3; // SUM-Y stage 2+: 最多同时部署4个
case 'char_2023_ling': return mod === 'uniequip_002_ling' && stage >= 2 ? 4 : 3; // SUM-Y stage 2+
case 'char_4195_radian': return 3;
case 'char_4072_ironmn': return 2;
case 'char_4212_nasti': return 2;
case 'char_179_cgbird': return 3; // PRTS 幻影 备注
case 'char_2027_wang': return mod === 'uniequip_002_wang' ? 7 : 6; // TRP-X +1
case 'char_4048_doroth': return 9; // 10 / 13, the hand's 9 cap
default: return 1;
}
}
const gd = new GameData(getData({ log: { warn() {}, error() {}, info() {} } }), 'mode_multi_hard');
test('every 自选 summoner: the hand count of each placeable summon in every form, skill and module is the official one', () => {
const owners = new Map(); // charId → placeable token ids it makes
for (const [tid, t] of Object.entries(BACKUPS.tokens)) {
if (t.kind !== 'summon' || t.placeable !== true) continue;
for (const key of Object.keys(t.variants)) {
const cid = key.split('@')[0];
if (!owners.has(cid)) owners.set(cid, new Set());
owners.get(cid).add(tid);
}
}
assert.ok(['char_248_mgllan', 'char_2023_ling', 'char_4195_radian', 'char_4072_ironmn', 'char_179_cgbird', 'char_2027_wang'].every((c) => owners.has(c)));
let checked = 0;
for (const [charId] of owners) {
if (!BACKUPS.diy.ownedPool.includes(charId)) continue;
for (const tier of [5, 6]) {
for (const elite of [false, true]) {
const form = BACKUPS.units[charId].forms[statusOf(tier, elite)];
for (const mod of elite ? [null, ...(form.modules ?? []).map((m) => m.uniEquipId)] : [null]) {
for (const s of form.skills) {
const pick = { charId, skillIndex: s.index, uniEquipId: mod };
const slot = diySlot(SLOT[tier], DATA);
const id = elite ? slot.goldenId : slot.baseId;
const rec = diyRecord(SLOT[tier], pick, { elite, data: DATA });
assert.ok(rec, `${charId} T${tier} ${elite ? 'elite' : 'normal'} ${mod} S${s.index + 1}`);
const view = diyGameData(gd, new Map([[id, rec]]));
for (const { tokenId, count } of view.placeableTokens(id, { skillIndex: s.index, moduleId: mod })) {
assert.equal(count, official(charId, mod, elite ? STAGE[tier] : 0), `${charId} T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'} S${s.index + 1}: ${tokenId}`);
checked++;
}
}
}
}
}
}
assert.ok(checked > 200, `${checked} hand counts checked`);
});
test('the data: deploy limit / holding = the attribute frame + the token talents\' max_deploy_count / max_deck_stack_cnt (a module token part of the same talent replaces it)', () => {
const v = (tid, owner) => BACKUPS.tokens[tid].variants[owner];
// 麦哲伦: 1 + 2 standing, 0 + 5 held; SUM-Y +3 held, stage 3 its token part max_deploy_count 3 ⇒ 4
const d1 = 'token_10005_mgllan_drone1';
assert.deepEqual([v(d1, 'char_248_mgllan@2/1/4/0').stats.deployLimit, v(d1, 'char_248_mgllan@2/1/4/0').stats.deckStack], [3, 5]);
assert.deepEqual([v(d1, 'char_248_mgllan@2/60/7/1').byModule.uniequip_003_mgllan.stats.deployLimit, v(d1, 'char_248_mgllan@2/60/7/1').byModule.uniequip_003_mgllan.stats.deckStack], [3, 8]);
assert.deepEqual([v(d1, 'char_248_mgllan@2/60/7/3').byModule.uniequip_003_mgllan.stats.deployLimit, v(d1, 'char_248_mgllan@2/60/7/3').byModule.uniequip_003_mgllan.stats.deckStack], [4, 8]);
// 夜莺's 幻影: its visible talent's max_deploy_count 2 (RIN-Y stage 3 restates the same talent: not counted twice)
const ph = 'token_10003_cgbird_bird';
assert.equal(v(ph, 'char_179_cgbird@2/1/4/0').stats.deployLimit, 3);
assert.equal(v(ph, 'char_179_cgbird@2/60/7/3').byModule.uniequip_003_cgbird.stats.deployLimit, 3);
// 望's 棋子: the rank-2 talent (+1) is a potential talent — potential 0 keeps 6
assert.equal(v('token_10064_wang_stone1', 'char_2027_wang@2/1/4/0').stats.deployLimit, 6);
// the chess summons of tokens.json have no such talent: 凯瑟琳's 爬行号 2 of 3, everyone else 1
const tokens = load('tokens');
assert.equal(tokens.token_10041_cathy_catsld.variants.chess_char_4_11_a.stats.deployLimit, 2);
assert.equal(tokens.token_10000_silent_healrb.variants.chess_char_2_02_a.stats.deployLimit, 1);
});
test('#24 a summon no owner shows is never a hand card: 予愿安洁莉娜 S3\'s “一会儿见!” is not placeable, every placeable summon is displayed', () => {
const MARKER = 'token_10071_aglna2_agairp';
const AGLNA2 = 'char_1015_aglna2';
assert.equal(BACKUPS.tokens[MARKER].placeable, false);
const shows = (v) => (v.sources ?? []).includes('display') || Object.values(v.bySkill ?? {}).some((a) => (a.sources ?? []).includes('display'));
for (const t of [...Object.values(load('tokens')), ...Object.values(BACKUPS.tokens)]) {
if (t.kind !== 'summon') continue;
if (t.placeable) assert.ok(Object.values(t.variants).some(shows), `${t.tokenId} ${t.name}: placeable and displayed`);
else if (!Object.values(t.variants).some(shows)) assert.ok(t.displayType === 'HIDDEN' || t.tokenId === MARKER, `${t.tokenId} ${t.name}: undisplayed ⇒ HIDDEN (or the marker)`);
}
for (const tier of [5, 6]) {
for (const elite of [false, true]) {
const slot = diySlot(SLOT[tier], DATA);
const id = elite ? slot.goldenId : slot.baseId;
for (const skillIndex of [0, 1, 2]) {
const rec = diyRecord(SLOT[tier], { charId: AGLNA2, skillIndex, uniEquipId: null }, { elite, data: DATA });
const view = diyGameData(gd, new Map([[id, rec]]));
assert.deepEqual(view.placeableTokens(id, { skillIndex, moduleId: null }), [], `T${tier} ${elite ? 'elite' : 'normal'} S${skillIndex + 1}: no hand piece`);
}
}
}
});
+3 -1
View File
@@ -9,7 +9,9 @@ import { skillSpecSource } from '../../server/sim/content/index.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import { kitCoverage } from '../../tools/kit-coverage.mjs';
import { wolfShadows, wolfTacticalPoint } from '../../server/sim/content/tokens.js';
import KITS from '../../server/sim/content/kits/tier3.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
const KITS = TIER_KITS[2];
const ds = getDefaultSource();
/** Skill index of `skillId` on chess `id`. */
+3 -1
View File
@@ -5,9 +5,11 @@ import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import KITS from '../../server/sim/content/kits/tier3.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
import { COLS } from '../../server/sim/constants.js';
const KITS = TIER_KITS[2];
const ds = getDefaultSource();
const D = (id) => ds.getChess(id);
const BB = (id) => D(id).skill.bb;
+3 -1
View File
@@ -4,9 +4,11 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import kits from '../../server/sim/content/kits/tier4.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
const kits = TIER_KITS[3];
const ds = getDefaultSource();
const D = (id) => ds.getChess(id);
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
+3 -1
View File
@@ -3,7 +3,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import KITS from '../../server/sim/content/kits/tier5.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
const KITS = TIER_KITS[4];
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≉ ${b}`);
const dummy = (key = 'enemy_dummy', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
+2 -16
View File
@@ -39,12 +39,11 @@ const ANY_ATK = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c
const extras = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('aglna2:extra'));
/** 予愿安洁莉娜 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, skill = 0, row = 10, col = 4, others = [], seed = 5, marker = null } = {}) {
const piece = marker ? [{ uid: 9, kind: 'token', tokenId: MARKER, ownerUid: 1, row: marker[0], col: marker[1] }] : [];
function field({ tier = 5, elite = false, skill = 0, row = 10, col = 4, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'deploy', 'dodge'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: AGL, skillIndex: skill }, elite, row, col }, ...piece, ...others],
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: AGL, skillIndex: skill }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
@@ -349,19 +348,6 @@ test('S3 move: blocking nothing, she flies (2 tiles/s, 无敌, disarmed) to the
assert.equal(u.extraRangeKeys, null);
done(h);
}
// a placed “一会儿见!” piece never deploys by itself; it is the marker
{
const { h, u } = field({ tier: 6, elite: true, skill: 2, marker: [12, 9] });
const piece = h.b.allyUnits.find((t) => t.kind === 'token' && t.uid === 9);
assert.ok(piece && !piece.alive && piece.deferDeploy, 'not deployed at the start');
u.skill.gainSp(999);
h.spawn('enemy_fly', { pos: [11, 7] });
assert.ok(h.runUntil(() => u.tileR === 11 && u.tileC === 7, 5));
assert.ok(piece.alive && piece.tileR === 10 && piece.tileC === 4, 'the piece marks her deployment tile');
u.skill.end('test');
assert.ok(!piece.alive && !piece.removed, 'withdrawn, kept as a piece');
done(h);
}
// blocking something, she stays
{
const { h, u } = field({ tier: 5, skill: 2 });
+27 -11
View File
@@ -76,7 +76,7 @@ function storeUp(h, u, n, e, max = 60) {
return log;
}
test('黑键 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 3-14, ranged arts every 3 s that hits air units, blocks 1, ground-targetable, the data\'s DEFAULT triggers, 空 bond, no 特质', () => {
test('黑键 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 3-14, ranged arts every 3 s that hits air units, blocks 1, ground-targetable, the data\'s DEFAULT triggers (S2: the kit casts it), 空 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[EBN], KITS[EBN]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
@@ -88,7 +88,7 @@ test('黑键 in every 自选 form: his operator kit (all three skills authored),
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.res + (m?.attr.res ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 3], `${label(f)}: 秘术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`);
assert.equal(u.skill.rule, 'DEFAULT', `${label(f)}: the data's trigger`);
assert.equal(u.skill.rule, skill === 1 ? 'NEVER' : 'DEFAULT', `${label(f)}: the data's trigger (S2: the kit's own cast)`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
done(h);
@@ -292,16 +292,29 @@ test('S1 渐快急板 (MANUAL, data DEFAULT): 5 s, range 4-1, attack interval ×
}
});
test('S2 荒芜回响 (AUTO, data DEFAULT): spends every energy (elite first) on energies + 1 旧日残影 on free tiles of his range — the enemy\'s tile first, then the nearest; fewer tiles ⇒ fewer spent; no free tile ⇒ no cast (the charge back)', () => {
test('S2 荒芜回响 (AUTO: full SP and one free placeable tile in his range, no enemy needed — PRTS 备注, prefab _minTileNum 1): spends every energy (elite first) on energies + 1 旧日残影 on free tiles of his range — the enemy\'s tile first, then the nearest; fewer tiles ⇒ fewer spent; no free tile ⇒ no cast', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, MX]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.skillType, sk.bb.atk_scale, sk.bb.force, sk.spCost, sk.initSp], ['AUTO', elite ? 2 : 1.7, 1, elite ? 14 : 15, 0]);
// no enemy on the field: it casts as soon as its SP is full
{
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.equal(u.skill.rule, 'NEVER', 'the kit casts it');
let before = stored(u);
for (let i = 0; i < (sk.spCost + 1) * 30 && u.skill.activations === 0; i++) { before = stored(u); h.step(); }
assert.equal(u.skill.activations, 1, 'cast at full SP with no enemy anywhere');
approx(h.b.time, sk.spCost, `T${tier}: at ${sk.spCost} s`, 0.2);
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT && t.alive).length, before.n + before.e + 1, 'the energies stored meanwhile + 1 remnants');
assert.deepEqual(stored(u), { n: 0, e: 0 }, 'every energy spent');
done(h);
}
const { h, u } = field({ tier, elite, mod, skill: 1 });
const N = traitOf(tier, elite, mod).times;
holdSkill(u);
storeUp(h, u, N, 1);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'cast with an enemy in range');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'cast');
const rem = h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT);
assert.equal(rem.length, N + 2, `${N} + 1 energies + 1 remnants`);
assert.deepEqual([rem[0].tileR, rem[0].tileC], [10, 7], 'the enemy\'s tile first');
@@ -315,27 +328,30 @@ test('S2 荒芜回响 (AUTO, data DEFAULT): spends every energy (elite first) on
// the flat stage's edge (col 2: RANGED tiles; cols 0–1 none): facing LEFT from (10, 2) no tile of his range is free
{
const { h, u } = field({ tier: 5, skill: 1, row: 10, col: 2, dir: 'LEFT' });
holdSkill(u);
storeUp(h, u, 3, 1);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 1] });
assert.ok(h.runUntil(() => u.skill.activations >= 1, 5), 'the DEFAULT condition holds (an enemy in range)');
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token').length, 0, 'no placeable tile: no remnant');
assert.ok(u.skill.ready, 'the charge is back');
u.skill.gainSp(999);
h.run(5);
assert.equal(u.skill.activations, 0, 'no placeable tile: no cast, an enemy in range notwithstanding');
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token').length, 0, 'no remnant');
assert.ok(u.skill.ready, 'the charge waits');
done(h);
}
// fewer free tiles than energies + 1: facing LEFT from (10, 3) only (10, 2) and (11, 2) are placeable
{
const { h, u } = field({ tier: 5, skill: 1, row: 10, col: 3, dir: 'LEFT' });
holdSkill(u);
storeUp(h, u, 3, 1);
u.skill.gainSp(999);
let atEnd = null;
h.b.on('skillEnd', (c) => { if (c.unit === u) atEnd = stored(u); });
h.spawn('enemy_dummy', { pos: [10, 2] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const rem = h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT);
assert.deepEqual(rem.map((t) => [t.tileR, t.tileC]), [[10, 2], [11, 2]], 'the enemy\'s tile, then the other free one');
assert.deepEqual(atEnd, { n: 3, e: 0 }, 'one energy spent — the elite one first');
assert.ok(h.runUntil(() => energyHits(h, u).length === 3, 2), 'the attack that follows releases the three left');
assert.ok(h.runUntil(() => energyHits(h, u).length === 3, 4), 'his next attack (3 s interval) releases the three left');
done(h);
}
});
+1 -1
View File
@@ -62,7 +62,7 @@ test('斩业星熊 in every 自选 form: her operator kit (all three skills auth
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [HSG, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, 15], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 'melee', false, 'phys', 1.6], `${label(f)}: 驭法铁卫`);
assert.equal(form.dmgType, 'arts', 'the data carries the skill-on type');
assert.equal(form.dmgType, 'phys', 'the data carries the normal attack\'s type (build-data classifyAttack: "技能开启时…法术伤害")');
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
+4 -3
View File
@@ -105,12 +105,13 @@ test('白铁 in every 自选 form: his kit (all three skills authored), stats +
{ ok: true, picks: { [SLOT[6]]: { charId: IRON, skillIndex: 2, uniEquipId: Y } } });
});
test('<支援装置> hand pieces: the picked skill\'s device only (variant sources), deploy limit 1 in the data (official 1 + max_deploy_count 1 = 2: reported), the module costs / redeploy times', () => {
test('<支援装置> hand pieces: the picked skill\'s device only (variant sources), deploy limit 2 in the data (1 + the hidden max_deploy_count 1: E2 "最多可部署2个"), the module costs / redeploy times', () => {
for (const [skill, id] of TOK.entries()) {
for (const s of [0, 1, 2]) assert.equal(tokOf(id, 6, true, s, null).sources.includes('skill'), s === skill, `${id} under S${s + 1}`);
const v = tokOf(id, 6, true, skill, null);
assert.equal(v.stats.deployLimit, 1, `${id}: the data's deploy limit`);
assert.equal(v.talents.find((t) => t.bb.max_deploy_count != null).bb.max_deploy_count, 1, `${id}: the hidden max_deploy_count (E2 "最多可部署2个")`);
assert.equal(v.talents.find((t) => t.bb.max_deploy_count != null).bb.max_deploy_count, 1, `${id}: the hidden max_deploy_count`);
assert.equal(v.stats.deployLimit, 2, `${id}: the data's deploy limit (tools/build-data.mjs tokenTalentDeckBonus)`);
assert.deepEqual([v.stats.deckStack, tokOf(id, 6, true, skill, X).stats.deckStack], [3, 4], `${id}: the holding 3 (CRA-X +1)`);
}
// CRA-X 部署费用 −1 / −1 / −4, CRA-Y 再部署时间 −5 / −5 / −10 (PRTS module page)
const cost = (mod) => TOK.map((id, s) => tokOf(id, 6, true, s, mod).stats.cost);
+5 -4
View File
@@ -98,13 +98,14 @@ test('电弧\'s skills in every form: the data\'s SP (time SP, spCost, initSp, c
}
});
test('a 自选 pick: 电弧 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks (the 岁的界园志异 modules SO-A / SO-B are not ISW-*)', () => {
test('a 自选 pick: 电弧 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks — without a module: the 岁的界园志异 modules SO-A / SO-B are refused like ISW-A (0.2.0 WE2)', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(RAD));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(RAD), `tier ${t}`);
for (const uniEquipId of [SOA, SOB, null]) {
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } } });
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId: null } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId: null } } });
for (const uniEquipId of [SOA, SOB]) {
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } }, { data, kitted: KITTED_CHARS }).error, 'BAD_TARGET', uniEquipId);
}
});
+1 -1
View File
@@ -80,7 +80,7 @@ test('黍 in every 自选 form: her operator kit (all three skills authored), th
if (skill === 0) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies, u.skill.triggerHpAtMost], ['DEFAULT', 'DEFAULT', 'AUTO', true, 0.5], `${label(f)}: S1`);
if (skill === 1) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies], ['DEFAULT', 'TAKE_DAMAGE', 'MANUAL', false], `${label(f)}: S2 (the 重装 exception)`);
if (skill === 2) {
assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.skillType], ['TRY_SEARCH_ALLY_SKILL', 'TRY_SEARCH_ALLY_SKILL', 'MANUAL'], `${label(f)}: S3 data rule`);
assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.trigger.allies, sk.trigger.customRangeGrid, sk.skillType], ['SKILL_RANGE', 'TRY_SEARCH_ALLY_SKILL', true, sk.rangeGrid, 'MANUAL'], `${label(f)}: S3 data rule (build-data TRIGGER_ALLY_RULES)`);
assert.deepEqual([u.skill.rule, u.skill.triggerAllies, u.skill.triggerGrid], ['SKILL_RANGE', true, sk.rangeGrid], `${label(f)}: S3 engine rule`);
}
done(h);
+1 -1
View File
@@ -14,7 +14,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { PITHST_ELEMENTS } from '../../server/sim/content/kits/tier6.js';
import { PITHST_ELEMENTS } from '../../server/sim/content/kits/ops/chess_char_1_15-pithst.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
import { COLS } from '../../server/sim/constants.js';
+17 -5
View File
@@ -103,21 +103,33 @@ test('checkDiyPick: an owned 6★ chooses any of its 3 skills and any module of
for (const [slot, pick, re] of bad) assert.match(checkDiyPick(slot, pick, DATA).error, re, JSON.stringify(pick));
});
test('validateDiyPicks: a roster never carries a 集成战略 module (ISW-A) [ASSUMED, the owner\'s decision of 2026-10-05]; the record still composes it', () => {
test('validateDiyPicks: a roster never carries a module of another game mode — 集成战略 ISW-A / SO-A / SO-B, 生息演算 RA-A [ASSUMED, the owner\'s decision of 2026-10-05 for ISW-A]; the record still composes it', () => {
const backups = DATA.backups;
let checked = 0;
const refused = new Set();
for (const charId of backups.diy.ownedPool) {
for (const [slot, key] of [[T5, '2/60/7/1'], [T6, '2/60/7/3']]) {
for (const mod of backups.units[charId].forms[key]?.modules ?? []) {
const pick = { charId, skillIndex: 0, uniEquipId: mod.uniEquipId };
assert.ok(checkDiyPick(slot, pick, DATA).ok, `${charId} ${mod.uniEquipId}: a legal record (the sim and the kits field it)`);
const v = validateDiyPicks({ [slot]: pick }, { data: DATA });
if (/^ISW-/.test(mod.typeName)) { assert.match(v.detail || '', /集成战略 module/, `${charId} ${mod.uniEquipId}`); assert.equal(isDiyModule(mod), false); checked++; }
else assert.ok(v.ok, `${charId} ${mod.uniEquipId}: ${v.detail}`);
if (/^(ISW|SO|RA)-/.test(mod.typeName)) {
assert.match(v.detail || '', /module of another game mode/, `${charId} ${mod.uniEquipId}`);
assert.equal(isDiyModule(mod), false);
refused.add(`${mod.typeName} ${mod.uniEquipId}`);
} else assert.ok(v.ok, `${charId} ${mod.uniEquipId}: ${v.detail}`);
}
}
}
assert.ok(checked >= 6, 'the ISW-A modules of the pool (凯尔希, 傀影, 菲亚梅塔, 提丰, 艾丽妮, 霍尔海雅) at both stages');
// 凯尔希, 傀影, 菲亚梅塔, 提丰, 艾丽妮, 霍尔海雅 (ISW-A); 电弧 SO-A / SO-B, 机械师 SO-A; 森蚺 RA-A
assert.deepEqual([...refused].map((x) => x.split(' ')[0]).sort(), ['ISW-A', 'ISW-A', 'ISW-A', 'ISW-A', 'ISW-A', 'ISW-A', 'RA-A', 'SO-A', 'SO-A', 'SO-B']);
// every refused module's own parts are tied to its mode: the text "在…中" of its trait / talents
for (const charId of ['char_4195_radian', 'char_4230_mcnist', 'char_416_zumama']) {
for (const mod of backups.units[charId].forms['2/60/7/3'].modules) {
if (isDiyModule(mod)) continue;
const text = JSON.stringify([mod.traitOverride ?? null, mod.talentChanges ?? []]);
assert.match(text, /在(集成战略|【[^】]+】|生息演算)中/, `${charId} ${mod.uniEquipId}: a mode-only text`);
}
}
});
test('diyRecord: the slot\'s identity (tier, price, merge, status), no 特质, derived bonds, the operator\'s body at the slot form; module active on the elite only, stage 1 / 3', () => {
+112 -109
View File
@@ -1820,35 +1820,35 @@
"time": 211.6,
"ticks": 6348,
"reason": "cleared",
"killed": 82,
"killed": 74,
"total": 150,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 89,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":82,"total":150,"perfect":false,"leaks":68,"leaksUncounted":0,"coins":0,"dmg":1367204,"bossDmg":0,"heal":0,"deaths":13,"layerGains":{}}},
"players": {"p1":{"killed":74,"total":150,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":1358417,"bossDmg":0,"heal":0,"deaths":13,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_ifrit",1544,1,4,29,535201,0,0,93,4],
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_ifrit",1544,1,5,14,235845,0,0,72,39],
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_ifrit",1544,1,171,1,98866,1544,0,17,3],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_ifrit",1744,1,13,0,87409,0,0,74,4],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_ifrit",1744,1,6,18,156957,0,0,66,34],
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_ifrit",1544,1,4,30,548258,0,0,93,4],
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_ifrit",1544,1,5,13,217959,0,0,72,39],
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_ifrit",1544,1,171,1,99441,1544,0,17,3],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_ifrit",1744,1,13,1,87328,0,0,74,4],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_ifrit",1744,1,6,12,157838,0,0,65,33],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_ifrit",0,0,14,0,63597,1744,0,20,3],
["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,121,0,2158,2522,0,3,0],
["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,154,8,58152,2522,0,10,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,100,2,21405,2522,0,10,1],
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,95,2,14759,3052,0,9,0],
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,90,3,27322,3052,0,3,2],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,8,5,65534,1526,0,20,1],
["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,154,6,51945,2522,0,11,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,100,2,19121,2522,0,10,1],
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,96,3,16304,3052,0,10,0],
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,90,3,32589,3052,0,4,2],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,8,3,61880,1526,0,17,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1249_lysdb_2":[60,34,26,5486,596279],"enemy_1251_lysyta":[51,35,16,909,240893],"enemy_1329_cbshld":[39,13,26,8801,530032]},
"hooks": [1,171,6348,433,431,1867,2367,0,95,163,92,92,18,29289,22,25,150,68,1,95,0,0,0],
"events": {"atk":1247,"deploy":18,"die":95,"dmg":2367,"engage":12,"fx":236,"leak":68,"skill":184,"spawn":165,"status":44},
"fx": {"aoe":73,"beam":3,"flame":100,"shieldBreak":60},
"snaps": "43307da7 4ff2063f 2a153be0 3aba4c54 30fdf3ac 1f9215af cf66cfaa 8012d31d 985f38b3 a23ece2e 38d610e7 33eab3e4 0832bad4 fdd45552 4c989016 b99015e8 e5e90c65 02b8067a c5784c78 5f21d2f4 59cec77b fe00505d"
"enemies": {"enemy_1249_lysdb_2":[60,32,28,4443,590753],"enemy_1251_lysyta":[51,32,19,942,239115],"enemy_1329_cbshld":[39,10,29,9811,528548]},
"hooks": [1,171,6348,432,430,1848,2352,0,87,163,91,91,15,29250,22,25,150,76,1,87,0,0,0],
"events": {"atk":1258,"deploy":18,"die":87,"dmg":2352,"engage":12,"fx":235,"leak":76,"skill":182,"spawn":165,"status":44},
"fx": {"aoe":72,"beam":3,"flame":100,"shieldBreak":60},
"snaps": "a92391fb 65d01483 6ce4a17f b71d2eb4 41903aab ff59f11c 19708ae2 91521e30 13cbe1cb 6aedec66 803f5122 4d21778b 6238ae29 63609ca2 b757c3a4 f2beec8c 6962ad79 f76f5262 3fdc9f26 ef4a8d27 c8a0adc1 98f390f9"
},
"diy-046": {
"about": "5_diy1_b=aphris/S1/002_aphris 5_diy2_b=aphris/S2/002_aphris 5_diy1_b=aphris/S3/002_aphris 6_diy1_a=aphris/S1 6_diy2_a=aphris/S2 6_diy1_a=aphris/S3 6_diy2_b=aphris/S1 6_diy1_b=aphris/S2 6_diy2_b=aphris/S3 6_diy1_b=aphris/S1/002_aphris 6_diy2_b=aphris/S2/002_aphris 6_diy1_b=aphris/S3/002_aphris",
@@ -3365,10 +3365,10 @@
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1019_jshoot_2":[57,41,16,9154,183638],"enemy_1299_ymkilr_2":[27,10,17,47487,203959],"enemy_1422_lrsldr_2":[39,16,23,1386,172539]},
"hooks": [1,149,6820,1026,1026,1315,1436,0,88,144,56,55,0,17849,892,94,123,56,1,88,0,6,0],
"hooks": [1,149,6820,1026,1026,1315,1436,0,88,144,56,55,0,17851,892,94,123,56,1,88,0,6,0],
"events": {"atk":1056,"deploy":23,"die":88,"dmg":1436,"engage":12,"fx":74,"leak":56,"skill":111,"spawn":138,"status":71},
"fx": {"aoe":20,"burst":6,"shield":24,"shieldBreak":24},
"snaps": "45ecc41c 880ee0dd b36699bc 992f1255 23143b3d 3ba40a64 ead1143c 29b600b7 60ec6974 eff36649 bbaee861 b11de034 d58d5c37 63232bfe 4e305d69 848a4366 cd76bd35 ad1f8c0f 8e3f9ba5 2b3b69eb 71bc494b 17b62c2a 2f3b24a4"
"snaps": "45ecc41c 880ee0dd b36699bc 992f1255 23143b3d 3ba40a64 ead1143c 29b600b7 60ec6974 eff36649 b5a79642 02d6faca e4063ab1 22bf8f0f 14cf16ac 3e4fc06b 12025e38 96906102 cd2db10c 106c766a bc2c5496 1bb121b3 410896a5"
},
"diy-079": {
"about": "5_diy1_b=headb2/S1/002_headb2 5_diy2_b=headb2/S2/002_headb2 5_diy1_b=headb2/S3/002_headb2 6_diy1_a=headb2/S1 6_diy2_a=headb2/S2 6_diy1_a=headb2/S3 6_diy2_b=headb2/S1 6_diy1_b=headb2/S2 6_diy2_b=headb2/S3 6_diy1_b=headb2/S1/002_headb2 6_diy2_b=headb2/S2/002_headb2 6_diy1_b=headb2/S3/002_headb2",
@@ -4867,45 +4867,45 @@
"time": 205.67,
"ticks": 6170,
"reason": "cleared",
"killed": 124,
"total": 189,
"killed": 121,
"total": 183,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 8,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":124,"total":189,"perfect":false,"leaks":65,"leaksUncounted":0,"coins":0,"dmg":549243,"bossDmg":0,"heal":0,"deaths":16,"layerGains":{}}},
"players": {"p1":{"killed":121,"total":183,"perfect":false,"leaks":62,"leaksUncounted":0,"coins":0,"dmg":524053,"bossDmg":0,"heal":0,"deaths":17,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,1884,1,191,8,54401,1884,0,59,2],
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,210,41,38926,3768,0,48,2],
["p1",3,"op","chess_char_6_diy1_a",2,null,1884,1,218,11,57866,1884,0,43,1],
["p1",4,"op","chess_char_6_diy2_b",0,"none",2216,1,196,14,85238,2216,0,68,2],
["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,153,18,110415,4432,0,41,1],
["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,182,13,101208,4432,0,48,1],
["p1",1,"op","chess_char_6_diy1_a",0,null,1884,1,190,7,74139,1884,0,56,2],
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,204,35,51212,3768,0,38,2],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,193,8,36822,3768,0,38,1],
["p1",4,"op","chess_char_6_diy2_b",0,"none",2216,1,212,5,23864,2216,0,45,2],
["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,153,18,103390,4432,0,41,1],
["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,182,14,109015,4432,0,46,1],
["p1",7,"op","chess_char_5_diy1_a",0,null,1359,1,4,0,0,0,0,0,0],
["p1",8,"op","chess_char_5_diy2_a",1,null,1359,1,5,2,31646,0,0,53,53],
["p1",9,"op","chess_char_5_diy1_a",2,null,1359,1,154,0,39160,1359,0,42,3],
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,180,2,9770,3140,0,25,2],
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,175,0,6679,3140,0,21,4],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,141,4,9738,3140,0,12,1],
["p1",8,"op","chess_char_5_diy2_a",1,null,1359,1,5,3,28459,0,0,52,0],
["p1",9,"op","chess_char_5_diy1_a",2,null,1359,1,176,0,49348,1359,0,39,2],
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,181,4,14787,3140,0,38,3],
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,174,1,9156,3140,0,21,4],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,141,4,11236,3140,0,12,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
["p1","~1","token","token_10024_ebnhlz_rcube",null,null,1000,0,37,0,2623,0,0,0,null],
["p1","~2","token","token_10024_ebnhlz_rcube",null,null,1000,0,53,0,1,0,0,0,null],
["p1","~3","token","token_10024_ebnhlz_rcube",null,null,1000,0,71,0,1572,0,0,0,null],
["p1","~4","token","token_10024_ebnhlz_rcube",null,null,1000,0,83,0,0,0,0,0,null]
["p1","~1","token","token_10024_ebnhlz_rcube",null,null,1000,0,37,0,1572,0,0,0,null],
["p1","~2","token","token_10024_ebnhlz_rcube",null,null,1000,0,52,0,0,0,0,0,null],
["p1","~3","token","token_10024_ebnhlz_rcube",null,null,1000,0,67,0,6859,0,0,0,null],
["p1","~4","token","token_10024_ebnhlz_rcube",null,null,1000,0,81,0,4195,0,0,0,null]
],
"enemies": {
"enemy_1197_sfshu": [15,11,4,2272,51823],
"enemy_1197_sfshu_2": [39,21,18,6413,151033],
"enemy_1198_msfshu": [33,26,7,0,81],
"enemy_1198_msfshu_2": [63,54,9,0,225],
"enemy_9010_acpupp": [39,12,27,16255,412992]
"enemy_1197_sfshu": [15,10,5,1799,50834],
"enemy_1197_sfshu_2": [39,20,19,6094,139516],
"enemy_1198_msfshu": [30,26,4,0,79],
"enemy_1198_msfshu_2": [60,54,6,0,217],
"enemy_9010_acpupp": [39,11,28,18930,401042]
},
"hooks": [1,218,6170,550,550,2484,2286,0,140,209,72,69,61,11555,18,37,189,65,1,153,0,0,0],
"events": {"atk":913,"deploy":26,"die":144,"dmg":2286,"engage":11,"fx":143,"leak":65,"skill":141,"spawn":208,"status":408},
"fx": {"aoe":4,"dash":3,"displace":8,"ember":13,"pull":8,"shieldBreak":37,"summon":36,"takeoff":9,"telegraph":13,"volley":12},
"snaps": "97d774f4 d56f2c40 38cfa54f 7e2be674 45424794 b549bc6d d02f3886 8eef35d8 186b734d ef22c4fe 6eed958f 1c82384a 2d257b7e e4c0bf84 b322c2f5 561c30ac c139ed4b 0f61007b e04f8bf8 c8e87b59 3c8fbdd4"
"hooks": [1,212,6170,507,507,2408,2252,0,138,204,19,17,61,12292,18,39,183,62,1,150,0,0,0],
"events": {"atk":827,"deploy":26,"die":142,"dmg":2252,"engage":11,"fx":136,"leak":62,"skill":36,"spawn":202,"status":382},
"fx": {"aoe":4,"dash":3,"displace":8,"ember":12,"pull":8,"revive":1,"shieldBreak":28,"summon":34,"takeoff":9,"telegraph":12,"volley":17},
"snaps": "97d774f4 3a87e9c0 6315b127 d1ff957b 8b0cf599 ffcafc10 f97c63e0 b090bfcf 503cb562 30e78f94 453e3e4b 5c62e222 794d1210 f8da69f3 40109745 bfd2d8ad 9529555d def80e98 4395baef ad6ed566 87adb778"
},
"diy-115": {
"about": "5_diy1_b=ebnhlz/S1/002_ebnhlz 5_diy2_b=ebnhlz/S2/002_ebnhlz 5_diy1_b=ebnhlz/S3/002_ebnhlz 5_diy2_b=ebnhlz/S1/003_ebnhlz 5_diy1_b=ebnhlz/S2/003_ebnhlz 5_diy2_b=ebnhlz/S3/003_ebnhlz 5_diy1_b=ebnhlz/S1/004_ebnhlz 5_diy2_b=ebnhlz/S2/004_ebnhlz 5_diy1_b=ebnhlz/S3/004_ebnhlz 6_diy1_a=ebnhlz/S1 6_diy2_a=ebnhlz/S2 6_diy1_a=ebnhlz/S3",
@@ -4916,59 +4916,61 @@
"round": 12,
"seed": 2479968306,
"waveId": "act1autochess_h05",
"time": 193.8,
"ticks": 5814,
"time": 193.6,
"ticks": 5808,
"reason": "cleared",
"killed": 195,
"total": 246,
"killed": 197,
"total": 243,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":195,"total":246,"perfect":false,"leaks":51,"leaksUncounted":0,"coins":0,"dmg":515319,"bossDmg":0,"heal":0,"deaths":9,"layerGains":{}}},
"players": {"p1":{"killed":197,"total":243,"perfect":false,"leaks":46,"leaksUncounted":0,"coins":0,"dmg":532641,"bossDmg":0,"heal":0,"deaths":8,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_ebnhlz",1570,1,10,7,61901,0,0,45,5],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_ebnhlz",1570,1,11,9,35088,0,0,28,25],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_ebnhlz",1570,1,1,9,12719,0,0,24,4],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_ebnhlz",1650,1,3,21,55599,0,0,72,7],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_ebnhlz",1650,1,5,7,56916,0,0,60,33],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_ebnhlz",1650,1,7,12,124221,0,0,54,4],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_ebnhlz",1570,1,11,9,33720,0,0,27,0],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_ebnhlz",1570,1,1,11,16041,0,0,24,4],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_ebnhlz",1650,1,3,15,56353,0,0,77,7],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_ebnhlz",1650,1,5,10,52596,0,0,60,6],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_ebnhlz",1650,1,7,13,127550,0,0,51,4],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_004_ebnhlz",1570,1,9,0,0,0,0,0,3],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_004_ebnhlz",1570,1,12,6,63692,0,0,49,11],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_004_ebnhlz",0,0,194,2,8623,3140,0,8,2],
["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,172,10,12844,2718,0,33,2],
["p1",11,"op","chess_char_6_diy2_a",1,null,1025.48,1,275,3,14668,3052,0,17,7],
["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,269,0,16023,4077,0,12,1],
["p1","~1","token","token_10024_ebnhlz_rcube",null,null,1000,0,29,0,10534,0,0,0,null],
["p1","~2","token","token_10024_ebnhlz_rcube",null,null,1000,0,32,0,0,0,0,0,null],
["p1","~3","token","token_10024_ebnhlz_rcube",null,null,1000,0,56,0,7762,0,0,0,null],
["p1","~4","token","token_10024_ebnhlz_rcube",null,null,1000,0,78,1,4611,0,0,0,null],
["p1","~5","token","token_10024_ebnhlz_rcube",null,null,1000,0,87,0,0,0,0,0,null],
["p1","~6","token","token_10024_ebnhlz_rcube",null,null,1000,0,106,0,7762,0,0,0,null],
["p1","~7","token","token_10024_ebnhlz_rcube",null,null,1000,0,128,0,0,0,0,0,null],
["p1","~8","token","token_10024_ebnhlz_rcube",null,null,1000,0,133,0,2772,0,0,0,null],
["p1","~9","token","token_10024_ebnhlz_rcube",null,null,1000,0,150,0,2219,0,0,0,null],
["p1","~10","token","token_10024_ebnhlz_rcube",null,null,1000,0,162,0,0,0,0,0,null],
["p1","~11","token","token_10024_ebnhlz_rcube",null,null,1000,0,169,1,4611,0,0,0,null],
["p1","~12","token","token_10024_ebnhlz_rcube",null,null,1000,0,197,0,2218,0,0,0,null],
["p1","~13","token","token_10024_ebnhlz_rcube",null,null,1000,0,219,0,0,0,0,0,null],
["p1","~14","token","token_10024_ebnhlz_rcube",null,null,1000,0,220,0,2772,0,0,0,null],
["p1","~15","token","token_10024_ebnhlz_rcube",null,null,1000,0,240,0,2776,0,0,0,null],
["p1","~16","token","token_10024_ebnhlz_rcube",null,null,1000,1,254,0,0,0,0,0,null],
["p1","~17","token","token_10024_ebnhlz_rcube",null,null,1000,0,256,0,4990,0,0,0,null]
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_004_ebnhlz",1570,1,12,8,66486,0,0,49,13],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_004_ebnhlz",0,0,225,5,6116,3140,0,10,2],
["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,164,8,12174,2718,0,31,2],
["p1",11,"op","chess_char_6_diy2_a",1,null,1025.48,1,275,5,13604,3052,0,16,0],
["p1",12,"op","chess_char_6_diy1_a",2,null,81.61,1,270,2,15684,3995,0,14,1],
["p1","~1","token","token_10024_ebnhlz_rcube",null,null,1000,0,29,0,0,0,0,0,null],
["p1","~2","token","token_10024_ebnhlz_rcube",null,null,1000,0,30,1,12752,0,0,0,null],
["p1","~3","token","token_10024_ebnhlz_rcube",null,null,1000,0,55,0,5544,0,0,0,null],
["p1","~4","token","token_10024_ebnhlz_rcube",null,null,1000,0,70,0,4990,0,0,0,null],
["p1","~5","token","token_10024_ebnhlz_rcube",null,null,1000,0,72,0,0,0,0,0,null],
["p1","~6","token","token_10024_ebnhlz_rcube",null,null,1000,0,92,0,4990,0,0,0,null],
["p1","~7","token","token_10024_ebnhlz_rcube",null,null,1000,0,122,0,12197,0,0,0,null],
["p1","~8","token","token_10024_ebnhlz_rcube",null,null,1000,0,125,0,0,0,0,0,null],
["p1","~9","token","token_10024_ebnhlz_rcube",null,null,1000,0,138,0,2772,0,0,0,null],
["p1","~10","token","token_10024_ebnhlz_rcube",null,null,1000,0,155,0,2221,0,0,0,null],
["p1","~11","token","token_10024_ebnhlz_rcube",null,null,1000,0,160,0,0,0,0,0,null],
["p1","~12","token","token_10024_ebnhlz_rcube",null,null,1000,0,171,0,4990,0,0,0,null],
["p1","~13","token","token_10024_ebnhlz_rcube",null,null,1000,0,204,0,9425,0,0,0,null],
["p1","~14","token","token_10024_ebnhlz_rcube",null,null,1000,0,216,0,0,0,0,0,null],
["p1","~15","token","token_10024_ebnhlz_rcube",null,null,1000,0,223,0,2772,0,0,0,null],
["p1","~16","token","token_10024_ebnhlz_rcube",null,null,1000,0,243,0,2776,0,0,0,null],
["p1","~17","token","token_10024_ebnhlz_rcube",null,null,1000,1,254,0,0,0,0,0,null],
["p1","~18","token","token_10024_ebnhlz_rcube",null,null,1000,0,257,0,4990,0,0,0,null],
["p1","~19","token","token_10024_ebnhlz_rcube",null,null,1000,0,272,0,0,0,0,0,null]
],
"enemies": {
"enemy_1195_sfyin": [45,24,21,8753,152906],
"enemy_1196_msfyin": [48,44,4,0,90],
"enemy_1197_sfshu": [39,30,9,4234,206523],
"enemy_1198_msfshu": [90,76,14,0,242],
"enemy_1195_sfyin": [45,30,15,8741,173581],
"enemy_1196_msfyin": [60,51,9,0,105],
"enemy_1197_sfshu": [39,25,14,4164,201605],
"enemy_1198_msfshu": [75,70,5,0,216],
"enemy_1199_sfjin": [12,12,0,0,186325],
"enemy_1200_msfjin": [12,9,3,0,348]
"enemy_1200_msfjin": [12,9,3,0,346]
},
"hooks": [1,281,5814,450,450,1977,2088,0,204,271,104,102,0,27867,0,31,246,51,1,204,0,0,0],
"events": {"atk":450,"deploy":35,"die":220,"dmg":2088,"engage":11,"fx":189,"leak":51,"skill":206,"spawn":275},
"fx": {"aoe":11,"displace":13,"pull":13,"summon":83,"volley":69},
"snaps": "b588f908 018cc7d1 02df24b0 d7fb7008 01901fca 88a112ed f0544f8d 91caefd2 24dd8e93 26798334 2b5c19c3 091cb2b8 45da698c 1d8e5f22 91d224aa a413c6ff af6b4e5f d69eff16 da950540 179fa098"
"hooks": [1,280,5808,450,450,1944,2063,0,205,269,47,45,0,28772,0,29,243,46,1,205,0,0,0],
"events": {"atk":450,"deploy":37,"die":223,"dmg":2063,"engage":11,"fx":197,"leak":46,"skill":92,"spawn":274},
"fx": {"aoe":13,"displace":15,"pull":15,"summon":86,"volley":68},
"snaps": "b588f908 30b58fc4 0067aae8 073e6898 0cbd5145 a0736062 6e599248 f14345d4 7c4901d1 2f52111e a8cc7280 ac1fd796 f60de3a9 9b4b7177 cc02ceba e6974a52 cf27fc35 a4a00c5a a7501807 6a48f2bc"
},
"diy-116": {
"about": "6_diy1_b=ebnhlz/S1 6_diy2_b=ebnhlz/S2 6_diy1_b=ebnhlz/S3 6_diy2_b=ebnhlz/S1/002_ebnhlz 6_diy1_b=ebnhlz/S2/002_ebnhlz 6_diy2_b=ebnhlz/S3/002_ebnhlz 6_diy1_b=ebnhlz/S1/003_ebnhlz 6_diy2_b=ebnhlz/S2/003_ebnhlz 6_diy1_b=ebnhlz/S3/003_ebnhlz 6_diy2_b=ebnhlz/S1/004_ebnhlz 6_diy1_b=ebnhlz/S2/004_ebnhlz 6_diy2_b=ebnhlz/S3/004_ebnhlz",
@@ -4986,31 +4988,32 @@
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 5,
"rngDraws": 6,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":1,"total":123,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":271272,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}},
"players": {"p1":{"killed":1,"total":123,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":286822,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_b",0,"none",1570,1,147,0,23015,3140,0,31,2],
["p1",2,"op","chess_char_6_diy2_b",1,"none",0,0,123,0,19192,3140,0,10,1],
["p1",3,"op","chess_char_6_diy1_b",2,"none",1570,1,134,0,52931,1570,0,26,2],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_ebnhlz",0,0,146,0,15092,3140,0,15,1],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_ebnhlz",0,0,82,0,13594,3140,0,7,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_ebnhlz",0,0,78,0,9982,3140,0,6,0],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_003_ebnhlz",0,0,74,0,14427,3490,0,4,0],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_003_ebnhlz",0,0,7,0,14109,1745,0,5,1],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_003_ebnhlz",0,0,9,0,19195,1745,0,7,1],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_004_ebnhlz",0,0,79,0,8532,3140,0,4,0],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_004_ebnhlz",0,0,4,1,20024,1570,0,6,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_004_ebnhlz",0,0,6,0,34442,1570,0,6,1],
["p1","~1","token","token_10024_ebnhlz_rcube",null,null,1000,0,27,0,15400,0,0,0,null],
["p1","~2","token","token_10024_ebnhlz_rcube",null,null,1000,0,28,0,4454,0,0,0,null],
["p1","~3","token","token_10024_ebnhlz_rcube",null,null,1000,0,29,0,6883,0,0,0,null]
["p1",1,"op","chess_char_6_diy1_b",0,"none",1570,1,148,1,25031,3140,0,31,2],
["p1",2,"op","chess_char_6_diy2_b",1,"none",0,0,124,0,18799,3140,0,10,2],
["p1",3,"op","chess_char_6_diy1_b",2,"none",1570,1,135,0,49295,1570,0,24,2],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_ebnhlz",0,0,147,0,16828,3140,0,16,1],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_ebnhlz",0,0,83,0,13594,3140,0,7,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_ebnhlz",0,0,79,0,9982,3140,0,6,0],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_003_ebnhlz",0,0,75,0,14427,3490,0,4,0],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_003_ebnhlz",0,0,7,0,16808,1745,0,7,0],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_003_ebnhlz",0,0,9,0,22161,1745,0,7,1],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_004_ebnhlz",0,0,80,0,8532,3140,0,4,0],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_004_ebnhlz",0,0,4,0,18212,1570,0,4,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_004_ebnhlz",0,0,6,0,39389,1570,0,8,1],
["p1","~1","token","token_10024_ebnhlz_rcube",null,null,1000,0,26,0,4454,0,0,0,null],
["p1","~2","token","token_10024_ebnhlz_rcube",null,null,1000,0,27,0,17360,0,0,0,null],
["p1","~3","token","token_10024_ebnhlz_rcube",null,null,1000,0,28,0,4876,0,0,0,null],
["p1","~4","token","token_10024_ebnhlz_rcube",null,null,1000,0,37,0,7074,0,0,0,null]
],
"enemies": {"enemy_1006_shield_3":[21,0,21,8166,127088],"enemy_1043_zomsbr":[66,1,65,14521,60428],"enemy_1402_tgshd_2":[36,0,36,7843,83756]},
"hooks": [1,147,6528,189,189,333,355,10424,20,145,11,11,0,8727,0,21,123,122,1,20,0,1,0],
"events": {"atk":189,"deploy":24,"die":23,"dmg":355,"engage":12,"fx":96,"leak":122,"skill":22,"spawn":138,"status":36},
"fx": {"aoe":3,"burst":1,"displace":5,"expose":67,"pull":5,"summon":3,"volley":12},
"snaps": "389ca6e3 c8f155a4 e2e7a3b2 2f4c8838 6a9c72f0 8c585b7e a26f5d0c 80f56fed a04d35ab 88374c55 043f03c3 acc9e8b4 6dc5a029 a28ee86b d72cad57 77ed9536 4cd333ad 94a7e729 29475fd8 b7079863 f08523df a6844f3a"
"enemies": {"enemy_1006_shield_3":[21,0,21,8025,133387],"enemy_1043_zomsbr":[66,1,65,13522,62006],"enemy_1402_tgshd_2":[36,0,36,8983,91429]},
"hooks": [1,148,6528,187,187,341,362,10129,20,146,11,11,0,8777,0,22,123,122,1,20,0,1,0],
"events": {"atk":187,"deploy":25,"die":24,"dmg":362,"engage":12,"fx":108,"leak":122,"skill":22,"spawn":139,"status":38},
"fx": {"aoe":4,"burst":1,"displace":6,"expose":74,"pull":6,"summon":4,"volley":13},
"snaps": "389ca6e3 d4a0046f e8e318e4 2dfb6564 d6b9ac5b 05f87e6d 4fddc6c0 abdaf56e 564d85ec f523de7f 8fc91296 e8062bef a97ba650 2328edb5 b076db9a 504c2000 80dda69d 2e369d1e 56349199 70fdf2ec 97f6afb9 059a1cef"
},
"diy-117": {
"about": "5_diy1_a=mgllan/S1 5_diy2_a=mgllan/S2 5_diy1_a=mgllan/S3 5_diy2_b=mgllan/S1 5_diy1_b=mgllan/S2 5_diy2_b=mgllan/S3 5_diy1_b=mgllan/S1/002_mgllan 5_diy2_b=mgllan/S2/002_mgllan 5_diy1_b=mgllan/S3/002_mgllan 5_diy2_b=mgllan/S1/003_mgllan 5_diy1_b=mgllan/S2/003_mgllan 5_diy2_b=mgllan/S3/003_mgllan",
+13 -13
View File
@@ -1623,7 +1623,7 @@
"boost": null,
"setup": {"stageId":"act1autochess_m03","bossId":"boss_7","hiddenBossId":"boss_10","factions":["ELEMENT","INVISIBLE","REFLECTION"]},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":55,"bossPoolLeft":0,"virtualMs":122900},
"rng": {"setup":25,"shop":461,"waves":0,"draft":34,"bots":16433,"meta":20},
"rng": {"setup":25,"shop":503,"waves":0,"draft":34,"bots":16504,"meta":20},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [
{"round":1,"h_0":[1,27,4,0,0,5,1,"3e4697a0",0,"",5,5,0,3],"ai_0":[1,28,4,0,0,1,2,"a1734bc6",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]},
@@ -1666,18 +1666,18 @@
},
{
"round": 11,
"h_0": [1,27,14,0,2,5,8,"7c42dc27",357,"arcaneShip 0/2, indomShip 1/16, preciShip 2/109, sargonShip 1/137, skillfulShip 1/8, steadShip 1/51, suntShip 1/0, victoriaShip 0/19, visiShip 1/36",19,19,0,105],
"h_0": [1,27,14,0,2,6,8,"a5cfb056",363,"arcaneShip 0/2, indomShip 1/16, preciShip 2/116, sargonShip 1/139, steadShip 1/51, suntShip 1/0, swiftShip 1/5, victoriaShip 0/19, visiShip 1/36",19,19,0,106],
"ai_0": [1,28,12,0,0,5,8,"fe5ac1a4",53,"sargonShip 0/4, siracusaShip 1/12, skillfulShip 1/12, soloShip 1/0, suntShip 2/0, visiShip 1/0, yanShip 1/29",19,19,0,99]
},
{
"round": 12,
"h_0": [1,27,16,0,2,6,8,"3cdb76eb",388,"arcaneShip 0/2, indomShip 2/16, preciShip 2/141, sargonShip 1/179, skillfulShip 0/8, steadShip 0/51, suntShip 1/0, victoriaShip 0/19, visiShip 1/52",33,33,0,122],
"ai_0": [1,28,12,0,0,6,8,"547fcf92",67,"sargonShip 0/8, siracusaShip 1/16, skillfulShip 1/18, soloShip 1/0, suntShip 2/0, yanShip 1/33",16,33,17,112]
"h_0": [1,27,16,0,2,6,8,"750c100e",381,"arcaneShip 0/2, emptyShip 1/0, indomShip 0/16, preciShip 2/148, sargonShip 1/181, steadShip 0/51, suntShip 1/0, swiftShip 0/5, victoriaShip 0/19, visiShip 1/52",33,33,0,125],
"ai_0": [1,28,12,0,0,6,8,"d3c53bfd",63,"sargonShip 0/8, siracusaShip 1/12, skillfulShip 1/18, soloShip 1/0, suntShip 2/0, visiShip 1/0, yanShip 1/33",18,33,15,112]
},
{
"round": 13,
"h_0": [1,27,16,0,0,6,8,"d0f5bd71",508,"arcaneShip 0/2, indomShip 1/16, preciShip 2/173, sargonShip 1/239, skillfulShip 0/8, steadShip 0/51, suntShip 1/0, swiftShip 1/12, victoriaShip 0/19, visiShip 1/68",50,50,0,138],
"ai_0": [1,28,12,0,0,6,8,"d2ff3200",90,"sargonShip 0/8, siracusaShip 1/24, skillfulShip 1/24, soloShip 1/0, suntShip 2/0, victoriaShip 0/5, yanShip 1/42",32,50,18,125]
"h_0": [1,27,16,4,0,6,8,"d90306b7",486,"arcaneShip 0/2, indomShip 0/16, maniShip 1/0, preciShip 2/166, sargonShip 1/209, steadShip 0/51, suntShip 1/0, swiftShip 0/5, victoriaShip 1/31, visiShip 1/80",50,50,0,140],
"ai_0": [1,28,12,0,0,6,8,"5700d2b4",81,"miraShip 1/0, sargonShip 0/8, siracusaShip 1/20, skillfulShip 1/24, soloShip 1/0, suntShip 2/0, visiShip 1/0, yanShip 1/37",14,50,36,124]
}
],
"players": {
@@ -1691,9 +1691,9 @@
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":534,"bossDamage":572474,"dmgDealt":4818483,"gold":154,"itemsEquipped":23,"kills":349,"leaks":0,"lpLost":0,"merges":4,"perfectRounds":13,"refreshes":23},
"lineup": ["5_21_b@12,8[3_07_e_b+1_05_e_a]","5_22_a@11,7","4_20_b@11,8","5_02_a@10,5[5_01_e_a+6_01_e_a]","6_05_a@10,6[2_01_e_a+4_08_e_a]","6_06_a@10,8[1_01_e_a+3_04_e_a]","4_07_b@9,8[5_03_e_a+4_11_e_b]","4_23_b@9,9[4_04_e_a]"],
"bonds": ["arcaneShip 0/2","indomShip 1/16","preciShip 2/173","sargonShip 1/239","skillfulShip 0/8","steadShip 0/51","suntShip 1/0","swiftShip 1/22","victoriaShip 0/19","visiShip 1/84"]
"stats": {"activatedLayers":502,"bossDamage":596123,"dmgDealt":5126250,"gold":156,"itemsEquipped":23,"kills":364,"leaks":0,"lpLost":0,"merges":4,"perfectRounds":13,"refreshes":29},
"lineup": ["6_05_a@12,8[2_01_e_a+4_08_e_a]","5_02_a@11,6[5_01_e_a+6_01_e_a]","6_08_a@11,7[3_09_e_a]","4_20_b@11,8[6_07_e_a+1_05_e_a]","6_11_a@10,6","5_21_b@10,8[3_04_e_a+6_09_e_a]","4_07_b@9,8[3_07_e_a+5_07_e_a]","4_23_b@9,9[4_04_e_b+4_11_e_a]"],
"bonds": ["arcaneShip 0/2","indomShip 0/16","maniShip 1/0","preciShip 2/166","sargonShip 1/209","steadShip 0/51","suntShip 1/0","swiftShip 0/5","victoriaShip 1/31","visiShip 1/96"]
},
"ai_0": {
"alive": true,
@@ -1705,9 +1705,9 @@
"title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":113,"bossDamage":215026,"dmgDealt":2640057,"gold":137,"itemsEquipped":20,"kills":247,"leaks":46,"lpLost":0,"merges":6,"perfectRounds":8,"refreshes":13},
"lineup": ["6_05_a@12,8[3_07_e_a+6_09_e_a]","2_04_b@11,7","1_07_b@11,8","6_11_a@10,6","3_03_b@10,8[3_11_e_b+2_04_e_a]","5_02_a@9,7[1_02_e_b]","1_08_b@9,8[4_06_e_a+1_05_e_a]","3_02_b@9,9[4_10_e_a+4_11_e_a]"],
"bonds": ["maniShip 1/0","preciShip 1/0","sargonShip 1/8","siracusaShip 1/24","skillfulShip 1/30","soloShip 1/0","suntShip 2/0","victoriaShip 0/10","yanShip 1/51"]
"stats": {"activatedLayers":105,"bossDamage":191377,"dmgDealt":2449031,"gold":135,"itemsEquipped":20,"kills":231,"leaks":62,"lpLost":0,"merges":6,"perfectRounds":8,"refreshes":12},
"lineup": ["2_04_b@12,8[6_02_e_a+6_09_e_a]","5_02_a@11,7","6_05_a@11,8","5_20_a@10,5[1_05_e_a+1_02_e_b]","6_11_a@10,6","3_03_b@10,8[3_11_e_b+2_04_e_a]","1_08_b@9,8[4_06_e_a+4_11_e_a]","3_02_b@9,9[4_04_e_a]"],
"bonds": ["maniShip 1/0","miraShip 1/0","preciShip 1/0","sargonShip 1/8","siracusaShip 1/28","skillfulShip 1/30","soloShip 1/0","suntShip 1/0","visiShip 1/0","yanShip 1/39"]
}
},
"diy": {
@@ -1730,7 +1730,7 @@
[9,"",""],
[10,"",""],
[11,"",""],
[12,"",""],
[12,"","6_diy1_a→char_609_acguad"],
[13,"",""]
],
"stock": {"chess_char_5_diy1_a":8,"chess_char_5_diy2_a":8,"chess_char_6_diy1_a":5,"chess_char_6_diy2_a":5}
+58
View File
@@ -0,0 +1,58 @@
// test/match/feedback5-we2-bot-diy.test.js — 0.2.0 WE2 #13: AI 托管 of a human with 自选 picks buys the player's own slotted
// pieces more readily (bot.js buyScore +DIY_PIECE_BONUS) and levels the 调度中心 one round earlier for the step that opens a
// slotted tier (levelUp, diyOpensAt). Bots field no 自选 piece: nothing changes for them.
// Run: node --test test/match/feedback5-we2-bot-diy.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buyScoreOf, levelUp, diyOpensAt, DIY_PIECE_BONUS } from '../../server/match/bot.js';
import { diyGameData } from '../../server/match/player/diy.js';
import { makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
const T5A = 'chess_char_5_diy1_a';
const PICK = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' }; // 推进之王
function prep(seed = 911) {
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: { [T5A]: PICK } },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true },
];
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed }).start();
h.toPrep(1);
return { h, m: h.m, p0: h.ps('p_0'), p1: h.ps('p_1') };
}
test('#13 buyScore: the player\'s own 自选 piece scores DIY_PIECE_BONUS more than the same record without the 自选 mark', () => {
const { m, p0 } = prep();
const rec = p0.gd.chess(T5A);
assert.ok(rec.diyFor, 'a composed 自选 record');
const withMark = buyScoreOf(m, p0, T5A);
// the same player and record, only the 自选 mark taken off (a view of the match's data with that record)
const plain = { ...rec };
delete plain.diyFor;
const saved = p0.gd;
p0.gd = diyGameData(m.gd, new Map([[T5A, plain]]));
let without;
try { without = buyScoreOf(m, p0, T5A); } finally { p0.gd = saved; }
assert.equal(withMark - without, DIY_PIECE_BONUS);
checkInvariants(m);
m.dispose();
});
test('#13 levelUp: one round earlier for the step that opens a slotted tier (round 9: level 4 → 5 at a price above 2); a player without picks waits', () => {
const { m, p0, p1 } = prep(912);
m.round = 9;
for (const ps of [p0, p1]) {
ps.shop.level = 4;
ps.shop.upgradePrice = 5;
ps.funds = 6;
const ids = chessOfTier(1).filter((id) => m.pool.has(id) && m.pool.left(id) > 0).slice(0, 6);
for (const id of ids) give(m, ps, id, 'hand');
}
assert.ok(diyOpensAt(p0, 5) && !diyOpensAt(p1, 5) && !diyOpensAt(p0, 6));
levelUp(m, p0);
levelUp(m, p1);
assert.equal(p0.shop.level, 5, 'p_0 (a tier-5 自选 piece) levels now');
assert.equal(p1.shop.level, 4, 'p_1 waits for round 10 (LEVEL_TARGET)');
m.dispose();
});
@@ -0,0 +1,78 @@
// test/match/feedback5-we2-diy-grants.test.js — 0.2.0 WE2 #9: an effect / reward / 机变 card that grants a player a random
// operator draws that player's 自选 stock too (「自选干员放入后模拟中的补给池随机范围也将被相应扩大」; PRTS 新手教程 "调度中心随机资源
// 的范围将被扩大"): effectsMeta ctx.rollChess / ctx.rollPool (player/diy.js diyStockEntries), choices.js 驰援 fallback —
// the roll's own tier rules, bonds through the player's view; never another player's stock.
// Run: node --test test/match/feedback5-we2-diy-grants.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeCtx } from '../../server/match/effectsMeta.js';
import { applyCard, tacticCard } from '../../server/match/choices.js';
import { makeMatch, checkInvariants } from './harness.js';
const T5A = 'chess_char_5_diy1_a';
const SIEGE = 'char_112_siege'; // 推进之王 (维多利亚)
const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
/** A co-op match: p_0 slots 推进之王 at T5A, p_1 slots nothing; first prep. */
function prep(seed = 911) {
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: { [T5A]: PICK } },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true },
];
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed }).start();
h.toPrep(1);
return { h, m: h.m, p0: h.ps('p_0'), p1: h.ps('p_1') };
}
/** Take every shared-pool copy of the chess `pred` admits (the draw then sees only what is left); m.restore() gives them back. */
function drain(m, pred) {
const back = (m.drained ??= []);
for (const [id, e] of m.pool.entries) if (pred(id, e) && e.left > 0) back.push([id, m.pool.take(id, e.left)]);
m.restore = () => { for (const [id, n] of back.splice(0)) m.pool.give(id, n); };
}
test('#9 effect rolls (ctx.rollChess, any tier / bond filter) draw the player\'s 自选 stock — its own player only', () => {
const { m, p0, p1 } = prep();
assert.equal(p0.diyStock.left(T5A), 8);
drain(m, (id, e) => e.tier === 5);
const c0 = makeCtx(m, p0, { key: 'test' }, 'onTest');
const c1 = makeCtx(m, p1, { key: 'test' }, 'onTest');
assert.equal(c0.rollChess({ tier: 5 }), T5A, 'the only tier-5 copies left: her stock');
assert.equal(c1.rollChess({ tier: 5 }), null, 'another player never draws it');
// the 调度中心 level is not the shop's gate here: an effect's tier rule alone (p_0 is at level 1)
assert.ok(p0.shop.level < 5);
// a bond filter reads the operator: 推进之王 is 维多利亚
drain(m, (id) => (m.gd.chess(id)?.bonds || []).includes('victoriaShip'));
assert.equal(c0.rollChess({ bond: 'victoriaShip' }), T5A);
assert.equal(c0.rollChess({ bond: 'yanShip', tier: 5 }), null, 'not a 炎 operator');
// granting it takes one copy of her own stock
assert.ok(c0.grantChess(T5A));
assert.equal(p0.diyStock.left(T5A), 7);
m.restore();
checkInvariants(m);
m.dispose();
});
test('#9 reward pools (ctx.rollPool: a shared-pool chess pool) and the 维多利亚驰援 card draw it too', () => {
const { m, p0, p1 } = prep(910);
drain(m, (id, e) => e.tier === 5);
const c0 = makeCtx(m, p0, { key: 'test' }, 'onTest');
const c1 = makeCtx(m, p1, { key: 'test' }, 'onTest');
assert.deepEqual(c0.rollPool('pool_chess_shop_5_reward'), { kind: 'chess', id: T5A, golden: false });
assert.equal(c1.rollPool('pool_chess_shop_5_reward'), null);
// 维多利亚驰援 (allybuff_select_7_7, single_special_choice_gain_bond_chess) with every 维多利亚 copy of the pool taken:
// the card grants her 推进之王 from her own stock
drain(m, (id) => (m.gd.chess(id)?.bonds || []).includes('victoriaShip'));
const card = tacticCard(m.gd.choices.cards.tactic.find((c) => c.effectId === 'allybuff_select_7_7'));
const owned = (ps) => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((x) => x && x.kind === 'chess' && x.id === T5A).length;
const before = p0.diyStock.left(T5A);
applyCard(m, p0, card);
assert.equal(owned(p0), 1, 'she got her 自选 piece');
assert.equal(p0.diyStock.left(T5A), before - 1, 'from her stock');
const had1 = [...p1.hand, ...p1.temp].filter(Boolean).length;
applyCard(m, p1, card);
assert.equal([...p1.hand, ...p1.temp].filter(Boolean).length, had1, 'p_1 has no 自选 piece to draw: nothing');
m.restore();
checkInvariants(m);
m.dispose();
});
+181
View File
@@ -0,0 +1,181 @@
// test/match/feedback5-we2-placement.test.js — 0.2.0 WE2: where the 自选 summon pieces may be placed in prep.
// #22 望's 棋子 go on any tile class (PRTS 棋子 部署位置 "全部位", 备注 "游戏内召唤物信息与实际不符(显示为仅部署在近战位)").
// #26 Mon3tr's 重构体 only inside her attack range on the ground (her talent "可以在攻击范围内的地面使用一个…重构体");
// 凯尔希·思衡托's 战术锚点 only on a ranged tile outside her attack range (PRTS 战术锚点 特性 "仅可以部署在凯尔希·思衡托攻击范围外的
// 远程位") — server (PlayerState._legal / summonRange / summonExcluded, board.js 'high'), the client mirror (gameLogic
// canPlace) and the re-orientation lift.
// Run: node --test test/match/feedback5-we2-placement.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR } from '../../shared/constants.js';
import { FIELD, canPlace, placeClass, tileKey, parseKey } from '../../server/match/board.js';
import { placementContext, canPlace as clientCanPlace, diyGetter } from '../../public/js/ui/gameLogic.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const T5A = 'chess_char_5_diy1_a';
const WANG = 'char_2027_wang';
const STONE = 'token_10064_wang_stone1';
const MONSTR = 'char_4179_monstr';
const PROSTS = 'token_10050_monstr_prosts';
const KALTS2 = 'char_1052_kalts2';
const ANCHOR = 'token_10068_kalts2_mtship';
/** A solo match whose human slots `picks`, in its first prep on `stage` (战场#01: 3 高台 tiles) with an empty board and hand. */
function prep(picks, seed = 2101, stage = 'act2autochess_m01') {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: picks }];
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
h.toPrep(1);
h.setStage(stage);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
ps.funds = 100;
return { h, m: h.m, ps };
}
/** Free tiles of the player's deploy map of class `cls` ('melee' | 'ranged'), reading order. */
function tilesOf(ps, cls) {
const map = ps.deployMap();
const out = [];
for (let r = FIELD.r1; r >= FIELD.r0; r--) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
if (!ps.board.has(tileKey(r, c)) && map.get(tileKey(r, c)) === cls) out.push([r, c]);
}
return out;
}
/** Gain slot `slotId` (its 自选 operator) and place it on the first free tile of its class; returns the piece. */
function placeDiy(m, ps, slotId, at = null) {
const piece = ps.acquireChess(slotId, { source: 'buy' });
assert.ok(piece, `${slotId} gained`);
const map = ps.deployMap();
const pos = placeClass(ps, ps.gd.chess(slotId));
let tile = at;
for (let r = FIELD.r1; r >= FIELD.r0 && !tile; r--) for (let c = FIELD.c0; c <= FIELD.c1 && !tile; c++) {
if (!ps.board.has(tileKey(r, c)) && canPlace(map, pos, r, c)) tile = [r, c];
}
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true }, `${slotId} placed`);
return piece;
}
test('#22 望\'s 棋子: a hand piece goes on a 高台 (ranged tile) as well as on the ground — PRTS 棋子 全部位', () => {
const { m, ps } = prep({ [T5A]: { charId: WANG, skillIndex: 0 } });
assert.equal(ps.gd.token(STONE).position, 'ALL');
const wang = placeDiy(m, ps, T5A);
const stack = ps.hand.find((p) => p && p.kind === 'token' && p.id === STONE && p.ownerUid === wang.uid);
assert.ok(stack && stack.count === 6, 'her 6 棋子 joined the hand');
const high = tilesOf(ps, 'ranged');
const ground = tilesOf(ps, 'melee');
assert.ok(high.length && ground.length, 'the stage has both tile classes');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: high[0][0], col: high[0][1] } }), { ok: true }, 'onto a 高台');
const left = ps.hand.find((p) => p && p.kind === 'token' && p.id === STONE);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: left.uid, to: { area: 'board', row: ground[0][0], col: ground[0][1] } }), { ok: true }, 'onto the ground');
// still refused off the deploy map (a forbidden tile of the field)
const map = ps.deployMap();
let off = null;
for (let r = FIELD.r1; r >= FIELD.r0 && !off; r--) for (let c = FIELD.c0; c <= FIELD.c1 && !off; c++) if (!map.get(tileKey(r, c)) && !ps.board.has(tileKey(r, c))) off = [r, c];
if (off) {
const again = ps.hand.find((p) => p && p.kind === 'token' && p.id === STONE);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: again.uid, to: { area: 'board', row: off[0], col: off[1] } }), { error: ERR.BAD_TILE }, 'not a deployable tile');
}
checkInvariants(m);
m.dispose();
});
/** The owner's attack-range keys on the board (the server's own reading). */
const rangeOf = (ps, owner) => ps._ownerRangeOf({ kind: 'token', ownerUid: owner.uid, id: '' });
/** The summon stack of `owner` in the hand. */
const stackOf = (ps, owner, tokenId) => ps.hand.find((p) => p && p.kind === 'token' && p.id === tokenId && p.ownerUid === owner.uid);
/** The client's legality of dropping `uid` on (r, c), from the player's private view. */
function clientOk(m, ps, uid, r, c) {
const priv = ps.privateView();
const getChess = diyGetter((id) => DATA.chess[id] ?? null, priv, DATA);
const ctx = placementContext({
priv, stage: m.stage, editable: true, getChess, backups: DATA.backups,
getToken: (id) => DATA.tokens[id] ?? DATA.backups.tokens[id] ?? null, getItem: (id) => DATA.items[id] ?? null,
});
return clientCanPlace(ctx, uid, { area: 'board', row: r, col: c }).ok;
}
/** Every free tile of the field: [r, c, serverLegal]. */
function freeTiles(ps, piece) {
const out = [];
for (let r = FIELD.r1; r >= FIELD.r0; r--) for (let c = FIELD.c0; c <= FIELD.c1; c++) if (!ps.board.has(tileKey(r, c))) out.push([r, c, ps._legal(piece, r, c)]);
return out;
}
test('#26 Mon3tr\'s 重构体: only on a ground tile of her attack range — data ownerRange from her talent; server and client agree', () => {
assert.equal(DATA.backups.tokens[PROSTS].ownerRange, true);
const { m, ps } = prep({ [T5A]: { charId: MONSTR, skillIndex: 0 } });
const mon = placeDiy(m, ps, T5A, [10, 5]);
const stack = stackOf(ps, mon, PROSTS);
assert.ok(stack, 'her 重构体 joined the hand');
const range = rangeOf(ps, mon);
const map = ps.deployMap();
const tiles = freeTiles(ps, stack);
let inside = 0, outside = 0;
for (const [r, c, legal] of tiles) {
const k = tileKey(r, c);
assert.equal(legal, range.has(k) && map.get(k) === 'melee', `server ${k}`);
assert.equal(clientOk(m, ps, stack.uid, r, c), legal, `client ${k}`);
if (legal) inside++; else if (map.get(k) === 'melee') outside++;
}
assert.ok(inside > 0 && outside > 0, `${inside} legal ground tiles in her range, ${outside} outside`);
const [r0, c0] = tiles.find((t) => t[2]);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: r0, col: c0 } }), { ok: true });
const off = tiles.find(([r, c]) => map.get(tileKey(r, c)) === 'melee' && !range.has(tileKey(r, c)));
const again = ps.board.get(tileKey(r0, c0));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: again.uid, to: { area: 'board', row: off[0], col: off[1] } }), { error: ERR.BAD_TILE }, 'outside her range');
checkInvariants(m);
m.dispose();
});
test('#26 凯尔希·思衡托\'s 战术锚点 (S3): only on a ranged tile outside her attack range; re-oriented so that her range covers it, it goes back to the hand', () => {
const rec = DATA.backups.tokens[ANCHOR];
assert.deepEqual([rec.position, rec.ownerRangeOutside, rec.rangedTilesOnly, rec.ownerRange], ['RANGED', true, true, false]);
// act1 m03: 高台 at (10, 3), (10, 4), (11, 7), (11, 8); she stands on the ground at (12, 5), facing RIGHT — her y-6 takes
// (11, 7) in and leaves the other three out
const { h, m, ps } = prep({ [T5A]: { charId: KALTS2, skillIndex: 2 } }, 2102, 'act1autochess_m03');
const map = ps.deployMap();
const highs = [...map].filter(([, v]) => v === 'ranged').map(([k]) => parseKey(k));
assert.ok(highs.length >= 2, 'the stage has 高台 tiles');
const kal = placeDiy(m, ps, T5A, [12, 5]);
const stack = stackOf(ps, kal, ANCHOR);
assert.ok(stack && (stack.count || 1) === 1, 'one 战术锚点 in the hand (S3)');
const range = rangeOf(ps, kal);
const tiles = freeTiles(ps, stack);
let legalN = 0;
for (const [r, c, legal] of tiles) {
const k = tileKey(r, c);
assert.equal(legal, map.get(k) === 'ranged' && !range.has(k), `server ${k}`);
assert.equal(clientOk(m, ps, stack.uid, r, c), legal, `client ${k}`);
if (legal) legalN++;
}
const insideHigh = highs.find(([r, c]) => range.has(tileKey(r, c)) && !ps.board.has(tileKey(r, c)));
const ground = tiles.find(([r, c]) => map.get(tileKey(r, c)) === 'melee' && !range.has(tileKey(r, c)));
assert.ok(ground, 'a ground tile outside her range');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: ground[0], col: ground[1] } }), { error: ERR.BAD_TILE }, 'not on the ground');
if (insideHigh) assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: insideHigh[0], col: insideHigh[1] } }), { error: ERR.BAD_TILE }, 'not inside her range');
assert.ok(legalN > 0, 'a 高台 outside her range');
// placed on a 高台 outside her range; then she turns so that her range takes that tile in: the anchor goes back
for (const dir of ['RIGHT', 'UP', 'DOWN', 'LEFT']) {
const atDir = { key: tileKey(12, 5), piece: kal, dir };
const keys = ps._ownerRangeOf({ kind: 'token', ownerUid: kal.uid, id: '' }, atDir);
const spot = tiles.find(([r, c, legal]) => legal && keys.has(tileKey(r, c)));
if (!spot) continue;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: spot[0], col: spot[1] } }), { ok: true }, 'onto a free 高台 outside her range');
const placed = ps.board.get(tileKey(spot[0], spot[1]));
assert.equal(placed.id, ANCHOR);
const toasts = h.sent.length;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: kal.uid, to: { area: 'board', row: 12, col: 5 }, dir }), { ok: true }, `re-oriented ${dir}`);
assert.ok(!ps.board.has(tileKey(spot[0], spot[1])), 'the anchor left the board');
assert.ok(stackOf(ps, kal, ANCHOR), 'back on its stack');
const said = h.sent.slice(toasts).filter(([, msg]) => msg.t === 'm.toast').map(([, msg]) => msg.msgid || msg.text);
assert.ok(said.some((x) => String(x).includes('只能部署在召唤者攻击范围外')), `the toast: ${said.join(' / ')}`);
checkInvariants(m);
m.dispose();
return;
}
assert.fail('no facing of hers covers a legal 高台');
});
+1 -1
View File
@@ -12,7 +12,7 @@ after(async () => { if (srv) await srv.close(); });
test('/sim/ serves the simulation modules read-only; nothing else of server/ is reachable', async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const get = (p, headers = {}) => fetch(srv.url + p, { redirect: 'manual', headers });
for (const p of ['/sim/spec.js', '/sim/Battle.js', '/sim/simdata.js', '/sim/constants.js', '/sim/content/index.js', '/sim/content/kits/tier1.js', '/sim/content/support/index.js']) {
for (const p of ['/sim/spec.js', '/sim/Battle.js', '/sim/simdata.js', '/sim/constants.js', '/sim/content/index.js', '/sim/content/kits/index.js', '/sim/content/support/index.js']) {
const r = await get(p);
assert.equal(r.status, 200, p);
assert.match(r.headers.get('content-type'), /^text\/javascript/, p);
+1 -1
View File
@@ -12,7 +12,7 @@ import { COLS } from '../../server/sim/constants.js';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { findSummonTile } from '../../server/sim/content/tokens.js';
import { frontTile, sideTiles, alliesAround, N4 } from '../../server/sim/content/support/index.js';
import { freeTileAround } from '../../server/sim/content/kits/tier1.js';
import { freeTileAround } from '../../server/sim/content/kits/shared/tier1.js';
import { DIRS as PROTOCOL_DIRS } from '../../shared/protocol.js';
const K = (r, c) => r * COLS + c;
+1 -1
View File
@@ -22,7 +22,7 @@ import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/batt
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import { PUSH_TILES, PUSH_TILES_EFFECT, PULL_STOP_RADIUS, ASPD_MIN, COLS } from '../../server/sim/constants.js';
import { alliesInGridOf } from '../../server/sim/content/kits/tier1.js';
import { alliesInGridOf } from '../../server/sim/content/kits/shared/tier1.js';
import { Unit } from '../../server/sim/units.js';
import { spawnYanyou, TOKEN_IDS } from '../../server/sim/content/tokens.js';
+70
View File
@@ -0,0 +1,70 @@
// test/ui/feedback5-we2-diybanned.test.js — 0.2.0 WE2 #11: the player's own 自选 pieces left out of the shop because every
// bond of the operator is switched off this match (m.private.diyBanned) are named under 本局禁用干员 — the briefing, the
// strategy draft's 本局信息 dialog (ui/matchInfo.js) and the in-game 本局信息 tab (ui/enemyDrawer.js).
// Run: node --test test/ui/feedback5-we2-diybanned.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const G = await import('../../public/js/ui/gameLogic.js');
const { matchInfoModel, DiyBannedLine } = await import('../../public/js/ui/matchInfo.js');
const { data } = await import('../../public/js/data.js');
const { setLang, addMessages } = await import('../../shared/i18n.js');
const { DATA } = await import('../match/harness.js');
await data.loadAll('chess', 'bonds', 'assets', 'backups');
const T5A = 'chess_char_5_diy1_a';
const T6A = 'chess_char_6_diy1_a';
const SIEGE = 'char_112_siege';
const LING = 'char_2023_ling';
const D = { chess: DATA.chess, backups: DATA.backups };
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const priv = {
diy: { [T5A]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, [T6A]: { charId: LING, skillIndex: 0, uniEquipId: null } },
diyBanned: [T6A],
};
const textOf = (v) => (Array.isArray(v) ? v.map(textOf).join('') : v == null || typeof v === 'boolean' ? '' : typeof v === 'object' ? textOf(v.props?.children) : String(v));
test('#11 diyBannedPieces: the banned slots with their operator\'s name, slot order; none without picks', () => {
assert.deepEqual(G.diyBannedPieces(priv, getChess, D), [{ slotId: T6A, charId: LING, name: DATA.backups.units[LING].name }]);
assert.deepEqual(G.diyBannedPieces({ diy: priv.diy, diyBanned: [] }, getChess, D), []);
assert.deepEqual(G.diyBannedPieces({ diyBanned: [T6A] }, getChess, D), [], 'no pick for the slot: nothing');
assert.deepEqual(G.diyBannedPieces(null, getChess, D), []);
});
test('#11 the match-info model and its line: the own banned 自选 piece is named; nothing without priv / diyBanned', () => {
const pub = { bannedChess: [], drawnDisabledBonds: [], modeId: 'mode_multi_hard' };
const model = matchInfoModel(pub, { bonds: [], chess: getChess, priv, diyData: D });
assert.deepEqual(model.diyBanned.map((x) => x.slotId), [T6A]);
setLang('zh');
const line = DiyBannedLine({ model });
assert.ok(line, 'a line');
const txt = textOf(line);
assert.ok(txt.includes('自选') && txt.includes(DATA.backups.units[LING].name) && txt.includes('不会出现在你的商店'), txt);
assert.equal(DiyBannedLine({ model: matchInfoModel(pub, { bonds: [], chess: getChess }) }), null, 'no priv: no line');
assert.equal(DiyBannedLine({ model: matchInfoModel(pub, { bonds: [], chess: getChess, priv: { ...priv, diyBanned: [] }, diyData: D }) }), null);
// English: the msgid is in public/i18n/en.json
const en = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
const id = '{names}的盟约本局全部禁用,不会出现在你的商店';
assert.ok(en[id] && en[id].includes('{names}'), 'translated with its placeholder');
addMessages('en', { [id]: en[id], 自选: en['自选'] });
setLang('en');
try {
const t2 = textOf(DiyBannedLine({ model }));
assert.ok(t2.includes('Custom') && t2.includes('not in your shop'), t2);
} finally { setLang('zh'); }
});
+4 -3
View File
@@ -193,17 +193,18 @@ test('the briefing, the strategy draft and the in-game 本局信息 tab all read
const brief = read('public/js/screens/briefing.js');
assert.match(brief, /import \{ MatchInfo, matchInfoModel \} from '\.\.\/ui\/matchInfo\.js';/);
assert.match(brief, /<section class="brief__right">\s*<\$\{MatchInfo\} model=\$\{info\} \/>\s*<\/section>/);
assert.match(brief, /const info = matchInfoModel\(pub, \{ bonds: gd\.list\('bonds'\), chess: gd\.chess, mode \}\);/);
// (+ the viewer's 自选 pieces out of the shop: priv / diyData, 0.2.0 WE2)
assert.match(brief, /const info = matchInfoModel\(pub, \{ bonds: gd\.list\('bonds'\), chess: gd\.chess, mode, priv, diyData: \{ chess: data\.get\('chess'\), backups: data\.get\('backups'\) \} \}\);/);
assert.doesNotMatch(brief, /brief-legend|brief-banned|function BondRow|bannedPerBond|disabledBondSets/, 'no own copy of the blocks');
const draft = read('public/js/screens/bandDraft.js');
assert.match(draft, /import \{ MatchInfoDialog, matchInfoModel \} from '\.\.\/ui\/matchInfo\.js';/);
assert.match(draft, /const info = infoOpen \? matchInfoModel\(pub, \{ bonds: gd\.list\('bonds'\), chess: gd\.chess, mode \}\) : null;/);
assert.match(draft, /const info = infoOpen \? matchInfoModel\(pub, \{ bonds: gd\.list\('bonds'\), chess: gd\.chess, mode, priv, diyData: \{ chess: data\.get\('chess'\), backups: data\.get\('backups'\) \} \}\) : null;/);
assert.match(draft, /data-testid="match-info-open"[\s\S]*?onClick=\$\{\(\) => setInfoOpen\(true\)\}>查看禁用盟约与干员</);
assert.match(draft, /<\$\{MatchInfoDialog\} open=\$\{infoOpen\} onClose=\$\{\(\) => setInfoOpen\(false\)\} model=\$\{info\}/);
// a turn change or my pick closes it; the draft's end unmounts the screen
assert.match(draft, /const turnKey = `\$\{draft\.turnPid \|\| ''\}\|\$\{myPick \|\| ''\}`;\n\s*useEffect\(\(\) => \{ setInfoOpen\(false\); \}, \[turnKey\]\);/);
const drawer = read('public/js/ui/enemyDrawer.js');
assert.match(drawer, /import \{ matchInfoModel \} from '\.\/matchInfo\.js';/);
assert.match(drawer, /import \{ matchInfoModel, DiyBannedLine \} from '\.\/matchInfo\.js';/);
assert.match(drawer, /matchInfoModel\(pub, \{\s*bonds: data\.list\('bonds'\), chess: \(id\) => data\.lookup\('chess', id\), mode: data\.get\('config'\)\?\.modes\?\.\[pub\?\.modeId\],/);
assert.doesNotMatch(drawer, /bannedPerBond|disabledBondSets/, 'the drawer derives nothing on its own');
});
+100 -15
View File
@@ -432,6 +432,13 @@ function immunitiesOf(attrs) {
/** Official trigger rule names → DESIGN §5.6 names. */
const TRIGGER_RENAME = { ALWAYS: 'SP_FULL', CUSTOM_RANGE_SEARCH_ENEMY: 'CUSTOM_RANGE' };
/**
* Official rows the engine plays as one of its own rules on an ally condition (`allies: true` — server/sim/skills.js: a
* healable, injured ally on the trigger grid instead of an enemy): 黍 S3 离离枯荣's TRY_SEARCH_ALLY_SKILL, PRTS 卫戍协议/帮助
* 特殊策略 "技能范围内存在可治疗的我方单位时释放技能" — SKILL_RANGE on the skill's own range (x-2), checked every tick.
*/
const TRIGGER_ALLY_RULES = Object.freeze({ TRY_SEARCH_ALLY_SKILL: 'SKILL_RANGE' });
/**
* A skill whose rangeId is its new ATTACK range — "攻击范围扩大 / 改变 / 缩小 / 缩短", "攻击距离+1 / 加长 / 缩短", "攻击范围与
* 溅射范围扩大" — and not a 技能范围 of its own (PRTS 卫戍协议/帮助 技能操作: "拥有技能范围的技能(非攻击距离增加)").
@@ -527,6 +534,8 @@ const ACTIVE_RANGE_OVER = new Set(['DEFAULT', 'SEARCH']);
* - else, for an operator's MANUAL skill with a 技能范围 (a rangeId that is not an attack-range change): SKILL_RANGE,
* "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能", customRangeGrid = the skill range;
* - else DEFAULT (the basic strategy: ready + about to attack / heal);
* - an ally row the engine plays as one of its rules (TRIGGER_ALLY_RULES: 黍 S3's TRY_SEARCH_ALLY_SKILL → SKILL_RANGE on
* the skill range with `allies: true` — an injured, healable ally there);
* - last, the deliberate deviations (TRIGGER_DEVIATIONS, per chess and skill; STANDIN_TRIGGER_DEVIATIONS, per stand-in
* unit and skill): `rule` from the table, `rawRule` the official row (a SKILL_RANGE deviation takes the skill's own
* range as `customRangeGrid`);
@@ -568,6 +577,10 @@ function resolveTrigger(ctx, char, charId, skillIdx, skill, { operator = false,
if (!skill.rangeGrid) warn(`trigger deviation ${chessId} ${skill.skillId}: SKILL_RANGE without a 技能范围`);
return { rule: deviation, rawRule, customRangeGrid: skill.rangeGrid ? skill.rangeGrid.map((p) => p.slice()) : null };
}
if (!deviation && TRIGGER_ALLY_RULES[rawRule]) {
if (!skill.rangeGrid) warn(`skill ${skill.skillId}: ${rawRule} without a 技能范围`);
return { rule: TRIGGER_ALLY_RULES[rawRule], rawRule, customRangeGrid: skill.rangeGrid ? skill.rangeGrid.map((p) => p.slice()) : null, allies: true };
}
const rule = deviation || TRIGGER_RENAME[rawRule] || rawRule;
if (ACTIVE_RANGE_OVER.has(rule) && operator && manual && baseGrid?.length) {
const active = activeAttackGrid(skill, baseGrid);
@@ -792,9 +805,13 @@ function classifyAttack(char, traitText) {
const prof = char.profession;
const sub = char.subProfessionId;
const trait = traitText || '';
// 驭法铁卫 (斩业星熊) "技能开启时普通攻击会造成法术伤害": arts is the skill-on type; the normal attack — the record's
// dmgType, what a card and the bot read — is physical (display only: her kit attacks physically off-skill and in arts
// while a skill runs, op-hsgma2.js)
const skillOnArts = /技能开启时[^,。;]*法术伤害/.test(trait);
let dmgType;
if ((prof === 'MEDIC' && sub !== 'incantationmedic') || sub === 'bard') dmgType = 'heal';
else if (/法术伤害/.test(trait) || prof === 'CASTER') dmgType = 'arts';
else if ((/法术伤害/.test(trait) && !skillOnArts) || prof === 'CASTER') dmgType = 'arts';
else dmgType = 'phys';
let attackKind;
@@ -1368,8 +1385,44 @@ function classifyToken(char, traitText) {
return c;
}
/** The blackboard keys of a summon's own talents that add to its deploy limit / holding (statsFrom: deployLimit / deckStack). */
const TOKEN_DECK_KEYS = Object.freeze(['max_deploy_count', 'max_deck_stack_cnt']);
/**
* Build one owner-specific variant of a token / map character at (phase, level, skill level).
* A summon's talent additions to its deploy limit and holding: the blackboard keys `max_deploy_count` /
* `max_deck_stack_cnt` of the token's own talents — the hidden "TOKEN数+N" talent of 麦哲伦's drones and 令's summons
* (E2: max_deck_stack_cnt 5, max_deploy_count 2), of 白铁's devices (E2: 3, 1), the visible 转瞬即逝的幻影 of 夜莺's
* 幻影 (max_deploy_count 2) — which the client adds to the attribute frame's maxDeployCount / maxDeckStackCnt (statsFrom
* reads the frame only): PRTS 幻影 备注 "每次部署夜莺时获得2个可部署的幻影,最大可部署数量为3" (1 + 2); the owners' own
* talents "最多同时部署3个" (麦哲伦 / 令: 1 + 2), "最多可部署2个" (白铁: 1 + 1); SUM-Y stage 2+ "最多同时部署4个" — its token part
* (the same talent, max_deploy_count 3) replaces the talent's values. The candidates as everywhere: the token's phase /
* level at potential 0 (望's rank-2 棋子 +1 stays out); a module's token part (`moduleTokenParts`, unlocked at the owner's
* phase / level as moduleTalentChanges) replaces the base candidate of the same prefabKey key by key (mergeTalentChanges:
* what it does not restate stays) — matched by prefabKey, not talentIndex (夜莺's RIN-Y stage 3 part names talentIndex 0
* for her 幻影's talent 1). The hand count of a placeable summon is this deploy limit (PRTS 卫戍协议/帮助 "根据召唤物部署数量
* 上限(非初始持有量),发送等量召唤物至手牌区"; gamedata / player/diy.js placeableTokens).
* @returns {Record<string, number>} the non-zero additions by blackboard key
*/
function tokenTalentDeckBonus(char, phase, level, moduleTokenParts, modPhase, modLevel) {
const byKey = new Map();
const take = (c, merge) => {
if (!c) return;
const key = String(c.prefabKey ?? '');
const bb = {};
for (const b of c.blackboard || []) if (TOKEN_DECK_KEYS.includes(b.key) && typeof b.value === 'number') bb[b.key] = b.value;
byKey.set(key, merge ? { ...(byKey.get(key) || {}), ...bb } : bb);
};
for (const t of char.talents || []) take(bestCandidate(t?.candidates, phase, level), false);
for (const part of moduleTokenParts || []) take(bestCandidate(part?.addOrOverrideTalentDataBundle?.candidates, modPhase, modLevel), true);
const out = {};
for (const bb of byKey.values()) for (const [k, v] of Object.entries(bb)) out[k] = (out[k] || 0) + v;
for (const k of Object.keys(out)) if (!out[k]) delete out[k];
return out;
}
/**
* Build one owner-specific variant of a token / map character at (phase, level, skill level). Its stats add the module's
* token attributes and the summon's talent additions to its deploy limit / holding (tokenTalentDeckBonus).
* @returns {object}
*/
function tokenVariant(ctx, tokenId, char, { phase, level, skillIndex, skillLevel, modulePhase, moduleTokenParts, label }) {
@@ -1378,6 +1431,7 @@ function tokenVariant(ctx, tokenId, char, { phase, level, skillIndex, skillLevel
const bonus = {};
const tokBonus = modulePhase?.tokenAttributeBlackboard?.[tokenId];
for (const b of Array.isArray(tokBonus) ? tokBonus : []) bonus[b.key] = (bonus[b.key] || 0) + b.value;
for (const [k, v] of Object.entries(tokenTalentDeckBonus(char, ph, lv, moduleTokenParts, phase, level))) bonus[k] = (bonus[k] || 0) + v;
const attrs = interpolateAttrs(char, ph, lv);
const tc = bestCandidate(char.trait?.candidates, ph, lv);
const tbb = flattenBB(tc?.blackboard);
@@ -1446,6 +1500,15 @@ const TOKEN_ABNORMAL = Object.freeze({
enemy_9012_acloon: ['isolated'], // “炎佑”
});
/**
* Summon deployment positions the client's token row gets wrong, from the PRTS summon pages: 望's 棋子 — PRTS 棋子
* 部署位置 "全部位", 备注 "游戏内召唤物信息与实际不符(显示为仅部署在近战位)" (character_table: MELEE). The record's `position`
* (the prep's placement class, board.js positionClass) takes this value.
*/
const TOKEN_POSITION_CORRECTIONS = Object.freeze({
token_10064_wang_stone1: 'ALL', // 棋子
});
/**
* The tokens.json record of a summon (buildTokens; the 自选 picks' summons, buildDiyTokens) from its per-owner
* `variants` (key → variant; `owners` = the keys, in order; the defaults are the first owner's variant).
@@ -1459,24 +1522,39 @@ const TOKEN_ABNORMAL = Object.freeze({
* 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
* 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
* (`produced`: a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant
* (`sources`, `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
* takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
* SKILL_SUMMON_START_DEPLOY).
* (`sources`, `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). And only a token an owner shows (`displayed`: the owner's
* displayTokenDict, the variants' `display` source): a summon no owner shows is its skill's own object, never a card —
* every such token of the season is HIDDEN in the shop state (纸偶, 香槟炸弹, 从不混淆的方向, …), and the one the shop state
* does not list is 予愿安洁莉娜 S3's “一会儿见!” (PRTS 予愿安洁莉娜 S3 备注 "每次移动后若不位于初始位置…于初始位置部署一个
* “一会儿见!”", PRTS “一会儿见!” "无法被玩家选择查看详细信息"; O20) — every summon a player places is displayed. In battle a
* skill's summon deploys once at the start, then takes its tile again each time the skill gives one
* (sim/content/tokens.js dockSkillSummons, shared/constants.js SKILL_SUMMON_START_DEPLOY).
* `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
* 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
* ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere).
* `abnormal` = TOKEN_ABNORMAL (PRTS).
* 特性) or an owner's talent naming it "可以在攻击范围内(的地面)部署 / 使用…" (`ownerTexts`: Mon3tr's 重构体 "可以在攻击范围内的地面
* 使用一个…重构体", 0.2.0 WE2; 莱伊's 沙地兽, whose text says it too): its hand piece may only be placed on a tile of its
* owner's attack range (server/match/board.js ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's
* tactical point could go anywhere). `ownerRangeOutside` (present when true): the token text "部署在…攻击范围外" — the
* piece may only stand OUTSIDE its owner's attack range; `rangedTilesOnly` (present when true): "仅可以部署在…远程位" — only
* on a ranged (高台) tile, the mode's "所有行动内远程干员可部署在近战位" being an operators' rule: 凯尔希·思衡托's 战术锚点
* "仅可以部署在凯尔希·思衡托攻击范围外的远程位" (PRTS 战术锚点 特性; 0.2.0 WE2, O24).
* `abnormal` = TOKEN_ABNORMAL (PRTS); `position` = TOKEN_POSITION_CORRECTIONS (PRTS) or the token row's.
*/
function summonRecord(ctx, tokenId, char, variants, produced) {
function summonRecord(ctx, tokenId, char, variants, produced, ownerTexts = []) {
const displayType = ctx.ac.shopStateTokenDict?.[tokenId]?.tokenDisplayType || null;
const owners = Object.keys(variants);
const first = variants[owners[0]];
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
const text = stripRich(first.trait.desc) || '';
const ownerRange = /只能部署在\S*攻击范围内/.test(text) || ownerTexts.some((d) => /可以在攻击范围内(?:的[^,。;]*?)?(?:部署|使用)/.test(stripRich(d) || ''));
const ownerRangeOutside = /部署在[^,。;]*攻击范围外/.test(text);
const rangedTilesOnly = /仅可以部署在[^,。;]*远程位/.test(text);
const shows = (list) => Array.isArray(list) && list.includes('display');
const displayed = Object.values(variants).some((v) => shows(v.sources) || Object.values(v.bySkill || {}).some((a) => shows(a.sources)));
return {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType, placeable: displayType !== 'HIDDEN' && produced, ownerRange,
profession: char.profession, subProfessionId: char.subProfessionId, position: TOKEN_POSITION_CORRECTIONS[tokenId] ?? char.position,
displayType, placeable: displayType !== 'HIDDEN' && produced && displayed, ownerRange,
...(ownerRangeOutside ? { ownerRangeOutside: true } : null), ...(rangedTilesOnly ? { rangedTilesOnly: true } : null),
owners,
// Defaults = first owner's variant; per-owner data in variants[owner].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
@@ -1551,14 +1629,15 @@ function buildDiyTokens(ctx, units, owned) {
}
}
if (!owners.has(tokenId)) owners.set(tokenId, []);
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced });
const texts = form.talents.filter((t) => t.tokenKey === tokenId).map((t) => t.desc || '');
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced, texts });
}
}
}
const out = {};
for (const tokenId of [...owners.keys()].sort(naturalCmp)) {
const list = owners.get(tokenId);
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced));
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced), list.flatMap((o) => o.texts));
}
return out;
}
@@ -1601,7 +1680,8 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
}
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced);
const ownerTexts = owners.flatMap((o) => (chess[o.chessId]?.talents || []).filter((t) => t.tokenKey === tokenId).map((t) => t.desc || ''));
out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced, ownerTexts);
}
// 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template.
@@ -3740,6 +3820,11 @@ function validateAll(f) {
if (!tokens[id]) err(`TOKEN_ABNORMAL: ${id} is not a token`);
for (const f of fl) if (f !== 'healFree' && f !== 'isolated') err(`TOKEN_ABNORMAL: ${id}: unknown effect ${f}`);
}
for (const [id, pos] of Object.entries(TOKEN_POSITION_CORRECTIONS)) {
const t = tokens[id] ?? backups?.tokens?.[id];
if (!t || t.kind !== 'summon') err(`TOKEN_POSITION_CORRECTIONS: ${id} is not a summon`);
else if (t.position !== pos || !['MELEE', 'RANGED', 'ALL'].includes(pos)) err(`TOKEN_POSITION_CORRECTIONS: ${id}: position ${t.position}`);
}
for (const t of Object.values(tokens)) {
if (!t.stats) err(`token ${t.tokenId}: no stats`);
for (const [o, v] of Object.entries(t.variants || {})) if (!Array.isArray(v.sources) || !v.sources.length) err(`token ${t.tokenId}@${o}: no sources`);