diff --git a/CHANGELOG.md b/CHANGELOG.md index 60097a89..9a009d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - 隐秘核心的「伤害超过 X%」播报只算这一场领袖的伤害,上一回合的播报不会拖到下一回合。 - 播报按官方优先级排队,领袖伤害播报不会被升级调度中心等播报挤掉。 - 修复联防中隐德来希的心烛在超时时被算成漏怪、导致战斗结果被服务器拒绝的问题。 +- 修复叠层特质很强的阵容(如六谢拉格冻结流)一场作战叠层较多时,战斗结果被服务器误判为异常、改由服务器重算的问题。 ### 界面 @@ -67,6 +68,9 @@ - 干员详情卡的攻击范围小图实时显示技能期间的范围;整备期棋盘上的范围提示、转向轮盘和详情卡显示同一个范围。 - 网页卡顿、切到后台或中途进场观战时,不会再漏掉敌人的变身,也不会在切回来后补播早已结束的变身动画。 - 灼热 / 炽焰源石虫:整合包附带官方模型;从源码运行且没有提取本机客户端素材时,显示染成橙色 / 红橙色的源石虫。 +- 变形同构体的详情卡列出天赋栏里的对应关系:每个盟约由哪些装备搭配,本局禁用的盟约会标出;装在干员身上时高亮正在生效的那一组,各盟约装备的详情也写明与变形同构体一同装备时携带者视为哪个盟约的成员。 +- 调和让核心盟约人数 +1 时,盟约详情写明「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思);查看队友的盟约时也一样。 +- 选择策略时,围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)标出「本局禁用」并在详情里说明,仍可选择。 ### 人机 @@ -74,6 +78,7 @@ - 人机遵守新规则:深水区、战术点范围、突变细胞、本局禁用的盟约、阵法术师的群攻。 - AI 托管不会为了腾地方销毁你的道具。 - 人机不再把本回合刚买的干员马上卖掉。 +- 人机不再选择围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)。 ### 工具 diff --git a/data/bands.json b/data/bands.json index e6d0dc57..bb39685a 100644 --- a/data/bands.json +++ b/data/bands.json @@ -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","chess":"chess_item_3_12_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关【标准模拟】后解锁"},"band_ermengard":{"bandId":"band_ermengard","sortId":7,"name":"埃芒加德","iconId":"icon_ermengard","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_48","effectName":"命结之秘","desc":"【命结之秘】战斗中前3个被击倒的干员立刻复活!","descRaw":"【命结之秘】战斗中前3个被击倒的干员立刻复活!","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"max_respawn_cnt":3},"bbStr":{"key":"act1autochess_band28_buff"}}],"params":{"max_respawn_cnt":3,"key":"act1autochess_band28_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【险境模拟】中通过第9回合后解锁"},"band_lmlee":{"bandId":"band_lmlee","sortId":8,"name":"老鲤","iconId":"icon_lmlee","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_46","effectName":"得闲饮茶","desc":"【得闲饮茶】第1、2回合的资金暂存至第3回合发放;第3回合进入休整期时,获得2、4阶随机干员各1名","descRaw":"【得闲饮茶】第1、2回合的资金暂存至第3回合发放;第3回合进入休整期时,获得2、4阶随机干员各1名","buffs":[{"key":"give_coin_in_round","bb":{"round":1,"coin":0},"bbStr":{}},{"key":"give_coin_in_round","bb":{"round":2,"coin":0},"bbStr":{}},{"key":"give_coin_in_round","bb":{"round":3,"coin":15},"bbStr":{}},{"key":"prep_start_gain_chess_from_pool_in_round","bb":{"round":3,"count":1},"bbStr":{"type":"char","pool":"pool_chess_shop_2_reward"}},{"key":"prep_start_gain_chess_from_pool_in_round","bb":{"round":3,"count":1},"bbStr":{"type":"char","pool":"pool_chess_shop_4_reward"}}],"params":{"round":1,"coin":0,"count":1,"type":"char","pool":"pool_chess_shop_2_reward"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟2次解锁"},"band_kirara":{"bandId":"band_kirara","sortId":9,"name":"绮良","iconId":"icon_kirara","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_24","effectName":"通关奖励","desc":"【通关奖励】每使用20资金,获得一名不高于当前调度中心等级的随机干员","descRaw":"【通关奖励】每使用20资金,获得一名不高于当前调度中心等级的随机干员","buffs":[{"key":"band_coin_cost_gain_random_char_by_shop_level","bb":{"coin_cnt":20,"count":1},"bbStr":{}}],"params":{"coin_cnt":20,"count":1},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【险境模拟】中通过第13回合后解锁"},"band_pepe":{"bandId":"band_pepe","sortId":10,"name":"佩佩","iconId":"icon_pepe","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_29","effectName":"博学多通","desc":"【博学多通】升级调度中心至2、4和6级后,获得1次特殊刷新:此次刷新出现的干员优先为<萨尔贡>干员\n在<萨尔贡>部分干员缺席时体验可能不完整","descRaw":"【博学多通】升级调度中心至2、4和6级后,获得1次特殊刷新:此次刷新出现的干员优先为<萨尔贡>干员\n<@ba.vdown>在<萨尔贡>部分干员缺席时体验可能不完整","buffs":[{"key":"up_shop_next_refresh_must_present_bond_char","bb":{"cnt":0,"price":0},"bbStr":{"bond":"sargonShip","lvlist":"2,4,6"}}],"params":{"cnt":0,"price":0,"bond":"sargonShip","lvlist":"2,4,6"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【萨尔贡】干员解锁"},"band_harold":{"bandId":"band_harold","sortId":11,"name":"哈洛德","iconId":"icon_harold","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_30","effectName":"人才盲盒","desc":"【人才盲盒】从第4回合开始每2回合获得1名<维多利亚>干员\n在<维多利亚>部分干员缺席时体验可能不完整","descRaw":"【人才盲盒】从第4回合开始每2回合获得1名<维多利亚>干员\n<@ba.vdown>在<维多利亚>部分干员缺席时体验可能不完整","buffs":[{"key":"gain_bond_char_per_round","bb":{"round":4,"count":1,"preround":2},"bbStr":{"bond":"victoriaShip"}}],"params":{"round":4,"count":1,"preround":2,"bond":"victoriaShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【维多利亚】干员解锁"},"band_sciurus":{"bandId":"band_sciurus","sortId":12,"name":"休露丝","iconId":"icon_sciurus","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_31","effectName":"雪域礼赠","desc":"【雪域礼赠】每回合购买的首名<谢拉格>干员消耗资金为1\n在<谢拉格>部分干员缺席时体验可能不完整","descRaw":"【雪域礼赠】每回合购买的首名<谢拉格>干员消耗资金为1\n<@ba.vdown>在<谢拉格>部分干员缺席时体验可能不完整","buffs":[{"key":"first_buy_in_round_char_price_change","bb":{"price":1},"bbStr":{"bond":"kjeragShip"}}],"params":{"price":1,"bond":"kjeragShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【谢拉格】干员解锁"},"band_paganini":{"bandId":"band_paganini","sortId":13,"name":"潘格尼尼","iconId":"icon_paganini","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_32","effectName":"定制铳械","desc":"【定制铳械】累计花费55资金后,获得1名4阶及以上随机的精锐<拉特兰>干员\n在<拉特兰>部分干员缺席时体验可能不完整","descRaw":"【定制铳械】累计花费55资金后,获得1名4阶及以上随机的精锐<拉特兰>干员\n<@ba.vdown>在<拉特兰>部分干员缺席时体验可能不完整","buffs":[{"key":"band_cost_coin_reach_cnt_gain_chess_from_pool","bb":{"coin_cnt":55,"count":1},"bbStr":{"type":"char","pool":"pool_char_later"}}],"params":{"coin_cnt":55,"count":1,"type":"char","pool":"pool_char_later"},"victorCount":3,"rewardModulus":1,"unlockDesc":"多场模拟中【拉特兰】盟约累计200层解锁"},"band_clementia":{"bandId":"band_clementia","sortId":14,"name":"克莱门莎","iconId":"icon_clementia","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_33","effectName":"崇高牺牲","desc":"【崇高牺牲】<阿戈尔>干员被击倒时,提供等同于该干员等阶的<阿戈尔>盟约层数\n在<阿戈尔>部分干员缺席时体验可能不完整","descRaw":"【崇高牺牲】<阿戈尔>干员被击倒时,提供等同于该干员等阶的<阿戈尔>盟约层数\n<@ba.vdown>在<阿戈尔>部分干员缺席时体验可能不完整","buffs":[{"key":"env_gbuff_new_with_verify","bb":{},"bbStr":{"key":"act1autochess_band13_buff","bond_type":"bond_by_id","bond_id":"egirShip","bond_add_type":"by_charlevel"}}],"params":{"key":"act1autochess_band13_buff","bond_type":"bond_by_id","bond_id":"egirShip","bond_add_type":"by_charlevel"},"victorCount":3,"rewardModulus":1,"unlockDesc":"多场模拟中【阿戈尔】盟约累计200层解锁"},"band_emperor":{"bandId":"band_emperor","sortId":15,"name":"大帝","iconId":"icon_emperor","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_36","effectName":"加急调派","desc":"【加急调派】我方干员每次部署后再部署时间减少50%","descRaw":"【加急调派】我方干员每次部署后再部署时间减少50%","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"respawn_time":-0.5,"value":0},"bbStr":{"key":"act1autochess_band16_buff"}}],"params":{"respawn_time":-0.5,"value":0,"key":"act1autochess_band16_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时本局内,累计获取过6名特种干员解锁"},"band_mberry":{"bandId":"band_mberry","sortId":16,"name":"桑葚","iconId":"icon_mberry","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_37","effectName":"药枚实验","desc":"【药枚实验】战斗开始时,使最右边的所有我方单位获得效果:每次攻击时有25%的概率获得1层护盾(最多1层)","descRaw":"【药枚实验】战斗开始时,使最右边的所有我方单位获得效果:每次攻击时有25%的概率获得1层护盾(最多1层)","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"prob":0.25},"bbStr":{"key":"act1autochess_band17_buff"}}],"params":{"prob":0.25,"key":"act1autochess_band17_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟4次解锁"},"band_humus":{"bandId":"band_humus","sortId":17,"name":"休谟斯","iconId":"icon_humus","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_38","effectName":"回收利用","desc":"【回收利用】地面干员技能结束时使周围四格随机1名干员获得3点技力","descRaw":"【回收利用】地面干员技能结束时使周围四格随机1名干员获得3点技力","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"sp":3},"bbStr":{"key":"act1autochess_band18_buff"}}],"params":{"sp":3,"key":"act1autochess_band18_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁"},"band_quintus":{"bandId":"band_quintus","sortId":18,"name":"昆图斯","iconId":"icon_quintus","modeTypeList":["SINGLE","MULTI"],"totalHp":31,"effectId":"aceffect_band_41","effectName":"不稳定要素","desc":"【不稳定要素】第3回合获得1件特殊装备<突变细胞>\n<突变细胞>:战斗结束后使装备者变为高一阶的随机干员(最高6阶)","descRaw":"【不稳定要素】第3回合获得1件特殊装备<突变细胞>\n<@ba.acrem><突变细胞>:战斗结束后使装备者变为高一阶的随机干员(最高6阶)","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":3,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_08_e_a"}}],"params":{"round":3,"count":1,"type":"equip","chess":"chess_item_5_08_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁"},"band_yu":{"bandId":"band_yu","sortId":19,"name":"余","iconId":"icon_yu","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_50","effectName":"文火慢炖","desc":"【文火慢炖】第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层","descRaw":"【文火慢炖】第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层","buffs":[{"key":"round_start_bond_check_gain_layer","bb":{"round":8,"factioncount":1,"count1":36,"count2":12},"bbStr":{}}],"params":{"round":8,"factioncount":1,"count1":36,"count2":12},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【标准模拟】中通过第9回合后解锁"},"band_doberm":{"bandId":"band_doberm","sortId":20,"name":"杜宾","iconId":"icon_doberm","modeTypeList":["SINGLE","MULTI"],"totalHp":30,"effectId":"aceffect_band_25","effectName":"加练!","desc":"【加练!】每2个回合获得1个特殊法术<教鞭>\n<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金","descRaw":"【加练!】每2个回合获得1个特殊法术<教鞭>\n<@ba.acrem><教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金","buffs":[{"key":"preparation_start_gain_chess_every_n_round","bb":{"round":2,"count":1},"bbStr":{"type":"trap","chess":"chess_item_6_03_m"}}],"params":{"round":2,"count":1,"type":"trap","chess":"chess_item_6_03_m"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_cathy":{"bandId":"band_cathy","sortId":21,"name":"凯瑟琳","iconId":"icon_cathy","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_26","effectName":"定向投放","desc":"【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件","descRaw":"【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件","buffs":[{"key":"up_shop_add_special_goods","bb":{"count":3,"choice":1},"bbStr":{"pool":"pool_equip_kathe"}}],"params":{"count":3,"choice":1,"pool":"pool_equip_kathe"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_malkie":{"bandId":"band_malkie","sortId":22,"name":"马克维茨","iconId":"icon_malkie","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_27","effectName":"商业包装","desc":"【商业包装】初始获得1个<商业包装方案>\n<商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员","descRaw":"【商业包装】初始获得1个<商业包装方案>\n<@ba.acrem><商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_07_e_a"}}],"params":{"round":1,"count":1,"type":"equip","chess":"chess_item_5_07_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_qalaisa":{"bandId":"band_qalaisa","sortId":23,"name":"卡莱莎","iconId":"icon_qalaisa","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_35","effectName":"食腐之蝶","desc":"【食腐之蝶】我方干员被击倒时,场上剩余我方单位攻击力+20%(最大200%,持续至该干员被击倒或战斗结束)","descRaw":"【食腐之蝶】我方干员被击倒时,场上剩余我方单位攻击力+20%(最大200%,持续至该干员被击倒或战斗结束)","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"atk":0.2,"max_stack_cnt":10},"bbStr":{"key":"act1autochess_band15_buff"}}],"params":{"atk":0.2,"max_stack_cnt":10,"key":"act1autochess_band15_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_chen":{"bandId":"band_chen","sortId":24,"name":"陈","iconId":"icon_chen","modeTypeList":["SINGLE","MULTI"],"totalHp":22,"effectId":"aceffect_band_39","effectName":"以己之长","desc":"【以己之长】所有干员造成的物理和法术伤害变为弱点伤害(根据敌人的防御力和法术抗性变更伤害类型)","descRaw":"【以己之长】所有干员造成的物理和法术伤害变为弱点伤害(根据敌人的防御力和法术抗性变更伤害类型)","buffs":[{"key":"env_gbuff_new_with_verify","bb":{},"bbStr":{"key":"act1autochess_band19_buff"}}],"params":{"key":"act1autochess_band19_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_damaztic":{"bandId":"band_damaztic","sortId":25,"name":"“变形者集群”","iconId":"icon_damaztic","modeTypeList":["SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_40","effectName":"变形同构","desc":"【变形同构】每5回合获得1件特殊装备<变形同构体>\n<变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员","descRaw":"【变形同构】每5回合获得1件特殊装备<变形同构体>\n<@ba.acrem><变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":5,"count":1},"bbStr":{"type":"equip","chess":"chess_item_6_09_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_6_09_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":15,"count":1},"bbStr":{"type":"equip","chess":"chess_item_6_09_e_a"}}],"params":{"round":5,"count":1,"type":"equip","chess":"chess_item_6_09_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_pith":{"bandId":"band_pith","sortId":26,"name":"Pith","iconId":"icon_pith","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_42","effectName":"优等生","desc":"【优等生】第1回合获得1名可以造成神经、灼燃、凋亡损伤的特殊干员,其属于调和盟约:可以使部分盟约人数计数+1","descRaw":"【优等生】第1回合获得1名可以造成神经、灼燃、凋亡损伤的特殊干员,其属于调和盟约:可以使部分盟约人数计数+1","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"char","chess":"chess_char_1_15_a"}}],"params":{"round":1,"count":1,"type":"char","chess":"chess_char_1_15_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_dusk":{"bandId":"band_dusk","sortId":27,"name":"夕","iconId":"icon_dusk","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_45","effectName":"墨色真颜","desc":"【墨色真颜】若你的场上存在同名干员,这些干员攻击力+30%\n【幻画为真】第1回合获得1个特殊法术<画卷>\n<画卷>:使用后可以复制该法术范围内的1名干员(包括精锐状态和携带的装备)","descRaw":"【墨色真颜】若你的场上存在同名干员,这些干员攻击力+30%\n【幻画为真】第1回合获得1个特殊法术<画卷>\n<@ba.acrem><画卷>:使用后可以复制该法术范围内的1名干员(包括精锐状态和携带的装备)","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"trap","chess":"chess_item_6_02_m"}},{"key":"env_gbuff_new_with_verify","bb":{"atk":0.3},"bbStr":{"key":"act2autochess_band12_buff"}}],"params":{"round":1,"count":1,"type":"trap","chess":"chess_item_6_02_m","atk":0.3,"key":"act2autochess_band12_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_cannot":{"bandId":"band_cannot","sortId":28,"name":"坎诺特","iconId":"icon_cannot","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_34","effectName":"利滚利","desc":"【利滚利】每回合剩余的资金可以继承,剩余至少5资金时,每回合额外获得1资金","descRaw":"【利滚利】每回合剩余的资金可以继承,剩余至少5资金时,每回合额外获得1资金","buffs":[{"key":"coin_carry_over","bb":{"capital":5,"interest":1,"max":1},"bbStr":{}}],"params":{"capital":5,"interest":1,"max":1},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_ducklord":{"bandId":"band_ducklord","sortId":29,"name":"鸭爵","iconId":"icon_ducklord","modeTypeList":["SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_49","effectName":"“神秘顾客”","desc":"【“神秘顾客”】从第5回合起,你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>,击倒这些敌人者获得1资金奖励(包括联防阶段)","descRaw":"【“神秘顾客”】从第5回合起,你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>,击倒这些敌人者获得1资金奖励(包括联防阶段)","buffs":[{"key":"round_start_all_player_change_enemy_2","bb":{"min":0,"max":2,"round":5,"count":1,"minweight":0.6,"maxweight":0.99},"bbStr":{"enemylist":"enemy_2002_bearmi_2,enemy_2034_sythef_2,enemy_2085_skzjxd_2,enemy_2001_duckmi_2"}}],"params":{"min":0,"max":2,"round":5,"count":1,"minweight":0.6,"maxweight":0.99,"enemylist":"enemy_2002_bearmi_2,enemy_2034_sythef_2,enemy_2085_skzjxd_2,enemy_2001_duckmi_2"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_lisa":{"bandId":"band_lisa","sortId":30,"name":"铃兰","iconId":"icon_lisa","modeTypeList":["SINGLE","MULTI"],"totalHp":20,"effectId":"aceffect_band_51","effectName":"御守之力","desc":"【御守之力】每个回合开始时,触发战场上一名干员的获得时效果一次(优先选择最靠右和最靠下)","descRaw":"【御守之力】每个回合开始时,触发战场上一名干员的获得时效果一次(优先选择最靠右和最靠下)","buffs":[{"key":"round_start_activate_char_chess_effect_in_board","bb":{"count":1},"bbStr":{"event_type":"SERVER_GAIN"}}],"params":{"count":1,"event_type":"SERVER_GAIN"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_vodfox":{"bandId":"band_vodfox","sortId":31,"name":"巫恋","iconId":"icon_vodfox","modeTypeList":["SINGLE","MULTI"],"totalHp":22,"effectId":"aceffect_band_52","effectName":"替身娃娃","desc":"【替身娃娃】每回合首次出售初始干员时,改为与调度中心中随机一名干员交换","descRaw":"【替身娃娃】每回合首次出售初始干员时,改为与调度中心中随机一名干员交换","buffs":[{"key":"first_sell_char_chess_exchange_char_chess_in_shop","bb":{"count":1},"bbStr":{}}],"params":{"count":1},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_ioleta":{"bandId":"band_ioleta","sortId":32,"name":"伊奥莱塔·罗素","iconId":"icon_ioleta","modeTypeList":["SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_53","effectName":"统御号令","desc":"【统御号令】战斗开始时,场上每有一个精锐干员,精锐干员的攻击力,生命值+10%","descRaw":"【统御号令】战斗开始时,场上每有一个精锐干员,精锐干员的攻击力,生命值+10%","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"atk_per_cnt":0.1,"max_hp_per_cnt":0.1},"bbStr":{"key":"act2autochess_band3_buff"}}],"params":{"atk_per_cnt":0.1,"max_hp_per_cnt":0.1,"key":"act2autochess_band3_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_jesica":{"bandId":"band_jesica","sortId":33,"name":"杰西卡","iconId":"icon_jesica","modeTypeList":["SINGLE","MULTI"],"totalHp":22,"effectId":"aceffect_band_54","effectName":"集结指示","desc":"【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块>\n<寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","descRaw":"【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块>\n<@ba.acrem><寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":4,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":7,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":13,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}}],"params":{"round":4,"count":1,"type":"equip","chess":"chess_item_4_01_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_mlyss":{"bandId":"band_mlyss","sortId":34,"name":"缪尔赛思","iconId":"icon_mlyss","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_55","effectName":"至纯凝结","desc":"【至纯凝结】在第1回合开始时获得一个进阶<博士投影>\n进阶<博士投影>:任意干员可装备,立即使该干员晋升为精锐干员","descRaw":"【至纯凝结】在第1回合开始时获得一个进阶<博士投影>\n<@ba.acrem>进阶<博士投影>:任意干员可装备,立即使该干员晋升为精锐干员","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_06_e_b"}}],"params":{"round":1,"count":1,"type":"equip","chess":"chess_item_5_06_e_b"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁"},"band_makiri":{"bandId":"band_makiri","sortId":35,"name":"松桐","iconId":"icon_makiri","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_56","effectName":"九流之缘","desc":"【九流之缘】偶数回合的休整期开始时,随机获得调度中心的一名干员","descRaw":"【九流之缘】偶数回合的休整期开始时,随机获得调度中心的一名干员","buffs":[{"key":"round_start_gain_char_chess_in_shop_every_n_round","bb":{"round":2,"count":1},"bbStr":{}}],"params":{"round":2,"count":1},"victorCount":3,"rewardModulus":1,"unlockDesc":null},"band_fang":{"bandId":"band_fang","sortId":36,"name":"芬","iconId":"icon_fang","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_57","effectName":"协力共进","desc":"【协力共进】第8、10、12、14回合时,获得1个<信标>\n<信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友","descRaw":"【协力共进】第8、10、12、14回合时,获得1个<信标>\n<@ba.acrem><信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":8,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":12,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":14,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}}],"params":{"round":8,"count":1,"type":"equip","chess":"chess_item_5_04_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁"},"band_mlynar":{"bandId":"band_mlynar","sortId":37,"name":"玛恩纳","iconId":"icon_mlynar","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_58","effectName":"业务指标","desc":"【业务指标】每购买一名<卡西米尔>干员,下回合开始时资金+1(每回合至多3资金)\n在<卡西米尔>部分干员缺席时体验可能不完整","descRaw":"【业务指标】每购买一名<卡西米尔>干员,下回合开始时资金+1(每回合至多3资金)\n<@ba.vdown>在<卡西米尔>部分干员缺席时体验可能不完整","buffs":[{"key":"round_start_gain_coin_by_bond_char_chess_buy","bb":{"count":1,"max_count":3},"bbStr":{"bond":"kazimierzShip"}}],"params":{"count":1,"max_count":3,"bond":"kazimierzShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【卡西米尔】干员解锁"},"band_chiave":{"bandId":"band_chiave","sortId":38,"name":"贾维","iconId":"icon_chiave","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_59","effectName":"团伙行动","desc":"【团伙行动】主动刷新6次调度中心后获得一名不高于当前调度中心等级的<叙拉古>干员(每回合至多获得2名)\n在<叙拉古>部分干员缺席时体验可能不完整","descRaw":"【团伙行动】主动刷新6次调度中心后获得一名不高于当前调度中心等级的<叙拉古>干员(每回合至多获得2名)\n<@ba.vdown>在<叙拉古>部分干员缺席时体验可能不完整","buffs":[{"key":"refresh_shop_count_gain_coin_bond_char_chess","bb":{"refresh_count":6,"max_count":2},"bbStr":{"bond":"siracusaShip"}}],"params":{"refresh_count":6,"max_count":2,"bond":"siracusaShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【叙拉古】干员解锁"},"band_narant":{"bandId":"band_narant","sortId":39,"name":"娜仁图亚","iconId":"icon_narant","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_60","effectName":"见者有份","desc":"【见者有份】偶数回合进入休整期时,调度中心特殊刷新两件装备,免费获取其中一件;萨尔贡盟约<在场6名不同【萨尔贡】干员>效果替换为【萨尔贡】干员技能开启后,周围八格干员同时享受该干员携带的不高于V阶的装备效果,持续60秒\n在<萨尔贡>部分干员缺席时体验可能不完整","descRaw":"【见者有份】偶数回合进入休整期时,调度中心特殊刷新两件装备,免费获取其中一件;萨尔贡盟约<在场6名不同【萨尔贡】干员>效果替换为【萨尔贡】干员技能开启后,周围八格干员同时享受该干员携带的不高于V阶的装备效果,持续60秒\n<@ba.vdown>在<萨尔贡>部分干员缺席时体验可能不完整","buffs":[{"key":"preparation_start_add_special_goods_every_n_round","bb":{"round":2,"count":1,"refresh_cnt":2,"choice_cnt":1},"bbStr":{"type":"equip","pool":"pool_equip_narant"}}],"params":{"round":2,"count":1,"refresh_cnt":2,"choice_cnt":1,"type":"equip","pool":"pool_equip_narant"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【萨尔贡】干员解锁"},"band_amedic":{"bandId":"band_amedic","sortId":40,"name":"Touch","iconId":"icon_amedic","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_61","effectName":"外勤医疗","desc":"【外勤医疗】所有玩家场地上出现一名<预备干员-医疗>;战斗开始时,若场上至少有两名精锐干员,则替换为","descRaw":"【外勤医疗】所有玩家场地上出现一名<预备干员-医疗>;战斗开始时,若场上至少有两名精锐干员,则替换为","buffs":[{"key":"auto_chess_change_map","bb":{"cnt":1,"char_605_cmedic#1":1,"char_605_cmedic#2_multi_only":1,"char_605_cmedic#3":1,"char_605_cmedic#4_multi_only":1,"char_613_acmedc#1":0,"char_613_acmedc#2_multi_only":0,"char_613_acmedc#3":0,"char_613_acmedc#4_multi_only":0},"bbStr":{"common_condition":"condition_golden_chess_le"}},{"key":"auto_chess_change_map","bb":{"cnt":2,"char_613_acmedc#1":1,"char_613_acmedc#2_multi_only":1,"char_613_acmedc#3":1,"char_613_acmedc#4_multi_only":1,"char_605_cmedic#1":0,"char_605_cmedic#2_multi_only":0,"char_605_cmedic#3":0,"char_605_cmedic#4_multi_only":0},"bbStr":{"common_condition":"condition_golden_chess_ge"}}],"params":{"cnt":1,"char_605_cmedic#1":1,"char_605_cmedic#2_multi_only":1,"char_605_cmedic#3":1,"char_605_cmedic#4_multi_only":1,"char_613_acmedc#1":0,"char_613_acmedc#2_multi_only":0,"char_613_acmedc#3":0,"char_613_acmedc#4_multi_only":0,"common_condition":"condition_golden_chess_le"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁"}} \ No newline at end of file +{"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":"chess_item_3_12_e_a"}}],"params":{"round":1,"count":1,"type":"equip","chess":"chess_item_3_12_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关【标准模拟】后解锁","bondIds":[]},"band_ermengard":{"bandId":"band_ermengard","sortId":7,"name":"埃芒加德","iconId":"icon_ermengard","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_48","effectName":"命结之秘","desc":"【命结之秘】战斗中前3个被击倒的干员立刻复活!","descRaw":"【命结之秘】战斗中前3个被击倒的干员立刻复活!","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"max_respawn_cnt":3},"bbStr":{"key":"act1autochess_band28_buff"}}],"params":{"max_respawn_cnt":3,"key":"act1autochess_band28_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【险境模拟】中通过第9回合后解锁","bondIds":[]},"band_lmlee":{"bandId":"band_lmlee","sortId":8,"name":"老鲤","iconId":"icon_lmlee","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_46","effectName":"得闲饮茶","desc":"【得闲饮茶】第1、2回合的资金暂存至第3回合发放;第3回合进入休整期时,获得2、4阶随机干员各1名","descRaw":"【得闲饮茶】第1、2回合的资金暂存至第3回合发放;第3回合进入休整期时,获得2、4阶随机干员各1名","buffs":[{"key":"give_coin_in_round","bb":{"round":1,"coin":0},"bbStr":{}},{"key":"give_coin_in_round","bb":{"round":2,"coin":0},"bbStr":{}},{"key":"give_coin_in_round","bb":{"round":3,"coin":15},"bbStr":{}},{"key":"prep_start_gain_chess_from_pool_in_round","bb":{"round":3,"count":1},"bbStr":{"type":"char","pool":"pool_chess_shop_2_reward"}},{"key":"prep_start_gain_chess_from_pool_in_round","bb":{"round":3,"count":1},"bbStr":{"type":"char","pool":"pool_chess_shop_4_reward"}}],"params":{"round":1,"coin":0,"count":1,"type":"char","pool":"pool_chess_shop_2_reward"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟2次解锁","bondIds":[]},"band_kirara":{"bandId":"band_kirara","sortId":9,"name":"绮良","iconId":"icon_kirara","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_24","effectName":"通关奖励","desc":"【通关奖励】每使用20资金,获得一名不高于当前调度中心等级的随机干员","descRaw":"【通关奖励】每使用20资金,获得一名不高于当前调度中心等级的随机干员","buffs":[{"key":"band_coin_cost_gain_random_char_by_shop_level","bb":{"coin_cnt":20,"count":1},"bbStr":{}}],"params":{"coin_cnt":20,"count":1},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【险境模拟】中通过第13回合后解锁","bondIds":[]},"band_pepe":{"bandId":"band_pepe","sortId":10,"name":"佩佩","iconId":"icon_pepe","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_29","effectName":"博学多通","desc":"【博学多通】升级调度中心至2、4和6级后,获得1次特殊刷新:此次刷新出现的干员优先为<萨尔贡>干员\n在<萨尔贡>部分干员缺席时体验可能不完整","descRaw":"【博学多通】升级调度中心至2、4和6级后,获得1次特殊刷新:此次刷新出现的干员优先为<萨尔贡>干员\n<@ba.vdown>在<萨尔贡>部分干员缺席时体验可能不完整","buffs":[{"key":"up_shop_next_refresh_must_present_bond_char","bb":{"cnt":0,"price":0},"bbStr":{"bond":"sargonShip","lvlist":"2,4,6"}}],"params":{"cnt":0,"price":0,"bond":"sargonShip","lvlist":"2,4,6"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【萨尔贡】干员解锁","bondIds":["sargonShip"]},"band_harold":{"bandId":"band_harold","sortId":11,"name":"哈洛德","iconId":"icon_harold","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_30","effectName":"人才盲盒","desc":"【人才盲盒】从第4回合开始每2回合获得1名<维多利亚>干员\n在<维多利亚>部分干员缺席时体验可能不完整","descRaw":"【人才盲盒】从第4回合开始每2回合获得1名<维多利亚>干员\n<@ba.vdown>在<维多利亚>部分干员缺席时体验可能不完整","buffs":[{"key":"gain_bond_char_per_round","bb":{"round":4,"count":1,"preround":2},"bbStr":{"bond":"victoriaShip"}}],"params":{"round":4,"count":1,"preround":2,"bond":"victoriaShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【维多利亚】干员解锁","bondIds":["victoriaShip"]},"band_sciurus":{"bandId":"band_sciurus","sortId":12,"name":"休露丝","iconId":"icon_sciurus","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_31","effectName":"雪域礼赠","desc":"【雪域礼赠】每回合购买的首名<谢拉格>干员消耗资金为1\n在<谢拉格>部分干员缺席时体验可能不完整","descRaw":"【雪域礼赠】每回合购买的首名<谢拉格>干员消耗资金为1\n<@ba.vdown>在<谢拉格>部分干员缺席时体验可能不完整","buffs":[{"key":"first_buy_in_round_char_price_change","bb":{"price":1},"bbStr":{"bond":"kjeragShip"}}],"params":{"price":1,"bond":"kjeragShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【谢拉格】干员解锁","bondIds":["kjeragShip"]},"band_paganini":{"bandId":"band_paganini","sortId":13,"name":"潘格尼尼","iconId":"icon_paganini","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_32","effectName":"定制铳械","desc":"【定制铳械】累计花费55资金后,获得1名4阶及以上随机的精锐<拉特兰>干员\n在<拉特兰>部分干员缺席时体验可能不完整","descRaw":"【定制铳械】累计花费55资金后,获得1名4阶及以上随机的精锐<拉特兰>干员\n<@ba.vdown>在<拉特兰>部分干员缺席时体验可能不完整","buffs":[{"key":"band_cost_coin_reach_cnt_gain_chess_from_pool","bb":{"coin_cnt":55,"count":1},"bbStr":{"type":"char","pool":"pool_char_later"}}],"params":{"coin_cnt":55,"count":1,"type":"char","pool":"pool_char_later"},"victorCount":3,"rewardModulus":1,"unlockDesc":"多场模拟中【拉特兰】盟约累计200层解锁","bondIds":["lateranoShip"]},"band_clementia":{"bandId":"band_clementia","sortId":14,"name":"克莱门莎","iconId":"icon_clementia","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_33","effectName":"崇高牺牲","desc":"【崇高牺牲】<阿戈尔>干员被击倒时,提供等同于该干员等阶的<阿戈尔>盟约层数\n在<阿戈尔>部分干员缺席时体验可能不完整","descRaw":"【崇高牺牲】<阿戈尔>干员被击倒时,提供等同于该干员等阶的<阿戈尔>盟约层数\n<@ba.vdown>在<阿戈尔>部分干员缺席时体验可能不完整","buffs":[{"key":"env_gbuff_new_with_verify","bb":{},"bbStr":{"key":"act1autochess_band13_buff","bond_type":"bond_by_id","bond_id":"egirShip","bond_add_type":"by_charlevel"}}],"params":{"key":"act1autochess_band13_buff","bond_type":"bond_by_id","bond_id":"egirShip","bond_add_type":"by_charlevel"},"victorCount":3,"rewardModulus":1,"unlockDesc":"多场模拟中【阿戈尔】盟约累计200层解锁","bondIds":["egirShip"]},"band_emperor":{"bandId":"band_emperor","sortId":15,"name":"大帝","iconId":"icon_emperor","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_36","effectName":"加急调派","desc":"【加急调派】我方干员每次部署后再部署时间减少50%","descRaw":"【加急调派】我方干员每次部署后再部署时间减少50%","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"respawn_time":-0.5,"value":0},"bbStr":{"key":"act1autochess_band16_buff"}}],"params":{"respawn_time":-0.5,"value":0,"key":"act1autochess_band16_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时本局内,累计获取过6名特种干员解锁","bondIds":[]},"band_mberry":{"bandId":"band_mberry","sortId":16,"name":"桑葚","iconId":"icon_mberry","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_37","effectName":"药枚实验","desc":"【药枚实验】战斗开始时,使最右边的所有我方单位获得效果:每次攻击时有25%的概率获得1层护盾(最多1层)","descRaw":"【药枚实验】战斗开始时,使最右边的所有我方单位获得效果:每次攻击时有25%的概率获得1层护盾(最多1层)","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"prob":0.25},"bbStr":{"key":"act1autochess_band17_buff"}}],"params":{"prob":0.25,"key":"act1autochess_band17_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟4次解锁","bondIds":[]},"band_humus":{"bandId":"band_humus","sortId":17,"name":"休谟斯","iconId":"icon_humus","modeTypeList":["LOCAL","SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_38","effectName":"回收利用","desc":"【回收利用】地面干员技能结束时使周围四格随机1名干员获得3点技力","descRaw":"【回收利用】地面干员技能结束时使周围四格随机1名干员获得3点技力","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"sp":3},"bbStr":{"key":"act1autochess_band18_buff"}}],"params":{"sp":3,"key":"act1autochess_band18_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁","bondIds":[]},"band_quintus":{"bandId":"band_quintus","sortId":18,"name":"昆图斯","iconId":"icon_quintus","modeTypeList":["SINGLE","MULTI"],"totalHp":31,"effectId":"aceffect_band_41","effectName":"不稳定要素","desc":"【不稳定要素】第3回合获得1件特殊装备<突变细胞>\n<突变细胞>:战斗结束后使装备者变为高一阶的随机干员(最高6阶)","descRaw":"【不稳定要素】第3回合获得1件特殊装备<突变细胞>\n<@ba.acrem><突变细胞>:战斗结束后使装备者变为高一阶的随机干员(最高6阶)","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":3,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_08_e_a"}}],"params":{"round":3,"count":1,"type":"equip","chess":"chess_item_5_08_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁","bondIds":[]},"band_yu":{"bandId":"band_yu","sortId":19,"name":"余","iconId":"icon_yu","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_50","effectName":"文火慢炖","desc":"【文火慢炖】第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层","descRaw":"【文火慢炖】第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层","buffs":[{"key":"round_start_bond_check_gain_layer","bb":{"round":8,"factioncount":1,"count1":36,"count2":12},"bbStr":{}}],"params":{"round":8,"factioncount":1,"count1":36,"count2":12},"victorCount":3,"rewardModulus":1,"unlockDesc":"在【标准模拟】中通过第9回合后解锁","bondIds":[]},"band_doberm":{"bandId":"band_doberm","sortId":20,"name":"杜宾","iconId":"icon_doberm","modeTypeList":["SINGLE","MULTI"],"totalHp":30,"effectId":"aceffect_band_25","effectName":"加练!","desc":"【加练!】每2个回合获得1个特殊法术<教鞭>\n<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金","descRaw":"【加练!】每2个回合获得1个特殊法术<教鞭>\n<@ba.acrem><教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金","buffs":[{"key":"preparation_start_gain_chess_every_n_round","bb":{"round":2,"count":1},"bbStr":{"type":"trap","chess":"chess_item_6_03_m"}}],"params":{"round":2,"count":1,"type":"trap","chess":"chess_item_6_03_m"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_cathy":{"bandId":"band_cathy","sortId":21,"name":"凯瑟琳","iconId":"icon_cathy","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_26","effectName":"定向投放","desc":"【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件","descRaw":"【定向投放】每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件","buffs":[{"key":"up_shop_add_special_goods","bb":{"count":3,"choice":1},"bbStr":{"pool":"pool_equip_kathe"}}],"params":{"count":3,"choice":1,"pool":"pool_equip_kathe"},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_malkie":{"bandId":"band_malkie","sortId":22,"name":"马克维茨","iconId":"icon_malkie","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_27","effectName":"商业包装","desc":"【商业包装】初始获得1个<商业包装方案>\n<商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员","descRaw":"【商业包装】初始获得1个<商业包装方案>\n<@ba.acrem><商业包装方案>:每出售8名任意干员后,获得1名装备者的同盟约普通干员","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_07_e_a"}}],"params":{"round":1,"count":1,"type":"equip","chess":"chess_item_5_07_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_qalaisa":{"bandId":"band_qalaisa","sortId":23,"name":"卡莱莎","iconId":"icon_qalaisa","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_35","effectName":"食腐之蝶","desc":"【食腐之蝶】我方干员被击倒时,场上剩余我方单位攻击力+20%(最大200%,持续至该干员被击倒或战斗结束)","descRaw":"【食腐之蝶】我方干员被击倒时,场上剩余我方单位攻击力+20%(最大200%,持续至该干员被击倒或战斗结束)","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"atk":0.2,"max_stack_cnt":10},"bbStr":{"key":"act1autochess_band15_buff"}}],"params":{"atk":0.2,"max_stack_cnt":10,"key":"act1autochess_band15_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_chen":{"bandId":"band_chen","sortId":24,"name":"陈","iconId":"icon_chen","modeTypeList":["SINGLE","MULTI"],"totalHp":22,"effectId":"aceffect_band_39","effectName":"以己之长","desc":"【以己之长】所有干员造成的物理和法术伤害变为弱点伤害(根据敌人的防御力和法术抗性变更伤害类型)","descRaw":"【以己之长】所有干员造成的物理和法术伤害变为弱点伤害(根据敌人的防御力和法术抗性变更伤害类型)","buffs":[{"key":"env_gbuff_new_with_verify","bb":{},"bbStr":{"key":"act1autochess_band19_buff"}}],"params":{"key":"act1autochess_band19_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_damaztic":{"bandId":"band_damaztic","sortId":25,"name":"“变形者集群”","iconId":"icon_damaztic","modeTypeList":["SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_40","effectName":"变形同构","desc":"【变形同构】每5回合获得1件特殊装备<变形同构体>\n<变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员","descRaw":"【变形同构】每5回合获得1件特殊装备<变形同构体>\n<@ba.acrem><变形同构体>:与特定装备一同装备时装备者将视为特定盟约成员","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":5,"count":1},"bbStr":{"type":"equip","chess":"chess_item_6_09_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_6_09_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":15,"count":1},"bbStr":{"type":"equip","chess":"chess_item_6_09_e_a"}}],"params":{"round":5,"count":1,"type":"equip","chess":"chess_item_6_09_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_pith":{"bandId":"band_pith","sortId":26,"name":"Pith","iconId":"icon_pith","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_42","effectName":"优等生","desc":"【优等生】第1回合获得1名可以造成神经、灼燃、凋亡损伤的特殊干员,其属于调和盟约:可以使部分盟约人数计数+1","descRaw":"【优等生】第1回合获得1名可以造成神经、灼燃、凋亡损伤的特殊干员,其属于调和盟约:可以使部分盟约人数计数+1","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"char","chess":"chess_char_1_15_a"}}],"params":{"round":1,"count":1,"type":"char","chess":"chess_char_1_15_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_dusk":{"bandId":"band_dusk","sortId":27,"name":"夕","iconId":"icon_dusk","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_45","effectName":"墨色真颜","desc":"【墨色真颜】若你的场上存在同名干员,这些干员攻击力+30%\n【幻画为真】第1回合获得1个特殊法术<画卷>\n<画卷>:使用后可以复制该法术范围内的1名干员(包括精锐状态和携带的装备)","descRaw":"【墨色真颜】若你的场上存在同名干员,这些干员攻击力+30%\n【幻画为真】第1回合获得1个特殊法术<画卷>\n<@ba.acrem><画卷>:使用后可以复制该法术范围内的1名干员(包括精锐状态和携带的装备)","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"trap","chess":"chess_item_6_02_m"}},{"key":"env_gbuff_new_with_verify","bb":{"atk":0.3},"bbStr":{"key":"act2autochess_band12_buff"}}],"params":{"round":1,"count":1,"type":"trap","chess":"chess_item_6_02_m","atk":0.3,"key":"act2autochess_band12_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_cannot":{"bandId":"band_cannot","sortId":28,"name":"坎诺特","iconId":"icon_cannot","modeTypeList":["SINGLE","MULTI"],"totalHp":27,"effectId":"aceffect_band_34","effectName":"利滚利","desc":"【利滚利】每回合剩余的资金可以继承,剩余至少5资金时,每回合额外获得1资金","descRaw":"【利滚利】每回合剩余的资金可以继承,剩余至少5资金时,每回合额外获得1资金","buffs":[{"key":"coin_carry_over","bb":{"capital":5,"interest":1,"max":1},"bbStr":{}}],"params":{"capital":5,"interest":1,"max":1},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_ducklord":{"bandId":"band_ducklord","sortId":29,"name":"鸭爵","iconId":"icon_ducklord","modeTypeList":["SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_49","effectName":"“神秘顾客”","desc":"【“神秘顾客”】从第5回合起,你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>,击倒这些敌人者获得1资金奖励(包括联防阶段)","descRaw":"【“神秘顾客”】从第5回合起,你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>,击倒这些敌人者获得1资金奖励(包括联防阶段)","buffs":[{"key":"round_start_all_player_change_enemy_2","bb":{"min":0,"max":2,"round":5,"count":1,"minweight":0.6,"maxweight":0.99},"bbStr":{"enemylist":"enemy_2002_bearmi_2,enemy_2034_sythef_2,enemy_2085_skzjxd_2,enemy_2001_duckmi_2"}}],"params":{"min":0,"max":2,"round":5,"count":1,"minweight":0.6,"maxweight":0.99,"enemylist":"enemy_2002_bearmi_2,enemy_2034_sythef_2,enemy_2085_skzjxd_2,enemy_2001_duckmi_2"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_lisa":{"bandId":"band_lisa","sortId":30,"name":"铃兰","iconId":"icon_lisa","modeTypeList":["SINGLE","MULTI"],"totalHp":20,"effectId":"aceffect_band_51","effectName":"御守之力","desc":"【御守之力】每个回合开始时,触发战场上一名干员的获得时效果一次(优先选择最靠右和最靠下)","descRaw":"【御守之力】每个回合开始时,触发战场上一名干员的获得时效果一次(优先选择最靠右和最靠下)","buffs":[{"key":"round_start_activate_char_chess_effect_in_board","bb":{"count":1},"bbStr":{"event_type":"SERVER_GAIN"}}],"params":{"count":1,"event_type":"SERVER_GAIN"},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_vodfox":{"bandId":"band_vodfox","sortId":31,"name":"巫恋","iconId":"icon_vodfox","modeTypeList":["SINGLE","MULTI"],"totalHp":22,"effectId":"aceffect_band_52","effectName":"替身娃娃","desc":"【替身娃娃】每回合首次出售初始干员时,改为与调度中心中随机一名干员交换","descRaw":"【替身娃娃】每回合首次出售初始干员时,改为与调度中心中随机一名干员交换","buffs":[{"key":"first_sell_char_chess_exchange_char_chess_in_shop","bb":{"count":1},"bbStr":{}}],"params":{"count":1},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_ioleta":{"bandId":"band_ioleta","sortId":32,"name":"伊奥莱塔·罗素","iconId":"icon_ioleta","modeTypeList":["SINGLE","MULTI"],"totalHp":29,"effectId":"aceffect_band_53","effectName":"统御号令","desc":"【统御号令】战斗开始时,场上每有一个精锐干员,精锐干员的攻击力,生命值+10%","descRaw":"【统御号令】战斗开始时,场上每有一个精锐干员,精锐干员的攻击力,生命值+10%","buffs":[{"key":"env_gbuff_new_with_verify","bb":{"atk_per_cnt":0.1,"max_hp_per_cnt":0.1},"bbStr":{"key":"act2autochess_band3_buff"}}],"params":{"atk_per_cnt":0.1,"max_hp_per_cnt":0.1,"key":"act2autochess_band3_buff"},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_jesica":{"bandId":"band_jesica","sortId":33,"name":"杰西卡","iconId":"icon_jesica","modeTypeList":["SINGLE","MULTI"],"totalHp":22,"effectId":"aceffect_band_54","effectName":"集结指示","desc":"【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块>\n<寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","descRaw":"【集结指示】在第4、7、10、13回合进入休整期时获得1个<寻呼模块>\n<@ba.acrem><寻呼模块>:装备时销毁,立即在调度中心中刷新三名与该干员盟约相同的干员,免费获得其中一名!(不超过当前调度中心等级)","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":4,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":7,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":13,"count":1},"bbStr":{"type":"equip","chess":"chess_item_4_01_e_a"}}],"params":{"round":4,"count":1,"type":"equip","chess":"chess_item_4_01_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_mlyss":{"bandId":"band_mlyss","sortId":34,"name":"缪尔赛思","iconId":"icon_mlyss","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_55","effectName":"至纯凝结","desc":"【至纯凝结】在第1回合开始时获得一个进阶<博士投影>\n进阶<博士投影>:任意干员可装备,立即使该干员晋升为精锐干员","descRaw":"【至纯凝结】在第1回合开始时获得一个进阶<博士投影>\n<@ba.acrem>进阶<博士投影>:任意干员可装备,立即使该干员晋升为精锐干员","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":1,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_06_e_b"}}],"params":{"round":1,"count":1,"type":"equip","chess":"chess_item_5_06_e_b"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟1次解锁","bondIds":[]},"band_makiri":{"bandId":"band_makiri","sortId":35,"name":"松桐","iconId":"icon_makiri","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_56","effectName":"九流之缘","desc":"【九流之缘】偶数回合的休整期开始时,随机获得调度中心的一名干员","descRaw":"【九流之缘】偶数回合的休整期开始时,随机获得调度中心的一名干员","buffs":[{"key":"round_start_gain_char_chess_in_shop_every_n_round","bb":{"round":2,"count":1},"bbStr":{}}],"params":{"round":2,"count":1},"victorCount":3,"rewardModulus":1,"unlockDesc":null,"bondIds":[]},"band_fang":{"bandId":"band_fang","sortId":36,"name":"芬","iconId":"icon_fang","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_57","effectName":"协力共进","desc":"【协力共进】第8、10、12、14回合时,获得1个<信标>\n<信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友","descRaw":"【协力共进】第8、10、12、14回合时,获得1个<信标>\n<@ba.acrem><信标>:装备后销毁携带干员,并在调度中心刷新两名同等阶干员免费获取其中一名;原干员在下回合传递给对应盟约人数最多的队友","buffs":[{"key":"preparation_start_gain_chess_from_round","bb":{"round":8,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":10,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":12,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}},{"key":"preparation_start_gain_chess_from_round","bb":{"round":14,"count":1},"bbStr":{"type":"equip","chess":"chess_item_5_04_e_a"}}],"params":{"round":8,"count":1,"type":"equip","chess":"chess_item_5_04_e_a"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁","bondIds":[]},"band_mlynar":{"bandId":"band_mlynar","sortId":37,"name":"玛恩纳","iconId":"icon_mlynar","modeTypeList":["SINGLE","MULTI"],"totalHp":24,"effectId":"aceffect_band_58","effectName":"业务指标","desc":"【业务指标】每购买一名<卡西米尔>干员,下回合开始时资金+1(每回合至多3资金)\n在<卡西米尔>部分干员缺席时体验可能不完整","descRaw":"【业务指标】每购买一名<卡西米尔>干员,下回合开始时资金+1(每回合至多3资金)\n<@ba.vdown>在<卡西米尔>部分干员缺席时体验可能不完整","buffs":[{"key":"round_start_gain_coin_by_bond_char_chess_buy","bb":{"count":1,"max_count":3},"bbStr":{"bond":"kazimierzShip"}}],"params":{"count":1,"max_count":3,"bond":"kazimierzShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【卡西米尔】干员解锁","bondIds":["kazimierzShip"]},"band_chiave":{"bandId":"band_chiave","sortId":38,"name":"贾维","iconId":"icon_chiave","modeTypeList":["SINGLE","MULTI"],"totalHp":23,"effectId":"aceffect_band_59","effectName":"团伙行动","desc":"【团伙行动】主动刷新6次调度中心后获得一名不高于当前调度中心等级的<叙拉古>干员(每回合至多获得2名)\n在<叙拉古>部分干员缺席时体验可能不完整","descRaw":"【团伙行动】主动刷新6次调度中心后获得一名不高于当前调度中心等级的<叙拉古>干员(每回合至多获得2名)\n<@ba.vdown>在<叙拉古>部分干员缺席时体验可能不完整","buffs":[{"key":"refresh_shop_count_gain_coin_bond_char_chess","bb":{"refresh_count":6,"max_count":2},"bbStr":{"bond":"siracusaShip"}}],"params":{"refresh_count":6,"max_count":2,"bond":"siracusaShip"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【叙拉古】干员解锁","bondIds":["siracusaShip"]},"band_narant":{"bandId":"band_narant","sortId":39,"name":"娜仁图亚","iconId":"icon_narant","modeTypeList":["SINGLE","MULTI"],"totalHp":26,"effectId":"aceffect_band_60","effectName":"见者有份","desc":"【见者有份】偶数回合进入休整期时,调度中心特殊刷新两件装备,免费获取其中一件;萨尔贡盟约<在场6名不同【萨尔贡】干员>效果替换为【萨尔贡】干员技能开启后,周围八格干员同时享受该干员携带的不高于V阶的装备效果,持续60秒\n在<萨尔贡>部分干员缺席时体验可能不完整","descRaw":"【见者有份】偶数回合进入休整期时,调度中心特殊刷新两件装备,免费获取其中一件;萨尔贡盟约<在场6名不同【萨尔贡】干员>效果替换为【萨尔贡】干员技能开启后,周围八格干员同时享受该干员携带的不高于V阶的装备效果,持续60秒\n<@ba.vdown>在<萨尔贡>部分干员缺席时体验可能不完整","buffs":[{"key":"preparation_start_add_special_goods_every_n_round","bb":{"round":2,"count":1,"refresh_cnt":2,"choice_cnt":1},"bbStr":{"type":"equip","pool":"pool_equip_narant"}}],"params":{"round":2,"count":1,"refresh_cnt":2,"choice_cnt":1,"type":"equip","pool":"pool_equip_narant"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关时的结算阵容中,包含6名【萨尔贡】干员解锁","bondIds":["sargonShip"]},"band_amedic":{"bandId":"band_amedic","sortId":40,"name":"Touch","iconId":"icon_amedic","modeTypeList":["SINGLE","MULTI"],"totalHp":28,"effectId":"aceffect_band_61","effectName":"外勤医疗","desc":"【外勤医疗】所有玩家场地上出现一名<预备干员-医疗>;战斗开始时,若场上至少有两名精锐干员,则替换为","descRaw":"【外勤医疗】所有玩家场地上出现一名<预备干员-医疗>;战斗开始时,若场上至少有两名精锐干员,则替换为","buffs":[{"key":"auto_chess_change_map","bb":{"cnt":1,"char_605_cmedic#1":1,"char_605_cmedic#2_multi_only":1,"char_605_cmedic#3":1,"char_605_cmedic#4_multi_only":1,"char_613_acmedc#1":0,"char_613_acmedc#2_multi_only":0,"char_613_acmedc#3":0,"char_613_acmedc#4_multi_only":0},"bbStr":{"common_condition":"condition_golden_chess_le"}},{"key":"auto_chess_change_map","bb":{"cnt":2,"char_613_acmedc#1":1,"char_613_acmedc#2_multi_only":1,"char_613_acmedc#3":1,"char_613_acmedc#4_multi_only":1,"char_605_cmedic#1":0,"char_605_cmedic#2_multi_only":0,"char_605_cmedic#3":0,"char_605_cmedic#4_multi_only":0},"bbStr":{"common_condition":"condition_golden_chess_ge"}}],"params":{"cnt":1,"char_605_cmedic#1":1,"char_605_cmedic#2_multi_only":1,"char_605_cmedic#3":1,"char_605_cmedic#4_multi_only":1,"char_613_acmedc#1":0,"char_613_acmedc#2_multi_only":0,"char_613_acmedc#3":0,"char_613_acmedc#4_multi_only":0,"common_condition":"condition_golden_chess_le"},"victorCount":3,"rewardModulus":1,"unlockDesc":"通关任意模拟3次解锁","bondIds":[]}} \ No newline at end of file diff --git a/docs/DATA.md b/docs/DATA.md index 629df58a..200ecb00 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -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 "在部分干员缺席时体验可能不完整" 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) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d8bf9bba..cc580559 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 "在部分干员缺席时体验可能不完整" 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). diff --git a/docs/META.md b/docs/META.md index 2fc5ab6e..6719328b 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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 }` diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 7f8d9066..ac82af92 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -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. 最终攻势与隐秘核心 diff --git a/public/css/screens/draft.css b/public/css/screens/draft.css index 261f9402..57751172 100644 --- a/public/css/screens/draft.css +++ b/public/css/screens/draft.css @@ -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); } diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index d8c3d787..58f34f4f 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -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 { diff --git a/public/dev/game-mock.html b/public/dev/game-mock.html index 4fcdd077..72d98c78 100644 --- a/public/dev/game-mock.html +++ b/public/dev/game-mock.html @@ -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. --> diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js index 293ef97b..d4b3e72f 100644 --- a/public/dev/game-mock.js +++ b/public/dev/game-mock.js @@ -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'], diff --git a/public/js/screens/bandDraft.js b/public/js/screens/bandDraft.js index 99378a71..4f2c24b8 100644 --- a/public/js/screens/bandDraft.js +++ b/public/js/screens/bandDraft.js @@ -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`本局禁用` : 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`

<${Icon} name="info" />本局禁用${names.map((n, i) => html`【${n}】`)}盟约,此策略效果可能无法发挥

`; +} + /** * 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``; @@ -252,6 +270,7 @@ export function BandDraftScreen() {
初始生命值<${LpTower} value=${band.totalHp} size="lg" />

${band.name}

+ <${BandOffNote} names=${bandOffBonds(band, offBonds).map((id) => gd.bond(id)?.name || id)} />
<${MicroLabel} tone="mint">EFFECT ${band.effectName || ''} diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index 8144255a..2f9c7fb6 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -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`
+ return html`
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})` : ''}`} /> ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}
`; })} @@ -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` ` : 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`

拖拽装备至该干员以配发(最多 2 件)

`} + ${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} carried=${items} off=${offBonds} />`) : html`

拖拽装备至该干员以配发(最多 2 件)

`} ` // 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`
${t.name}<${RichText} text=${t.descRaw || t.desc} class="dtext" />
`)} @@ -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`
@@ -392,6 +447,8 @@ export function ItemDetail({ item, piece, editable, onDestroy }) {
<${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`

<${Icon} name="info" />${item.note}

` : null} ${item.itemType === 'MAGIC' ? html`

<${Icon} name="info" />将其拖拽至战场上的格子使用

` @@ -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|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
${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}
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 5bcc381e..9ca6c3e3 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -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} itemRecs items.json records (data.list('items')) + * @param {Iterable} bondRecs bonds.json records in data order (data.list('bonds')) + * @param {{ off?: Set|null, carried?: Array|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|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 --------------------------------------------------------------------------------------------- /** diff --git a/public/js/ui/watchBonds.js b/public/js/ui/watchBonds.js index 566d4675..69f955ad 100644 --- a/public/js/ui/watchBonds.js +++ b/public/js/ui/watchBonds.js @@ -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 }) { diff --git a/server/match/bondsMeta.js b/server/match/bondsMeta.js index 2c503cd8..c068c112 100644 --- a/server/match/bondsMeta.js +++ b/server/match/bondsMeta.js @@ -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, hand: Array, layers: Record, bondCountBonus?: Record }} ps - * @returns {Record} + * @returns {Record} */ 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} */ + /** @type {Record} */ 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} 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) : []; diff --git a/server/match/bot.js b/server/match/bot.js index 0109b4f0..1d6c7cf2 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -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; } /** diff --git a/server/match/fields.js b/server/match/fields.js index c8061052..5929da6e 100644 --- a/server/match/fields.js +++ b/server/match/fields.js @@ -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} 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'); diff --git a/server/match/gamedata.js b/server/match/gamedata.js index 999c1e0b..cfb0d5aa 100644 --- a/server/match/gamedata.js +++ b/server/match/gamedata.js @@ -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) diff --git a/shared/bandBonds.js b/shared/bandBonds.js new file mode 100644 index 00000000..db8893bf --- /dev/null +++ b/shared/bandBonds.js @@ -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 "在部分干员缺席时体验可能不完整" 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|Array<{ bondId: string, name?: string }>, + * pools?: Record|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)); +} diff --git a/test/match/feedback1-gaps.test.js b/test/match/feedback1-gaps.test.js new file mode 100644 index 00000000..d474c8cc --- /dev/null +++ b/test/match/feedback1-gaps.test.js @@ -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 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 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 "在部分干员缺席时体验可能不完整" 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\)/); +}); diff --git a/test/match/watch-bonds.test.js b/test/match/watch-bonds.test.js index 72c173bd..570f9bd7 100644 --- a/test/match/watch-bonds.test.js +++ b/test/match/watch-bonds.test.js @@ -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 diff --git a/test/ui/feedback1-gaps.e2e.test.js b/test/ui/feedback1-gaps.e2e.test.js new file mode 100644 index 00000000..83df82ea --- /dev/null +++ b/test/ui/feedback1-gaps.e2e.test.js @@ -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= 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}-x.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(); + }); + } +}); diff --git a/test/ui/feedback1-gaps.test.js b/test/ui/feedback1-gaps.test.js new file mode 100644 index 00000000..0cbb61f5 --- /dev/null +++ b/test/ui/feedback1-gaps.test.js @@ -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); + }); +}); diff --git a/tools/botbench.mjs b/tools/botbench.mjs index a39fba65..42858dfa 100644 --- a/tools/botbench.mjs +++ b/tools/botbench.mjs @@ -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; diff --git a/tools/build-data.mjs b/tools/build-data.mjs index 1e4050d3..34750a51 100644 --- a/tools/build-data.mjs +++ b/tools/build-data.mjs @@ -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 };