feedback1 final: merge the 0.1.1 gaps (变形同构体 pairings, 调和 +1 row, bands tied to a switched-off bond, the client-result layer bound)

This commit is contained in:
sganggs committed 2026-10-03 18:45:56 +08:00
commit 3b4d44bda8
26 files changed
+1332 -65

No files matched your search

+5
View File
@@ -60,6 +60,7 @@
- 隐秘核心的「伤害超过 X%」播报只算这一场领袖的伤害,上一回合的播报不会拖到下一回合。
- 播报按官方优先级排队,领袖伤害播报不会被升级调度中心等播报挤掉。
- 修复联防中隐德来希的心烛在超时时被算成漏怪、导致战斗结果被服务器拒绝的问题。
- 修复叠层特质很强的阵容(如六谢拉格冻结流)一场作战叠层较多时,战斗结果被服务器误判为异常、改由服务器重算的问题。
### 界面
@@ -67,6 +68,9 @@
- 干员详情卡的攻击范围小图实时显示技能期间的范围;整备期棋盘上的范围提示、转向轮盘和详情卡显示同一个范围。
- 网页卡顿、切到后台或中途进场观战时,不会再漏掉敌人的变身,也不会在切回来后补播早已结束的变身动画。
- 灼热 / 炽焰源石虫:整合包附带官方模型;从源码运行且没有提取本机客户端素材时,显示染成橙色 / 红橙色的源石虫。
- 变形同构体的详情卡列出天赋栏里的对应关系:每个盟约由哪些装备搭配,本局禁用的盟约会标出;装在干员身上时高亮正在生效的那一组,各盟约装备的详情也写明与变形同构体一同装备时携带者视为哪个盟约的成员。
- 调和让核心盟约人数 +1 时,盟约详情写明「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思);查看队友的盟约时也一样。
- 选择策略时,围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)标出「本局禁用」并在详情里说明,仍可选择。
### 人机
@@ -74,6 +78,7 @@
- 人机遵守新规则:深水区、战术点范围、突变细胞、本局禁用的盟约、阵法术师的群攻。
- AI 托管不会为了腾地方销毁你的道具。
- 人机不再把本回合刚买的干员马上卖掉。
- 人机不再选择围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)。
### 工具
+1 -1
View File
@@ -1 +1 @@
{"band_bldsk":{"bandId":"band_bldsk","sortId":1,"name":"华法琳","iconId":"icon_bldsk","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_21","effectName":"重点监护","desc":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","descRaw":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","buffs":[{"key":"prep_finish_char_bond_add_layer","bb":{"layer":2},"bbStr":{}}],"params":{"layer":2},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_amiya":{"bandId":"band_amiya","sortId":2,"name":"阿米娅","iconId":"icon_amiya","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":25,"effectId":"aceffect_band_22","effectName":"众志合一","desc":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","descRaw":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5},"bbStr":{"key":"act1autochess_band2_buff"}}],"params":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5,"key":"act1autochess_band2_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_duyaoy":{"bandId":"band_duyaoy","sortId":3,"name":"杜遥夜","iconId":"icon_duyaoy","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_28","effectName":"广交豪杰","desc":"【广交豪杰】每回合前2次主动刷新为特殊刷新:优先刷新出1名<炎>干员\n在<炎>部分干员缺席时体验可能不完整","descRaw":"【广交豪杰】每回合前2次主动刷新为特殊刷新:优先刷新出1名<炎>干员\n<@ba.vdown>在<炎>部分干员缺席时体验可能不完整</>","buffs":[{"key":"band_first_self_refresh_present_char","bb":{"count":1},"bbStr":{"bond":"yanShip"}}],"params":{"count":1,"bond":"yanShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_sarkazb":{"bandId":"band_sarkazb","sortId":4,"name":"歌利亚","iconId":"icon_sarkazb","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":45,"effectId":"aceffect_band_44","effectName":"坚不可摧","desc":"【坚不可摧】初始目标生命值为45点","descRaw":"【坚不可摧】初始目标生命值为45点","buffs":[],"params":{},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_orchid":{"bandId":"band_orchid","sortId":5,"name":"梓兰","iconId":"icon_orchid","modeTypeList":["SINGLE","MULTI"],"totalHp":30,"effectId":"aceffect_band_23","effectName":"猎头顾问","desc":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","descRaw":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<@ba.acrem><寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)</>","buffs":[{"key":"band_shop_refresh_copy_max_lv_char","bb":{"count":1},"bbStr":{}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}}],"params":{"count":1,"round":10,"type":"equip","chess":"chess_item_4_01_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【标准模拟】中通过第9回合后解锁"},"band_justin":{"bandId":"band_justin","sortId":6,"name":"小贾斯汀","iconId":"icon_justin","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_47","effectName":"私募基金","desc":"【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐>\n<骑士储蓄罐>:装备时随机获得1~6资金并销毁","descRaw":"【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐>\n<@ba.acrem><骑士储蓄罐>:装备时随机获得1~6资金并销毁</>","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":4,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":7,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}}],"params":{"round":1,"count":1,"type":"equip","chLine truncated
{"band_bldsk":{"bandId":"band_bldsk","sortId":1,"name":"华法琳","iconId":"icon_bldsk","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_21","effectName":"重点监护","desc":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","descRaw":"【重点监护】开始作战时,使场上每个不同等阶的1个随机我方干员的所属盟约层数+2","buffs":[{"key":"prep_finish_char_bond_add_layer","bb":{"layer":2},"bbStr":{}}],"params":{"layer":2},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_amiya":{"bandId":"band_amiya","sortId":2,"name":"阿米娅","iconId":"icon_amiya","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":25,"effectId":"aceffect_band_22","effectName":"众志合一","desc":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","descRaw":"【众志合一】场上存在3、4或5种以上激活盟约时,所有干员攻击力、生命值分别+20/30/40%","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5},"bbStr":{"key":"act1autochess_band2_buff"}}],"params":{"atk_1":0.2,"atk_2":0.3,"atk_3":0.4,"max_hp_1":0.2,"max_hp_2":0.3,"max_hp_3":0.4,"value_1":3,"value_2":4,"value_3":5,"key":"act1autochess_band2_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_duyaoy":{"bandId":"band_duyaoy","sortId":3,"name":"杜遥夜","iconId":"icon_duyaoy","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_28","effectName":"广交豪杰","desc":"【广交豪杰】每回合前2次主动刷新为特殊刷新:优先刷新出1名<炎>干员\n在<炎>部分干员缺席时体验可能不完整","descRaw":"【广交豪杰】每回合前2次主动刷新为特殊刷新:优先刷新出1名<炎>干员\n<@ba.vdown>在<炎>部分干员缺席时体验可能不完整</>","buffs":[{"key":"band_first_self_refresh_present_char","bb":{"count":1},"bbStr":{"bond":"yanShip"}}],"params":{"count":1,"bond":"yanShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":["yanShip"]},"band_sarkazb":{"bandId":"band_sarkazb","sortId":4,"name":"歌利亚","iconId":"icon_sarkazb","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":45,"effectId":"aceffect_band_44","effectName":"坚不可摧","desc":"【坚不可摧】初始目标生命值为45点","descRaw":"【坚不可摧】初始目标生命值为45点","buffs":[],"params":{},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_orchid":{"bandId":"band_orchid","sortId":5,"name":"梓兰","iconId":"icon_orchid","modeTypeList":["SINGLE","MULTI"],"totalHp":30,"effectId":"aceffect_band_23","effectName":"猎头顾问","desc":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","descRaw":"【猎头顾问】每次主动刷新均变为特殊刷新:必定刷新2名同名干员,并冻结其中一名;第10回合获得1个<寻呼模块>\n<@ba.acrem><寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)</>","buffs":[{"key":"band_shop_refresh_copy_max_lv_char","bb":{"count":1},"bbStr":{}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}}],"params":{"count":1,"round":10,"type":"equip","chess":"chess_item_4_01_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【标准模拟】中通过第9回合后解锁","bondIds":[]},"band_justin":{"bandId":"band_justin","sortId":6,"name":"小贾斯汀","iconId":"icon_justin","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_47","effectName":"私募基金","desc":"【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐>\n<骑士储蓄罐>:装备时随机获得1~6资金并销毁","descRaw":"【私募基金】在第1、4、7、10回合进入休整期时获得1个<骑士储蓄罐>\n<@ba.acrem><骑士储蓄罐>:装备时随机获得1~6资金并销毁</>","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":4,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":7,"count":1},"bbStr":{"type":"equip","chess":"chess_item_3_12_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess"Line truncated
+1
View File
@@ -302,6 +302,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
| `effectId`, `effectName`, `desc`, `descRaw` | `"aceffect_band_21"`, `"重点监护"`, `"【重点监护】开始作战时…"` | |
| `buffs[]`, `params` | `[{"key":"prep_finish_char_bond_add_layer","bb":{"layer":2},"bbStr":{}}]` | |
| `victorCount`, `rewardModulus`, `unlockDesc` | `3`, `1`, `null` | |
| `bondIds` | `["lateranoShip"]` (潘格尼尼) / `[]` | the bonds the strategy is built around (`shared/bandBonds.js`: the bond names in <…> of its text — the official note "在<X>部分干员缺席时体验可能不完整" among them — and the bond ids / bond pools of its blackboards, `pool` → choices.json `pools[pool].bond`); 9 bands are tied. The bot never picks, and the strategy draft marks 本局禁用, a band whose bond the mode switches off (DESIGN §21.26) |
## 7. `effects.json` — `{ [effectId]: Effect }` (361: every effect of the season)
+19 -7
View File
@@ -367,7 +367,7 @@ Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (inco
### 6.3 Bonds & layers (bondsMeta.js)
- `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count, etc. (research 02).
- `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count that has a real member on the board (the views mark such an entry `harmony: 1`, §21.26), etc. (research 02).
- 变形同构体: an operator wearing it together with an item that has a `giveBondId` is a member of that bond — counted like any member (BOARD: on the board only; once per operator), in the battle, and in the client's member list (tagged 同构) and bond chips (§21.11).
- `tier` from thresholds; `active = tier ≥ 1`.
- `layers[bondId]` persist all match; gains come from garrisons/bands/items/choices/bond effects; IN_BATTLE gains are collected from `BattleResult.layerGains` at SETTLE (disabled in unite/boss/hidden).
@@ -384,7 +384,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); 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`; `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); 机变 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`; `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); 机变 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).
---
@@ -415,7 +415,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMa
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3)
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
@@ -438,7 +438,7 @@ S→C:
temp: [ Piece | null ], // length 5
board: [ Piece & { row, col, dir } ], // dir ∈ UP|RIGHT|DOWN|LEFT (§3; bench pieces carry none)
deployCap, deployCount,
bonds: [ { bondId, count, active, tier, layers, thresholds, countsHand } ], // sorted: active first, then layers desc
bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
nextEnemies: [ { enemyKey, count, tag } ], // preview of the upcoming round's wave (after waves.js generation)
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
@@ -550,7 +550,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
- `m.public.fields[]` gains `progress: { killed, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
- `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ total ≤ 4·spawns + 100`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12), none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ total ≤ 4·spawns + 100`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**Boss rounds.** The shared pool (`SharedBossPool`, co-op size = `bloodPoint[difficulty]`, see corrections) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`; BOSS_HIT tickers from each player's damage to that round's pool, §21.10) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end` — except a `'cleared'` result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s) has not caught up: it is **held** (`f.heldResult`; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (`_endFinal`) completes the field with it; a forged report gains nothing a `b.progress` could not already get. Any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds more than the report covers — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw.
@@ -1002,7 +1002,7 @@ Still [ASSUMED] (each in its section): the class rows covering every MANUAL skil
- **Asked**: the co-op leaders as hard to kill as in the official game. Research 11 (`docs/research/11-limits-official.md`) found two official caps in the client (2.7.71 il2cpp) that the remake lacked.
- **Official** [DATA]: `AutoChessBattleConst.MAX_GARRISON_STACK = 999` — `AddBondCount` stores `min(L + n, 999)`, per bond. `MAX_BATTLE_DAMAGE = 300000` — in step mode (every boss battle outside training) `AutoChessStepModeManager._OnBossEnemyTakeDamage` runs `modifier.Cancel()` when `ceil(value) ≥ 300000` on an `IsBossEnemy` unit; `IsBossEnemy` (disassembled, research 11 §2.3) = enemyId ∈ `autoChessData.bossInfoDict[*].enemyId` = data/bosses.json `enemyKey`. [COMM] bonds stay at 999 while fed; 「一次超过30w会被卡掉」: players avoid extra multipliers at 999 layers. 限伤 is a deliberate constant, **not** an engine fixed-point overflow — the boss-HP workstream's earlier reading (2³¹, "not modelled", §20.10) is superseded.
- **Now — layers**: the single implementation is `shared/constants.js BOND_LAYER_CAP` + `layerGainRoom(before, n)` = min(n, 999 − before), never negative (the boss-HP workstream's duplicate `sim/constants.js BOND_LAYER_CAP / layerRoom` was removed in the merge). Every writer uses it: `PlayerState.addLayers` (特质, items, bands, 机变 cards, bonds), the SETTLE of `BattleResult.layerGains`, `Battle.addLayers` (the live copy, clamped after the `layerGain` hook, like the client), and the dev tools' direct writes (`matchrun --layers N`, `balance.mjs applyBoard`). A gain at the cap adds 0: no onLayers, hook or 'layer' event, so the per-N milestones (远见, 奇迹, 维多利亚 …) stop with the count. Client results: per-bond `layerGains ≤ min(60 + 4·round, 999 − starting layers)`. `invariants.js` flags a bond above 999; the UI shows the server's capped count.
- **Now — layers**: the single implementation is `shared/constants.js BOND_LAYER_CAP` + `layerGainRoom(before, n)` = min(n, 999 − before), never negative (the boss-HP workstream's duplicate `sim/constants.js BOND_LAYER_CAP / layerRoom` was removed in the merge). Every writer uses it: `PlayerState.addLayers` (特质, items, bands, 机变 cards, bonds), the SETTLE of `BattleResult.layerGains`, `Battle.addLayers` (the live copy, clamped after the `layerGain` hook, like the client), and the dev tools' direct writes (`matchrun --layers N`, `balance.mjs applyBoard`). A gain at the cap adds 0: no onLayers, hook or 'layer' event, so the per-N milestones (远见, 奇迹, 维多利亚 …) stop with the count. Client results: per-bond `layerGains ≤ min(60 + 4·round, 999 − starting layers)` (+ the lineup's layer 特质, §21.26). `invariants.js` flags a bond above 999; the UI shows the server's capped count.
- **Now — 限伤**: kinds `'boss'` / `'hidden'` only; a leader is `isBoss` (the tag-'boss' units: those 10 keys and their mirrored copies — parts, 刺胄之弹, drones and escorts never count). A hit with `ceil(final) ≥ 300000` is cancelled: checked in `dealDamage` after DEF/RES and every multiplier, before HP shields, and in `loseHp` (a part's 传递, a drone's death). A cancelled hit causes no HP or pool loss, no credit or stats, no damage number, no `damaged` / `fatal` / kill; the attack, its SP, the `hit` hook and separate element 损伤 hits stay. Element bursts and DoT ticks are checked, gauge fills are not. The sim emits fx `hitCap {id, n}`, drawn as nothing (deterministic in Node and browsers; SP_VERIFY digests agree).
- **The line vs the pool**: 【死亡集群】 drone deaths cost 0.02 × the pool max (§20.10, §20.13) — at most 144 000 today (boss_8 终极 7.2M), so it lands; a pool above 14 999 950 would cancel every drone death. An alive-scaled pool (§20.9, open) can only shrink, but any larger pool must re-check it; a real-kit test pins the line.
- **[ASSUMED]**: the scene server clamps prep gains the same way; a cancelled hit shows nothing; a loss passed on to a leader is one hit; the order hit-step blocks → 限伤 → HP shields — 阿利斯泰尔's 莫非王土 vest (`boss:vest`, 5000): a hit of 300000–304999 is cancelled and the barrier stays; 假想敌:再生's blocks on 假想敌:管 (hidden h08_03) negate a hit past the line before the limit sees it.
@@ -1230,7 +1230,7 @@ Eighteen workstreams (WA–WR, each on its own branch from master 7f13473) were
- **Seen**: with 变形同构体 (缪尔赛思's 特质 garrison_76 / band 变形者集群) and the matching item the wearer gets the bond's effect, but "那个盟约的人口数量没有把转职之后的干员算进去". **Partly real**: the count that activates the bond already included the wearer everywhere (`bondsMeta.pieceBonds` for the prep count, m.private / m.public, the battle input; `support.unitBonds` in the battle — checked on real matches for all 36 pairings). The client did not: the popup's member list and the wearer's card read only the records' bonds (在场 3/6 next to 成员 2/10, no chip).
- **Official**: band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; eff_acarm073 "携带者获得额外盟约(由另一件携带装备而定)"; the item's talent lists the 14 pairings; a member counts on the board only (BOARD) and once per operator.
- **Now**: `gameLogic.grantedBonds` = the server's rule (a test pins the three together); the popup lists wearers tagged 同构, the card shows the granted bond as a dashed 同构 chip; a 同构 row opens the wearer's card with its item ids; a card without an own piece lists the carried items read-only under 装备. UnitInfo `items?` (sim `unitInfo`, `Match.prepFieldMeta`) carries an ally operator's item ids, so teammates' popups and cards show wearers too.
- [ASSUMED], unchanged: operator talents that test "【拉特兰】/【卡西米尔】/【叙拉古】… 干员" read the record's bonds (kits tier1 内线 ammo pick, tier5 inFaction, tier6 hasBond, tokens.js 卡西米尔 sword) — no source says whether a converted operator counts for them; 调和's +1 raises 在场 for core bonds without a member row.
- [ASSUMED], unchanged: operator talents that test "【拉特兰】/【卡西米尔】/【叙拉古】… 干员" read the record's bonds (kits tier1 内线 ammo pick, tier5 inFaction, tier6 hasBond, tokens.js 卡西米尔 sword) — no source says whether a converted operator counts for them; 调和's +1 raises 在场 for core bonds without a member row (explained since §21.26: 含调和 +1 and a 调和 row).
- Tests: `test/ui/morph-bonds.test.js`, `test/ui/watch-bonds.test.js`, server-side locks in the match suite.
### 21.12 源石溶剂 is damage; 信仰搅拌机 S3 counters any damage (D1) — `sim/damage.js periodicDamage / isHpLoss / applyHpLoss`, `items/battle.js periodic_damage`, kits tier1 / 2 / 4 / 5, `content/enemies.js`, `bonds/addon/battle.js`, `choices.js`
@@ -1407,3 +1407,15 @@ Not done (noted, no primary source or out of scope): whether 华法琳 S2's 流
- **The prep dispatcher's equipped items** (pointed out in PR #2, whose 突变细胞 rule is superseded by §21.1): step 5 iterated `holder.items` live while handlers move and destroy pieces — normal 博士投影 destroys itself at the round start (the splice skipped the next item on that holder), 突变细胞 transforms its holder (the next item ran with a holder that was gone). It now walks a snapshot of the owned chess and of each holder's items and runs an item only while it is still equipped on its still-owned holder; a piece gained or equipped meanwhile waits for the next dispatch (effectsMeta header, META §2.2). No shipped item has a second hook on those events, so play is unchanged; a synthetic spy handler pins the rule (`test/match/feedback1-dispatch-snapshot.test.js`). QA after the integration: the items were read per holder as the walk reached it, so an item equipped meanwhile onto a holder not yet walked ran in the same dispatch and an item moved to a later holder ran twice (latent: no shipped item equips or moves items on those hooks); every [holder, item] pair is now taken before the first item runs, and two more synthetic cases pin it.
- **fetch-assets never shrinks the committed manifest by accident** (pointed out in PR #7, whose `data/assets.json`, regenerated on a machine where some downloads failed, was 42 audio entries short — all 42 still resolve upstream): a rebuilt manifest leaves out every entry whose files are missing on that machine. When it would lose an entry of the current `data/assets.json`, the run keeps the current file, prints the entries it would drop (also the report's `droppedEntries` / `manifestWritten`) and exits 1; `--allow-shrink` (or `--prune`) writes it (ASSETS.md; `test/fetch-assets-shrink.test.js`).
### 21.26 Gaps found while triaging GitHub issues #1 / #8 (v0.1.1)
Where: `ui/gameLogic.js morphPairings / HARMONY_BOND / harmonyMembers`, `ui/detailPanel.js MorphPairings / MorphGrantLine / ItemDetail / ChessDetail / BondChips`, `ui/bondStrip.js BondPopup / BondStrip`, `match/bondsMeta.js computeBonds / bondList`, `shared/bandBonds.js` (→ bands.json `bondIds`), `match/gamedata.js bandBondIds`, `match/bot.js botPickBand`, `screens/bandDraft.js BandOffTag / BandOffNote`, `ui/gameLogic.js bandOffBonds`, `tools/botbench.mjs` (`--band`), `match/fields.js layerAllowanceOf`, `public/dev/game-mock.js` (variants `funny` / `morph` / `harmony`). Three gaps the user decided to close in 0.1.1 (no rule changes), and a false rejection of honest client results found on the way.
- **变形同构体 shows its pairings** (issue #1: players read the "转职球" as doing nothing). Its text says "具体对应关系可在模拟中查看本装备天赋栏", and no screen showed that 天赋栏. **Now** the item card (shop, reward, hand / temp: `ItemDetail`) has a 天赋 section: "搭配以下装备时,携带者视为对应盟约的成员:", then one line per bond in bonds.json order — 【bond】 and the items whose `giveBondId` is it (one name per item family, by tier): the 14 pairings of the official talent (character_table trap_1073_acarm073, "“炎国短刀”→【炎】盟约 …"; a test checks the two against each other), its "“维式重锤”系列装备" as the 5 hammers — 18 items, built from the same `giveBondId` the server counts with (`morphPairings`; `pieceBonds` / `grantedBonds`). A bond the mode never activates is struck through with 本局禁用 (`modeOffBonds`, §21.7; 标准: 拉特兰 阿戈尔 卡西米尔 奥术 突袭). On a wearer's card (its 装备 rows; also a teammate's unit and the card a 同构 row opens) the pairing it wears is highlighted — 生效中, or 已搭配 when that bond is off — and a wearer without one reads 暂未生效. A bond item's card and row say "与变形同构体一同装备时,携带者视为【X】成员" (`MorphGrantLine`). The 机密商店 cards have no detail view and keep the official text. Phones: the list keeps ≥ 9 px (px floors in `game-panels.css`).
- **调和's +1 explained** (issue #1: "一些不考虑整备区的盟约计算人数时也会把整备区的干员算进去" — the +1 read as a miscount; §21.11 had left "调和's +1 raises 在场 for core bonds without a member row"). The rule is unchanged (§6.3; one +1 total, research 02 §2.1 [ASSUMED]). **Now** a bond state whose count holds the +1 carries `harmony: 1` (`computeBonds`) and both views send it — m.private `bonds[]` and m.public `players[].bonds[]` entries `{ …, harmony? }` (absent otherwise; not in the battle input, `bondSnapshot`) — so the client never re-derives the rule. The popup says 在场 n(含调和 +1) and a 调和 row heads its member list, naming the 调和 operators on that board (`harmonyMembers`: 缪尔赛思, Pith's 盟约·辅助干员; a tap opens the first one's card; with that board not on screen "调和已激活:核心盟约激活人数 +1") — for your own strip and a teammate's (§20.15: the entry from m.public, the operators from the field on screen). The strip disc's and the card chip's titles add (含调和 +1).
- **Bots skip strategies built around a switched-off bond**: in 标准 the bot took 潘格尼尼 (【定制铳械】 an elite <拉特兰>), 克莱门莎 (<阿戈尔>) or 玛恩纳 (<卡西米尔>) in 5 of 200 solo and 23 of 800 co-op picks (seeds 1–200). bands.json `bondIds` holds a band's tie, read from its own data at build time (`tools/build-data.mjs` through `shared/bandBonds.js`; `GameData.bandBondIds` and the client read the field) — the bond names in <…> of its text (the official note "在<X>部分干员缺席时体验可能不完整" among them; a bracketed item or enemy adds nothing) and the bond ids / bond pools of its blackboards (`bond`, `bond_id`; `pool` → choices.json `pools[pool].bond`, 潘格尼尼's pool_char_later) — nine bands are tied (杜遥夜 炎, 佩佩 / 娜仁图亚 萨尔贡, 哈洛德 维多利亚, 休露丝 谢拉格, 潘格尼尼 拉特兰, 克莱门莎 阿戈尔, 玛恩纳 卡西米尔, 贾维 叙拉古). `botPickBand` weighs a band tied to a bond of `gd.modeInactiveBonds` 0 — one rng draw per pick, the default band when every offered band is excluded: 0 of 200 and 0 of 800 in 标准, while the other modes keep exactly the earlier picks (绝境: 5 / 200, 23 / 800). `tools/botbench.mjs --band` still forces any band.
- **The strategy draft marks them 本局禁用** for human players (the owner's follow-up): a band whose `bondIds` meet the mode's inactive bonds (`gameLogic.bandOffBonds`, the same field and rule as the bot — a test pins the two in every mode) carries a 本局禁用 tag on its card and, in the detail pane, "本局禁用【拉特兰】盟约,此策略效果可能无法发挥" with the bond struck through (§21.7's look); it stays selectable — the official game lets you pick it.
- **Found on the way — the client-result layer bound** (`match/fields.js layerAllowanceOf / validateClientResult`): with the bots' new picks the full-match suite's coop/FUNNY/107 (4 humans on AI 托管) reached a 6-谢拉格 freeze board at R12 (初雪 + 银灰: garrison_28, 50 % +1 per freeze in range, no per-battle cap in the data; 凛御银灰 hands out a 60 % +2) whose honest +112 谢拉格 layers passed the flat 60 + 4·12 = 108, so the server rejected the client and re-simulated (SP_VERIFY=all agrees with the client on all 57 battles of that match). Uncapped traits are common (菲莱 / 百炼嘉维尔 per skill, 斯卡蒂 per 2 kills, 莫斯提马 per 6 bullets …) and capped ones reach 100–200 (史尔特尔, 荒芜拉普兰德), so the flat bound now adds, per bond, what the player's IN_BATTLE layer 特质 can add to it — each trait of its units and each one their ADD_BOND traits hand out (once per operator), on the bonds it names, up to the cap the sim applies (`max_add_count_per_battle`, the handed-out 华法琳 trait's `GRANTED_CAP_OVERRIDE`); a trait without a cap — or a 魔王, whose +extra counts toward none — leaves its bonds bounded only by the 999 room. A forged gain on any other bond meets the flat bound as before.
- Normative lines rewritten with it: §6.3 (`harmony` in the views), §6.6 (the band pick), §8.2 / §8.3 (`bonds[]` entries `harmony?`), §14 / §20.12 (the layer bound), §21.11 (its 调和 note).
- Tests: `test/ui/feedback1-gaps.test.js`, `test/match/feedback1-gaps.test.js`; opt-in `test/ui/feedback1-gaps.e2e.test.js` (`SP_E2E=1`; 1920×1080, 844×390, 756×366; `SP_E2E_OUT` moves the screenshots).
+16 -5
View File
@@ -212,6 +212,11 @@ The rest of this section is the legacy server-run mode
另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field.
### 1.5 AI player (bot.js)
**Strategy** (`botPickBand`): weighted by starting LP among the offered bands (alone, 老鲤's withheld first-round funds
× 0.02); a band whose mechanic rides on a bond the mode switches off weighs 0 — `GameData.bandBondIds` (bands.json
`bondIds`, built by shared/bandBonds.js: the bond names in <…> of its text, the bond ids / bond pools of its blackboards)
∩ `modeInactiveBonds`: 标准's 潘格尼尼, 克莱门莎, 玛恩纳 — and with every offered band excluded it takes the default band;
one rng draw per pick (DESIGN §21.26). The strategy draft marks the same bands 本局禁用 for humans (still selectable).
Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), completes the merges it can afford,
then levels on a curve (L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free
levels always), then spends the rest (a band that keeps its leftover funds — 坎诺特 利滚利 — holds its interest capital
@@ -535,22 +540,27 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
promotion, an owner moved while hand and temp are full) the summon leaves the board and its stack comes back at the
next round start (`grantTokensFor`), so no out-of-range placement reaches the battle. The client lights and accepts
the same tiles (`ui/gameLogic.js summonRange`), the bot plans inside them. Battle side: SIM.md §1.1 token pieces.
* **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1, 独行 downward, 助力 upper tiers,
变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end.
* **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1 — the state and the views mark it
`harmony: 1`, DESIGN §21.26 —, 独行 downward, 助力 upper tiers, 变形同构体 grants). Σ activated layers for the hidden
core = Σ layers of active bonds at the boss round's prep end.
**变形同构体** (变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员", 缪尔赛思's 特质 hands it out): its wearer
with a bond item (`giveBondId`, the 14 pairings of the item's official talent) is a member of that bond — counted like
any member (on the board only: the 14 bonds are BOARD; one member per operator, normal and elite copies alike), and
so in the battle input / battle, the views, and on the client: the bond popup lists it among the members (tagged
同构, `gameLogic.bondMembers` / `grantedBonds`, the same rule as `pieceBonds`) and its card shows the granted bond
chip; a teammate's units carry their item ids for that (UnitInfo `items`, `Match.prepFieldMeta`, SIM.md §9), and a
card without an own piece (a teammate's unit, the card a 同构 row opens) lists those items read-only under 装备.
card without an own piece (a teammate's unit, the card a 同构 row opens) lists those items read-only under 装备. The
item's card lists the 14 pairings of its 天赋栏 from the same `giveBondId` (`gameLogic.morphPairings`, 本局禁用 marked),
the wearer's card highlights the pairing it wears, and a bond item's card names the bond it gives (DESIGN §21.26).
Not covered [ASSUMED]: operator talents that test "【拉特兰】/【卡西米尔】/… 干员" in the kits read the record's bonds.
**Layer cap** (research 11 §1; the client's `MAX_GARRISON_STACK` / `AddBondCount` = min(L + n, 999)): each bond's
layers stop at `BOND_LAYER_CAP` = 999 (shared/constants.js; 0 / Infinity = off). Every writer clamps with
`layerGainRoom` — `PlayerState.addLayers` (all prep-side gains: 特质, items, bands, 机变 cards, bonds; `ctx.addLayers`),
the SETTLE of the in-battle gains and the battle's own live copy (`Battle.addLayers`, SIM.md §6); a gain at the cap
adds 0 and dispatches no onLayers. The client-result check bounds a reported gain by the room left from the bond's
starting layers (`fields.js validateClientResult`, 'layer bound'); `invariants.js` flags a bond above the cap. The
starting layers and by 60 + 4·round plus what the player's IN_BATTLE layer 特质 can add to that bond (their per-battle
caps; an uncapped trait leaves only the room — `fields.js layerAllowanceOf`, DESIGN §21.26) (`validateClientResult`,
'layer bound'); `invariants.js` flags a bond above the cap. The
bond strip, its popup, the effect text and the detail card show the server's capped count. The dev tools' direct
writes (`tools/matchrun.mjs --layers N`, `tools/balance.mjs applyBoard`) stop at the cap too.
* **Waves**: waves.js header (stage/factions/boss per match, faction replacement per round with `k` copies, scaling by
@@ -660,7 +670,8 @@ drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`,
`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4),
`players[].bonds` = `ps.alive ? bondList(gd, ps.bondsView()) : []` — every bond with members, layers or an active tier, the same list
and order as the player's own `m.private bonds` minus `thresholds` / `countsHand` (the client reads those from
bonds.json): a teammate watching the player shows it in the bond strip (DESIGN §20.15); `[]` once the player is
bonds.json; an entry whose count holds 调和's +1 carries `harmony: 1` in both lists — the bond popup's 调和 row, DESIGN
§21.26): a teammate watching the player shows it in the bond strip (DESIGN §20.15); `[]` once the player is
eliminated (nobody can watch them; the result screen reads `m.result`'s own bonds), and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT),
`sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, descRaw?, coin?,
payout?, rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }`
+2 -1
View File
@@ -96,11 +96,12 @@
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
- **调和**:激活时,场上已有成员的核心盟约人数各 +1(只加 1 人,不论有几名调和干员)。盟约详情会写「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思)。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。
- **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果:「变形同构体」与另一件盟约装备(如维式重锤)一同携带时,携带者视为该装备对应盟约的成员。对应关系见变形同构体详情卡的「天赋」一栏(本局禁用的盟约会标出;装在干员身上时,正在生效的那一组会高亮)。
## 7. 最终攻势与隐秘核心
+8
View File
@@ -75,3 +75,11 @@
box-shadow: 0 .03rem .1rem rgba(0, 0, 0, .6);
}
.draft-detail__status--taken { color: var(--amber); }
/* a strategy built around a bond this mode switches off (bandDraft.js BandOffTag / BandOffNote, DESIGN §21.26 — the
§21.7 本局禁用 look: dim, dashed, the bond struck through): information only, it stays selectable. Px floors keep it
readable on phones (1rem = 40 px). */
.dband__off { font-size: max(.12rem, 8px); line-height: 1.5; padding: 0 .05rem; color: var(--text-dim); border: 1px dashed rgba(255, 255, 255, .28); white-space: nowrap; }
.draft-detail__off { align-self: stretch; display: flex; align-items: flex-start; gap: .06rem; margin: 0; padding: .06rem .1rem;
font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-lo); background: rgba(255, 255, 255, .03); border-left: 2px solid rgba(255, 255, 255, .28); }
.draft-detail__off .icon { width: max(.16rem, 10px); height: max(.16rem, 10px); flex: none; margin-top: .02rem; }
.draft-detail__offname { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
+32
View File
@@ -209,6 +209,27 @@
.dhint--merge { color: var(--gold); }
/* a rule the official item text leaves out (items.json `note`: 突变细胞 returns to the hand after each use) */
.dhint--rule { color: var(--text-md); }
/* 变形同构体's 天赋栏 (detailPanel MorphPairings, DESIGN §21.26): one line per bond — 【bond】 | the items that pair with it;
本局禁用 struck through, the pairing a wearer wears highlighted. Px floors keep it readable on phones (1rem = 40 px). */
.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-md); }
.dmorph__lead { margin: 0; color: var(--text-hi); }
.dmorph__list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 1px; }
.dmorph__row { display: grid; grid-template-columns: 6.2em minmax(0, 1fr); gap: .06rem; align-items: baseline; padding: .02rem .06rem;
background: rgba(255, 255, 255, .025); border-left: 2px solid transparent; }
.dmorph__bond { font-weight: 700; color: var(--mint-400); white-space: nowrap; }
.dmorph__items { min-width: 0; overflow-wrap: anywhere; }
.dmorph__row.is-off .dmorph__bond { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
.dmorph__row.is-off .dmorph__items { color: var(--text-dim); }
.dmorph__row.is-worn { background: rgba(78, 216, 175, .14); border-left-color: var(--mint-400); }
.dmorph__item.is-worn { color: #fff; font-weight: 700; }
.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(.11rem, 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
.dmorph__tag.is-on { background: var(--mint-500); color: #062019; }
.dmorph__tag.is-off { border: 1px dashed rgba(255, 255, 255, .25); color: var(--text-dim); font-weight: 500; }
.dmorph__none { margin: 0; color: var(--gold-2); }
/* a bond item's line: what it gives a 变形同构体 wearer (MorphGrantLine) */
.dhint--morph { font-size: max(.14rem, 9px); color: var(--text-md); }
.dhint--morph.is-worn { color: var(--mint-400); }
.dhint--morph.is-off { color: var(--text-dim); }
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
.dsell { margin-left: .06rem; color: #1d1400; }
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
@@ -269,6 +290,17 @@
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: .11rem; line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
.dbonds--top .dbond.is-granted { border-style: dashed; }
.dbond__iso { font-size: .11rem; font-weight: 700; color: var(--gold-2); }
/* a count that holds 调和's +1 (the server's bond entry `harmony`, DESIGN §21.26): 在场 n(含调和 +1) and the 调和 row that
heads the member list, naming the 调和 operators on the board (px floors: readable on phones) */
.bpop__facts small.bpop__hnote { font-size: max(.12rem, 8px); color: var(--gold-2); }
.bpop__harmony { display: flex; align-items: center; gap: .08rem; width: 100%; margin: 0; padding: .05rem .1rem; text-align: left;
font: inherit; font-size: max(.14rem, 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
button.bpop__harmony { cursor: pointer; }
button.bpop__harmony:hover { border-style: solid; color: #fff; }
.bpop__harmony b { flex: none; color: var(--gold-2); font-weight: 700; white-space: nowrap; }
.bpop__harmony span { min-width: 0; }
.bpop__harmony .bglyph { width: max(.24rem, 14px); height: max(.24rem, 14px); }
.bpop__harmony .bglyph img { filter: brightness(0) invert(.78) sepia(1) saturate(3) hue-rotate(5deg); }
/* ---- enemy / info drawer ---- */
.edrawer {
+1
View File
@@ -7,6 +7,7 @@
&variant=reward|bounty|supply|shop|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
|leaker (联防: you let enemies through — the ×N counter)
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
|funny (标准: 本局禁用 bonds) |morph (变形同构体 in the hand / shop, a wearer on the board) |harmony (缪尔赛思 on the board)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
Keyboard: ` toggles the switcher.
-->
+37 -4
View File
@@ -66,26 +66,33 @@ function chessPiece(c, golden = false) {
}
function itemPiece(it) { return { uid: nextUid(), kind: 'item', id: it.id, golden: !!it.isGolden, tier: it.tier }; }
// the mock server's bond counts: BOARD distinct members, plus 调和 (maniShip) like server/match/bondsMeta.js — a 调和
// operator on the board adds 1 to every core bond that has a member and marks the entry `harmony: 1` (the server sends it);
// the mode's inactive bonds (标准) are left out, as the server does
function computeBonds(priv) {
const counts = new Map();
const seen = new Set();
const inactive = new Set(data.get('config')?.modes?.[S?.pub?.modeId]?.inactiveBondIds || []);
for (const p of priv.board) {
if (p.kind !== 'chess') continue;
const c = data.lookup('chess', p.id);
const base = c?.baseId || p.id;
if (seen.has(base)) continue;
seen.add(base);
for (const b of c?.bonds || []) counts.set(b, (counts.get(b) || 0) + 1);
for (const b of c?.bonds || []) if (!inactive.has(b)) counts.set(b, (counts.get(b) || 0) + 1);
}
const harmony = counts.get('maniShip') > 0;
const out = [];
for (const [bondId, count] of counts) {
for (const [bondId, raw] of counts) {
const b = data.lookup('bonds', bondId);
const plus = harmony && b?.isCore ? 1 : 0;
const count = raw + plus;
const thresholds = b?.thresholds || [2];
let tier = 0;
for (const t of thresholds) if (count >= t) tier++;
const layers = S?.layers?.[bondId] ?? (tier ? Math.floor(rnd() * 40) + 4 : Math.floor(rnd() * 6));
if (S) S.layers[bondId] = layers;
out.push({ bondId, count, active: tier > 0, tier, layers, thresholds, countsHand: !!b?.countsHand });
out.push({ bondId, count, active: tier > 0, tier, layers, ...(plus ? { harmony: plus } : {}), thresholds, countsHand: !!b?.countsHand });
}
return out.sort((a, b) => (b.active - a.active) || (b.layers - a.layers));
}
@@ -206,6 +213,31 @@ function buildState() {
S = { stage, pub, priv, layers: {}, battle: null, pool, requests: [] };
}
/**
* The 0.1.1 gaps (DESIGN §21.26): `funny` — the match is 标准 (its 本局禁用 bonds: 5 of 变形同构体's 14 pairings off);
* `morph` — a 变形同构体 in the hand and in the shop's item slot, the first board operator wearing 变形同构体 + 维式重锤 (its
* card highlights that pairing); `harmony` — 缪尔赛思 (调和) in place of the second board operator, so the core bonds with a
* member count +1 and carry `harmony` (the bond popup's 调和 row).
*/
function applyGapVariants() {
if (VARIANTS.has('funny')) { S.pub.modeId = 'mode_multi_funny'; S.pub.difficulty = 'FUNNY'; }
if (VARIANTS.has('morph')) {
const iso = data.list('items').find((i) => i.canGiveBond && !i.isGolden);
const hammer = data.list('items').filter((i) => i.giveBondId === 'victoriaShip' && !i.isGolden).sort((a, b) => a.tier - b.tier)[0];
if (iso) {
S.priv.hand[5] = itemPiece(iso);
const slot = S.priv.shop.slots.findIndex((s) => s && s.kind === 'item');
if (slot >= 0) S.priv.shop.slots[slot] = { kind: 'item', id: iso.id, price: iso.price, basePrice: iso.price, sold: false };
if (S.priv.board[0] && hammer) S.priv.board[0].items = [{ uid: nextUid(), id: iso.id }, { uid: nextUid(), id: hammer.id }];
}
}
if (VARIANTS.has('harmony')) {
const mani = visibleChess().find((c) => c.bonds.includes('maniShip'));
const at = S.priv.board[1];
if (mani && at) S.priv.board[1] = { ...chessPiece(mani), row: at.row, col: at.col };
}
}
/**
* `loadout` variant (DESIGN §16): the first board operator and the first shop card fight with a non-default skill, the
* elite board operator with its module unequipped (m.private.loadout). Data without `skills[]` / `modules[]` (before the
@@ -250,6 +282,7 @@ function setPhase(phase, variant) {
for (const v of (variant || '').split(',').filter(Boolean)) VARIANTS.add(v);
if (VARIANTS.has('reward') && !S.priv.shop.rewardOffer) S.priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) };
if (VARIANTS.has('temp') && !S.priv.temp.some(Boolean)) { S.priv.temp[0] = chessPiece(pick(S.pool)); S.priv.temp[1] = itemPiece(shopItems()[2]); }
applyGapVariants();
const pub = S.pub;
pub.phase = phase;
store.set({ match: emptyMatch(), ticker: [], emotes: [] });
@@ -700,7 +733,7 @@ const SWITCH = [
['INFO_CHECK', PHASE.INFO_CHECK, ''], ['BAND_DRAFT', PHASE.BAND_DRAFT, ''], ['BAND_DRAFT solo', PHASE.BAND_DRAFT, 'solo'],
['BATTLE_CHECK', PHASE.BATTLE_CHECK, ''], ['PREP', PHASE.PREP, ''], ['PREP + reward', PHASE.PREP, 'reward'],
['PREP + temp', PHASE.PREP, 'temp'], ['PREP frozen', PHASE.PREP, 'frozen'], ['PREP dead', PHASE.PREP, 'dead'],
['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'],
['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'], ['PREP 标准 同构体 + 调和', PHASE.PREP, 'funny,morph,harmony'],
['SP bounty', PHASE.SP_DRAFT, 'bounty'], ['SP supply', PHASE.SP_DRAFT, 'supply'], ['SP shop', PHASE.SP_DRAFT, 'shop'], ['SP tactic', PHASE.SP_DRAFT, 'tactic'], ['SP solo', PHASE.SP_DRAFT, 'solo'],
['COMBAT', PHASE.COMBAT, ''], ['COMBAT done', PHASE.COMBAT, 'done'], ['UNITE', PHASE.UNITE, ''], ['UNITE leaker', PHASE.UNITE, 'leaker'], ['SETTLE', PHASE.SETTLE, ''],
['FINAL_ASSAULT', PHASE.FINAL_ASSAULT, ''], ['FA overtime soon', PHASE.FINAL_ASSAULT, 'overtime'], ['FA draining', PHASE.FINAL_ASSAULT, 'drain'],
+22 -3
View File
@@ -9,6 +9,9 @@
// is reported (g.bandFocus) and the server assigns it while it is free, else 「华法琳」, else the first free strategy
// (timeoutBand). It starts on that default, so the tip under the order list always names what a timeout gives.
// Solo, and a co-op match with a single human (the server's soloUntimed: draft.untimed): no clock at all.
// A strategy built around a bond the mode switches off (bands.json bondIds ∩ the mode's inactive bonds — 标准: 潘格尼尼
// 拉特兰, 克莱门莎 阿戈尔, 玛恩纳 卡西米尔; the bot never picks one) reads 本局禁用 on its card and in the detail pane
// (BandOffTag / BandOffNote, DESIGN §21.26, §21.7's look); it stays selectable — information only.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
@@ -18,9 +21,21 @@ import { actions, act } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js';
import { useStore } from '../store.js';
import { audio } from '../audio.js';
import { modeOffBonds, bandOffBonds, bandOffLine } from '../ui/gameLogic.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** 本局禁用 on a strategy card whose bonds `names` the mode switches off (bandOffBonds); nothing otherwise. */
export function BandOffTag({ names = [] }) {
return names.length ? html`<span class="dband__off" title=${bandOffLine(names)}>本局禁用</span>` : null;
}
/** The detail pane's note for such a strategy: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥" (the bond names struck through). */
export function BandOffNote({ names = [] }) {
if (!names.length) return null;
return html`<p class="draft-detail__off" role="note" aria-label=${bandOffLine(names)}><${Icon} name="info" /><span>本局禁用${names.map((n, i) => html`<span key=${i}>【<s class="draft-detail__offname">${n}</s>】</span>`)}盟约,此策略效果可能无法发挥</span></p>`;
}
/**
* Bands selectable in a mode (modeTypeList contains the mode's type), sorted by sortId.
* @param {any[]} bands
@@ -137,6 +152,7 @@ export function BandDraftScreen() {
const [skipped, setSkipped] = useState(false);
const mode = gd.config?.modes?.[pub?.modeId];
const offBonds = modeOffBonds(mode); // the bonds this mode never activates (标准: 10 of 23)
const solo = roomSolo || mode?.type === 'SINGLE' || String(pub?.modeId || '').includes('single');
const bands = useMemo(() => allowedBands(gd.list('bands'), mode?.type || (solo ? 'SINGLE' : 'MULTI')), [gd.ready, mode?.type, solo]);
const players = sortedPlayers(pub);
@@ -233,12 +249,14 @@ export function BandDraftScreen() {
${bands.map((b) => {
const who = pickers.get(b.bandId) || [];
const isTaken = taken.has(b.bandId);
return html`<button key=${b.bandId} type="button" role="option" aria-selected=${sel === b.bandId ? 'true' : 'false'}
aria-disabled=${isTaken ? 'true' : 'false'} title=${isTaken ? '队友已选' : undefined}
class=${cx('dband', sel === b.bandId && 'is-sel', myPick === b.bandId && 'is-mine', isTaken && 'is-taken')} onClick=${() => { setSel(b.bandId); audio.sfx('tab', { volume: 0.5 }); }}>
const offNames = bandOffBonds(b, offBonds).map((id) => gd.bond(id)?.name || id); // 本局禁用 (still selectable)
return html`<button key=${b.bandId} type="button" role="option" aria-selected=${sel === b.bandId ? 'true' : 'false'} data-band=${b.bandId}
aria-disabled=${isTaken ? 'true' : 'false'} title=${isTaken ? '队友已选' : offNames.length ? bandOffLine(offNames) : undefined}
class=${cx('dband', sel === b.bandId && 'is-sel', myPick === b.bandId && 'is-mine', isTaken && 'is-taken', offNames.length && 'is-off')} onClick=${() => { setSel(b.bandId); audio.sfx('tab', { volume: 0.5 }); }}>
<${BandIcon} bandId=${b.bandId} size="lg" />
<span class="dband__name">${b.name}</span>
<span class="dband__lp num"><i></i>${b.totalHp}</span>
<${BandOffTag} names=${offNames} />
${who.length ? html`<span class="dband__who">${who.slice(0, 4).map((p) => html`<${PlayerAvatar} key=${p.playerId} player=${p} size="sm" />`)}</span>` : null}
${isTaken ? html`<span class="dband__taken">队友已选</span>` : null}
</button>`;
@@ -252,6 +270,7 @@ export function BandDraftScreen() {
</div>
<div class="draft-detail__hp"><span>初始生命值</span><${LpTower} value=${band.totalHp} size="lg" /></div>
<h2 class="draft-detail__name">${band.name}</h2>
<${BandOffNote} names=${bandOffBonds(band, offBonds).map((id) => gd.bond(id)?.name || id)} />
<div class="draft-detail__eff">
<${MicroLabel} tone="mint">EFFECT</${MicroLabel}>
<b>${band.effectName || ''}</b>
+24 -8
View File
@@ -3,8 +3,11 @@
// layer-resolved numbers), then the full description and the member list with owned / on-board state — operators that
// are members through 变形同构体 included (tagged 同构, gameLogic bondMembers; such a row opens the wearer's card with its
// items: the pair and the granted chip), so the list's count agrees with 在场 (a bond the mode never activates says
// 本局禁用 — gameLogic modeOffBonds). Opened from the detail panel's bond chips it docks beside that panel (`beside`: the
// panel's side).
// 本局禁用 — gameLogic modeOffBonds). A core bond whose count holds 调和's +1 (the view entry's `harmony`, sent by the
// server — server/match/bondsMeta.js; never re-derived here) says so: 在场 n(含调和 +1), and a 调和 row heads the member
// list naming the 调和 operators on that board (gameLogic harmonyMembers: 缪尔赛思 …; a tap opens the first one's card) —
// the +1 used to read as a miscount (GitHub issue #1, DESIGN §21.26). Opened from the detail panel's bond chips it docks
// beside that panel (`beside`: the panel's side).
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
@@ -13,7 +16,7 @@
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { sortBonds, bondMembers, nextThreshold, bondTier } from './gameLogic.js';
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -46,11 +49,12 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
const rec = data.lookup('bonds', b.bondId);
const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || [];
const next = nextThreshold(b.count ?? 0, th);
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} data-harmony=${b.harmony > 0 ? b.harmony : null}
class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}`} />
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? `(含调和 +${b.harmony})` : ''}`} />
<span class=${cx('bslot__count', 'num', next == null && 'is-max')}>${b.count ?? 0}<small>/${next ?? th[th.length - 1] ?? '-'}</small></span>
</div>`;
})}
@@ -68,7 +72,8 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
* detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `onMember`
* gets a 变形同构体 row's item ids too (its card shows the pair and the granted chip), null for a plain member; `off`: the
* mode never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a
* note, no 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays)
* note, no 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays).
* `entry.harmony` (the server's bond views: the +1 调和 added to `count`): 在场 n(含调和 +1) and the 调和 row
*/
export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null, owner = null, off = false }) {
const b = data.lookup('bonds', bondId);
@@ -78,11 +83,17 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount);
const active = !off && (entry ? !!entry.active : tier > 0);
const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id), (id) => data.lookup('items', id));
const getChess = (id) => data.lookup('chess', id);
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id));
const countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th);
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc);
const at = place || (beside === 'left' ? 'beside' : 'left');
// 调和's +1 in this count (the server's word), and the 调和 operators on that board who give it
const harmony = !off && Number.isInteger(entry?.harmony) && entry.harmony > 0 ? entry.harmony : 0;
const harmonyBy = harmony ? harmonyMembers(priv, getChess) : [];
const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || '调和';
const harmonyText = `${harmonyBy.length ? `${harmonyBy.map((x) => x.name).join('、')} 在场` : `${harmonyName}已激活`}:核心盟约激活人数 +${harmony}`;
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over', owner && 'is-other')} data-place=${at} data-owner=${owner || null}
role="dialog" aria-label=${owner ? `${owner} 的盟约:${b.name}` : `盟约:${b.name}`}>
<button type="button" class="bpop__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
@@ -93,7 +104,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
${owner ? html`<span class="bpop__owner"><${GIcon} name="eye" /><b>${owner}</b> 的盟约</span>` : null}
<h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts">
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}</span>`}
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}${harmony ? html`<small class="bpop__hnote" data-harmony=${harmony}>(含${harmonyName} +${harmony})</small>` : null}</span>`}
<span>层数 <b class="num t-mint">${layers}</b></span>
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span>
</div>
@@ -113,6 +124,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
</section>
<section class="bpop__sec">
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
${harmony ? (harmonyBy.length
? html`<button type="button" class="bpop__harmony" data-harmony=${harmony} title=${harmonyText} onClick=${() => onMember?.(harmonyBy[0].id, null)}>
<${BondGlyph} bondId=${HARMONY_BOND} /><b>${harmonyName} +${harmony}</b><span>${harmonyText}</span></button>`
: html`<div class="bpop__harmony" role="note" data-harmony=${harmony} title=${harmonyText}>
<${BondGlyph} bondId=${HARMONY_BOND} /><b>${harmonyName} +${harmony}</b><span>${harmonyText}</span></div>`) : null}
<div class="bpop__members">
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted')}
onClick=${() => onMember?.(mb.id, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
+68 -11
View File
@@ -10,7 +10,10 @@
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
// art, else its letter), equipped items (read-only from those `items` when the card has no own piece), talents
// (CHESS_SECTIONS); items — icon, tier,
// effect; tokens — the owner's variant (a golden owner's summon: its `_b` stats / skill), how a placed summon takes
// effect; 变形同构体 (`canGiveBond`) also its 天赋栏 list (MorphPairings, gameLogic morphPairings: per bond the items that
// make the wearer its member, 本局禁用 marked; on a wearer's card the pairing it wears highlighted, 生效中 — GitHub issue
// #1, DESIGN §21.26) and a bond item (`giveBondId`) the line "与变形同构体一同装备时,携带者视为【X】成员" (MorphGrantLine);
// tokens — the owner's variant (a golden owner's summon: its `_b` stats / skill), how a placed summon takes
// the field (shared/constants.js SKILL_SUMMON_START_DEPLOY), its token skill and talents; enemies — stats, rank,
// faction tags, abilities. Selling / destroying is the underframe's job in the
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
@@ -28,7 +31,7 @@
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds } from './gameLogic.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
@@ -158,11 +161,58 @@ function Section({ title, micro, children, class: cls }) {
</section>`;
}
function ItemRow({ itemId }) {
const isOffIn = (off, bondId) => !!(off && typeof off.has === 'function' && off.has(bondId));
/**
* The 变形同构体's 天赋栏 (gameLogic morphPairings): "搭配以下装备时,携带者视为对应盟约的成员:", then one line per bond — 【bond】
* and the items that pair with it; a bond the mode never activates (`off`, gameLogic modeOffBonds) struck through with
* 本局禁用. `carried` (the wearer's items — the card shows the item on an operator): the pairing it wears is highlighted
* (生效中; 已搭配 when that bond is off), and a wearer without one reads 暂未生效.
* @param {{ off?: Set<string>|null, carried?: Array<string|{id:string}>|null }} props
*/
export function MorphPairings({ off = null, carried = null }) {
const rows = morphPairings(data.list('items'), data.list('bonds'), { off, carried });
if (!rows.length) return null;
const wearer = Array.isArray(carried);
return html`<div class=${cx('dmorph', wearer && 'is-wearer')}>
<p class="dmorph__lead">搭配以下装备时,携带者视为对应盟约的成员:</p>
<ul class="dmorph__list" aria-label="变形同构体对应关系">
${rows.map((r) => html`<li key=${r.bondId} class=${cx('dmorph__row', r.off && 'is-off', r.worn && 'is-worn')} data-bond=${r.bondId}
title=${`${r.items.map((it) => it.name).join('、')} → 【${r.name}】${r.off ? '(本局禁用)' : ''}`}>
<span class="dmorph__bond">【${r.name}】</span>
<span class="dmorph__items">${r.items.map((it, i) => html`<span key=${it.id} class=${cx('dmorph__item', it.worn && 'is-worn')}>${i ? '、' : ''}${it.name}</span>`)}${r.worn
? html`<span class="dmorph__tag is-on">${r.off ? '已搭配' : '生效中'}</span>` : null}${r.off ? html`<span class="dmorph__tag is-off">本局禁用</span>` : null}</span>
</li>`)}
</ul>
${wearer && !rows.some((r) => r.worn) ? html`<p class="dmorph__none">暂未生效:需与上表中的一件装备一同携带</p>` : null}
</div>`;
}
/**
* A bond item's own line (items.json `giveBondId`): "与变形同构体一同装备时,携带者视为【X】成员" — 本局禁用 when the mode never
* activates X; on a wearer that also carries a 变形同构体 (`carried`) it is in effect (生效中).
* @param {{ item: any, off?: Set<string>|null, carried?: Array<string|{id:string}>|null }} props
*/
export function MorphGrantLine({ item, off = null, carried = null }) {
const bond = item && !item.canGiveBond && typeof item.giveBondId === 'string' ? data.lookup('bonds', item.giveBondId) : null;
if (!bond) return null;
const morph = data.list('items').find((r) => r && r.canGiveBond);
if (!morph) return null;
const isOff = isOffIn(off, item.giveBondId);
const worn = grantedBonds(carried, (id) => data.lookup('items', id)).includes(item.giveBondId);
return html`<p class=${cx('dhint', 'dhint--morph', worn && 'is-worn', isOff && 'is-off')} data-bond=${item.giveBondId}>
<${Icon} name="info" /><span>与${morph.name}一同装备时,携带者视为【${bond.name}】成员${isOff ? html`<span class="dmorph__tag is-off">本局禁用</span>` : null}${worn
? html`<span class="dmorph__tag is-on">${isOff ? '已搭配' : '生效中'}</span>` : null}</span>
</p>`;
}
/** An equipped item of the card: icon, name, effect — and for 变形同构体 / a bond item its pairing (`carried`: the wearer's items). */
function ItemRow({ itemId, carried = null, off = null }) {
const it = data.lookup('items', itemId);
return html`<div class="ditem">
<${UnitThumb} kind="item" id=${itemId} size="sm" />
<div class="ditem__text"><b>${it?.name || itemId}</b><${RichText} text=${it?.descRaw || it?.desc || ''} class="ditem__desc" /></div>
<div class="ditem__text"><b>${it?.name || itemId}</b><${RichText} text=${it?.descRaw || it?.desc || ''} class="ditem__desc" />
${it?.canGiveBond ? html`<${MorphPairings} off=${off} carried=${carried || []} />` : it?.giveBondId ? html`<${MorphGrantLine} item=${it} off=${off} carried=${carried} />` : null}</div>
</div>`;
}
@@ -190,8 +240,10 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null;
const isoTag = iso.has(id) ? '(变形同构体)' : '';
// the count holds 调和's +1 (the server's bond entry says so, DESIGN §21.26)
const harmonyTag = !isOff && Number.isInteger(e?.harmony) && e.harmony > 0 ? `(含调和 +${e.harmony})` : '';
const label = isOff ? briefingBondTip(rec?.name || id, 'off')
: `${rec?.name || id}${isoTag}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
: `${rec?.name || id}${isoTag}:在场 ${count}${cap != null ? `/${cap}` : ''}${harmonyTag}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
const body = isOff
? html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
@@ -355,12 +407,13 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
</div>
<//>` : null;
// (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`)
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} carried=${items} off=${offBonds} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
<//>`
// no own piece (a teammate's unit, a bond popup's 变形同构体 row): what it carries, read-only
: !piece && carried.length ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${carried.length}/2`} class="dsec--equip">
${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} />`)}
${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} carried=${carried} off=${offBonds} />`)}
<//>` : null;
blocks.talents = Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
@@ -375,11 +428,13 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
}
/**
* An item's card (hand / temp / shop / equipped). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
* An item's card (hand / temp / shop / reward card). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`); a rule the
* official text leaves out (items.json `note`: 突变细胞 returns to the hand after each use) is shown under the effect.
* 变形同构体 lists its pairings (its 天赋栏: MorphPairings, `offBonds` marks 本局禁用); a bond item says which bond it gives
* a 变形同构体 wearer (MorphGrantLine). (An equipped item is shown on its wearer's card: ChessDetail's 装备 rows.)
*/
export function ItemDetail({ item, piece, editable, onDestroy }) {
export function ItemDetail({ item, piece, editable, onDestroy, offBonds = null }) {
const m = data.get('assets');
return html`
<div class="dhead dhead--item">
@@ -392,6 +447,8 @@ export function ItemDetail({ item, piece, editable, onDestroy }) {
</div>
</div>
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" /><//>
${item.canGiveBond ? html`<${Section} title="天赋" micro="TALENT" class="dsec--morph"><${MorphPairings} off=${offBonds} /><//>` : null}
${!item.canGiveBond && item.giveBondId ? html`<${MorphGrantLine} item=${item} off=${offBonds} />` : null}
${item.note ? html`<p class="dhint dhint--rule"><${Icon} name="info" />${item.note}</p>` : null}
${item.itemType === 'MAGIC'
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
@@ -564,7 +621,7 @@ export function resolveDetail(target, pieces) {
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
* bonds?: any[], offBonds?: Set<string>|null, loadout?: any, onBond?: (bondId:string)=>void, side?: 'left'|'right', shopOpen?: boolean }} props
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); offBonds: the bonds this mode never activates
* (gameLogic modeOffBonds — their chips read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
* (gameLogic modeOffBonds — their chips and the 变形同构体 pairing lines read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
* = the defaults
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
@@ -594,7 +651,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
</div>
+93
View File
@@ -404,6 +404,78 @@ export function pieceBondIds(chess, items, getItem = () => null) {
return out;
}
/**
* The 变形同构体 pairings — the list its 天赋栏 shows (the item text: "具体对应关系可在模拟中查看本装备天赋栏"; character_table
* trap_1073_acarm073 "“炎国短刀”→【炎】盟约 …", 14 bonds; GitHub issue #1: without it the item read as doing nothing).
* Built from the same `giveBondId` the server counts with (server/match/bondsMeta.js pieceBonds, grantedBonds here): every
* bond an item grants, in bonds.json order, with the items that grant it (one entry per item family — the normal and 进阶
* copies share a name —, by tier, then data order). `off`: the mode never activates the bond (modeOffBonds: 本局禁用 — 标准
* leaves 5 of the 14 off). `worn`: the wearer's items (`carried`, the card shows the 变形同构体 on an operator) pair it with
* one of the bond's items — the pairing in effect on that operator (that item `worn` too).
* @param {Iterable<any>} itemRecs items.json records (data.list('items'))
* @param {Iterable<any>} bondRecs bonds.json records in data order (data.list('bonds'))
* @param {{ off?: Set<string>|null, carried?: Array<string|{id:string}>|null }} [opts]
* @returns {Array<{ bondId: string, name: string, off: boolean, worn: boolean, items: Array<{ id: string, name: string, worn: boolean }> }>}
*/
export function morphPairings(itemRecs, bondRecs, { off = null, carried = null } = {}) {
const recs = [...(itemRecs || [])].filter(isObj);
const byId = new Map(recs.map((r) => [r.id, r]));
const getItem = (id) => byId.get(id) || null;
const worn = new Set(grantedBonds(carried, getItem));
const carriedIds = new Set((Array.isArray(carried) ? carried : []).map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string'));
const famOf = (r) => (typeof r.baseId === 'string' && r.baseId ? r.baseId : r.id);
/** bondId → family id → { id, name, tier, order, ids } */
const groups = new Map();
recs.forEach((r, order) => {
if (r.canGiveBond || typeof r.giveBondId !== 'string' || !r.giveBondId) return;
let g = groups.get(r.giveBondId);
if (!g) groups.set(r.giveBondId, (g = new Map()));
const fam = famOf(r);
const e = g.get(fam);
if (e) { e.ids.push(r.id); return; }
g.set(fam, { id: fam, name: typeof r.name === 'string' && r.name ? r.name : fam, tier: int(r.tier, 99), order, ids: [r.id] });
});
const out = [];
for (const b of bondRecs || []) {
const g = isObj(b) ? groups.get(b.bondId) : null;
if (!g) continue;
const bondWorn = worn.has(b.bondId);
const items = [...g.values()].sort((x, y) => (x.tier - y.tier) || (x.order - y.order))
.map((e) => ({ id: e.id, name: e.name, worn: bondWorn && e.ids.some((id) => carriedIds.has(id)) }));
out.push({ bondId: b.bondId, name: typeof b.name === 'string' && b.name ? b.name : b.bondId, off: !!(off && typeof off.has === 'function' && off.has(b.bondId)), worn: bondWorn, items });
}
return out;
}
/**
* Bond id of 调和 (bonds.json maniShip, "激活时使场上核心盟约的激活人数+1") — server/match/bondsMeta.js HARMONY_BOND; a test
* pins the two. Only to name the 调和 operators: whether a bond's count holds the +1 is the server's word (the view
* entry's `harmony`, DESIGN §21.26).
*/
export const HARMONY_BOND = 'maniShip';
/**
* The 调和 operators on a board, distinct (normal and elite copies of one operator once): who activates the +1 a bond
* entry's `harmony` reports (the bond popup's 调和 row names them — 缪尔赛思, the Pith strategy's 盟约·辅助干员).
* @param {any} priv m.private — or a teammate's field operators (ui/watchBonds.js ownerBoard)
* @param {(id:string)=>any} [getChess]
* @returns {Array<{ id: string, name: string }>} base chess ids
*/
export function harmonyMembers(priv, getChess = () => null) {
const out = [];
const seen = new Set();
for (const p of Array.isArray(priv?.board) ? priv.board : []) {
if (p?.kind !== 'chess') continue;
const c = getChess(p.id);
if (!Array.isArray(c?.bonds) || !c.bonds.includes(HARMONY_BOND)) continue;
const base = c.baseId || (typeof p.id === 'string' ? p.id.replace(/_b$/, '_a') : p.id);
if (seen.has(base)) continue;
seen.add(base);
out.push({ id: base, name: getChess(base)?.name || c.name || base });
}
return out;
}
/**
* Member rows of a bond popup: every visible member with owned / on-board / banned state, plus the player's operators
* that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that wears the
@@ -510,6 +582,27 @@ export function modeOffBonds(mode) {
return new Set(list.filter((b) => typeof b === 'string'));
}
/**
* The bonds a strategy is built around that this mode switches off: bands.json `bondIds` (shared/bandBonds.js at build
* time — the field the server's bot reads too, GameData.bandBondIds) ∩ `off` (modeOffBonds). 标准: 潘格尼尼 → 拉特兰,
* 克莱门莎 → 阿戈尔, 玛恩纳 → 卡西米尔; the strategy draft marks such a band 本局禁用 (still selectable, DESIGN §21.26).
* @param {any} band bands.json record @param {Set<string>|null} off
* @returns {string[]}
*/
export function bandOffBonds(band, off) {
if (!(off instanceof Set) || !off.size || !Array.isArray(band?.bondIds)) return [];
return band.bondIds.filter((b) => typeof b === 'string' && off.has(b));
}
/**
* The strategy draft's note for such a band: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥".
* @param {string[]} names the switched-off bonds' names
*/
export function bandOffLine(names) {
const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === 'string' && n);
return list.length ? `本局禁用${list.map((n) => `【${n}】`).join('')}盟约,此策略效果可能无法发挥` : '';
}
// ---- shop ---------------------------------------------------------------------------------------------
/**
+2 -1
View File
@@ -102,7 +102,8 @@ export function playerLayer(field, pub, myId, playerId) {
/**
* A player's bond list as the views carry it: your own m.private bonds (thresholds / countsHand included), a teammate's
* m.public players[].bonds ({ bondId, count, active, tier, layers }).
* m.public players[].bonds ({ bondId, count, active, tier, layers, harmony? } — `harmony`: 调和's +1 is in `count`, the
* popup's 调和 row; DESIGN §21.26).
* @returns {any[]}
*/
export function ownerBonds({ pub, priv = null, myId, ownerId }) {
+12 -6
View File
@@ -9,7 +9,10 @@
// an item that has a `giveBondId` (research 02 §2.1).
// Special rules:
// 调和 (maniShip): while active, every CORE bond that already has ≥ 1 real member on the board gets count +1
// (one +1 total, research 02 §2.1 [ASSUMED]).
// (one +1 total, research 02 §2.1 [ASSUMED]). Such a state carries `harmony: 1` — the views send it
// (bondList: m.private bonds, m.public players[].bonds), so the client's bond popup can say where the
// extra count comes from without re-deriving the rule (DESIGN §21.26); the battle input does not
// (bondSnapshot).
// 助力 (deputShip): tier 1 needs `thresholds[0]` distinct operators; the upper tiers count operators that differ
// in name OR elite state (PRTS 修正).
// 独行 (soloShip, count_threshold_downward): active iff 1 ≤ count ≤ maxCount (distinct 独行 operators).
@@ -45,10 +48,10 @@ export function pieceBonds(gd, piece) {
}
/**
* Compute every bond's state for a player.
* Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number }>}
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>}
*/
export function computeBonds(gd, ps) {
const boardChess = [];
@@ -71,7 +74,7 @@ export function computeBonds(gd, ps) {
}
const goldenOnBoard = boardChess.filter((p) => gd.isGolden(p.id)).length;
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number }>} */
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>} */
const out = {};
const layersOf = (id) => {
const v = ps.layers && ps.layers[id];
@@ -101,8 +104,8 @@ export function computeBonds(gd, ps) {
const harmonyActive = harmony && raw[HARMONY_BOND] != null && tierFor(harmony, raw[HARMONY_BOND]) >= 1;
for (const id of Object.keys(raw)) {
const bond = gd.bond(id);
let count = raw[id];
if (harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1) count += 1;
const harmonyBonus = harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1 ? 1 : 0;
const count = raw[id] + harmonyBonus;
let tier = tierFor(bond, count);
if (id === DEPUTY_BOND && tier >= 1) {
// upper tiers: operators differing in name OR elite state
@@ -113,6 +116,7 @@ export function computeBonds(gd, ps) {
tier = t;
}
out[id] = { count, active: tier >= 1, tier, layers: layersOf(id) };
if (harmonyBonus) out[id].harmony = harmonyBonus;
}
return out;
}
@@ -145,6 +149,7 @@ export function activatedLayers(bonds) {
/**
* View list for m.private / m.public: bonds with members or layers, active first, then layers desc, then data order.
* An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise).
* @param {import('./gamedata.js').GameData} gd
* @param {ReturnType<typeof computeBonds>} bonds
* @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
@@ -155,6 +160,7 @@ export function bondList(gd, bonds, { full = false } = {}) {
for (const [bondId, b] of Object.entries(bonds || {})) {
if (!(b.count > 0 || b.layers > 0 || b.active)) continue;
const e = { bondId, count: b.count, active: b.active, tier: b.tier, layers: b.layers };
if (b.harmony > 0) e.harmony = b.harmony;
if (full) {
const bond = gd.bond(bondId);
e.thresholds = bond ? thresholdsOf(bond) : [];
+24 -8
View File
@@ -59,6 +59,9 @@
// 6. resolve the temp slots (a 突变细胞 left there — it comes back after every transformation — gets a hand slot made
// for it, makeHandRoom, instead of being destroyed), keep one hand slot free (freeHandSlot: a kept bounty Art goes
// before a chess on a bot's own seat, never on a human's seat under AI 托管), then Ready.
// Strategy (botPickBand): weighted by starting LP among the offered bands; never one whose mechanic rides on a bond the
// mode switches off (gd.bandBondIds = bands.json bondIds: the bond its text names in <…> or its blackboards name — 标准's
// 潘格尼尼, 克莱门莎, 玛恩纳; DESIGN §21.26); alone, 老鲤's withheld first-round funds only rarely (× 0.02).
// 机变 (botPickCard): a bounty by its expected payout minus the expected LP lost — bountyKillChance runs the exposure
// model for that one enemy against the own board; a card the board is unlikely to beat wins only when nothing better
// is offered or it pays much more —; tactic cards by what they act on (a 盟誓 / 驰援 card on the own bonds, 升华 …);
@@ -118,19 +121,32 @@ const ECON_TRAIT_RE = /GOLD|REFRESH|COIN/;
const DEFAULT_MELEE_RANGE = [[0, 0], [0, 1]];
/**
* Band pick: weighted by starting LP (sturdier strategies are preferred). Alone, a band that withholds the first
* rounds' funds (老鲤 "资金暂存": no operator in R1–R2, every enemy leaks) is avoided — only 联防 teammates cover that.
* Band pick among the strategies the mode offers (gd.bandIds): weighted by starting LP (sturdier strategies are
* preferred). Alone, a band that withholds the first rounds' funds (老鲤 "资金暂存": no operator in R1–R2, every enemy
* leaks) is avoided — only 联防 teammates cover that. A band whose mechanic rides on a bond the mode switches off
* (gd.bandBondIds ∩ gd.modeInactiveBonds — 标准: 潘格尼尼 <拉特兰>, 克莱门莎 <阿戈尔>, 玛恩纳 <卡西米尔>) weighs 0, never taken
* (DESIGN §21.26); with every band excluded, the default band. One rng draw per call (deterministic per seed); modes
* without inactive bonds keep exactly the earlier picks.
*/
export function botPickBand(m, ps) {
const ids = m.gd.bandIds();
if (!ids.length) return m.gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(m.gd.band(id)?.desc || ''));
const pairs = ids.map((id) => [id, Math.max(1, (m.gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
const gd = m.gd;
const ids = gd.bandIds();
if (!ids.length) return gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || ''));
const offBond = (id) => gd.bandBondIds(id).some((b) => gd.modeInactiveBonds.has(b));
const pairs = ids.map((id) => [id, offBond(id) ? 0 : Math.max(1, (gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
let total = 0;
for (const [, w] of pairs) total += w;
let r = m.rngBots() * total;
for (const [id, w] of pairs) { r -= w; if (r < 0) return id; }
return pairs[pairs.length - 1][0];
if (!(total > 0)) return gd.defaultBandId;
let last = null;
for (const [id, w] of pairs) {
if (!(w > 0)) continue;
last = id;
r -= w;
if (r < 0) return id;
}
return last;
}
/**
+50 -5
View File
@@ -32,6 +32,7 @@
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js';
import { GRANTED_CAP_OVERRIDE } from '../sim/content/garrisons/battle.js';
export const MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30;
@@ -571,6 +572,47 @@ function layerBondsOf(p, gd) {
return out;
}
/**
* What the player's IN_BATTLE layer 特质 can add to each bond in one battle on top of the flat 60 + 4·round (DESIGN
* §21.26): the traits of its units (garrisons with `bond_add_count` / `bond_add_count_multi`) and the ones their ADD_BOND
* traits hand out (`give_garrison_id`, counted for every operator of the player — "所有【X】" reaches them all), each on
* the bonds it names (`bond_by_id` ids; `bond_self` / `bond_actived_maxstack`: every bond of the player's snapshot and
* units), up to the per-battle cap the sim applies (content/garrisons/battle.js: `max_add_count_per_battle`, the handed-out
* 华法琳 trait's GRANTED_CAP_OVERRIDE). A trait the data gives no cap (初雪 / 银灰's freeze trait, 菲莱 / 百炼嘉维尔's per-skill
* 萨尔贡, 斯卡蒂's per-kill …) — or a 魔王, whose +extra on every trait gain counts toward no cap — leaves its bonds bounded
* only by the room under 999 (Infinity here): such boards legitimately gain hundreds of layers a battle.
* @returns {Map<string, number>} bondId → extra allowance (Infinity: uncapped)
*/
function layerAllowanceOf(p, gd) {
const out = new Map();
if (typeof gd.garrison !== 'function' || typeof gd.chess !== 'function' || typeof gd.bond !== 'function') return out;
const units = (Array.isArray(p.units) ? p.units : []).filter((u) => u && u.kind !== 'token' && typeof u.chessId === 'string');
const lineup = new Set(Object.keys(p.bonds && typeof p.bonds === 'object' ? p.bonds : {}));
for (const u of units) for (const b of gd.chess(u.chessId)?.bonds || []) lineup.add(b);
let extra = false;
const credit = (g, times, handedOut) => {
const bb = g.bb || {};
if (Number.isFinite(bb.extra_cnt)) { extra = true; return; }
if (!Number.isFinite(bb.bond_add_count) && !Number.isFinite(bb.bond_add_count_multi)) return;
const s = g.bbStr || {};
const bonds = s.bond_type === 'bond_by_id' ? String(s.bond_id ?? '').split(',').map((x) => x.trim()).filter((x) => gd.bond(x)) : [...lineup];
const override = handedOut ? GRANTED_CAP_OVERRIDE[g.garrisonId] : undefined;
const cap = override ?? (Number(bb.max_add_count_per_battle) > 0 ? Number(bb.max_add_count_per_battle) : Infinity);
for (const b of bonds) out.set(b, (out.get(b) || 0) + cap * times);
};
for (const u of units) {
for (const gid of gd.chess(u.chessId)?.garrisonIds || []) {
const g = gd.garrison(gid);
if (!g || g.eventType !== 'IN_BATTLE') continue;
if (g.effectKey !== 'ADD_BOND') { credit(g, 1, false); continue; }
const given = gd.garrison(g.bbStr?.give_garrison_id);
if (given && given.eventType === 'IN_BATTLE') credit(given, units.length, true);
}
}
if (extra) for (const b of out.keys()) out.set(b, Infinity);
return out;
}
/**
* Bounds of a battle derived from its spec: spawn counts per enemy key, keys content may add, bounty coins, the
* player ids and each player's unit uids.
@@ -640,7 +682,7 @@ export function specBounds(spec, gd = null) {
const v = Number(b && b.layers);
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
}
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers });
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
}
const round = Number(spec && spec.round) || 0;
return {
@@ -686,8 +728,9 @@ const sameMods = (a, b) => {
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
* summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer
* gains ≤ 60 + 4·round and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only on bonds the
* player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* perfect consistent with the counted leaks; unit states only for the player's own units, within range.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
@@ -770,8 +813,10 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
// ≤ 60 + 4·round, and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
if (!finiteIn(n, 0, Math.min(B.layerCap, layerGainRoom(own.startLayers.get(bondId) || 0, Infinity)))) return bad('layer bound');
// ≤ 60 + 4·round + what the player's layer 特质 can add to the bond (layerAllowanceOf; uncapped ones: no flat
// bound), and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
const bound = Math.min(B.layerCap + (own.layerAllow.get(bondId) || 0), layerGainRoom(own.startLayers.get(bondId) || 0, Infinity));
if (!finiteIn(n, 0, bound)) return bad('layer bound');
if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled');
if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond');
if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond');
+20
View File
@@ -90,6 +90,8 @@ export class GameData {
for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort();
this.bondIds = Object.keys(this._bonds).sort((a, b) => (numOr(this._bonds[a].identifier, 99) - numOr(this._bonds[b].identifier, 99)) || (a < b ? -1 : 1));
this.modeInactiveBonds = new Set(Array.isArray(this.mode.inactiveBondIds) ? this.mode.inactiveBondIds : []);
/** bandBondIds memo */
this._bandBonds = new Map();
this.inactiveEnemies = new Set(Array.isArray(this.mode.inactiveEnemyKeys) ? this.mode.inactiveEnemyKeys : []);
/** data/tuning.json (titles only, see the header) */
this.tuning = this.raw.tuning && typeof this.raw.tuning === 'object' ? this.raw.tuning : {};
@@ -458,6 +460,24 @@ export class GameData {
return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp;
}
/**
* The bonds a strategy's mechanic is built around (DESIGN §21.26): bands.json `bondIds`, written at build time by
* shared/bandBonds.js from the band's own text and blackboards (潘格尼尼 → 拉特兰, 克莱门莎 → 阿戈尔, 玛恩纳 → 卡西米尔 …) —
* the field the strategy draft's 本局禁用 mark reads too. Known bond ids in data order; [] for an unknown band, one tied to
* no bond (华法琳, 阿米娅 …) or data without the field. The bot never picks a strategy tied to a bond the mode switches
* off (bot.js botPickBand).
* @param {string} bandId
* @returns {string[]}
*/
bandBondIds(bandId) {
if (this._bandBonds.has(bandId)) return this._bandBonds.get(bandId);
const listed = this.band(bandId)?.bondIds;
const set = new Set(Array.isArray(listed) ? listed : []);
const out = Object.freeze(this.bondIds.filter((id) => set.has(id)));
this._bandBonds.set(bandId, out);
return out;
}
/**
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its manually
* deployable summons (tokens.json `placeable`: 医疗探机, 诅咒娃娃, 海嗣, 狼群, 流形, 爬行号·防护单元; user playtest #6)
+47
View File
@@ -0,0 +1,47 @@
// shared/bandBonds.js — the bonds a strategy (band) is built around, read from the band's own data (DESIGN §21.26).
// Pure (no Node APIs). tools/build-data.mjs runs it once per band and writes the result as data/bands.json `bondIds` —
// the one field both sides read: the server (match/gamedata.js GameData.bandBondIds → bot.js botPickBand never takes a
// band whose bond the mode switches off) and the client (screens/bandDraft.js marks such a band 本局禁用, gameLogic
// bandOffBonds). A band is tied to:
// * the bonds its text names in <…> — the official note "在<X>部分干员缺席时体验可能不完整" names that bond too; a
// bracketed name that is no bond (<寻呼模块>, <画卷>, <鸭爵> …) adds nothing;
// * the bond ids and bond pools its effect's blackboards name (bb / bbStr string values, comma lists included):
// 克莱门莎's `bond_id` egirShip, 玛恩纳's `bond` kazimierzShip, 潘格尼尼's `pool` pool_char_later → choices.json
// `pools[pool].bond` lateranoShip; a pool without a bond adds nothing.
const isObj = (v) => !!v && typeof v === 'object';
/**
* Bond ids a band is tied to, in the order of `bonds`.
* @param {{ desc?: string, buffs?: Array<{ bb?: object, bbStr?: object }> }|null|undefined} band bands.json record
* @param {{ bonds: Record<string, { name?: string }>|Array<{ bondId: string, name?: string }>,
* pools?: Record<string, { bond?: string }>|null }} ctx bonds.json (map or list) and choices.json `pools`
* @returns {string[]}
*/
export function bandBondIds(band, { bonds, pools = null } = {}) {
const list = Array.isArray(bonds) ? bonds.filter(isObj).map((b) => [b.bondId, b]) : Object.entries(isObj(bonds) ? bonds : {});
const ids = list.map(([id]) => id).filter((id) => typeof id === 'string');
if (!isObj(band) || !ids.length) return [];
const known = new Set(ids);
const byName = new Map(list.filter(([, b]) => isObj(b) && typeof b.name === 'string').map(([id, b]) => [b.name, id]));
const found = new Set();
for (const [, name] of String(band.desc || '').matchAll(/<([^<>]+)>/g)) {
const id = byName.get(name.trim());
if (id) found.add(id);
}
const poolOf = (k) => (isObj(pools) && Object.hasOwn(pools, k) && isObj(pools[k]) ? pools[k] : null);
for (const buff of Array.isArray(band.buffs) ? band.buffs : []) {
for (const board of [buff && buff.bb, buff && buff.bbStr]) {
if (!isObj(board)) continue;
for (const v of Object.values(board)) {
if (typeof v !== 'string') continue;
for (const part of v.split(',').map((s) => s.trim())) {
if (known.has(part)) found.add(part);
const pool = poolOf(part);
if (pool && typeof pool.bond === 'string' && known.has(pool.bond)) found.add(pool.bond);
}
}
}
}
return ids.filter((id) => found.has(id));
}
+280
View File
@@ -0,0 +1,280 @@
// Gaps found while triaging GitHub issues #1 / #8 (DESIGN §21.26) — the server side.
// 2. 调和's +1: computeBonds marks a core bond's state whose count holds 调和's +1 (`harmony: 1`); the views send it
// (m.private bonds, m.public players[].bonds — a teammate's strip and popup), the battle input does not. The popup
// that reads it: test/ui/feedback1-gaps.test.js.
// 3. Bots never pick a strategy built around a bond the mode switches off: GameData.bandBondIds reads the tie from the
// band's own data (its <bond> names, its blackboards' bond ids and bond pools); botPickBand gives such a band weight 0
// — in 标准 潘格尼尼 (<拉特兰>, pool_char_later), 克莱门莎 (<阿戈尔>), 玛恩纳 (<卡西米尔>). Before: 5 of 200 solo picks and
// 23 of 800 co-op picks (seeds 1–200) were one of them; the other modes keep exactly the earlier picks.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { PHASE } from '../../shared/constants.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds, bondList, bondSnapshot, bondsWithGains, HARMONY_BOND } from '../../server/match/bondsMeta.js';
import { botPickBand } from '../../server/match/bot.js';
import { tileKey } from '../../server/match/board.js';
import { validateClientResult } from '../../server/match/fields.js';
import { bandBondIds as bandBondIdsOf } from '../../shared/bandBonds.js';
import { DATA, makeMatch, give, legalTileFor, checkInvariants } from './harness.js';
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (调和)
const members = (bond, n) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && !c.bonds.includes(HARMONY_BOND)).map((c) => c.chessId).sort().slice(0, n);
let uid = 1;
const piece = (id) => ({ uid: uid++, kind: 'chess', id, items: [] });
const state = (board, hand = []) => {
const b = new Map();
board.forEach((p, i) => b.set(`${9 + (i % 4)},${2 + Math.floor(i / 4)}`, p));
const h = new Array(10).fill(null);
hand.forEach((p, i) => { h[i] = p; });
return { board: b, hand: h, layers: {} };
};
describe('§21.26 2 — 调和\'s +1 in the bond states and views', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
test('computeBonds: `harmony: 1` exactly on the core bonds whose count holds the +1 — never an add-on bond, a core bond without a member, or 调和 itself', () => {
const yan = members('yanShip', 2);
const preci = members('preciShip', 1);
const s = computeBonds(gd, state([piece(MLYSS), ...yan.map(piece), ...preci.map(piece)]));
assert.equal(s[HARMONY_BOND].active, true);
assert.deepEqual(s.yanShip, { count: 3, active: true, tier: 1, layers: 0, harmony: 1 });
for (const [id, b] of Object.entries(s)) {
if (id === 'yanShip') continue;
assert.ok(!('harmony' in b), `${id}: no harmony (core with members: ${DATA.bonds[id].isCore && b.count > 0})`);
}
// the same board without 调和: no mark anywhere, the count is the real one
const bare = computeBonds(gd, state([...yan.map(piece), ...preci.map(piece)]));
assert.deepEqual(bare.yanShip, { count: 2, active: false, tier: 0, layers: 0 });
assert.ok(Object.values(bare).every((b) => !('harmony' in b)));
// 调和 on the bench does nothing (BOARD)
const bench = computeBonds(gd, state(yan.map(piece), [piece(MLYSS)]));
assert.equal(bench.yanShip.count, 2);
assert.ok(!('harmony' in bench.yanShip));
});
test('bondList carries it (m.private and m.public), bondsWithGains keeps it, bondSnapshot (the battle input) leaves it out', () => {
const s = computeBonds(gd, state([piece(MLYSS), ...members('yanShip', 2).map(piece)]));
const full = bondList(gd, s, { full: true }).find((b) => b.bondId === 'yanShip');
assert.deepEqual(full, { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, harmony: 1, thresholds: [3, 6, 9], countsHand: false });
const pub = bondList(gd, s).find((b) => b.bondId === 'yanShip');
assert.deepEqual(pub, { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, harmony: 1 });
assert.ok(bondList(gd, s).filter((b) => b.bondId !== 'yanShip').every((b) => !('harmony' in b)));
assert.equal(bondsWithGains(s, { yanShip: 4 }).yanShip.harmony, 1, 'the battle\'s gains keep the mark');
assert.deepEqual(bondSnapshot(s).yanShip, { count: 3, active: true, tier: 1, layers: 0 }, 'the battle reads count / active / tier / layers only');
});
test('a real co-op match: the deploying player\'s m.private and their m.public row (a teammate\'s strip) say the count holds the +1', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 2612, fake: true }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_1');
ps.board.clear();
const used = new Set();
for (const id of [MLYSS, ...members('yanShip', 2)]) {
const t = legalTileFor(m, ps, id, used);
used.add(tileKey(t[0], t[1]));
give(m, ps, id, 'board', t);
}
ps.recompute();
const own = ps.privateView().bonds.find((b) => b.bondId === 'yanShip');
assert.equal(own.count, 3);
assert.equal(own.harmony, 1);
const row = m.publicView().players.find((p) => p.playerId === 'p_1').bonds.find((b) => b.bondId === 'yanShip');
assert.deepEqual(row, { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, harmony: 1 }, 'what p_0 reads when watching p_1');
assert.ok(m.publicView().players.find((p) => p.playerId === 'p_0').bonds.every((b) => !('harmony' in b)), 'p_0 has no 调和');
// 缪尔赛思 back to the bench: the mark goes with the +1
const k = [...ps.board].find(([, p]) => p.id === MLYSS)[0];
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: ps.board.get(k).uid, to: { area: 'hand', idx: ps.hand.findIndex((x) => x == null) } }), { ok: true });
const after = ps.privateView().bonds.find((b) => b.bondId === 'yanShip');
assert.equal(after.count, 2);
assert.ok(!('harmony' in after));
checkInvariants(m);
m.dispose();
});
});
describe('§21.26 3 — strategies tied to a bond the mode switches off', () => {
const funny = new GameData(DATA, 'mode_multi_funny');
test('GameData.bandBondIds reads the tie from the band\'s data: its <bond> names, bond ids and bond pools in its blackboards', () => {
const tied = Object.fromEntries(funny.bandIds().map((id) => [DATA.bands[id].name, funny.bandBondIds(id)]).filter(([, b]) => b.length));
assert.deepEqual(tied, {
杜遥夜: ['yanShip'], 佩佩: ['sargonShip'], 哈洛德: ['victoriaShip'], 休露丝: ['kjeragShip'], 潘格尼尼: ['lateranoShip'],
克莱门莎: ['egirShip'], 玛恩纳: ['kazimierzShip'], 贾维: ['siracusaShip'], 娜仁图亚: ['sargonShip'],
});
// every tied band carries the official note "在<X>部分干员缺席时体验可能不完整" naming that bond
for (const id of funny.bandIds()) {
const b = DATA.bands[id];
const note = /在<([^<>]+)>部分干员缺席时体验可能不完整/.exec(b.desc);
if (funny.bandBondIds(id).length) assert.ok(note && funny.bandBondIds(id).some((x) => DATA.bonds[x].name === note[1]), b.name);
}
// the three 标准 switches off; a bracketed item (<寻呼模块>, <画卷>) is no bond; unknown bands: []
assert.deepEqual(funny.bandIds().filter((id) => funny.bandBondIds(id).some((b) => funny.modeInactiveBonds.has(b))), ['band_paganini', 'band_clementia', 'band_mlynar']);
assert.deepEqual(funny.bandBondIds('band_jesica'), []);
assert.deepEqual(funny.bandBondIds('band_dusk'), []);
assert.deepEqual(funny.bandBondIds('nope'), []);
});
test('the derivation (shared/bandBonds.js, run by the data build) catches a tie through a pool or a blackboard bond id alone; GameData reads the field', () => {
const ctx = { bonds: DATA.bonds, pools: DATA.choices.pools };
const pool = { ...DATA.bands.band_paganini, desc: '【定制铳械】累计花费55资金后,获得1名精锐干员' };
const bb = { ...DATA.bands.band_clementia, desc: '【崇高牺牲】' };
assert.deepEqual(bandBondIdsOf(pool, ctx), ['lateranoShip'], 'choices.json pools.pool_char_later.bond');
assert.deepEqual(bandBondIdsOf(bb, ctx), ['egirShip'], 'bbStr bond_id');
assert.deepEqual(bandBondIdsOf({ desc: '在<阿戈尔>部分干员缺席时体验可能不完整;<寻呼模块>', buffs: [{ bb: { bond: 'kazimierzShip,nope' } }] }, ctx), ['egirShip', 'kazimierzShip'], 'the note, comma lists; an item in <…> is no bond');
assert.deepEqual(bandBondIdsOf(DATA.bands.band_bldsk, ctx), []);
assert.deepEqual(bandBondIdsOf(null, ctx), []);
// the build wrote exactly this for every band (data/bands.json is not stale against the helper)
for (const [id, b] of Object.entries(DATA.bands)) assert.deepEqual(b.bondIds, bandBondIdsOf(b, ctx), id);
// GameData reads the field (known bonds, data order) — never re-derives
const gd = new GameData({ ...DATA, bands: { band_x: { ...DATA.bands.band_bldsk, bandId: 'band_x', bondIds: ['kazimierzShip', 'nope', 'egirShip'] }, band_old: { ...DATA.bands.band_paganini, bondIds: undefined } } }, 'mode_single_funny');
assert.deepEqual(gd.bandBondIds('band_x'), ['egirShip', 'kazimierzShip']);
assert.deepEqual(gd.bandBondIds('band_old'), [], 'data without the field: no tie');
});
/** Bands the bots took over seeds 1–200: solo (one AI's pick) and co-op (the real draft, 4 AI seats). */
function picks(difficulty) {
const solo = [];
const coop = [];
for (let seed = 1; seed <= 200; seed++) {
const s = makeMatch({ mode: 'solo', difficulty, seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed, fake: true });
solo.push(botPickBand(s.m, s.m.order[0]));
s.m.dispose();
const c = makeMatch({ mode: 'coop', difficulty, humans: 0, bots: 4, seed, fake: true }).start();
c.run(() => c.m.phase !== PHASE.INFO_CHECK && c.m.phase !== PHASE.BAND_DRAFT, { maxSteps: 1e5 });
assert.equal(c.m.phase, PHASE.BATTLE_CHECK, `seed ${seed}: the draft ended`);
for (const ps of c.m.order) coop.push(ps.bandId);
assert.equal(new Set(c.m.order.map((ps) => ps.bandId)).size, 4, 'no strategy twice');
c.m.dispose();
}
return { solo, coop };
}
const TIED = new Set(['band_paganini', 'band_clementia', 'band_mlynar']);
test('标准: over seeds 1–200 no bot picks 潘格尼尼 / 克莱门莎 / 玛恩纳 — solo (200) or in the co-op draft (800); every pick is offered', () => {
const { solo, coop } = picks('FUNNY');
assert.equal(solo.filter((id) => TIED.has(id)).length, 0, 'solo');
assert.equal(coop.filter((id) => TIED.has(id)).length, 0, 'co-op');
const offered = new Set(funny.bandIds());
assert.ok([...solo, ...coop].every((id) => offered.has(id)));
assert.ok(new Set(solo).size >= 15 && new Set(coop).size >= 25, 'the other strategies are still spread out');
});
test('绝境: the same seeds still give those strategies (nothing switched off), picks unchanged from the LP-only weighting', () => {
const { solo, coop } = picks('HARD');
assert.ok(solo.filter((id) => TIED.has(id)).length > 0, `solo ${solo.filter((id) => TIED.has(id)).length}/200`);
assert.ok(coop.filter((id) => TIED.has(id)).length > 0, `co-op ${coop.filter((id) => TIED.has(id)).length}/800`);
// the pre-0.1.1 pick (starting LP only), replayed on the same rng: identical where no bond is off
const before = (m) => {
const ids = m.gd.bandIds();
const lateFunds = (id) => /暂存/.test(String(m.gd.band(id)?.desc || ''));
const pairs = ids.map((id) => [id, Math.max(1, (m.gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
let r = m.rngBots() * pairs.reduce((a, [, w]) => a + w, 0);
for (const [id, w] of pairs) { r -= w; if (r < 0) return id; }
return pairs[pairs.length - 1][0];
};
for (const difficulty of ['NORMAL', 'HARD', 'ABYSS']) {
for (let seed = 1; seed <= 200; seed++) {
const seats = [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }];
const a = makeMatch({ mode: 'solo', difficulty, seats, seed, fake: true });
const b = makeMatch({ mode: 'solo', difficulty, seats, seed, fake: true });
assert.equal(botPickBand(a.m, a.m.order[0]), before(b.m), `${difficulty} seed ${seed}`);
a.m.dispose();
b.m.dispose();
}
}
});
test('deterministic per seed; one rng draw per pick; with every offered band excluded, the default band', () => {
const seats = [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }];
for (const seed of [3, 77, 140]) {
const a = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seats, seed, fake: true });
const b = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seats, seed, fake: true });
assert.equal(botPickBand(a.m, a.m.order[0]), botPickBand(b.m, b.m.order[0]));
a.m.dispose();
b.m.dispose();
}
let draws = 0;
const fake = (gd, r = 0.5) => ({ gd, isSolo: true, rngBots: () => { draws++; return r; } });
botPickBand(fake(funny), null);
assert.equal(draws, 1);
// every offered band tied to a bond 标准 switches off (the default band 华法琳 not offered here): the default band
const data = { ...DATA, bands: {
band_bldsk: { ...DATA.bands.band_bldsk, modeTypeList: ['MULTI'] },
band_paganini: DATA.bands.band_paganini, band_clementia: DATA.bands.band_clementia, band_mlynar: DATA.bands.band_mlynar,
} };
const gd = new GameData(data, 'mode_single_funny');
assert.deepEqual(gd.bandIds(), ['band_paganini', 'band_clementia', 'band_mlynar']);
for (const r of [0, 0.3, 0.999]) assert.equal(botPickBand(fake(gd, r), null), gd.defaultBandId);
// one free band left: always it, whatever the draw (also at the top of the range)
const one = new GameData({ ...data, bands: { ...data.bands, band_amiya: DATA.bands.band_amiya } }, 'mode_single_funny');
for (const r of [0, 0.5, 0.9999999]) assert.equal(botPickBand(fake(one, r), null), 'band_amiya');
});
});
describe('§21.26 found on the way — the client-result layer bound reads the lineup\'s layer 特质', () => {
// coop/FUNNY/107 (4 humans on AI 托管: their band picks changed with the bot's) reached a 6-谢拉格 freeze board at R12 —
// 初雪 + 银灰 (garrison_28: 50 % +1 per freeze in range, no per-battle cap) and 凛御银灰's handed-out 60 % +2 — whose honest
// +112 谢拉格 layers passed the flat 60 + 4·12 = 108: the server rejected the client and re-simulated (SP_VERIFY=all
// agrees with the client on all 57 battles of that match)
const gd = new GameData(DATA, 'mode_multi_funny');
const spec = (round, units, bonds = {}) => ({
kind: 'normal', round, timeLimit: 60, spawns: [], flags: { layerGainsEnabled: true },
players: [{ playerId: 'p_0', units: units.map((chessId, i) => ({ uid: i + 1, kind: 'chess', chessId, row: 9 + (i % 4), col: 2 + (i >> 2) })), bonds }],
});
const res = (gains) => ({ reason: 'cleared', time: 10, perPlayer: { p_0: { killed: 0, total: 0, leaked: [], perfect: true, layerGains: gains, coins: 0, unitsEnd: [], unitStats: [] } } });
const check = (s, gains, opts = { gd }) => { const v = validateClientResult(s, res(gains), opts); return v.ok ? 'ok' : v.reason; };
const SEED_107_R12 = ['chess_char_1_06_b', 'chess_char_4_14_a', 'chess_char_4_13_a', 'chess_char_3_14_a', 'chess_char_3_11_a', 'chess_char_4_22_a', 'chess_char_3_20_a', 'chess_char_5_14_b'];
test('an uncapped freeze trait leaves 谢拉格 bounded only by 999: the honest +112 passes; a bond without such a trait keeps 60 + 4·round', () => {
const s = spec(12, SEED_107_R12, { kjeragShip: { count: 6, active: true, tier: 2, layers: 39 }, swiftShip: { count: 2, active: true, tier: 1, layers: 0 } });
assert.equal(check(s, { kjeragShip: 112 }), 'ok', 'the board of coop/FUNNY/107 R12');
assert.equal(check(s, { kjeragShip: 960 }), 'ok', '999 − 39');
assert.equal(check(s, { kjeragShip: 961 }), 'layer bound', 'never past 999');
assert.equal(check(s, { swiftShip: 108 }), 'ok');
assert.equal(check(s, { swiftShip: 109 }), 'layer bound', 'no layer trait names 迅捷: the flat bound');
assert.equal(check(s, { kjeragShip: 112 }, {}), 'layer bound', 'without game data: the flat bound as before');
});
test('capped traits add their per-battle caps (handed-out ones once per operator); 魔王\'s +extra lifts the bonds traits raise', () => {
// 史尔特尔 (精锐): <部署时> 突袭 +16, at most 100 a battle → 60 + 4·3 + 100
const surtr = spec(3, ['chess_char_5_07_b']);
assert.equal(check(surtr, { raidShip: 172 }), 'ok');
assert.equal(check(surtr, { raidShip: 173 }), 'layer bound');
assert.equal(check(surtr, { arcaneShip: 73 }), 'layer bound', 'its other bond: no trait, the flat bound');
// 荒芜拉普兰德 (精锐) hands every 叙拉古 operator garrison_117_b (+2 per kill, at most 200): × the 2 operators
const lap = spec(3, ['chess_char_6_18_b', 'chess_char_1_03_a']);
assert.equal(check(lap, { siracusaShip: 72 + 400 }), 'ok');
assert.equal(check(lap, { siracusaShip: 72 + 401 }), 'layer bound');
// 魔王 in the lineup: every bond a trait raises is bounded by 999 only; the others keep the flat bound
const demon = spec(3, ['chess_char_5_07_b', 'chess_char_4_25_a']);
assert.equal(check(demon, { raidShip: 999 }), 'ok');
assert.equal(check(demon, { arcaneShip: 73 }), 'layer bound');
});
});
test('§21.26 docs: DESIGN (the subsection and the normative lines), META, PLAYING and the CHANGELOG say what the code does', () => {
const doc = (p) => readFileSync(new URL(`../../${p}`, import.meta.url), 'utf8');
const DESIGN = doc('docs/DESIGN.md');
const at = DESIGN.indexOf('### 21.26 Gaps found while triaging GitHub issues #1 / #8 (v0.1.1)');
assert.ok(at > 0, 'the subsection');
const s = DESIGN.slice(at);
for (const k of ['morphPairings', 'harmonyMembers', '`harmony: 1`', 'bandBondIds', 'botPickBand', '0 of 200 and 0 of 800', '5 of 200 solo and 23 of 800 co-op',
'shared/bandBonds.js', 'bandOffBonds', 'BandOffTag', '本局禁用【拉特兰】盟约,此策略效果可能无法发挥', 'it stays selectable']) assert.ok(s.includes(k), k);
assert.match(doc('docs/DATA.md'), /\| `bondIds` \| `\["lateranoShip"\]` \(潘格尼尼\) \/ `\[\]` \|/, 'DATA.md bands.json bondIds');
assert.match(DESIGN, /bonds:\[\{bondId,count,active,tier,layers,harmony\? \/\* 调和's \+1 is in count, §21\.26 \*\/\}\]/, '§8.2');
assert.match(DESIGN, /bonds: \[ \{ bondId, count, active, tier, layers, harmony\? \/\* 调和's \+1 is in count, §21\.26 \*\/, thresholds, countsHand \} \]/, '§8.3');
assert.match(DESIGN, /Strategy \(§21\.6\): pick a band \(weighted by starting LP; never one built around a bond the mode switches off — `gd\.bandBondIds`, §21\.26\)/, '§6.6');
const META = doc('docs/META.md');
assert.match(META, /\*\*Strategy\*\* \(`botPickBand`\)/);
assert.match(META, /carries `harmony: 1` in both lists/);
assert.match(doc('docs/PLAYING.md'), /盟约详情会写「含调和 \+1」/);
const log = doc('CHANGELOG.md');
const v011 = log.slice(log.indexOf('## 0.1.1'), log.indexOf('## 0.1.0'));
assert.match(v011, /变形同构体的详情卡列出天赋栏里的对应关系/);
assert.match(v011, /盟约详情写明「含调和 \+1」/);
assert.match(v011, /人机不再选择围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)/);
assert.match(v011, /选择策略时,围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)标出「本局禁用」并在详情里说明,仍可选择/);
assert.match(META, /The strategy draft marks the same bands 本局禁用 for humans \(still selectable\)/);
});
+3 -2
View File
@@ -1,8 +1,9 @@
// The data behind the watched player's bond strip (DESIGN §20.15, user report after playtest #6): what the server
// sends of every player's bonds and what the browser's battle runner adds live.
// * m.public players[].bonds = every bond with members, layers or an active tier — the player's own m.private list
// without the per-bond extras the client reads from bonds.json (thresholds / countsHand); nothing else — and nothing
// at all for an eliminated player (nobody can watch them).
// without the per-bond extras the client reads from bonds.json (thresholds / countsHand); nothing else (but `harmony`
// on an entry whose count holds 调和's +1, DESIGN §21.26: test/match/feedback1-gaps.test.js) — and nothing at all for
// an eliminated player (nobody can watch them).
// * From the end of COMBAT until the settlement the views (m.private and m.public) carry the finished normal battle's
// in-battle gains (PlayerState.bondsView / bondsMeta.bondsWithGains, capped at 999): the strip of a player — and of a
// teammate watching him in the 联防 — keeps the layers his battle reached. The persistent state is untouched and the
+187
View File
@@ -0,0 +1,187 @@
// The 0.1.1 gaps on screen (DESIGN §21.26), in headless Chrome (puppeteer-core + system Chrome) through the mock harness
// (public/dev/game-mock.html ?phase=PREP&variant=funny,morph,harmony: a 标准 match, a 变形同构体 in the shop's item slot, the
// first board operator wearing 变形同构体 + 维式重锤, 缪尔赛思 on the board). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/feedback1-gaps.e2e.test.js
// SP_E2E=1 SP_E2E_OUT=<dir> node --test test/ui/feedback1-gaps.e2e.test.js # screenshots elsewhere
//
// At 1920×1080 and on phones (844×390, 756×366, touch): the 变形同构体 card from the shop lists its 14 pairings (5 marked 本局禁用),
// the wearer's card highlights 维多利亚 (生效中), and the popup of a core bond holding 调和's +1 says 在场 n(含调和 +1) with the
// 调和 row naming 缪尔赛思 — every line inside its panel (the detail card without sideways scrolling), text ≥ 9 px on the
// phones. The strategy draft of a 标准 match (?phase=BAND_DRAFT&variant=funny, 1920×1080 and 844×390): 潘格尼尼, 克莱门莎 and
// 玛恩纳 — and no other — read 本局禁用 on their card, the detail pane says "本局禁用【拉特兰】盟约,此策略效果可能无法发挥", and
// 潘格尼尼 can still be picked. Screenshots:
// fb1-gaps-{morph-shop,morph-wearer,harmony,draft}-<w>x<h>.png. Unit counterparts: test/ui/feedback1-gaps.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = process.env.SP_E2E_OUT ? path.resolve(process.env.SP_E2E_OUT) : path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Geometry and text of the rows matching `sel` inside the scroller `box`: every row inside it, font size, overflow. */
const measure = (page, box, sel) => page.evaluate((box, sel) => {
const b = document.querySelector(box);
if (!b) return null;
const r = b.getBoundingClientRect();
const rows = [...b.querySelectorAll(sel)].map((el) => {
const q = el.getBoundingClientRect();
return { text: el.textContent.trim(), off: el.classList.contains('is-off'), worn: el.classList.contains('is-worn'), bond: el.getAttribute('data-bond'),
inside: q.left >= r.left - 0.5 && q.right <= r.right + 0.5, font: parseFloat(getComputedStyle(el).fontSize) };
});
return { rows, sideways: b.scrollWidth > b.clientWidth + 1 };
}, box, sel);
describe('0.1.1 gaps: 变形同构体 pairings and 调和\'s +1 (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
for (const [name, w, h, touch] of [['1920x1080', 1920, 1080, false], ['844x390', 844, 390, true], ['756x366', 756, 366, true]]) {
test(`${name}: the 变形同构体 card, its wearer's card and the 调和 row of the bond popup`, { timeout: 120000 }, async () => {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&render=fallback&phase=PREP&variant=funny,morph,harmony`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.shopbar__item .scard', { timeout: 20000 });
await sleep(600);
const minFont = touch ? 9 : 13.5;
const tap = async (sel) => { const el = await page.$(sel); assert.ok(el, sel); if (touch) await el.tap(); else await el.click(); await sleep(300); };
// 1. the shop's 变形同构体 card: the first tap opens its detail with the 天赋 list
await tap('.shopbar__item .scard');
await page.waitForSelector('.dpanel .dmorph__row', { timeout: 5000 });
await page.screenshot({ path: path.join(OUT, `fb1-gaps-morph-shop-${name}.png`) });
const shop = await measure(page, '.dpanel__scroll', '.dmorph__row');
assert.equal(shop.rows.length, 14, 'one line per bond');
assert.deepEqual(shop.rows.filter((r) => r.off).map((r) => r.bond), ['lateranoShip', 'egirShip', 'kazimierzShip', 'arcaneShip', 'raidShip'], '标准: 本局禁用');
assert.ok(shop.rows.filter((r) => r.off).every((r) => /本局禁用/.test(r.text)));
assert.match(shop.rows.find((r) => r.bond === 'victoriaShip').text, /^【维多利亚】维式重锤、战栗维式重锤、坚固维式重锤、加速维式重锤、灼燃维式重锤$/);
assert.equal(shop.rows.filter((r) => r.worn).length, 0, 'no wearer: no highlight');
assert.ok(shop.rows.every((r) => r.inside), 'every line inside the card');
assert.equal(shop.sideways, false, 'no sideways scrolling');
assert.ok(shop.rows.every((r) => r.font >= minFont), `font ${shop.rows[0].font} px`);
assert.match(await page.$eval('.dpanel .dmorph__lead', (el) => el.textContent), /搭配以下装备时,携带者视为对应盟约的成员:/);
await page.evaluate(() => document.querySelector('.dpanel .dmorph__list')?.scrollIntoView({ block: 'start' }));
await sleep(150);
await page.screenshot({ path: path.join(OUT, `fb1-gaps-morph-shop-${name}-list.png`) });
await page.keyboard.press('Escape');
await sleep(300);
// 2. the wearer's card (long-press / right-click on the board piece: contextmenu on the DOM board)
const uid = await page.evaluate(() => globalThis.__MOCK__.S().priv.board[0].uid);
await page.evaluate((uid) => document.querySelector(`.ff-piece[data-uid="${uid}"]`)?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })), uid);
await page.waitForSelector('.dpanel .dsec--equip .dmorph__row', { timeout: 5000 });
await page.evaluate(() => document.querySelector('.dpanel .dmorph__row.is-worn')?.scrollIntoView({ block: 'center' }));
await sleep(200);
await page.screenshot({ path: path.join(OUT, `fb1-gaps-morph-wearer-${name}.png`) });
const wearer = await measure(page, '.dpanel__scroll', '.dmorph__row');
const worn = wearer.rows.filter((r) => r.worn);
assert.deepEqual(worn.map((r) => r.bond), ['victoriaShip'], 'the pairing it wears');
assert.match(worn[0].text, /生效中$/);
assert.ok(wearer.rows.every((r) => r.inside) && !wearer.sideways);
assert.match(await page.$eval('.dpanel .dhint--morph', (el) => el.textContent.trim()), /与变形同构体一同装备时,携带者视为【维多利亚】成员生效中/);
await page.keyboard.press('Escape');
await sleep(300);
// 3. the popup of a core bond whose count holds 调和's +1 (the strip disc)
const bond = await page.evaluate(() => (globalThis.__MOCK__.store.get().match.private.bonds.find((b) => b.harmony > 0) || {}).bondId);
assert.ok(bond, 'a bond entry with the +1');
await tap(`.bslot[data-bond="${bond}"] .bond`);
await page.waitForSelector('.bpop .bpop__harmony', { timeout: 5000 });
await page.screenshot({ path: path.join(OUT, `fb1-gaps-harmony-${name}.png`) });
const facts = await page.$eval('.bpop .bpop__facts', (el) => el.textContent);
assert.match(facts, /在场\s*\d+\/\d+(含调和 \+1)/);
const note = await page.$eval('.bpop .bpop__hnote', (el) => parseFloat(getComputedStyle(el).fontSize));
assert.ok(note >= (touch ? 8 : 11.5), `the note at ${note} px`);
await page.evaluate(() => document.querySelector('.bpop .bpop__harmony')?.scrollIntoView({ block: 'center' }));
await sleep(150);
await page.screenshot({ path: path.join(OUT, `fb1-gaps-harmony-${name}-row.png`) });
const row = await measure(page, '.bpop', '.bpop__harmony');
assert.equal(row.rows.length, 1);
assert.match(row.rows[0].text, /^调和 \+1缪尔赛思 在场:核心盟约激活人数 \+1$/);
// (inside the popup's box; the popup's own few px of sideways overflow on phones predate this row)
assert.ok(row.rows[0].inside, 'inside the popup');
assert.ok(row.rows[0].font >= minFont, `font ${row.rows[0].font} px`);
// the 调和 row opens 缪尔赛思's card
await tap('.bpop .bpop__harmony');
await page.waitForFunction(() => /缪尔赛思/.test(document.querySelector('.dpanel .dhead__name')?.textContent || ''), { timeout: 5000 });
assert.deepEqual(problems, []);
await page.close();
});
}
// the strategy draft of a 标准 match (?phase=BAND_DRAFT&variant=funny; my turn): the strategies built around a bond 标准
// switches off read 本局禁用 on their card and in the detail pane — and can still be picked
for (const [name, w, h, touch] of [['1920x1080', 1920, 1080, false], ['844x390', 844, 390, true]]) {
test(`${name}: the 标准 strategy draft marks 潘格尼尼 / 克莱门莎 / 玛恩纳 本局禁用, and 潘格尼尼 can still be picked`, { timeout: 120000 }, async () => {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=BAND_DRAFT&variant=funny`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.draft-grid .dband[data-band]', { timeout: 20000 });
await sleep(500);
const tap = async (sel) => { const el = await page.$(sel); assert.ok(el, sel); await el.scrollIntoView(); if (touch) await el.tap(); else await el.click(); await sleep(300); };
const cards = await page.$$eval('.draft-grid .dband[data-band]', (els) => els.map((c) => {
const t = c.querySelector('.dband__off');
const r = c.getBoundingClientRect();
const q = t ? t.getBoundingClientRect() : null;
return { id: c.getAttribute('data-band'), off: c.classList.contains('is-off'), tag: t ? t.textContent : null, title: c.title || null,
font: t ? parseFloat(getComputedStyle(t).fontSize) : null, inside: !q || (q.left >= r.left - 0.5 && q.right <= r.right + 0.5 && q.bottom <= r.bottom + 0.5) };
}));
assert.ok(cards.length >= 30, `${cards.length} strategies on offer`);
assert.deepEqual(cards.filter((c) => c.tag).map((c) => c.id), ['band_paganini', 'band_clementia', 'band_mlynar'], 'only the three');
assert.ok(cards.filter((c) => c.tag).every((c) => c.off && c.tag === '本局禁用' && c.inside && c.font >= (touch ? 8 : 11.5)), JSON.stringify(cards.filter((c) => c.tag)));
assert.equal(cards.find((c) => c.id === 'band_paganini').title, '本局禁用【拉特兰】盟约,此策略效果可能无法发挥');
await page.screenshot({ path: path.join(OUT, `fb1-gaps-draft-${name}.png`) });
// its detail pane: the note, inside the pane; 确认选择 stays enabled (information only)
await tap('.draft-grid .dband[data-band="band_paganini"]');
await page.waitForSelector('.draft-detail .draft-detail__off', { timeout: 5000 });
const note = await page.evaluate(() => {
const n = document.querySelector('.draft-detail .draft-detail__off');
const p = document.querySelector('.draft-detail').getBoundingClientRect();
const r = n.getBoundingClientRect();
const btn = [...document.querySelectorAll('.draft-detail__btns button')].pop();
return { text: n.textContent.trim(), struck: n.querySelector('.draft-detail__offname')?.textContent, font: parseFloat(getComputedStyle(n).fontSize),
inside: r.left >= p.left - 0.5 && r.right <= p.right + 0.5, confirm: btn ? { text: btn.textContent.trim(), disabled: btn.disabled } : null };
});
assert.equal(note.text, '本局禁用【拉特兰】盟约,此策略效果可能无法发挥');
assert.equal(note.struck, '拉特兰');
assert.ok(note.inside && note.font >= (touch ? 9 : 13.5), JSON.stringify(note));
assert.deepEqual(note.confirm, { text: '确认选择', disabled: false }, 'still selectable');
await page.screenshot({ path: path.join(OUT, `fb1-gaps-draft-${name}-detail.png`) });
// a strategy tied to no switched-off bond: no note
await tap('.draft-grid .dband[data-band="band_bldsk"]');
assert.equal(await page.$('.draft-detail .draft-detail__off'), null);
// picking 潘格尼尼 works like any strategy
await tap('.draft-grid .dband[data-band="band_paganini"]');
await tap('.draft-detail__btns button:last-child');
await page.waitForFunction(() => /已选择「潘格尼尼」/.test(document.querySelector('.draft-detail__status')?.textContent || ''), { timeout: 5000 });
assert.deepEqual(problems, []);
await page.close();
});
}
});
+366
View File
@@ -0,0 +1,366 @@
// Gaps found while triaging GitHub issues #1 / #8 (DESIGN §21.26) — the client side.
// 1. 变形同构体 shows its pairings ("转职球" read as doing nothing: its text says "具体对应关系可在模拟中查看本装备天赋栏", and
// no screen showed that 天赋栏). gameLogic morphPairings builds the list from the items' `giveBondId` — the rule the
// server counts with — grouped by bond in bonds.json order; detailPanel MorphPairings draws it on the item card (shop,
// reward, hand) and on a wearer's card (装备), 本局禁用 marked (标准: 5 of the 14 bonds), the pairing a wearer wears
// highlighted; a bond item says what it gives a 变形同构体 wearer (MorphGrantLine).
// 2. 调和's +1 in the bond popup ("一些不考虑整备区的盟约计算人数时也会把整备区的干员算进去"): the server marks a count that holds
// 调和's +1 (`harmony` in m.private bonds / m.public players[].bonds — test/match/feedback1-gaps.test.js); the popup
// says 在场 n(含调和 +1) and a 调和 row heads the member list naming the 调和 operators on that board — own and a
// teammate's (ui/watchBonds.js popupView over the scouted field), the chip's and the strip's titles too.
// 4. The strategy draft marks a strategy built around a bond the mode switches off 本局禁用 (bands.json `bondIds`, built
// by shared/bandBonds.js — the field the bot reads too: gameLogic bandOffBonds = GameData.bandBondIds ∩ the mode's
// inactive bonds), on its card and in the detail pane (screens/bandDraft.js BandOffTag / BandOffNote); still selectable.
// (3, the bot's pick, is server-side: test/match/feedback1-gaps.test.js.)
// On screen (headless Chrome, opt-in): test/ui/feedback1-gaps.e2e.test.js.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
// the browser data store reads the real data files from disk
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 { morphPairings, modeOffBonds, grantedBonds, harmonyMembers, HARMONY_BOND, bandOffBonds, bandOffLine } = await import('../../public/js/ui/gameLogic.js');
const { data, getMode } = await import('../../public/js/data.js');
await data.loadAll('bonds', 'chess', 'items', 'assets', 'garrisons', 'config');
const { ItemDetail, ChessDetail, MorphPairings, MorphGrantLine, BondChips } = await import('../../public/js/ui/detailPanel.js');
const { BondPopup, BondStrip } = await import('../../public/js/ui/bondStrip.js');
const { popupView } = await import('../../public/js/ui/watchBonds.js');
const { HARMONY_BOND: SERVER_HARMONY_BOND } = await import('../../server/match/bondsMeta.js');
const { makeMatch, give, giveItem, legalTileFor } = await import('../match/harness.js');
const { tileKey } = await import('../../server/match/board.js');
const items = load('items.json');
const bonds = load('bonds.json');
const chess = load('chess.json');
const config = load('config.json');
const ITEMS = Object.values(items);
const BONDS = Object.values(bonds);
const ISO = 'chess_item_6_09_e_a'; // 变形同构体
const ISO_B = 'chess_item_6_09_e_b';
const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 → 维多利亚
const HAMMER_SHUDDER = 'chess_item_2_03_e_a'; // 战栗维式重锤 → 维多利亚 (an effect-only hammer)
const LATERANO_CLIP = 'chess_item_4_08_e_a'; // 拉特兰桥夹 → 拉特兰 (off in 标准)
const WEARER = 'chess_char_1_01_a'; // 隐现 (拉特兰 / 迅捷)
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (调和)
const YAN = Object.values(chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes('yanShip') && !c.bonds.includes(HARMONY_BOND)).map((c) => c.chessId).sort();
/** Every vnode of a tree; the panel's own hook-free components are expanded (RichText & co. stay unexpanded). */
const EXPAND = new Set(['Section', 'ItemRow', 'MorphPairings', 'MorphGrantLine']);
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
if (typeof v.type === 'function' && EXPAND.has(v.type.name)) { yield* walk(v.type(v.props)); return; }
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
if (typeof v.type === 'function' && EXPAND.has(v.type.name)) return textOf(v.type(v.props));
if (typeof v.type === 'function' && v.type.name === 'RichText') return String(v.props.text || '');
return textOf(v.props?.children);
};
const byClass = (tree, c) => [...walk(tree)].filter((v) => hasClass(v, c));
/** The expected list straight from the data: bond → the distinct names of the items whose giveBondId is it. */
function expectedPairings() {
const out = new Map();
for (const r of ITEMS) {
if (r.canGiveBond || !r.giveBondId) continue;
if (!out.has(r.giveBondId)) out.set(r.giveBondId, new Set());
out.get(r.giveBondId).add(r.name);
}
return out;
}
describe('§21.26 1 — 变形同构体: the pairing list (gameLogic morphPairings)', () => {
test('from the data: 14 bonds in bonds.json order, 18 items, the same giveBondId the server counts with', () => {
const rows = morphPairings(ITEMS, BONDS);
const want = expectedPairings();
assert.equal(rows.length, 14);
assert.equal(want.size, 14);
assert.deepEqual(rows.map((r) => r.bondId), BONDS.map((b) => b.bondId).filter((id) => want.has(id)), 'bonds.json order');
for (const r of rows) {
assert.equal(r.name, bonds[r.bondId].name);
assert.deepEqual(new Set(r.items.map((it) => it.name)), want.get(r.bondId), `${r.name}: its items`);
assert.equal(new Set(r.items.map((it) => it.id)).size, r.items.length, 'one entry per item family (normal / 进阶 share it)');
assert.equal(r.off, false);
assert.equal(r.worn, false);
}
assert.equal(rows.reduce((n, r) => n + r.items.length, 0), 18, '18 bond items');
const vic = rows.find((r) => r.bondId === 'victoriaShip');
assert.deepEqual(vic.items.map((it) => it.name), ['维式重锤', '战栗维式重锤', '坚固维式重锤', '加速维式重锤', '灼燃维式重锤'], 'by tier: the hammer series');
assert.ok(!rows.some((r) => r.items.some((it) => items[it.id].canGiveBond)), '变形同构体 itself is no partner');
// every listed item really grants its bond to a wearer (the client rule = the server's: test/ui/morph-bonds.test.js)
for (const r of rows) for (const it of r.items) assert.deepEqual(grantedBonds([ISO, it.id], (id) => items[id]), [r.bondId], it.name);
});
test('the official 天赋栏 (character_table trap_1073_acarm073) lists the same 14 pairings', { skip: !existsSync(path.join(ROOT, '.cache/gamedata/excel/character_table.json')) && 'no official cache' }, () => {
const ct = JSON.parse(readFileSync(path.join(ROOT, '.cache/gamedata/excel/character_table.json'), 'utf8'));
const talent = ct[items[ISO].trapId].talents[0].candidates.at(-1).description.replace(/\\n/g, '\n');
const pairs = [...talent.matchAll(/“([^”]+)”(?:系列装备)?→【([^】]+)】盟约/g)].map(([, item, bond]) => ({ item, bond }));
assert.equal(pairs.length, 14, talent);
const rows = morphPairings(ITEMS, BONDS);
assert.deepEqual(pairs.map((p) => p.bond).sort(), rows.map((r) => r.name).sort(), 'the same 14 bonds');
for (const p of pairs) {
const r = rows.find((x) => x.name === p.bond);
assert.ok(r.items.some((it) => it.name.includes(p.item)), `“${p.item}”→【${p.bond}】`);
}
});
test('本局禁用: 标准 marks the 5 bonds it switches off (拉特兰 阿戈尔 卡西米尔 奥术 突袭), the other modes none', () => {
for (const id of ['mode_single_funny', 'mode_multi_funny']) {
const off = modeOffBonds(getMode(id));
const rows = morphPairings(ITEMS, BONDS, { off });
const marked = rows.filter((r) => r.off).map((r) => r.bondId);
assert.deepEqual(marked, rows.map((r) => r.bondId).filter((b) => config.modes[id].inactiveBondIds.includes(b)), id);
assert.deepEqual(marked.map((b) => bonds[b].name), ['拉特兰', '阿戈尔', '卡西米尔', '奥术', '突袭']);
}
for (const id of ['mode_single_normal', 'mode_multi_hard', 'mode_multi_abyss']) {
assert.equal(morphPairings(ITEMS, BONDS, { off: modeOffBonds(getMode(id)) }).filter((r) => r.off).length, 0, id);
}
});
test('on a wearer: the pairing it wears is highlighted (bond and item), with a 进阶 变形同构体 too; nothing without a pair', () => {
const worn = (carried, off = null) => morphPairings(ITEMS, BONDS, { carried, off }).filter((r) => r.worn);
const vic = worn([ISO, HAMMER_SHUDDER]);
assert.deepEqual(vic.map((r) => r.bondId), ['victoriaShip']);
assert.deepEqual(vic[0].items.filter((it) => it.worn).map((it) => it.name), ['战栗维式重锤'], 'only the item it carries');
assert.deepEqual(worn([{ uid: 1, id: HAMMER }, { uid: 2, id: ISO_B }]).map((r) => r.bondId), ['victoriaShip'], 'pieces, golden 变形同构体');
const lat = worn([ISO, LATERANO_CLIP], modeOffBonds(getMode('mode_multi_funny')));
assert.ok(lat.length === 1 && lat[0].bondId === 'lateranoShip' && lat[0].off, 'worn, but 本局禁用 in 标准');
assert.deepEqual(worn([ISO]), [], 'the 变形同构体 alone');
assert.deepEqual(worn([HAMMER]), [], 'the bond item alone');
assert.deepEqual(worn([ISO, 'chess_item_1_03_e_a']), [], 'a partner with no bond');
assert.deepEqual(worn(null), []);
});
});
describe('§21.26 1 — 变形同构体 on the cards', () => {
const funny = modeOffBonds(getMode('mode_multi_funny'));
test('the item card (shop / reward / hand): a 天赋 section with the 14 lines, 本局禁用 struck through, no highlight', () => {
const v = ItemDetail({ item: items[ISO], piece: null, editable: false, onDestroy() {}, offBonds: funny });
const sec = [...walk(v)].find((x) => x.type?.name === 'Section' && x.props.title === '天赋');
assert.ok(sec, 'a 天赋 section (the item text points at its 天赋栏)');
assert.match(textOf(v), /搭配以下装备时,携带者视为对应盟约的成员:/);
const rows = byClass(v, 'dmorph__row');
assert.equal(rows.length, 14);
const off = rows.filter((r) => hasClass(r, 'is-off'));
assert.deepEqual(off.map((r) => r.props['data-bond']), ['lateranoShip', 'egirShip', 'kazimierzShip', 'arcaneShip', 'raidShip']);
for (const r of off) assert.match(textOf(r), /本局禁用/);
assert.equal(rows.filter((r) => !hasClass(r, 'is-off') && /本局禁用/.test(textOf(r))).length, 0);
assert.equal(byClass(v, 'is-worn').length, 0, 'no wearer: nothing highlighted');
assert.match(textOf(rows.find((r) => r.props['data-bond'] === 'victoriaShip')), /【维多利亚】维式重锤、战栗维式重锤、坚固维式重锤、加速维式重锤、灼燃维式重锤/);
assert.equal(byClass(v, 'dmorph__none').length, 0);
// other modes: no mark
assert.equal(byClass(ItemDetail({ item: items[ISO_B], piece: null, editable: false, onDestroy() {}, offBonds: new Set() }), 'is-off').length, 0, '进阶 copy, 绝境');
});
test('a wearer\'s card (装备): the 变形同构体 row lists the pairings with the worn one highlighted (生效中); the bond item\'s row says it', () => {
const piece = { uid: 1, kind: 'chess', id: WEARER, golden: false, tier: 1, items: [{ uid: 2, id: ISO }, { uid: 3, id: HAMMER }] };
const blocks = ChessDetail({ chess: chess[WEARER], piece, editable: false, bonds: [], offBonds: funny, loadout: null });
const equip = blocks.find((b) => b?.key === 'equip');
const rows = byClass(equip, 'dmorph__row');
assert.equal(rows.length, 14);
const worn = rows.filter((r) => hasClass(r, 'is-worn'));
assert.deepEqual(worn.map((r) => r.props['data-bond']), ['victoriaShip']);
assert.match(textOf(worn[0]), /生效中/);
assert.deepEqual(byClass(worn[0], 'dmorph__item').filter((x) => hasClass(x, 'is-worn')).map(textOf), ['维式重锤']);
const grant = byClass(equip, 'dhint--morph');
assert.equal(grant.length, 1, 'the 维式重锤 row');
assert.ok(hasClass(grant[0], 'is-worn'));
assert.match(textOf(grant[0]), /与变形同构体一同装备时,携带者视为【维多利亚】成员.*生效中/);
// worn with a bond the mode switches off: 已搭配 + 本局禁用, never 生效中
const lat = ChessDetail({ chess: chess[WEARER], piece: { ...piece, items: [{ uid: 2, id: ISO }, { uid: 4, id: LATERANO_CLIP }] }, editable: false, bonds: [], offBonds: funny, loadout: null });
const latRow = byClass(lat.find((b) => b?.key === 'equip'), 'dmorph__row').find((r) => hasClass(r, 'is-worn'));
assert.equal(latRow.props['data-bond'], 'lateranoShip');
assert.match(textOf(latRow), /已搭配/);
assert.match(textOf(latRow), /本局禁用/);
assert.doesNotMatch(textOf(latRow), /生效中/);
// a teammate's unit / a popup's 同构 row (no own piece): the same, from the item ids it carries
const ro = ChessDetail({ chess: chess[WEARER], piece: null, editable: false, bonds: [], offBonds: null, loadout: null, unitItems: [HAMMER_SHUDDER, ISO_B] });
assert.deepEqual(byClass(ro.find((b) => b?.key === 'equip'), 'dmorph__row').filter((r) => hasClass(r, 'is-worn')).map((r) => r.props['data-bond']), ['victoriaShip']);
});
test('a wearer without a pair reads 暂未生效; a bond item alone says what it would give', () => {
const lone = MorphPairings({ off: null, carried: [ISO] });
assert.equal(byClass(lone, 'is-worn').length, 0);
assert.match(textOf(byClass(lone, 'dmorph__none')[0]), /暂未生效/);
const card = ItemDetail({ item: items[HAMMER], piece: null, editable: false, onDestroy() {}, offBonds: funny });
const line = byClass(card, 'dhint--morph');
assert.equal(line.length, 1);
assert.match(textOf(line[0]), /^与变形同构体一同装备时,携带者视为【维多利亚】成员$/);
assert.ok(!hasClass(line[0], 'is-worn'));
const clip = byClass(ItemDetail({ item: items[LATERANO_CLIP], piece: null, editable: false, onDestroy() {}, offBonds: funny }), 'dhint--morph');
assert.ok(hasClass(clip[0], 'is-off'));
assert.match(textOf(clip[0]), /【拉特兰】成员本局禁用/);
assert.equal(MorphGrantLine({ item: items[ISO], off: null }), null, 'not on the 变形同构体 itself');
assert.equal(MorphGrantLine({ item: items['chess_item_1_03_e_a'], off: null }), null, 'not on an item without a bond');
});
});
describe('§21.26 2 — 调和\'s +1 in the bond popup', () => {
test('the client names 调和 by the server\'s id; harmonyMembers lists the 调和 operators on a board once each', () => {
assert.equal(HARMONY_BOND, SERVER_HARMONY_BOND);
const getChess = (id) => chess[id] || null;
const priv = { board: [{ kind: 'chess', id: MLYSS }, { kind: 'chess', id: chess[MLYSS].goldenId }, { kind: 'chess', id: YAN[0] }, { kind: 'token', id: 'x' }], hand: [{ kind: 'chess', id: 'chess_char_1_15_a' }] };
assert.deepEqual(harmonyMembers(priv, getChess), [{ id: MLYSS, name: '缪尔赛思' }], 'board only, one per operator');
assert.deepEqual(harmonyMembers({ board: [{ kind: 'chess', id: 'chess_char_1_15_a' }] }, getChess).map((x) => x.name), ['盟约·辅助干员'], 'the Pith strategy\'s hidden one');
assert.deepEqual(harmonyMembers(null, getChess), []);
});
/** A co-op match: p_1 deploys 缪尔赛思 + two 炎 operators (调和 makes 炎 3 = active), p_0 has nothing of it. */
function harmonyMatch() {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 2611, fake: true }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_1');
ps.board.clear();
const used = new Set();
const pieces = {};
for (const id of [MLYSS, YAN[0], YAN[1]]) {
const t = legalTileFor(m, ps, id, used);
used.add(tileKey(t[0], t[1]));
pieces[id] = give(m, ps, id, 'board', t);
}
ps.recompute();
return { h, m, ps, pieces };
}
test('own popup: 在场 3(含调和 +1), the 调和 row names 缪尔赛思 and opens her card; the member list stays 2 in play', () => {
const { h, m, ps } = harmonyMatch();
const priv = ps.privateView();
const view = popupView({ open: { id: 'yanShip', ownerId: 'p_1' }, pub: m.publicView(), priv, myId: 'p_1', field: null });
assert.equal(view.entry.harmony, 1, 'the server\'s entry says the count holds 调和\'s +1');
assert.equal(view.entry.count, 3);
const calls = [];
const v = BondPopup({ bondId: 'yanShip', entry: view.entry, priv: view.priv, onClose() {}, onMember: (...a) => calls.push(a) });
assert.match(textOf(byClass(v, 'bpop__facts')[0]).replace(/\s+/g, ''), /在场3\/6(含调和\+1)/);
const row = byClass(v, 'bpop__harmony');
assert.equal(row.length, 1);
assert.equal(row[0].type, 'button');
assert.match(textOf(row[0]), /调和 \+1/);
assert.match(textOf(row[0]), /缪尔赛思 在场:核心盟约激活人数 \+1/);
row[0].props.onClick();
assert.deepEqual(calls, [[MLYSS, null]], 'opens 缪尔赛思\'s card');
const head = [...walk(v)].filter((x) => x.type === 'h4').map(textOf).find((t) => t.startsWith('成员'));
assert.match(head.replace(/\s+/g, ''), /^成员2\//, 'the real members: 2 in play + the 调和 row = 在场 3');
// the popup of 调和 itself (an add-on bond) and of a bond without the +1: no row
const mani = popupView({ open: { id: HARMONY_BOND, ownerId: 'p_1' }, pub: m.publicView(), priv, myId: 'p_1' });
assert.equal(byClass(BondPopup({ bondId: HARMONY_BOND, entry: mani.entry, priv, onClose() {} }), 'bpop__harmony').length, 0);
m.dispose();
void h;
});
test('a teammate\'s popup (scouting their board): the entry from m.public carries the +1, the row names their 缪尔赛思', () => {
const { m, ps } = harmonyMatch();
const pub = m.publicView();
const field = m.prepFieldMeta(ps); // what 前往查看 shows p_0 (m.field)
const view = popupView({ open: { id: 'yanShip', ownerId: 'p_1' }, pub, priv: m.players.get('p_0').privateView(), myId: 'p_0', field });
assert.equal(view.name, ps.name);
assert.equal(view.entry.harmony, 1);
const v = BondPopup({ bondId: 'yanShip', entry: view.entry, priv: view.priv, owner: view.name, onClose() {} });
assert.match(textOf(byClass(v, 'bpop__facts')[0]), /含调和 \+1/);
assert.match(textOf(byClass(v, 'bpop__harmony')[0]), /缪尔赛思 在场/);
// their board not on screen (no field): the row still explains the +1, without names
const blind = popupView({ open: { id: 'yanShip', ownerId: 'p_1' }, pub, priv: null, myId: 'p_0', field: null });
const row = byClass(BondPopup({ bondId: 'yanShip', entry: blind.entry, priv: blind.priv, owner: view.name, onClose() {} }), 'bpop__harmony');
assert.equal(row[0].type, 'div');
assert.match(textOf(row[0]), /调和已激活:核心盟约激活人数 \+1/);
m.dispose();
});
test('without the server\'s +1 nothing changes; a 本局禁用 popup never shows it', () => {
const plain = { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6, 9] };
const priv = { board: [{ kind: 'chess', id: MLYSS }, { kind: 'chess', id: YAN[0] }], hand: [], temp: [] };
const v = BondPopup({ bondId: 'yanShip', entry: plain, priv, onClose() {} });
assert.equal(byClass(v, 'bpop__harmony').length, 0, 'the client does not re-derive the rule');
assert.doesNotMatch(textOf(byClass(v, 'bpop__facts')[0]), /调和/);
const off = BondPopup({ bondId: 'lateranoShip', entry: { bondId: 'lateranoShip', count: 2, harmony: 1 }, priv, onClose() {}, off: true });
assert.equal(byClass(off, 'bpop__harmony').length, 0);
});
test('the strip disc\'s and the card chip\'s titles say the count holds the +1', () => {
const entry = { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 5, harmony: 1, thresholds: [3, 6, 9] };
const strip = BondStrip({ bonds: [entry], onOpen() {} });
const slot = [...walk(strip)].find((x) => x.props?.['data-bond'] === 'yanShip');
assert.equal(slot.props['data-harmony'], 1);
const disc = [...walk(slot)].find((x) => typeof x.props?.title === 'string' && x.props.title.startsWith('炎'));
assert.equal(disc.props.title, '炎 3/6(含调和 +1)');
const chips = BondChips({ bondIds: ['yanShip'], bonds: [entry], onBond() {} });
const chip = [...walk(chips)].find((x) => x.props?.['data-bond'] === 'yanShip');
assert.equal(chip.props.title, '炎:在场 3/6(含调和 +1),已激活 1 阶');
const noPlus = [...walk(BondChips({ bondIds: ['yanShip'], bonds: [{ ...entry, harmony: undefined }] }))].find((x) => x.props?.['data-bond'] === 'yanShip');
assert.equal(noPlus.props.title, '炎:在场 3/6,已激活 1 阶');
});
});
describe('§21.26 4 — the strategy draft marks a strategy built around a bond the mode switches off (本局禁用)', () => {
const bands = load('bands.json');
const modeIds = Object.keys(config.modes);
const markedIn = (modeId) => Object.values(bands).map((b) => [b.bandId, bandOffBonds(b, modeOffBonds(getMode(modeId)))]).filter(([, ids]) => ids.length);
test('标准: exactly 潘格尼尼 (拉特兰), 克莱门莎 (阿戈尔) and 玛恩纳 (卡西米尔); 险境 / 绝境 / 终极: none; a band tied to no bond: never', () => {
for (const id of ['mode_single_funny', 'mode_multi_funny']) {
assert.deepEqual(markedIn(id), [['band_paganini', ['lateranoShip']], ['band_clementia', ['egirShip']], ['band_mlynar', ['kazimierzShip']]], id);
}
for (const id of ['mode_single_normal', 'mode_multi_normal', 'mode_single_hard', 'mode_multi_hard', 'mode_single_abyss', 'mode_multi_abyss']) assert.deepEqual(markedIn(id), [], id);
for (const b of Object.values(bands).filter((x) => !x.bondIds.length)) {
for (const id of modeIds) assert.deepEqual(bandOffBonds(b, modeOffBonds(getMode(id))), [], `${b.name} in ${id}`);
}
// a tied band whose bond stays on (杜遥夜 炎) is not marked in 标准 either
assert.deepEqual(bandOffBonds(bands.band_duyaoy, modeOffBonds(getMode('mode_multi_funny'))), []);
assert.deepEqual(bandOffBonds(bands.band_paganini, null), []);
assert.deepEqual(bandOffBonds({ bandId: 'old' }, modeOffBonds(getMode('mode_multi_funny'))), [], 'data without bondIds: no mark');
});
test('one source with the server: the bot\'s exclusion (GameData.bandBondIds ∩ modeInactiveBonds) is the draft\'s mark, in every mode', async () => {
const { GameData } = await import('../../server/match/gamedata.js');
const { getData } = await import('../../server/data.js');
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
for (const modeId of modeIds) {
const gd = new GameData(DATA, modeId);
for (const id of Object.keys(bands)) {
assert.deepEqual([...gd.bandBondIds(id)], bands[id].bondIds, `${id}: the same field`);
const server = gd.bandBondIds(id).filter((b) => gd.modeInactiveBonds.has(b));
assert.deepEqual(server, bandOffBonds(bands[id], modeOffBonds(getMode(modeId))), `${modeId} ${id}`);
}
}
});
test('the card\'s tag and the detail pane\'s note: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥", the bond struck through; nothing without one', async () => {
const { BandOffTag, BandOffNote } = await import('../../public/js/screens/bandDraft.js');
assert.equal(bandOffLine(['拉特兰']), '本局禁用【拉特兰】盟约,此策略效果可能无法发挥');
assert.equal(bandOffLine(['拉特兰', '阿戈尔']), '本局禁用【拉特兰】【阿戈尔】盟约,此策略效果可能无法发挥');
assert.equal(bandOffLine([]), '');
const tag = BandOffTag({ names: ['拉特兰'] });
assert.ok(hasClass(tag, 'dband__off'));
assert.equal(textOf(tag), '本局禁用');
assert.equal(tag.props.title, '本局禁用【拉特兰】盟约,此策略效果可能无法发挥');
const note = BandOffNote({ names: ['拉特兰'] });
assert.ok(hasClass(note, 'draft-detail__off'));
assert.equal(note.props.role, 'note');
assert.equal(textOf(note), '本局禁用【拉特兰】盟约,此策略效果可能无法发挥');
assert.deepEqual(byClass(note, 'draft-detail__offname').map(textOf), ['拉特兰'], 'the bond name struck through (§21.7)');
assert.equal(BandOffTag({ names: [] }), null);
assert.equal(BandOffNote({ names: [] }), null);
});
});
+3 -2
View File
@@ -79,8 +79,9 @@ function runOne(cfg, seed) {
roomCode: 'BENCH', mode: cfg.mode, difficulty: cfg.difficulty, seats, seed, data, log, scheduler: sched, botRehearsal: rehearsal,
send: () => true, broadcast: () => {}, onEnd: (s) => { summary = s; },
});
// --band: the only strategy on offer (botPickBand draws from gd.bandIds())
if (opt.band && cfg.mode === 'solo') m.gd.bandIds = () => [String(opt.band)];
// --band: the only strategy on offer (botPickBand draws from gd.bandIds()) — also one built around a bond the mode
// switches off, which botPickBand would otherwise skip for the default band (DESIGN §21.26)
if (opt.band && cfg.mode === 'solo') { m.gd.bandIds = () => [String(opt.band)]; m.gd.bandBondIds = () => []; }
const rec = { config: cfg.name, seed, stageId: m.stageId, bossId: m.bossId, preps: [], picks: [], rounds: [], bounties: [] };
// rehearsal stepping time (Battle.step of the rehearsal fields)
let rehMs = 0;
+9 -1
View File
@@ -35,6 +35,7 @@ import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Grid, DEPLOY_REFUSED_TILES } from '../server/sim/grid.js';
import { bandBondIds } from '../shared/bandBonds.js';
// ===== CLI & IO ==================================================================================
@@ -1433,7 +1434,10 @@ function buildItems(ctx, effects) {
// ===== bands (策略) ===============================================================================
/** Build data/bands.json: the 40 season strategies. */
/**
* Build data/bands.json: the 40 season strategies. `bondIds` (the bonds a strategy is built around: shared/bandBonds.js
* over its text and blackboards, needing bonds.json and choices.json pools) is added in main() once those are built.
*/
function buildBands(ctx, effects) {
const { act, ac } = ctx;
const out = {};
@@ -3072,6 +3076,7 @@ function validateAll(f) {
if (it.requiresBondId && !bonds[it.requiresBondId]) err(`item ${it.id}: requiresBond ${it.requiresBondId} missing`);
}
for (const b of Object.values(bands)) if (!effects[b.effectId]) err(`band ${b.bandId}: effect missing`);
for (const b of Object.values(bands)) for (const id of b.bondIds || []) if (!bonds[id]) err(`band ${b.bandId}: bond ${id} missing`);
for (const w of Object.values(waves)) {
for (const sp of w.spawns) {
if (sp.action) continue;
@@ -3145,6 +3150,9 @@ async function main() {
const factions = buildFactions(ctx, enemies);
const bosses = buildBosses(ctx, enemies, waves);
const choices = buildChoices(ctx, effects, items, chess);
// the bonds each strategy is built around (DESIGN §21.26): the bot skips, and the strategy draft marks 本局禁用, a band
// whose bond the mode switches off
for (const b of Object.values(bands)) b.bondIds = bandBondIds(b, { bonds, pools: choices.pools });
const config = buildConfig(ctx, waves, stages, bands);
const files = { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens };