feedback1 integrate: WC 深水区 refuses deployment; tactical points only inside the summoner's attack range

# Conflicts:
#	server/match/bot.js
This commit is contained in:
sganggs committed 2026-10-03 06:35:09 +08:00
commit b1644f5ad7
27 files changed
+870 -81

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"act1autochess_m01":{"id":"act1autochess_m01","name":"战场#01","weight":50,"active":true,"modes":["mode_training_1","mode_single_normal","mode_single_funny","mode_multi_normal","mode_multi_funny"],"size":[19,21],"rows":["aaaaaaaaaa#aaaaaaaaaa","###IAAAAA###AAAAAI###","##Err###rrOrr###rrE##","###brbbbrfffrbbbrb###","###brbbbrfffrbbbrb###","###brrrrrfOfrrrrrb###","XXXXXXXXXXXXXXXXXXXXX","aaaaaaaaaa###########","####AAAAA##I#########","##Err###rrSrr###rrS##","###brbbbrf#brbbbrf###","###brbbbrf#brbbbrf###","###brrrrrfSbrrrrrfS##","XXXXXXXXXXXXXXXXXXXXX","#######fffffff#######","#######Sffffff#######","#######ppppppp#######","#######fffffff#######","#######Sffffff#######"],"tiles":{"a":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{"isValidHand":1}},"#":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"I":{"tileKey":"tile_telin","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"A":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"E":{"tileKey":"tile_end","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"r":{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"O":{"tileKey":"tile_telout","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"b":{"tileKey":"tile_fence_bound","height":"LOW","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"f":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"X":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"NONE","groundPassable":false,"flyPassable":false,"special":null,"bb":{}},"S":{"tileKey":"tile_start","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"p":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{"previewNotAlloed":1}}},"devices":[{"key":"trap_1112_acblzd","name":"盟约寒风","alias":null,"pos":[18,0],"dir":"UP","hidden":false,"role":"coldWind","stats":{"maxHp":100,"atk":0,"def":0,"res":0,"bat":1,"aspd":100,"blockCnt":0},"rangeGrid":null,"rangeTiles":null,"skill":{"skillId":"sktok_acblzd","name":"谢拉格寒风","level":1,"desc":"每隔一定时间对全场敌人造成一定时间的寒冷","descRaw":"每隔一定时间对全场敌人造成一定时间的<$ba.cold>寒冷</>","bb":{},"bbStr":{}},"desc":null,"active":true},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#1","pos":[9,7],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,6],[10,6],[11,6],[12,6],[13,6],[9,7],[10,7],[11,7],[12,7],[13,7],[9,8],[10,8],[11,8],[12,8],[13,8]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"damage_scale_per_stack":0.001,"max_attack_speed":300,"max_damage_scale":1.3},"bbStr":{}},"desc":null,"active":false},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#2","pos":[9,15],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,14],[10,14],[11,14],[12,14],[13,14],[9,15],[10,15],[11,15],[12,15],[13,15],[9,16],[10,16],[11,16],[12,16],[13,16]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"daLine truncated
{"act1autochess_m01":{"id":"act1autochess_m01","name":"战场#01","weight":50,"active":true,"modes":["mode_training_1","mode_single_normal","mode_single_funny","mode_multi_normal","mode_multi_funny"],"size":[19,21],"rows":["aaaaaaaaaa#aaaaaaaaaa","###IAAAAA###AAAAAI###","##Err###rrOrr###rrE##","###brbbbrfffrbbbrb###","###brbbbrfffrbbbrb###","###brrrrrfOfrrrrrb###","XXXXXXXXXXXXXXXXXXXXX","aaaaaaaaaa###########","####AAAAA##I#########","##Err###rrSrr###rrS##","###brbbbrf#brbbbrf###","###brbbbrf#brbbbrf###","###brrrrrfSbrrrrrfS##","XXXXXXXXXXXXXXXXXXXXX","#######fffffff#######","#######Sffffff#######","#######ppppppp#######","#######fffffff#######","#######Sffffff#######"],"tiles":{"a":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{"isValidHand":1}},"#":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"I":{"tileKey":"tile_telin","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"A":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"E":{"tileKey":"tile_end","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"r":{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"O":{"tileKey":"tile_telout","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"b":{"tileKey":"tile_fence_bound","height":"LOW","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"f":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"X":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"NONE","groundPassable":false,"flyPassable":false,"special":null,"bb":{}},"S":{"tileKey":"tile_start","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"p":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{"previewNotAlloed":1}}},"devices":[{"key":"trap_1112_acblzd","name":"盟约寒风","alias":null,"pos":[18,0],"dir":"UP","hidden":false,"role":"coldWind","stats":{"maxHp":100,"atk":0,"def":0,"res":0,"bat":1,"aspd":100,"blockCnt":0},"rangeGrid":null,"rangeTiles":null,"skill":{"skillId":"sktok_acblzd","name":"谢拉格寒风","level":1,"desc":"每隔一定时间对全场敌人造成一定时间的寒冷","descRaw":"每隔一定时间对全场敌人造成一定时间的<$ba.cold>寒冷</>","bb":{},"bbStr":{}},"desc":null,"active":true},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#1","pos":[9,7],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,6],[10,6],[11,6],[12,6],[13,6],[9,7],[10,7],[11,7],[12,7],[13,7],[9,8],[10,8],[11,8],[12,8],[13,8]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"damage_scale_per_stack":0.001,"max_attack_speed":300,"max_damage_scale":1.3},"bbStr":{}},"desc":null,"active":false},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#2","pos":[9,15],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,14],[10,14],[11,14],[12,14],[13,14],[9,15],[10,15],[11,15],[12,15],[13,15],[9,16],[10,16],[11,16],[12,16],[13,16]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"daLine truncated
+1 -1
View File
@@ -1 +1 @@
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"abnormal":[],"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon","name":"诅咒娃Line truncated
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"ownerRange":false,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"abnormal":[],"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon"Line truncated
+5 -3
View File
@@ -405,12 +405,12 @@ exactly (the builder warns if a multi-fragment template ever appears).
| `weight`, `active`, `modes` | `50`, `true`, `["mode_single_normal",…]` | match-start pick weight (act1 m05–m07 weight 0) |
| `size` | `[19,21]` | |
| `rows[]` | `rows[9] = "##Errr#rrrSrrr#rrrS##"` | 19 strings, **index = row (0 = bottom)**, one glyph per col |
| `tiles[glyph]` | `{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}}` | actual tile properties of each glyph used |
| `tiles[glyph]` | `{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}}` | actual tile properties of each glyph used. `buildable` is the **effective** deploy type: the level's buildableType, except a tile whose mechanism refuses deployment — 深水区 `tile_deepsea` (PRTS 深水区 地形信息 "部署类型 全部位 … 地形机制 拒绝部署(待补充)"; player report #3 after 0.1.0) — which is `NONE` and keeps the level's value in `buildableType` (`server/sim/grid.js DEPLOY_REFUSED_TILES`, shared with the builder) |
| `devices[]` | `{"key":"trap_013_blower","name":"源石流发生装置","alias":"trap_013_blower#001","pos":[13,5],"dir":"DOWN","hidden":false,"role":"blower","active":true,"stats":{…},"rangeGrid":[[0,0],…],"rangeTiles":[[13,5],[12,5],[11,5],[10,5]],"skill":{"skillId":"sktok_blower","bb":{"blower_s_character[equal].atk":0.3,…}},"desc":null}` | predefined devices; `role` ∈ crate, platform, mound, blower, mireController, tideController, turret (双眼皮, activated by band 机械援助), coldWind, sandstorm, waterPlatform, sealedFloor, bush, bossSpawn; `active` = present at match start (= not `hidden` in the level file); `rangeGrid` is facing right, `rangeTiles` = absolute tiles after rotating by `dir` (RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)`), clipped to the grid (null without a range). Terrain cards / band effects toggle devices by `alias` |
| `mapChars[]` | `{"key":"char_605_cmedic","alias":"char_605_cmedic#1","pos":[10,2],…}` | band_amedic map characters (→ `tokens.json`) |
| `special` | `{"mire":{"intervalSec":3,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,…}}`, `{"infection":{"bb":{"damage":70,"atk":0.2,"attack_speed":20,"duration":300}}}`, `{"deepsea":{"skillId":"sktok_tidectrl_3","bb":{…}}}`, `{"smog":{"rule":"operatorsNotTargetableByEnemyRanged"}}`, `{"blower":{…}}` | special-terrain parameters |
| `runes[]`, `globalBuffs[]` | `{"key":"map_tile_blackb_assign","bb":{…},"bbStr":{…}}` | level runes (act2 m03 reed rune has no reed tiles — leftover) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW buildable ALL/MELEE (ranged may use them too), `rangedOnly` = high ground / active platforms; tiles under active crates/mounds excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW (effective) buildable ALL/MELEE (ranged may use them too) — and a 深水区 under an active 特制水上平台 (`waterPlatform`, "在水上建立可以部署任意单位的平台"; act1 m05 only — the prep's deploy map only: the sim does not model the canoes, so its Grid keeps those tiles NONE, automatic placements avoid them and the path tie-break counts them as non-blockable [ASSUMED, weight 0 this season]) —, `rangedOnly` = high ground / active platforms; tiles under active crates/mounds and the 深水区 excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) |
| `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — plus the remake's road-over-floor preference: among equal-length routes the fewest non-blockable floor / gate tiles, and smoothing never cuts across floor the grid route does not walk, e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane; DESIGN §14 corrections) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live |
| `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | |
@@ -430,7 +430,8 @@ Glyph legend (`rows`):
| `A` | tile_achand | HIGH | ALL | no | temp hand slot (row 8 cols 4–8) |
| `S` / `E` | tile_start / tile_end | LOW | NONE | yes | red gate / blue objective |
| `I` / `O` | tile_telin / tile_telout | LOW | NONE | yes | teleport in / out (boss field) |
| `m` `g` `d` `i` | tile_mire / tile_smog / tile_deepsea / tile_infection | LOW | ALL | yes | special terrain (see `special`) |
| `m` `g` `i` | tile_mire / tile_smog / tile_infection | LOW | ALL | yes | special terrain (see `special`) |
| `d` | tile_deepsea | LOW | NONE (level: ALL) | yes | 深水区 (see `special.deepsea`): enemies wade through it; no unit may be deployed on it (PRTS 深水区 地形信息 "拒绝部署(待补充)") — 战场#08's pool at board (10–12, 6), (10–12, 14), boss (3–5, 6) / (3–5, 14) |
## 13. `bosses.json` — `{ [bossId]: Boss }` (10; boss_8–10 hidden)
@@ -455,6 +456,7 @@ Glyph legend (`rows`):
| `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | |
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | |
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only) / `null` (not listed). `placeable` (a prep hand piece) = a manually deployable summon (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"; user playtest #6): not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 海嗣, 狼群, 流形 and 爬行号·防护单元 (凯瑟琳's talent device — the only pool summon missing from `shopStateTokenDict`, read as shown: placed by hand in the base game, by the friend's report, and confirmed by the user after playtest #6, DESIGN §20); 投递坐标 (HIDDEN) is not. In battle a skill's summon deploys once at the battle start, then on its tile each time the skill gives one (SIM.md, token pieces) |
| `ownerRange` | `true` | the token text reads "只能部署在召唤者攻击范围内" (`desc`; the tacticians' 援军 — 狼群, 流形; PRTS 狼群 特性): its hand piece may only stand on a tile of its owner's attack range (server/match/board.js `ownerRangeKeys`, `PlayerState._legal`, the client's `gameLogic.summonRange`; player report #9 after 0.1.0). `false` for every other token |
| `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | |
| `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults |
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
+19 -6
View File
@@ -443,11 +443,14 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
the hand / temp (kept through the next prep, see Hand). In a boss round's prep the tile is read on the player's half of
the boss field (board coordinates unchanged). `onMerge` carries `area` ('board' | 'hand' | 'temp').
* **Board**: rows 9–12 × cols 2–10, legality from `stages[id].tiles` + devices (board.js); deploy cap 8 (+effects);
summons don't use slots; board↔hand swaps always allowed. A terrain change (terrain 机变 cards such as 模拟战场演变·
模式二 "阻隔工事变为射击台", content `setDeviceActive` / `setTileOverride`) is checked at the next `recompute()` (at the
latest when the battle input is built): a piece left on a tile it may no longer occupy — a melee operator on a new
射击台 — is withdrawn to the hand (overflow temp: re-placed during the prep; its summons leave with it), a summon back
onto its stack, with a toast (`PlayerState._evictIllegal`).
summons don't use slots; board↔hand swaps always allowed. The legend's `buildable` is the effective deploy type: the
深水区 (`tile_deepsea`, 战场#08's pool — level buildableType ALL) refuses deployment (PRTS 深水区 地形信息
"拒绝部署(待补充)"; a 特制水上平台 device makes its tile deployable for any unit; player report #3 after 0.1.0). A
terrain change (terrain 机变 cards such as 模拟战场演变·模式二 "阻隔工事变为射击台", content `setDeviceActive` /
`setTileOverride`) is checked at the next `recompute()` (at the latest when the battle input is built): a piece left
on a tile it may no longer occupy — a melee operator on a new 射击台 — is withdrawn to the hand (overflow temp:
re-placed during the prep; its summons leave with it), a summon back onto its stack, with a toast
(`PlayerState._evictIllegal`).
* **Summons** (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): an operator placed on the board sends its manually deployable
summons (tokens.json `placeable`: 赫默 S2 医疗探机, 巫恋 S2 诅咒娃娃, 凯瑟琳 爬行号·防护单元, 浊心斯卡蒂 海嗣, 伺夜 狼群,
缪尔赛思 流形 — only those its equipped skill / module makes, `gamedata.placeableTokens(chessId, loadout)`) to the hand as
@@ -457,7 +460,17 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
where it was dropped) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区";
`PlayerState._liftTokensOf`). A stack that overflowed into temp and was removed at a prep deadline comes back at the
next round start (§手牌区 "干员所属召唤物会于下一回合返还": `PlayerState.startRound` tops every board owner's summons up
to the deploy limit, `grantTokensFor`). Battle side: SIM.md §1.1 token pieces.
to the deploy limit, `grantTokensFor`). A summon whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`:
the tacticians' 援军, 伺夜 狼群 / 缪尔赛思 流形 — choosing the tactical point; player report #9 after 0.1.0) only goes on
a tile of its owner's attack range: the owner's loadout grid (`attackRangeGrid`) rotated by its facing around its tile
(`PlayerState._legal` / `summonRange`, `board.js ownerRangeKeys`; a summon dragged onto its own owner is checked from
the owner's new tile, an operator dragged onto the summon from the summon's new tile). When the owner is re-oriented in
place (or promoted) a summon its new range leaves out goes back onto its stack with a toast (`_liftOutOfRange` in
`recompute`) — one still inside stays [ASSUMED]. A re-orientation that would leave such a summon with no stack and no
free hand / temp slot is refused (HAND_FULL, like withdrawing a summon into a full hand); in the other cases (a
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.
**Layer cap** (research 11 §1; the client's `MAX_GARRISON_STACK` / `AddBondCount` = min(L + n, 999)): each bond's
+2 -2
View File
@@ -62,12 +62,12 @@
## 4. 摆阵与朝向
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。**水里不能部署**:战场#08(涨潮控制)中间的那条水池(深水区)和官方一样拒绝部署,任何干员和召唤物都放不上去;突袭盟约的干员再部署、战术家的援军自动选战术点时也只会落在岸上。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员和「停止攻击」类技能期间除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
- 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击;**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但它不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。
+6 -3
View File
@@ -103,7 +103,10 @@ waits on its tile and takes the field there each time the skill gives one (stock
once it left, free; never while its owner is off the field [ASSUMED] — a stocked one deploys as soon as the owner is
back; the doll also leaves when 巫恋 leaves, the drone stays when 赫默 leaves: PRTS token 备注). A skill's summon, a
device or 海嗣 that was not placed never appears (the hidden 待部署区 deploys nothing by itself); the tacticians' 狼群 /
流形 still come as their 援军 on a tactical point without a piece (content/tokens.js `tacticalPoint`). The start deploy
流形 still come as their 援军 on a tactical point without a piece (content/tokens.js `tacticalPoint`). Their piece stands
inside the tactician's attack range — the prep enforces "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`, player
report #9 after 0.1.0) — and the kits re-use the piece's tile for a re-summoned pack only while it is a free, standable
tile of her initial range (tier3 `tacticalPoint`, tokens.js `ensureReinforcement`). The start deploy
is the user's call after playtest #6 (DESIGN §20); `shared/constants.js SKILL_SUMMON_START_DEPLOY = false` would bring
back the playtest #4 reading (only with the skill) in the sim and the summon card's hint (docs/PLAYING.md §4 and this
passage must follow; test/ui/playtest6_summons.test.js checks).
@@ -601,9 +604,9 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random |
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
| `findTacticalPoint(unit)`, `groundPathTiles()` | tactical point (战术点, tactician 援军 / talent tokens): a free (`isReservedTile`) walkable tile of the initial range **on an enemy ground path first** (`groundPathTiles`: grid paths of every non-FLY route, cached per grid version), then nearest (Chebyshev, rows break ties), then the lowest tile key |
| `findTacticalPoint(unit)`, `groundPathTiles()` | tactical point (战术点, tactician 援军 / talent tokens): a free (`isReservedTile`) walkable, standable (`canStand`: never the 深水区) tile of the initial range **on an enemy ground path first** (`groundPathTiles`: grid paths of every non-FLY route, cached per grid version), then nearest (Chebyshev, rows break ties), then the lowest tile key |
| `effectiveProfile(unit)`, `reduceElement(unit, amount, el?)` | |
| `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})`, `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) |
| `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})` (every automatic placement: the 突袭 landing tile, tactical points, summon tiles — `build` is the effective deploy type, so never the 深水区 `tile_deepsea` (PRTS 深水区 地形信息 "拒绝部署(待补充)"; the 特制水上平台 of the inactive act1 m05 is not modelled, its tiles stay NONE here [ASSUMED]); no melee unit on a hard-blocked 射击台 / mound tile), `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) |
| `battle.data` | DataSource: `getChess(id, loadout?)`, `getEnemy(key)`, `getToken(id, ownerChessId, ownerLoadout?)`, `getStage(id)`, `getWave(id)` (normalised defs; raw record in `def.raw`; the per-battle loadout view, §12) |
---
+48 -7
View File
@@ -5,10 +5,13 @@
//
// Board = own normal field (GEO.FIELD rows 9–12, cols 2–10). Melee chess stand on `melee` deploy tiles
// (LOW, buildable ALL/MELEE); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
// derived from the tile legend when missing). Tokens follow their own `position`. In the prep of a boss round
// (最终攻势 / 隐秘核心) the tiles are the player's half of the boss field (`deployFieldOf`: 'bossL' / mirrored
// 'bossR', board (r, c) = stage tile (r − 7, c) / (r − 7, 20 − c)) like the server's deploy map (server/match/
// board.js field; user playtest #5 item 7) — board coordinates stay the same.
// derived from the tile legend when missing — the legend's `buildable` is the effective type: 深水区 tile_deepsea
// refuses deployment, PRTS 深水区 地形信息 "拒绝部署(待补充)", player report #3 after 0.1.0). Tokens follow their
// own `position`; a summon whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群,
// 缪尔赛思's 流形) also needs a tile of its owner's attack range (`summonRange`, player report #9). In the prep of a
// boss round (最终攻势 / 隐秘核心) the tiles are the player's half of the boss field (`deployFieldOf`: 'bossL' /
// mirrored 'bossR', board (r, c) = stage tile (r − 7, c) / (r − 7, 20 − c)) like the server's deploy map
// (server/match/board.js field; user playtest #5 item 7) — board coordinates stay the same.
// Hand = 10 slots (index = col). Temp slots are server-filled only (no move target).
// Dropping onto an occupied tile/slot swaps (both pieces must be legal at their new spots);
// an EQUIP item dropped onto a chess piece equips it (tokens can't carry items); an Arts (MAGIC) item
@@ -19,7 +22,8 @@
import { GEO, PHASE, UF } from '../../../shared/constants.js';
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js';
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { rangeTiles, pieceDir } from './facing.js';
import { layoutPen } from '../render/pen.js';
import { BOSS_ROW_SHIFT, MAX_COL } from '../render/prepfield.js';
import { bossLevelSeconds } from './matchStatus.js';
@@ -637,6 +641,8 @@ export function deploySets(stage, field = 'normal', overrides = {}) {
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
const PLATFORM_ROLES = new Set(['platform']);
/** 特制水上平台 (act1 m05, weight 0 this season): its 深水区 tile takes any unit ("在水上建立可以部署任意单位的平台"). */
const WATER_PLATFORM_ROLES = new Set(['waterPlatform']);
const hasKeys = (o) => isObj(o) && Object.keys(o).length > 0;
/**
@@ -712,6 +718,7 @@ export function deployMap(stage, { deviceOverrides = {}, tileOverrides = {}, fie
if (!active) continue;
if (OBSTACLE_ROLES.has(d.role)) map.delete(tileKey(r, c));
else if (PLATFORM_ROLES.has(d.role)) map.set(tileKey(r, c), 'ranged');
else if (WATER_PLATFORM_ROLES.has(d.role)) map.set(tileKey(r, c), 'melee');
}
for (const [k, v] of Object.entries(isObj(tileOverrides) ? tileOverrides : {})) {
const [r, c] = String(k).split(',').map(Number);
@@ -808,6 +815,33 @@ export function tileAllows(ctx, piece, row, col) {
return pos === 'MELEE' ? ctx.deploy.melee.has(k) : ctx.deploy.ranged.has(k);
}
/**
* Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`:
* 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when
* the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's
* current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange.
* @param {ReturnType<typeof placementContext>} ctx
* @returns {Set<string> | null}
*/
export function summonRange(ctx, piece, owner = null) {
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
let at = owner;
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
const rec = at && ctx.getChess(at.piece.id);
if (!rec) return null;
let grid = null;
try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ }
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
}
/** tileAllows plus the owner-range rule of a range-bound summon (summonRange). */
function unitAllowed(ctx, piece, row, col, owner = null) {
if (!tileAllows(ctx, piece, row, col)) return false;
const range = summonRange(ctx, piece, owner);
return !range || range.has(tileKey(row, col));
}
/**
* Placement legality of dropping piece `uid` on `target` (mirror of server/match/PlayerState.js move / equip /
* useArt and server/match/board.js canPlace):
@@ -872,9 +906,16 @@ export function canPlace(ctx, uid, target) {
const deployable = ctx.deploy.ranged.has(tileKey(row, col));
return no('BAD_TILE', deployable && piecePosition(ctx, piece) === 'MELEE' ? '近战单位只能部署在地面' : '无法部署在该位置');
}
// a range-bound summon (战术点): inside its owner's attack range — seen from the summon's old tile when it is
// dropped onto its own owner (the two swap)
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
const range = summonRange(ctx, piece, ownerSwap);
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围内');
if (src.area === 'board') {
// board → board: move or swap (the occupant must be legal on the source tile)
if (occ && !tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置');
// board → board: move or swap (the occupant must be legal on the source tile — the mover's own summon excepted:
// a moved operator's summons go back to the hand anyway)
const ownSummon = occ && occ.piece.kind === 'token' && occ.piece.ownerUid === piece.uid;
if (occ && !ownSummon && !unitAllowed(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置');
return { ok: true, action: occ ? 'swap' : 'move' };
}
if (piece.kind === 'token') {
+104 -8
View File
@@ -48,7 +48,14 @@
// it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区");
// a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
// tile when the skill fires (sim/content/tokens.js dockSkillSummons).
// tile when the skill fires (sim/content/tokens.js dockSkillSummons). A summon whose text reads "只能部署在召唤者
// 攻击范围内" (tokens.json `ownerRange`: the tacticians' 狼群 / 流形 — their tactical point; player report #9 after
// 0.1.0) only goes on a tile of its owner's attack range (_legal / summonRange: the loadout's grid rotated by the
// owner's facing); a swap with its owner is checked from the owner's new tile, and one an in-place re-orientation
// (or a promotion) leaves outside goes back onto its stack (recompute → _liftOutOfRange) [ASSUMED: kept when still
// inside]. A re-orientation that would leave such a summon with no stack and no free hand / temp slot is refused
// (HAND_FULL); elsewhere (a promotion, an owner moved with no room) it leaves the board and its stack comes back at
// the next round start (grantTokensFor) — no out-of-range placement reaches the battle.
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
@@ -62,7 +69,8 @@
import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile } from './board.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js';
import { offsetTile } from '../sim/dir.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains } from './bondsMeta.js';
import { itemKey } from './gamedata.js';
@@ -1003,7 +1011,78 @@ export class PlayerState {
return positionClass(rec);
}
_legal(piece, r, c) { return canPlace(this.deployMap(), this._placementOf(piece), r, c); }
/**
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形), a tile of its owner's
* attack range (summonRange). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a
* summon swapped with its own owner).
*/
_legal(piece, r, c, owner = null) {
if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false;
const range = this.summonRange(piece, owner);
return !range || range.has(tileKey(r, c));
}
/**
* The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical
* point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid
* (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board
* tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other
* rules refuse such a placement).
* @param {any} piece
* @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one
* @returns {Set<string> | null}
*/
summonRange(piece, owner = null) {
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
let at = owner;
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
const rec = at && this.gd.chess(at.piece.id);
if (!rec) return null;
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(rec)))) || rec.rangeGrid;
const [r, c] = parseKey(at.key);
return ownerRangeKeys(grid, r, c, at.dir);
}
/**
* Range-bound summons left outside their owner's attack range (the owner re-oriented in place, promoted, its loadout
* changed, moved with no room to take its summons back) go back onto their stack — a summon still inside stays
* [ASSUMED: the official moves every summon of a MOVED owner back, PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场
* 并重置至手牌区"; an in-place re-orientation keeps the ones it can]. One with no stack and no free hand / temp slot
* leaves the board: its stack comes back at the next round start like a summon stack removed from temp at a prep
* deadline (startRound → grantTokensFor, "干员所属召唤物会于下一回合返还"), so no illegal placement reaches the
* battle. Returns the number taken off the board; a toast names them.
*/
_liftOutOfRange() {
const back = [], gone = [];
for (const [k, p] of [...this.board]) {
if (p.kind !== 'token') continue;
const range = this.summonRange(p);
if (!range || range.has(k)) continue;
this.board.delete(k);
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
}
if (back.length) this.m.toast(this, 'warn', `${back.join('、')}只能部署在召唤者攻击范围内,已退回整备区`);
if (gone.length) this.m.toast(this, 'warn', `${gone.join('、')}只能部署在召唤者攻击范围内,整备区已满,下回合返还`);
return back.length + gone.length;
}
/**
* Whether every range-bound summon of `owner` that the range from (ownerKey, dir) leaves out can go back onto its
* stack or into a free hand / temp slot (_reorient refuses otherwise: the player's own re-orientation never costs a
* summon, like withdrawing one into a full hand gives HAND_FULL).
*/
_roomForOutOfRange(owner, ownerKey, dir) {
const at = { key: ownerKey, piece: owner, dir };
const stacks = [...this.hand, ...this.temp].filter((p) => p && p.kind === 'token' && p.ownerUid === owner.uid);
const needSlot = new Set();
for (const [k, p] of this.board) {
if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue;
const range = this.summonRange(p, at);
if (range && !range.has(k)) needSlot.add(p.id);
}
return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length;
}
_moveChessToBoard(loc, r, c, dir = 'RIGHT') {
const piece = loc.piece;
@@ -1013,10 +1092,12 @@ export class PlayerState {
if (occ === piece) return this._reorient(piece, dir);
if (loc.area === 'board') {
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing); an
// operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf)
// operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf) — its
// own summon swapped onto its old tile included, so that one needs no tile check
if (occ) {
const [sr, sc] = parseKey(loc.key);
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
const ownSummon = occ.kind === 'token' && occ.ownerUid === piece.uid;
if (!ownSummon && !this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
this.board.set(loc.key, occ);
if (occ.kind === 'chess') this._liftTokensOf(occ.uid);
} else {
@@ -1049,9 +1130,19 @@ export class PlayerState {
return OK;
}
/** In-place re-orientation (g.move onto the piece's own tile with a new direction). */
/**
* In-place re-orientation (g.move onto the piece's own tile with a new direction). An owner's range-bound summons the
* new range leaves out go back onto their stack (recompute → _liftOutOfRange); HAND_FULL (nothing changes) when one
* of them would have nowhere to go.
*/
_reorient(piece, dir) {
if (pieceDir(piece) !== dir) { piece.dir = dir; this.dirty(); }
if (pieceDir(piece) === dir) return OK;
if (piece.kind === 'chess') {
const loc = this.find(piece.uid);
if (loc && loc.area === 'board' && !this._roomForOutOfRange(piece, loc.key, dir)) return fail(ERR.HAND_FULL);
}
piece.dir = dir;
this.recompute();
return OK;
}
@@ -1077,9 +1168,13 @@ export class PlayerState {
_moveTokenToBoard(loc, r, c, dir = 'RIGHT') {
const piece = loc.piece;
if (!inField(r, c) || !this._legal(piece, r, c)) return fail(ERR.BAD_TILE);
if (!inField(r, c)) return fail(ERR.BAD_TILE);
const key = tileKey(r, c);
const occ = this.board.get(key) || null;
// a summon dragged onto its own owner swaps with it: a range-bound one must be inside the owner's range from the
// tile the owner takes (the summon's, with the owner's facing)
const ownerAfter = loc.area === 'board' && occ && occ !== piece && occ.uid === piece.ownerUid ? { key: loc.key, piece: occ, dir: pieceDir(occ) } : null;
if (!this._legal(piece, r, c, ownerAfter)) return fail(ERR.BAD_TILE);
if (occ === piece) return this._reorient(piece, dir);
if (loc.area === 'board') {
// board → board: move or swap; an operator swapped onto the summon's old tile changed its tile, so its other
@@ -1421,6 +1516,7 @@ export class PlayerState {
recompute() {
this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale
if (this._legalityStale) this._evictIllegal();
this._liftOutOfRange();
this.bonds = computeBonds(this.gd, this);
this.dirty();
}
+31 -4
View File
@@ -2,10 +2,13 @@
//
// Deploy map: for every tile of the own board region (rows 9–12, cols 2–10) the legality class is derived from the
// stage legend (data/stages.json `tiles[glyph]`: height + buildable) and the stage devices:
// 'melee' — LOW tile with buildable ALL/MELEE: melee AND ranged chess may stand here
// ("所有行动内远程干员可部署在近战位").
// 'melee' — LOW tile with buildable ALL/MELEE, or a 深水区 under an active 特制水上平台 (waterPlatform, "在水上建立可以
// 部署任意单位的平台"): melee AND ranged chess may stand here ("所有行动内远程干员可部署在近战位").
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only.
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds).
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds, the 深水区 — the legend's
// `buildable` is the effective type: tile_deepsea refuses deployment, PRTS 深水区 地形信息
// "拒绝部署(待补充)", although its level buildableType is ALL; player report #3 after 0.1.0, 战场#08's
// pool).
// Per-player overrides (terrain 机变 cards, content): `deviceOverrides { alias: active }` toggles stage devices,
// `tileOverrides { 'r,c': 'melee'|'ranged'|'none' }` force a class. The result equals stages[id].deployTiles.normal
// for the unmodified stage (asserted in test/match/board.test.js).
@@ -20,11 +23,15 @@
// mapped to board coordinates (test/match/playtest5-deploy.test.js).
//
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// A token whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: the tacticians' 援军 — 伺夜's 狼群,
// 缪尔赛思's 流形; PRTS 狼群 特性) also needs a tile of its owner's attack range: `ownerRangeKeys` = the owner's range
// grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its facing around its board tile
// (PlayerState._legal; player report #9 after 0.1.0).
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
// `parseDir` validates an intent's optional direction.
import { GEO } from '../../shared/constants.js';
import { DEFAULT_DIR, isDir } from '../sim/dir.js';
import { DEFAULT_DIR, isDir, rotateOffset } from '../sim/dir.js';
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
export const FIELD = GEO.FIELD; // { r0: 9, r1: 12, c0: 2, c1: 10 }
@@ -38,6 +45,8 @@ export const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
const PLATFORM_ROLES = new Set(['platform']);
/** 特制水上平台 (act1 m05, weight 0 this season): its tile takes any unit. */
const WATER_PLATFORM_ROLES = new Set(['waterPlatform']);
/** Deploy fields: the own normal board, or the player's half of the boss field (left / mirrored right). */
export const DEPLOY_FIELDS = Object.freeze(['normal', 'bossL', 'bossR']);
@@ -108,6 +117,7 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
const k = tileKey(r, c);
if (OBSTACLE_ROLES.has(d.role)) map.delete(k);
else if (PLATFORM_ROLES.has(d.role)) map.set(k, 'ranged');
else if (WATER_PLATFORM_ROLES.has(d.role)) map.set(k, 'melee');
}
for (const [k, v] of Object.entries(tileOverrides || {})) {
const [r, c] = parseKey(k);
@@ -135,6 +145,23 @@ export function canPlace(map, pos, r, c) {
return true; // ranged / all: melee tiles and ranged tiles
}
/**
* Board tiles ('r,c' keys) of an owner's attack range: `grid` (facing-RIGHT [dRow, dCol] offsets) rotated by `dir`
* around (r, c) — where a "只能部署在召唤者攻击范围内" summon (tokens.json `ownerRange`) may stand. The owner's own tile is
* part of most grids but always occupied by the owner. public/js/ui/gameLogic.js mirrors it for the client.
* @param {number[][]|null} grid
* @returns {Set<string>}
*/
export function ownerRangeKeys(grid, r, c, dir) {
const out = new Set();
for (const g of Array.isArray(grid) ? grid : []) {
if (!Array.isArray(g)) continue;
const [dr, dc] = rotateOffset(g[0], g[1], dir);
out.add(tileKey(r + dr, c + dc));
}
return out;
}
/** Legal tiles for a placement class, in reading order (top→bottom, left→right). */
export function legalTiles(map, pos) {
const out = [];
+6 -2
View File
@@ -34,7 +34,9 @@
// generators that yield between whole actions (never with a transient board) — the same actions in the same
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
// them on the best remaining tiles — the tacticians' 狼群 / 流形 only inside their owner's attack range
// (PlayerState.summonRange; player report #9 after 0.1.0) —, 凯瑟琳's 支援装置 next to the best operator no device
// faces yet, facing it.
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler);
// 突变细胞 (it comes back after every transformation) on the least valuable single normal operator below 6阶 —
// never an elite or one of a merge pair (cellTarget)
@@ -609,9 +611,11 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
if (!r0) continue;
let best = null;
let bestV = -Infinity;
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
for (const [r, c] of legalTiles(map, positionClass(r0))) {
const k = tileKey(r, c);
if (taken.has(k)) continue;
if (taken.has(k) || (within && !within.has(k))) continue;
const noise = m.rngBots() * 1e-6;
const seen = new Set();
for (const dir of PLAN_DIRS) {
+5 -1
View File
@@ -7,7 +7,8 @@
// pieces unique uids; hand 10 / temp 5 slots; chess carry ≤ equipPerChess known items; a normal piece holds ≤ 1
// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never
// two copies of a mergeable normal item); every token's owner chess is deployed
// board tiles inside the own region and legal for the piece; no items on the board; chess count ≤ deploy cap
// board tiles inside the own region and legal for the piece (a range-bound summon inside its owner's attack
// range); no items on the board; chess count ≤ deploy cap
// (where a merge's elite goes — a consumed deployed copy's tile, else the hand — needs the state before the
// merge: audit.js checks it per merge)
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
@@ -111,6 +112,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; }
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
if (rec && !canPlace(dmap, positionClass(rec), r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
const range = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
if (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);
if (p.kind === 'chess') deployed++;
}
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
+3 -1
View File
@@ -23,7 +23,9 @@
// + 脆弱 ×damage_scale for weak[limit] s
// 助力 deputShip all operators DEF +(base + per·L), redeploy time ×(1 + respawn_time)
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署"
// next to the most advanced ground enemy: a real redeployment (retreat + free redeploy on the
// next to the most advanced ground enemy, on a free tile its position may be deployed on
// (grid.canStand: never the 深水区 — player report #3 after 0.1.0, members dropped into 战场#08's
// pool after an enemy wading in it): a real redeployment (retreat + free redeploy on the
// landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古)
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP +(base + per·L)
// until it leaves the field; later redeploys use its board tile again;
+3 -1
View File
@@ -24,7 +24,9 @@
// the field only (unite / boss fields hold both halves); Battle opts.deviceOverrides switch every half.
// `deviceOverridesOf(battle)` is the union (informational).
// Terrain numbers come from `stage.special` (fallback: the device's skill blackboard, then research 05 values).
// Not implemented: the devices of act1 m05–m07 (canoe platforms, sandstorm + mounds, bushes) — weight 0 this season.
// Not implemented: the devices of act1 m05–m07 (canoe platforms, sandstorm + mounds, bushes) — weight 0 this season. The
// canoe (特制水上平台) makes its 深水区 deployable in the prep's deploy map only (server/match/board.js); the sim's Grid
// keeps those tiles NONE (grid.js DEPLOY_REFUSED_TILES), so automatic placements never use them [ASSUMED].
import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js';
+6 -4
View File
@@ -137,12 +137,14 @@ function freeTile(battle, r, c) {
/** Walkable ground tile a melee summon can stand on. */
const groundTile = (battle, r, c) => battle.grid.groundPassable(r, c) && battle.grid.canStand(r, c);
/**
* Tactical point (战术点) of a tactician: `prefer` (the board piece's tile, i.e. the player's choice) when usable,
* else the shared tactical point (tokens.js tacticalPoint = Battle.findTacticalPoint: a free walkable tile of its
* initial range on an enemy ground path first, then the nearest one).
* Tactical point (战术点) of a tactician: `prefer` (the board piece's tile, i.e. the player's choice) when usable —
* free, standable ground (never 深水区: grid.canStand) inside her initial range ("只能部署在召唤者攻击范围内"; the prep
* already keeps the piece there, PlayerState._legal) —, else the shared tactical point (tokens.js tacticalPoint =
* Battle.findTacticalPoint: a free walkable tile of its initial range on an enemy ground path first, then the nearest).
*/
function tacticalPoint(battle, unit, prefer = null) {
if (prefer && freeTile(battle, prefer[0], prefer[1]) && groundTile(battle, prefer[0], prefer[1])) return prefer;
const inRange = (r, c) => (unit.baseRangeKeys || unit.rangeKeys || []).includes(r * COLS + c);
if (prefer && freeTile(battle, prefer[0], prefer[1]) && groundTile(battle, prefer[0], prefer[1]) && inRange(prefer[0], prefer[1])) return prefer;
return sharedTacticalPoint(battle, unit);
}
/** Tokens `tokenId` summoned by / placed for `owner` (board pieces included). */
+29 -4
View File
@@ -5,6 +5,13 @@
// Stage input: `{ id, rows: string[19] (bottom-first), legend?: {glyph: {...}}, devices?: [...] }`.
// Legend entries are normalised from either the research format (heightType/buildableType/passableMask/tileKey)
// or the build-data format (height/build/passable/terrain); missing glyphs fall back to DEFAULT_LEGEND.
// `build` is the EFFECTIVE deploy type: a tile whose mechanism refuses deployment (DEPLOY_REFUSED_TILES: 深水区
// tile_deepsea — PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)"; player report after 0.1.0: operators stood in
// 战场#08's pool and 突袭 members jumped into it) is NONE whatever its level buildableType (ALL), so `canStand` — every
// automatic placement: the 突袭 landing tile, tactical points, summon tiles — and the path tie-break below treat it as
// ground no operator can stand on. data/stages.json legends already carry the effective value (tools/build-data.mjs).
// Known limit [ASSUMED]: the 特制水上平台 that makes a 深水区 deployable (act1 m05, weight 0 this season) exists only in
// the match's deploy map; the sim does not model those canoes (content/devices.js), so here their tiles stay NONE.
//
// Pathfinding = the official client's (`Torappu.Battle.SPFA`, research 08 §3.1/§3.4): one FLOW FIELD per destination,
// a FIFO SPFA from the destination over the rect with the 4 neighbours UP (row+1), RIGHT, DOWN, LEFT (in that order,
@@ -18,7 +25,9 @@
// operator can block them"). Two refinements on top of the official algorithm; `dist` (so the crate cost 1000 and
// every grid route length) stays exactly the official one:
// * tie-break: the SPFA relaxes on (dist, pen) lexicographically, `pen` = number of NON-BLOCKABLE walkable tiles
// (floor / gate / goal / teleport tiles — `blockable()` false: not LOW ground buildable for melee) on the chain.
// (floor / gate / goal / teleport / 深水区 tiles — `blockable()` false: not LOW ground buildable for melee) on the
// chain (no gate route of 战场#08 changed when its 深水区 became non-buildable; a few off-route smoothed steps of its
// boss field, rows 4–5, now go around the water instead of cutting across it).
// Among equal-length chains the one with the fewest non-blockable tiles wins; remaining ties go to the first
// parent in SPFA order (the official order unless a pen improvement re-queued a tile).
// * smoothing: a line of sight may only cross a non-blockable tile (including the corner tiles of a diagonal step)
@@ -67,10 +76,19 @@ export const DEFAULT_LEGEND = Object.freeze({
O: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_telout', special: 'telout' },
m: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_mire', terrain: 'mire' },
g: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_smog', terrain: 'smog' },
d: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_deepsea', terrain: 'deepsea' },
d: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_deepsea', terrain: 'deepsea' },
i: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_infection', terrain: 'infection' },
});
/**
* Tile keys whose mechanism refuses deployment although the level's buildableType allows it: 深水区 tile_deepsea (PRTS
* 深水区 地形信息 "部署类型 全部位 … 地形机制 拒绝部署(待补充)"; PRTS 作战机制: the 地形标记 AdvancedBuildableMask —
* 深水 among them — "限制玩家仅能部署匹配的单位于其上"; the season-1 战场#05 puts a 特制水上平台 — "在水上建立可以部署
* 任意单位的平台" — on every one of its 深水区 tiles). Shared with tools/build-data.mjs (the stages.json legend's
* `buildable`).
*/
export const DEPLOY_REFUSED_TILES = Object.freeze(new Set(['tile_deepsea']));
const TERRAIN_BY_KEY = { tile_mire: 'mire', tile_smog: 'smog', tile_deepsea: 'deepsea', tile_infection: 'infection', tile_deepwater: 'deepsea' };
const SPECIAL_BY_KEY = { tile_start: 'start', tile_end: 'end', tile_telin: 'telin', tile_telout: 'telout' };
@@ -92,7 +110,8 @@ export function normalizeLegendEntry(glyph, e) {
const heightRaw = e.height ?? e.heightType;
const height = heightRaw == null ? base.height : (/HIGH/i.test(String(heightRaw)) ? 'HIGH' : 'LOW');
const buildRaw = e.build ?? e.buildable ?? e.buildableType;
const build = buildRaw == null ? base.build : (buildRaw === true ? 'ALL' : buildRaw === false ? 'NONE' : String(buildRaw).toUpperCase());
const build = DEPLOY_REFUSED_TILES.has(key) ? 'NONE'
: buildRaw == null ? base.build : (buildRaw === true ? 'ALL' : buildRaw === false ? 'NONE' : String(buildRaw).toUpperCase());
let pass;
if (e.pass != null) pass = normPass(e.pass);
else if (e.passable != null || e.passableMask != null) pass = normPass(e.passable ?? e.passableMask);
@@ -171,11 +190,17 @@ export class Grid {
flyPassable(r, c) { return this.inRect(r, c) && this.tile(r, c).pass !== 'NONE'; }
/** Whether a unit may stand on this tile. `ranged` units may also use LOW ALL/MELEE tiles (DESIGN §3). */
/**
* Whether a unit may be placed on this tile (every automatic placement: the 突袭 landing tile, tactical points, summon
* tiles). `ranged` units may also use LOW ALL/MELEE tiles (DESIGN §3). The effective `build` refuses the 深水区
* (DEPLOY_REFUSED_TILES); a hard-blocked tile (OB_BLOCK: a 射击台 switched on by a map card, a mound) takes no melee
* unit — the match's deploy map makes a 射击台 ranged-only (server/match/board.js).
*/
canStand(r, c, { ranged = false } = {}) {
const t = this.tile(r, c);
if (t.build === 'NONE') return false;
if (ranged) return t.build === 'ALL' || t.build === 'RANGED' || (t.height === 'LOW' && t.build === 'MELEE');
if (this.inBounds(r, c) && (this.obstacle[r * COLS + c] & OB_BLOCK)) return false;
return t.height === 'LOW' && (t.build === 'ALL' || t.build === 'MELEE');
}
+3 -2
View File
@@ -30,9 +30,9 @@ export function freePort() {
* Start the real server (`node server/index.js`, or test/e2e/fastServer.mjs with `fast` speed-ups) on a free port (or
* `port`: e.g. the port of a server just stopped — a server restart the open clients reconnect to).
* @param {{ port?: number, fast?: { timerScale?: number, combatSpeed?: number, startRound?: 'boss'|'hidden'|number, kit?: number, chess?: string[],
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[] } }} [opts]
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[], stage?: string } }} [opts]
* kits: per-human starter kits (seat order); botChess: the AI seats' kit; autoPlace: the kits go onto the board at the
* first prep; eliminate: humans (seat order) eliminated at the jump — fastServer.mjs hooks
* first prep; eliminate: humans (seat order) eliminated at the jump; stage: the stage of every match — fastServer.mjs hooks
* @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise<void> }>}
*/
export async function startRealServer(opts = {}) {
@@ -55,6 +55,7 @@ export async function startRealServer(opts = {}) {
if (opts.fast.botChess?.length) env.SP_BOT_CHESS = opts.fast.botChess.join(',');
if (opts.fast.autoPlace) env.SP_AUTO_PLACE = '1';
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage);
}
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = [];
+8
View File
@@ -20,6 +20,8 @@
// the hand: the seat keeps no board and leaks).
// SP_ELIMINATE=<humanIdx,…>: those humans (seat order) are eliminated at the jump, before the round's boss pairing — an
// eliminated spectator from the first round on (test/ui/watch-bonds.e2e.test.js, the Final Assault).
// SP_STAGE=<stageId>: every match is played on this stage instead of the drawn one (test/ui/feedback1-placement.e2e.test.js:
// 战场#08's pool).
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
import { startServer } from '../../server/index.js';
@@ -43,12 +45,18 @@ if (!process.env.NODE_TEST_CONTEXT) {
const botChess = String(process.env.SP_BOT_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean);
const autoPlace = process.env.SP_AUTO_PLACE === '1';
const eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
const forcedStage = String(process.env.SP_STAGE || '').trim();
class FastMatch extends Match {
constructor(opts) {
super({ ...opts, timerScale, combatSpeed });
this._jumped = !startAt;
this._placeRound = null;
if (forcedStage && this.gd.stage(forcedStage)) {
this.stageId = forcedStage;
this.stage = this.gd.stage(forcedStage);
for (const ps of this.players.values()) ps.invalidateDeployMap();
}
}
startRound(r) {
+6 -4
View File
@@ -172,9 +172,10 @@ test('battle input: board units in deploy order with items; tokens carry ownerUi
const it = giveItem(m, ps, 'chess_item_1_01_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: b.uid }), { ok: true });
const tok = ps.hand.find((p) => p && p.kind === 'token');
m.handle('p_0', { t: 'g.move', uid: tok.uid, to: { area: 'board', row: 9, col: 4 } });
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: tok.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
const input = ps.battleInput();
assert.deepEqual(input.units.map((u) => [u.row, u.col]), [[12, 3], [10, 5], [9, 4]], 'top→bottom then left→right');
assert.deepEqual(input.units.map((u) => [u.row, u.col]), [[12, 3], [10, 5], [9, 6]], 'top→bottom then left→right');
assert.deepEqual(input.units[0].items, ['chess_item_1_01_e_a']);
assert.equal(input.units[2].kind, 'token');
assert.equal(input.units[2].ownerUid, a.uid);
@@ -229,12 +230,13 @@ test('withdrawing a deployed summon into a full hand: HAND_FULL like any other c
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true });
const drone = ps.hand.find((p) => p && p.kind === 'token');
assert.ok(drone && drone.count === 1, 'one 狼群');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
// golden items never merge with each other: a plain full hand
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
assert.equal(ps.board.get('9,4'), drone, 'the 狼群 stays on the board');
assert.equal(ps.board.get('9,6'), drone, 'the 狼群 stays on the board');
assert.ok(ps.tempEmpty, 'nothing overflowed into temp (Ready stays possible)');
assert.equal(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'the same as withdrawing an operator');
// a free slot: the 狼群 comes back
+299
View File
@@ -0,0 +1,299 @@
// Player reports after the 0.1.0 release, placement legality on the server (PlayerState / board.js), the client's
// mirror (ui/gameLogic.js canPlace / boardTargets = the drag highlights) and the bots:
// #3 "有水池的那张图干员可以错误的被部署到水里" — 战场#08(下半) 涨潮控制 (act2autochess_m04): the 深水区 (tile_deepsea,
// board (10–12, 6)) refuses deployment (PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)"; 特制水上平台
// "在水上建立可以部署任意单位的平台" on every 深水区 of the season-1 战场#05) although the level's buildableType
// says ALL.
// #9 "干员伺夜这样的战术家的战术点能被错误的被放到攻击范围之外" — the tacticians' summons (伺夜's 狼群, 缪尔赛思's 流形)
// read "只能部署在召唤者攻击范围内" (token description; PRTS 狼群 特性): the piece may only stand on a tile of its
// owner's attack range (the rotated grid of its board tile and facing). Re-orienting the owner in place keeps the
// piece while it is still inside, else sends it back to its stack [ASSUMED] (refused, HAND_FULL, when it would have
// nowhere to go); moving the owner sends it back anyway (PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置
// 至手牌区") — with no room it leaves the board until the next round start.
// The sim side (突袭 landing tile, tactical point) is test/sim/feedback1-water.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDeployMap, canPlace as boardCanPlace, legalTiles, positionClass, tileKey, ownerRangeKeys } from '../../server/match/board.js';
import { ERR } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
import { botPrep, planLayout } from '../../server/match/bot.js';
import { placementContext, canPlace as clientCanPlace, boardTargets, deployMap as clientDeployMap } from '../../public/js/ui/gameLogic.js';
const STAGE = 'act2autochess_m04';
const WATER = ['10,6', '11,6', '12,6'];
const VIGIL = 'chess_char_3_19_a';
const MLYSS = 'chess_char_6_11_a';
const WOLF = 'token_10028_vigil_wolf';
const MANIFOLD = 'token_10030_mlyss_wtrman';
const MELEE = (c) => c.position === 'MELEE';
const RANGED = (c) => c.position === 'RANGED';
function prep(stageId = STAGE, seed = 11) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
h.toPrep(1);
h.setStage(stageId);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
return { h, m: h.m, ps };
}
const move = (m, uid, to, dir) => m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
const board = (r, c) => ({ area: 'board', row: r, col: c });
const stackOf = (ps, id) => [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'token' && p.id === id) ?? null;
const clientCtx = (m, ps, field = 'normal') => placementContext({
priv: ps.privateView(), stage: m.stage, editable: true, field,
getChess: (id) => DATA.chess[id] ?? null, getToken: (id) => DATA.tokens[id] ?? null, getItem: (id) => DATA.items[id] ?? null,
});
// ---------------------------------------------------------------------------------------------------------------------
// #3 the pool
test('#3 board.js: no deploy class on 战场#08\'s 深水区 — normal field, both boss halves; the client\'s map agrees', () => {
const st = DATA.stages[STAGE];
const n = buildDeployMap(st);
for (const k of WATER) assert.equal(n.get(k), undefined, `normal ${k}`);
assert.equal(n.get('11,5'), 'melee', 'the road beside it stays deployable');
for (const field of ['bossL', 'bossR']) {
const b = buildDeployMap(st, { field });
// boss (3–5, 6) = board (10–12, 6); the right half is mirrored (col 14 → board col 6)
for (const k of WATER) assert.equal(b.get(k), undefined, `${field} ${k}`);
assert.deepEqual([...clientDeployMap(st, { field })].sort(), [...b].sort(), `${field}: client = server`);
}
assert.deepEqual([...clientDeployMap(st)].sort(), [...n].sort(), 'normal: client = server');
});
test('#3 g.move: neither a melee nor a ranged operator nor a summon goes into the water (BAD_TILE); the client refuses and never lights it', () => {
const { m, ps } = prep();
const melee = give(m, ps, chessOfTier(1, MELEE).find((x) => m.pool.has(x)));
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
for (const k of WATER) {
const [r, c] = k.split(',').map(Number);
assert.equal(move(m, melee.uid, board(r, c)).error, ERR.BAD_TILE, `melee ${k}`);
assert.equal(move(m, ranged.uid, board(r, c)).error, ERR.BAD_TILE, `ranged ${k}`);
}
// 伺夜 (ranged) beside the pool facing it: her 狼群 (MELEE) may not go into the water either
const vigil = give(m, ps, VIGIL);
assert.deepEqual(move(m, vigil.uid, board(11, 4)), { ok: true });
const wolf = stackOf(ps, WOLF);
assert.ok(wolf);
assert.equal(move(m, wolf.uid, board(11, 6)).error, ERR.BAD_TILE, '狼群 into the water');
const ctx = clientCtx(m, ps);
for (const piece of [melee, ranged, wolf]) {
for (const k of WATER) {
const [r, c] = k.split(',').map(Number);
assert.equal(clientCanPlace(ctx, piece.uid, board(r, c)).ok, false, `client ${piece.id} ${k}`);
}
const lit = boardTargets(ctx, piece.uid).legal.map(([r, c]) => tileKey(r, c));
for (const k of WATER) assert.ok(!lit.includes(k), `${piece.id}: ${k} not lit`);
}
checkInvariants(m);
m.dispose();
});
test('#3 act1 m05 (season 1, inactive): a 特制水上平台 makes its 深水区 deployable for any unit; the raw level files agree', () => {
const st = DATA.stages.act1autochess_m05;
const map = buildDeployMap(st);
const platforms = st.devices.filter((d) => d.role === 'waterPlatform' && d.active).map((d) => tileKey(d.pos[0], d.pos[1]));
for (const k of platforms) {
const [r, c] = k.split(',').map(Number);
if (r < 9 || r > 12 || c < 2 || c > 10) continue;
assert.equal(map.get(k), 'melee', `platform ${k}`);
}
assert.deepEqual([...clientDeployMap(st)].sort(), [...map].sort());
});
test('#3 bots never put a piece into the water (layout planner and full bot preps on 战场#08)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 3, seed: 5 }).start();
h.setStage(STAGE);
let pieces = 0;
for (let round = 1; round <= 5; round++) {
h.toPrep(round);
for (const ps of h.m.players.values()) {
if (!ps.isBot || !ps.alive) continue;
if (!ps.ready) botPrep(h.m, ps);
for (const k of ps.board.keys()) { pieces++; assert.ok(!WATER.includes(k), `R${round} ${ps.playerId}: a piece on ${k}`); }
const plan = planLayout(h.m, ps, ps.allChess());
for (const k of plan.values()) assert.ok(!WATER.includes(k), `plan on ${k}`);
}
}
assert.ok(pieces > 10, 'the bots fielded pieces');
h.invariants();
h.m.dispose();
});
// ---------------------------------------------------------------------------------------------------------------------
// #9 tactical points
test('#9 data: 狼群 and 流形 are owner-range summons (token text "只能部署在召唤者攻击范围内"), no other hand summon is', () => {
assert.equal(DATA.tokens[WOLF].ownerRange, true);
assert.equal(DATA.tokens[MANIFOLD].ownerRange, true);
for (const t of Object.values(DATA.tokens)) if (t.placeable && t.tokenId !== WOLF && t.tokenId !== MANIFOLD) assert.ok(!t.ownerRange, t.name);
});
test('#9 g.move: 伺夜\'s 狼群 only on a tile of her attack range (rotated grid of her tile + facing); outside → BAD_TILE', () => {
const { m, ps } = prep();
const vigil = give(m, ps, VIGIL);
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'RIGHT'), { ok: true });
const wolf = stackOf(ps, WOLF);
const range = ownerRangeKeys(DATA.chess[VIGIL].rangeGrid, 10, 4, 'RIGHT');
assert.ok(range.has('11,5') && range.has('9,7') && !range.has('10,3') && !range.has('12,4'));
// behind her / two rows down: outside the 3-row, 4-column range
for (const [r, c] of [[10, 3], [12, 4], [12, 5], [9, 8]]) assert.equal(move(m, wolf.uid, board(r, c)).error, ERR.BAD_TILE, `outside ${r},${c}`);
assert.ok(stackOf(ps, WOLF), 'still in the hand');
assert.deepEqual(move(m, wolf.uid, board(11, 5)), { ok: true }, 'inside the range');
const placed = ps.board.get('11,5');
assert.equal(placed.id, WOLF);
// board → board: a move out of the range is refused too, one inside it is fine
assert.equal(move(m, placed.uid, board(12, 4)).error, ERR.BAD_TILE);
assert.deepEqual(move(m, placed.uid, board(9, 6)), { ok: true });
assert.equal(ps.board.get('9,6'), placed);
checkInvariants(m);
m.dispose();
});
test('#9 re-orienting the tactician: a summon still inside the new range stays, one left outside goes back to its stack', () => {
const { m, ps } = prep();
const vigil = give(m, ps, VIGIL);
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'RIGHT'), { ok: true });
assert.deepEqual(move(m, stackOf(ps, WOLF).uid, board(11, 5)), { ok: true });
const wolf = ps.board.get('11,5');
// facing UP the range is rows 10–13 × cols 3–5: (11,5) is still inside
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'UP'), { ok: true });
assert.equal(ps.board.get('10,4').dir, 'UP');
assert.equal(ps.board.get('11,5'), wolf, 'still in range: stays');
// facing LEFT the range is cols 1–4: (11,5) falls outside → back onto its stack in the hand
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'LEFT'), { ok: true });
assert.equal(ps.board.get('11,5'), undefined, 'outside the new range: lifted');
const stack = stackOf(ps, WOLF);
assert.ok(stack && stack.count === 1, 'back in the hand');
assert.equal(ps.battleInput().units.filter((u) => u.kind === 'token').length, 0);
checkInvariants(m);
m.dispose();
});
test('#9 hand and temp full: a re-orientation that would push 狼群 out is refused (HAND_FULL); a move drops it until the next round', () => {
const { h, m, ps } = prep();
const vigil = give(m, ps, VIGIL);
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'RIGHT'), { ok: true });
assert.deepEqual(move(m, stackOf(ps, WOLF).uid, board(11, 5)), { ok: true });
const wolf = ps.board.get('11,5');
assert.equal(stackOf(ps, WOLF), null, 'its only copy is placed: no stack left to return onto');
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (p, kind, text) => { toasts.push(text); return toast(p, kind, text); };
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
while (ps.hand.includes(null)) giveItem(m, ps, golden);
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
// facing LEFT would leave (11,5) outside her range and the wolf has nowhere to go: refused, nothing changes
assert.equal(move(m, vigil.uid, board(10, 4), 'LEFT').error, ERR.HAND_FULL);
assert.equal(ps.board.get('10,4').dir, 'RIGHT');
assert.equal(ps.board.get('11,5'), wolf);
// facing UP keeps it inside: allowed
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'UP'), { ok: true });
assert.equal(ps.board.get('11,5'), wolf);
checkInvariants(m);
// moving her (her summons go back to the hand; with no room a normal summon stays put) to (9,9) facing RIGHT, whose
// range (rows 8–10, cols 9–12) leaves the wolf out: it leaves the board — no illegal placement reaches the battle
assert.deepEqual(move(m, vigil.uid, board(9, 9), 'RIGHT'), { ok: true });
assert.equal(ps.board.get('11,5'), undefined, 'outside her range with nowhere to go: removed');
assert.ok(toasts.some((t) => t.includes('狼群') && t.includes('下回合返还')), toasts.join(' / '));
assert.equal(ps.battleInput().units.filter((u) => u.kind === 'token').length, 0);
checkInvariants(m);
// …and its stack comes back at the next round start (grantTokensFor, "干员所属召唤物会于下一回合返还")
h.toPrep(2);
assert.ok(stackOf(ps, WOLF), 'the wolf is back in the hand / temp');
checkInvariants(m);
m.dispose();
});
test('#9 swaps: a summon dragged onto its tactician, or an operator onto the summon, must leave the summon inside the range', () => {
const { m, ps } = prep();
const vigil = give(m, ps, VIGIL);
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'RIGHT'), { ok: true });
assert.deepEqual(move(m, stackOf(ps, WOLF).uid, board(10, 5)), { ok: true });
const wolf = ps.board.get('10,5');
// wolf onto 伺夜: she would stand on (10,5) facing RIGHT and the wolf behind her on (10,4) — outside → refused
assert.equal(move(m, wolf.uid, board(10, 4)).error, ERR.BAD_TILE);
assert.equal(ps.board.get('10,5'), wolf);
assert.equal(ps.board.get('10,4'), vigil);
// the wolf on (11,4), right above her: after the swap it is right below her (offset −1, 0) — inside → allowed
assert.deepEqual(move(m, wolf.uid, board(11, 4)), { ok: true });
assert.deepEqual(move(m, wolf.uid, board(10, 4)), { ok: true }, 'swap with the owner, still in range');
assert.equal(ps.board.get('10,4'), wolf);
assert.equal(ps.board.get('11,4'), vigil);
// another operator dragged onto the wolf would send the wolf to that operator's tile: outside → refused
const other = give(m, ps, chessOfTier(1, MELEE).find((x) => m.pool.has(x)));
assert.deepEqual(move(m, other.uid, board(12, 3)), { ok: true });
assert.equal(move(m, other.uid, board(10, 4)).error, ERR.BAD_TILE, '(12,3) is outside 伺夜\'s range (she stands on (11,4) now)');
assert.deepEqual(move(m, other.uid, board(12, 5)), { ok: true });
assert.deepEqual(move(m, other.uid, board(10, 4)), { ok: true }, '(12,5) is inside: the wolf moves there');
assert.equal(ps.board.get('12,5'), wolf);
checkInvariants(m);
m.dispose();
});
test('#9 the client mirrors it: the drag highlights of 狼群 / 流形 are exactly the free legal tiles of the owner\'s range', () => {
const { m, ps } = prep();
const vigil = give(m, ps, VIGIL);
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'RIGHT'), { ok: true });
const mly = give(m, ps, MLYSS);
assert.deepEqual(move(m, mly.uid, board(12, 8), 'LEFT'), { ok: true });
const map = buildDeployMap(m.stage);
for (const [owner, tid, dir, at] of [[vigil, WOLF, 'RIGHT', [10, 4]], [mly, MANIFOLD, 'LEFT', [12, 8]]]) {
const ctx = clientCtx(m, ps);
const tok = stackOf(ps, tid);
assert.ok(tok, tid);
const range = ownerRangeKeys(DATA.chess[owner.id].rangeGrid, at[0], at[1], dir);
const pos = positionClass(DATA.tokens[tid]);
const want = legalTiles(map, pos).map(([r, c]) => tileKey(r, c)).filter((k) => range.has(k) && !ps.board.has(k)).sort();
const lit = boardTargets(ctx, tok.uid).legal.map(([r, c]) => tileKey(r, c)).sort();
assert.deepEqual(lit, want, `${tid} highlights`);
assert.ok(want.length > 0);
// every lit tile is accepted by the server, every other deployable tile refused
for (const k of map.keys()) {
const [r, c] = k.split(',').map(Number);
if (ps.board.has(k)) continue;
assert.equal(clientCanPlace(ctx, tok.uid, board(r, c)).ok, lit.includes(k), `client ${tid} ${k}`);
assert.equal(boardCanPlace(map, pos, r, c) && range.has(k), lit.includes(k));
}
}
// the client's swap rule: 狼群 onto 伺夜 from (10,5) would end behind her → refused
assert.deepEqual(move(m, stackOf(ps, WOLF).uid, board(10, 5)), { ok: true });
const ctx = clientCtx(m, ps);
assert.equal(clientCanPlace(ctx, ps.board.get('10,5').uid, board(10, 4)).ok, false);
checkInvariants(m);
m.dispose();
});
test('#9 bots place 狼群 / 流形 only inside their owner\'s range', () => {
let placed = 0;
for (const seed of [3, 8, 21]) {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed }).start();
h.toPrep(2);
h.setStage(STAGE);
const bp = h.ps('ai_0');
for (const p of [...bp.board.values(), ...bp.hand.filter(Boolean)]) if (p.kind === 'chess') bp.returnCopies(p);
bp.board.clear();
bp.hand.fill(null);
bp.recompute();
give(h.m, bp, 'chess_char_1_10_a'); // 古米: a blocker
give(h.m, bp, VIGIL);
give(h.m, bp, MLYSS);
bp.ready = false;
botPrep(h.m, bp);
for (const [k, p] of bp.board) {
if (p.kind !== 'token') continue;
const owner = [...bp.board.entries()].find(([, q]) => q.uid === p.ownerUid);
assert.ok(owner, 'its owner is on the board');
const [or, oc] = owner[0].split(',').map(Number);
const range = ownerRangeKeys(DATA.chess[owner[1].id].rangeGrid, or, oc, owner[1].dir || 'RIGHT');
assert.ok(range.has(k), `seed ${seed}: ${p.id} on ${k} inside ${owner[1].id}'s range`);
placed++;
}
checkInvariants(h.m);
h.m.dispose();
}
assert.ok(placed > 0, 'the bots placed some summons');
});
+5 -1
View File
@@ -219,7 +219,9 @@ describe('#7 audit: every stage × deploy field (server = official level data =
});
test('independent check against the raw official level files (buildableType / heightType + non-hidden devices)', { skip: !HAS_LEVELS && 'no .cache/gamedata level files (run node tools/build-data.mjs once)' }, () => {
const ROLE = { trap_1105_accrate: 'block', trap_032_mound: 'block', trap_1106_achplat: 'platform' };
// trap_040_canoe 特制水上平台 "在水上建立可以部署任意单位的平台"; tile_deepsea refuses deployment whatever its
// buildableType (PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)" — player report #3 after 0.1.0, 战场#08's pool)
const ROLE = { trap_1105_accrate: 'block', trap_032_mound: 'block', trap_1106_achplat: 'platform', trap_040_canoe: 'water' };
for (const [id, st] of Object.entries(DATA.stages)) {
const lv = JSON.parse(readFileSync(levelFile(id), 'utf8'));
const map = lv.mapData.map, tiles = lv.mapData.tiles, H = map.length;
@@ -231,6 +233,8 @@ describe('#7 audit: every stage × deploy field (server = official level data =
if (!t || d === 'block') return null;
// 射击台: ranged only [ASSUMED, DATA §15.11] (the remake's rule for a raised platform)
if (d === 'platform') return 'ranged';
if (d === 'water') return 'melee';
if (t.tileKey === 'tile_deepsea') return null;
if (t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE')) return 'melee';
if (t.buildableType === 'RANGED' || (t.heightType === 'HIGHLAND' && t.buildableType === 'ALL')) return 'ranged';
return null;
+11 -11
View File
@@ -115,7 +115,7 @@ test('moving an owner on the board sends its placed summons back to its card (PR
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }, 'UP'), { ok: true });
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 2);
// a real move: both devices leave the board and go back onto one card of 2
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 0, 'no device left on the board');
const back = tokensOf(ps, DEVICE);
assert.equal(back.length, 1);
@@ -216,26 +216,26 @@ test('a summon dragged onto an operator swaps it away: that operator\'s other pl
const cat = give(m, ps, CATHY);
const hm = give(m, ps, SILENCE);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
const devCard = stackOf(ps, DEVICE);
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
assert.deepEqual(move(m, stackOf(ps, DRONE).uid, { area: 'board', row: 10, col: 7 }), { ok: true });
// 凯瑟琳's device (10,3) dropped onto 赫默 (10,6): 赫默 takes 10,3, so her drone goes back to her card; 凯瑟琳 did not move
assert.deepEqual(move(m, stackOf(ps, DRONE).uid, { area: 'board', row: 10, col: 8 }), { ok: true });
// 凯瑟琳's device (10,3) dropped onto 赫默 (10,7): 赫默 takes 10,3, so her drone goes back to her card; 凯瑟琳 did not move
const devA = ps.board.get('10,3');
assert.deepEqual(move(m, devA.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
assert.deepEqual(move(m, devA.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
assert.equal(ps.board.get('10,3')?.uid, hm.uid);
assert.equal(ps.board.get('10,6')?.uid, devA.uid, 'the dragged device is where it was dropped');
assert.equal(ps.board.get('10,7'), undefined, '赫默\'s placed drone left the board');
assert.equal(ps.board.get('10,7')?.uid, devA.uid, 'the dragged device is where it was dropped');
assert.equal(ps.board.get('10,8'), undefined, '赫默\'s placed drone left the board');
assert.equal(stackOf(ps, DRONE)?.count, 1, '… back onto her card');
assert.equal(ps.board.get('11,4')?.id, DEVICE, '凯瑟琳\'s other device stays');
// the other device (11,4) dropped onto its own owner (10,4): 凯瑟琳 takes 11,4; the dropped device stays at 10,4, her
// remaining placed device (10,6) goes back onto her card
// remaining placed device (10,7) goes back onto her card
const devB = ps.board.get('11,4');
assert.deepEqual(move(m, devB.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
assert.equal(ps.board.get('11,4')?.uid, cat.uid);
assert.equal(ps.board.get('10,4')?.uid, devB.uid);
assert.equal(ps.board.get('10,6'), undefined);
assert.equal(ps.board.get('10,7'), undefined);
assert.equal(stackOf(ps, DEVICE)?.count, 1);
assert.equal(tokensOf(ps, DEVICE).reduce((n, p) => n + (p.count || 1), 0), 2, 'still 2 devices in all');
checkInvariants(m);
@@ -263,7 +263,7 @@ test('a summon stack left in temp at the prep deadline comes back next round (PR
assert.deepEqual(move(m, card.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, PLAIN[i]);
// moving 凯瑟琳 with a full hand: her devices go back as one card of 2, which overflows into temp and blocks Ready
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 7 }), { ok: true });
const inTemp = ps.temp.find((p) => p && p.kind === 'token' && p.id === DEVICE);
assert.equal(inTemp?.count, 2);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
@@ -277,7 +277,7 @@ test('a summon stack left in temp at the prep deadline comes back next round (PR
assert.equal(back[0].count, 2, 'the deploy limit again');
assert.equal(back[0].ownerUid, cat.uid);
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 10, col: 5 }, 'RIGHT'), { ok: true });
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 11, col: 6 }, 'UP'), { ok: true });
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 11, col: 7 }, 'UP'), { ok: true });
assert.ok(ps.tempEmpty, 'placing both cleared temp');
// a later round start grants nothing more: both devices are on the board
h.drive(() => m.phase === PHASE.PREP && m.round === 3);
+87
View File
@@ -0,0 +1,87 @@
// Player report #3 after the 0.1.0 release: "有水池的那张图干员可以错误的被部署到水里,突袭分队干员也会自动落到水里".
// The pool is the 深水区 (tile_deepsea) of 战场#08(下半) 涨潮控制 (act2autochess_m04: board (10–12, 6), the partner's
// (10–12, 14), boss (3–5, 6) / (3–5, 14)). The level file says buildableType ALL, but the tile refuses deployment
// officially: PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)", and the season-1 战场#05 puts a 特制水上平台
// ("在水上建立可以部署任意单位的平台") on every one of its 深水区 tiles. The sim side: `grid.canStand` refuses it, so
// no automatic placement (the 突袭 landing tile, a tactician's tactical point, summon tiles) ever picks a water tile.
// The prep side (placement on the board) is test/match/feedback1-placement.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { inRange } from '../../server/sim/content/support/index.js';
const STAGE = 'act2autochess_m04';
const WATER = [[10, 6], [11, 6], [12, 6]];
const isWater = (b, r, c) => b.grid.tile(r, c).key === 'tile_deepsea';
const VIGIL = 'chess_char_3_19_a'; // 伺夜 (战术家): 狼群 on a tactical point
const WOLF = 'token_10028_vigil_wolf';
test('the stage data: 战场#08\'s 深水区 refuses deployment (effective buildable NONE; the level\'s buildableType kept)', () => {
const st = getDefaultSource().getStage(STAGE);
const raw = st.raw ?? st;
const d = raw.tiles.d;
assert.equal(d.tileKey, 'tile_deepsea');
assert.equal(d.buildable, 'NONE', 'PRTS 深水区 地形信息: 拒绝部署');
assert.equal(d.buildableType, 'ALL', 'the level file\'s own value is kept');
const deploy = new Set([...raw.deployTiles.normal.melee, ...raw.deployTiles.normal.rangedOnly].map(([r, c]) => `${r},${c}`));
for (const [r, c] of WATER) assert.ok(!deploy.has(`${r},${c}`), `deployTiles.normal excludes ${r},${c}`);
for (const side of ['bossLeft', 'bossRight']) {
const t = new Set([...raw.deployTiles[side].melee, ...raw.deployTiles[side].rangedOnly].map(([r, c]) => `${r},${c}`));
for (const [r, c] of [[3, 6], [4, 6], [5, 6], [3, 14], [4, 14], [5, 14]]) assert.ok(!t.has(`${r},${c}`), `${side} excludes ${r},${c}`);
}
});
test('grid.canStand refuses the water for melee and ranged units; the road next to it stays standable', () => {
const h = makeBattle({ stageId: STAGE, units: [] });
for (const [r, c] of WATER) {
assert.ok(isWater(h.b, r, c));
assert.equal(h.b.grid.canStand(r, c), false, `melee ${r},${c}`);
assert.equal(h.b.grid.canStand(r, c, { ranged: true }), false, `ranged ${r},${c}`);
assert.equal(h.b.grid.groundPassable(r, c), true, 'enemies still wade through it');
}
assert.equal(h.b.grid.canStand(11, 5), true);
assert.equal(h.b.grid.canStand(12, 7, { ranged: true }), true);
// the other tile the deploy map denies to melee units: a hard-blocked tile (a 射击台 a map card switched on, ranged
// only in the prep) — no automatic placement puts a melee unit there either
h.b.setObstacle(12, 7, true);
assert.equal(h.b.grid.canStand(12, 7), false, 'melee: not on a 射击台');
assert.equal(h.b.grid.canStand(12, 7, { ranged: true }), true, 'ranged: may stand on it');
});
test('#3 突袭: an idle member jumping to an enemy that wades in the pool lands on dry ground next to it, never in the water', () => {
const defs = {
chess: { r_m: chessRec({ id: 'r_m', bonds: ['raidShip'], profession: 'WARRIOR', skill: null }) },
enemies: { enemy_wader: enemyRec({ key: 'enemy_wader', hp: 1e7, speed: 0 }) },
};
for (const [er, ec] of WATER) {
const h = makeBattle({
stageId: STAGE, defs, bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 0 } },
enemies: [{ key: 'enemy_wader', pos: [er, ec] }], units: [{ chessId: 'r_m', row: 9, col: 3 }],
});
const u = h.unit('r_m');
assert.ok(h.runUntil(() => u.alive && (u.tileR !== 9 || u.tileC !== 3), 14), `jumped (enemy at ${er},${ec})`);
assert.ok(!isWater(h.b, u.tileR, u.tileC), `landed on ${u.tileR},${u.tileC}, not in the water (enemy ${er},${ec})`);
assert.ok(h.b.grid.canStand(u.tileR, u.tileC), 'a tile a melee operator may be deployed on');
assert.ok(inRange(u, h.enemy('enemy_wader')), 'the enemy is in range after the jump');
checkInvariants(h.b);
}
});
test('#3 the tactical point of a tactician never lies in the water (伺夜 facing the pool, no 狼群 piece placed)', () => {
// 伺夜 on the fenced tile (11,7) facing LEFT: her range covers the pool (10–12, 6) — the nearest tiles on the enemy
// path (11,6) — and the road (12,7) / (12,5) / (11,5) behind it
const h = makeBattle({ stageId: STAGE, units: [{ chessId: VIGIL, row: 11, col: 7, dir: 'LEFT' }] });
h.step(1);
const vigil = h.unit(VIGIL);
assert.ok(vigil && vigil.alive);
const tp = h.b.findTacticalPoint(vigil);
assert.ok(tp, 'a tactical point exists');
assert.ok(!isWater(h.b, tp[0], tp[1]), `tactical point ${tp} is not water`);
h.run(1);
const wolf = h.allies().find((u) => u.defId === WOLF && u.alive);
assert.ok(wolf, 'the pack came as her 援军');
assert.ok(!isWater(h.b, wolf.tileR, wolf.tileC), `the pack stands on ${wolf.tileR},${wolf.tileC}, not in the water`);
assert.ok((vigil.baseRangeKeys || []).includes(wolf.tileR * 21 + wolf.tileC), 'inside her attack range');
checkInvariants(h.b);
});
+9 -2
View File
@@ -188,7 +188,12 @@ const UNAVOIDABLE = {
act2autochess_m01: { low: '', up: '12,9 11,9 10,9', uLow: '9,10', uUp: '12,17 11,17 10,17 9,10', b5: '5,9 4,9 3,9|5,11 4,11 3,11', b2: '|' },
act2autochess_m02: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
act2autochess_m03: { low: '10,9', up: '12,9', uLow: '10,17 9,10 10,9', uUp: '12,17 9,10 10,9', b5: '5,9|5,11', b2: '3,9|3,11' },
act2autochess_m04: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
// the 深水区 (cols 6 / 14 between the fences) refuses deployment (player report #3 after 0.1.0): non-blockable, and
// every route crosses it — the routes themselves did not change
act2autochess_m04: {
low: '10,6 11,6', up: '12,9 12,6', uLow: '10,14 11,14 9,10 10,6 11,6', uUp: '12,17 12,14 9,10 10,6 11,6',
b5: '5,9 5,6|5,11 5,14', b2: '3,6 4,6|3,14 4,14',
},
};
test('audit: every active stage × gate × field — the non-blockable tiles crossed are exactly the listed unavoidable ones', REAL, () => {
@@ -269,7 +274,9 @@ test('audit: every WALK leg of every wave template (normal / 联防 / boss / hid
}
}
assert.ok(n > 200, `${n} legs audited (${extra} unavoidable non-blockable crossings)`);
assert.equal(accepted, 17, 'accepted boss-arena exceptions (update the count only after reviewing a new one)');
// (17 before 战场#08's 深水区 became non-blockable: on its boss field the +2 detours around the arena floor cross the
// water as well, so three of its legs are no longer exceptions — player report #3 after 0.1.0)
assert.equal(accepted, 14, 'accepted boss-arena exceptions (update the count only after reviewing a new one)');
});
// ---------------------------------------------------------------------------------------------------------------
+4 -1
View File
@@ -46,9 +46,12 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
for (const [r, c] of extraBlocks) blocked.add(`${r},${c}`);
const inside = (r, c) => (rect ? r >= rect.r0 && r <= rect.r1 && c >= rect.c0 && c <= rect.c1 : r >= 0 && r < H && c >= 0 && c < W);
const passable = (r, c) => inside(r, c) && ['ALL', 'WALK_ONLY'].includes(tile(r, c).passableMask) && !blocked.has(`${r},${c}`);
// non-blockable walkable terrain: not LOWLAND buildable for melee (floor, gates, goal, teleports)
// non-blockable walkable terrain: not LOWLAND buildable for melee (floor, gates, goal, teleports) — and the 深水区,
// which refuses deployment whatever its buildableType (PRTS 深水区 地形信息 "拒绝部署(待补充)"; player report #3
// after 0.1.0)
const nb = (r, c) => {
const t = tile(r, c);
if (t.tileKey === 'tile_deepsea') return true;
return !(t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE'));
};
const bres = ([r0, c0], [r1, c1], clear) => {
+136
View File
@@ -0,0 +1,136 @@
// Real-server browser check of two player reports after the 0.1.0 release (placement legality, headless Chrome):
// #3 "有水池的那张图干员可以错误的被部署到水里" — on 战场#08(下半) 涨潮控制 (act2autochess_m04, test/e2e/fastServer.mjs hook
// SP_STAGE) the drag highlights of an operator never light the 深水区 (board (10–12, 6)) and a drop there is refused.
// #9 "干员伺夜这样的战术家的战术点能被错误的被放到攻击范围之外" — while 伺夜's 狼群 card is dragged only the free legal
// tiles of her attack range light up; a drop outside is refused, one inside opens the wheel; re-orienting her so the
// pack falls outside her range sends it back to the hand.
// Real mouse drags (press, move, read the 'legal' highlight group of the view, release). Zero console errors.
//
// SP_E2E=1 node --test test/ui/feedback1-placement.e2e.test.js
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
import { ownerRangeKeys } from '../../server/match/board.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const STAGE = 'act2autochess_m04';
const VIGIL = 'chess_char_3_19_a', GUMMY = 'chess_char_1_10_a', WOLF = 'token_10028_vigil_wolf';
const WATER = ['10,6', '11,6', '12,6'];
const json = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
async function soloToPrep(c) {
await c.open();
await c.enter('煌');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 1 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
await sleep(1800);
}
const handOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.hand || []).find((p) => p && p.id === id) || null, id);
const boardOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.board || []).filter((p) => p.id === id), id);
const legalLit = (c) => c.page.evaluate(() => {
const hl = globalThis.__SP_VIEW__?.raw?.debug?.tiles?.highlights?.get('legal');
return hl ? hl.tiles.map((t) => (Array.isArray(t) ? `${t[0]},${t[1]}` : `${t.row},${t.col}`)) : [];
});
const wheelOpen = (c) => c.page.evaluate(() => !!document.querySelector('.fwheel__dia'));
/** Press on hand piece `uid`, move over `hover` and read the legal highlights mid-drag, then release on `drop`. */
async function dragRead(c, uid, hover, drop, shot = null) {
const from = await c.piecePoint(uid);
const over = await c.tilePoint(hover[0], hover[1]);
const to = await c.tilePoint(drop[0], drop[1]);
assert.ok(from && over && to, `piece ${uid} and tiles on screen`);
const m = c.page.mouse;
await m.move(from.x, from.y);
await m.down();
for (let i = 1; i <= 12; i++) { await m.move(from.x + ((over.x - from.x) * i) / 12, from.y + ((over.y - from.y) * i) / 12); await sleep(14); }
await sleep(250);
const lit = (await legalLit(c)).sort();
if (shot) await c.shot(shot);
for (let i = 1; i <= 8; i++) { await m.move(over.x + ((to.x - over.x) * i) / 8, over.y + ((to.y - over.y) * i) / 8); await sleep(14); }
await sleep(200);
await m.up();
await sleep(500);
return lit;
}
describe('player reports #3 / #9 after 0.1.0: placement highlights (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
test('the pool is never lit nor accepted; 狼群 lights and accepts only 伺夜\'s range', { timeout: 240000 }, async () => {
const puppeteer = (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2, chess: [VIGIL, GUMMY], stage: STAGE } });
const c = new Client(puppeteer, srv.base, 'feedback1', { prefix: 'fb1' });
try {
await soloToPrep(c);
assert.equal(await c.page.evaluate(() => globalThis.__SP__.store.get().match.public.stageId), STAGE, 'SP_STAGE hook');
const melee = new Set(json('stages.json')[STAGE].deployTiles.normal.melee.map(([r, col]) => `${r},${col}`));
for (const k of WATER) assert.ok(!melee.has(k), `data: ${k} not deployable`);
// 伺夜 onto (10,4) facing RIGHT through the wheel
const vigil = await handOf(c, VIGIL);
assert.ok(vigil, '伺夜 in the hand');
const lit0 = await dragRead(c, vigil.uid, [9, 3], [10, 4]);
for (const k of WATER) assert.ok(!lit0.includes(k), `伺夜: ${k} not lit (${lit0.join(' ')})`);
assert.ok(lit0.includes('11,5') && lit0.includes('10,7'), 'the dry tiles around the pool are lit');
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await sleep(600);
assert.deepEqual((await boardOf(c, VIGIL)).map((p) => [p.row, p.col, p.dir]), [[10, 4, 'RIGHT']]);
// #3: 古米 (melee) — no water lit, a drop into the pool is refused (no wheel, still in the hand)
const gummy = await handOf(c, GUMMY);
const lit1 = await dragRead(c, gummy.uid, [9, 3], [11, 6], 'pool-drag');
for (const k of WATER) assert.ok(!lit1.includes(k), `古米: ${k} not lit`);
assert.deepEqual(lit1, [...melee].sort(), 'every melee tile (伺夜\'s too: a swap), no water');
assert.equal(await wheelOpen(c), false, 'no wheel for a drop into the water');
assert.ok(await handOf(c, GUMMY), '古米 is still in the hand');
await c.shot('pool-refused');
// #9: the pack card lights exactly the free melee tiles of her range (rows 9–11 × cols 4–7 minus her tile and the pool)
const wolf = await handOf(c, WOLF);
assert.ok(wolf, 'the 狼群 card is in the hand');
const range = ownerRangeKeys(json('chess.json')[VIGIL].rangeGrid, 10, 4, 'RIGHT');
const want = [...melee].filter((k) => range.has(k) && k !== '10,4').sort();
const lit2 = await dragRead(c, wolf.uid, [11, 5], [12, 3], 'wolf-drag');
assert.deepEqual(lit2, want, `狼群 highlights = her range (${lit2.join(' ')})`);
for (const k of ['12,3', '12,4', '10,3', '9,8']) assert.ok(!lit2.includes(k), `${k} outside her range is not lit`);
assert.equal(await wheelOpen(c), false, 'a drop outside her range opens no wheel');
assert.ok(await handOf(c, WOLF), 'the pack stays in the hand');
// inside: the wheel opens and the pack is placed
await dragRead(c, wolf.uid, [11, 5], [11, 5]);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await sleep(600);
assert.deepEqual((await boardOf(c, WOLF)).map((p) => [p.row, p.col]), [[11, 5]], 'placed inside her range');
await c.shot('wolf-in-range');
// re-orient 伺夜 to LEFT in place: (11,5) leaves her range → the pack goes back to the hand
const v = (await boardOf(c, VIGIL))[0];
const from = await c.piecePoint(v.uid);
const to = await c.tilePoint(10, 4);
await c.drag(from, to);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('LEFT');
await sleep(800);
assert.deepEqual((await boardOf(c, VIGIL)).map((p) => p.dir), ['LEFT']);
assert.equal((await boardOf(c, WOLF)).length, 0, 'the pack left the board');
assert.ok(await handOf(c, WOLF), '… back to the hand');
await c.shot('wolf-lifted');
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});
+5 -2
View File
@@ -217,6 +217,9 @@ describe('placement mirror (canPlace)', () => {
const board = [];
const tiles = [[9, 3], [9, 4], [9, 5], [9, 7], [9, 8], [9, 9], [10, 5], [10, 7]];
for (const [row, col] of tiles) board.push({ ...piece(MELEE), row, col });
// the 狼群's owner: 伺夜 facing UP on (9,4) — her range (rows 9–12 × cols 3–5) covers the tiles tried below
// ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
board[1] = { ...piece('chess_char_3_19_a'), row: 9, col: 4, dir: 'UP' };
const extra = piece(MELEE);
const ctx = ctxFor(privWith({ board, hand: [extra] }));
const full = canPlace(ctx, extra.uid, { area: 'board', row: 11, col: 5 });
@@ -225,7 +228,7 @@ describe('placement mirror (canPlace)', () => {
const onBoard = board[0];
assert.equal(canPlace(ctx, onBoard.uid, { area: 'board', row: 11, col: 5 }).ok, true, 'moving on the board never hits the cap');
// tokens don't use deploy slots
const tok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[0].uid };
const tok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[1].uid };
const ctx2 = ctxFor(privWith({ board, hand: [tok] }));
assert.equal(canPlace(ctx2, tok.uid, { area: 'board', row: 11, col: 5 }).ok, true);
assert.equal(canPlace(ctx2, tok.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'MELEE token not on high ground');
@@ -234,7 +237,7 @@ describe('placement mirror (canPlace)', () => {
const ctx3 = ctxFor(privWith({ board, hand: [orphan] }));
assert.equal(canPlace(ctx3, orphan.uid, { area: 'board', row: 11, col: 5 }).code, 'BAD_TARGET', 'summoner must be deployed');
// a hand chess swapping with a board token takes a deploy slot (cap applies)
const bTok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[0].uid, row: 11, col: 5 };
const bTok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[1].uid, row: 11, col: 5 };
const ctx4 = ctxFor(privWith({ board: [...board, bTok], hand: [extra] }));
assert.equal(canPlace(ctx4, extra.uid, { area: 'board', row: 11, col: 5 }).code, 'BOARD_FULL');
});
+28 -10
View File
@@ -34,7 +34,7 @@ import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Grid } from '../server/sim/grid.js';
import { Grid, DEPLOY_REFUSED_TILES } from '../server/sim/grid.js';
// ===== CLI & IO ==================================================================================
@@ -1095,11 +1095,15 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
// SKILL_SUMMON_START_DEPLOY).
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
// `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
// 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
// ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere)
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
out[tokenId] = {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType: displayType(tokenId), placeable: displayType(tokenId) !== 'HIDDEN' && produced,
displayType: displayType(tokenId), placeable: displayType(tokenId) !== 'HIDDEN' && produced, ownerRange,
owners: owners.map((o) => o.chessId),
// Defaults = first owner's variant; per-owner data in variants[chessId].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
@@ -1120,7 +1124,7 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
tokenId: 'enemy_9012_acloon', kind: 'bondSummon', bondId: 'yanShip', name: loon.name, appellation: null,
desc: '【炎】6名成员激活时召唤的友方单位;开战时攻击力/生命值增加【炎】干员攻击力/生命值总和的30%(见 bonds.json yanShip)',
descRaw: null, profession: 'TOKEN', subProfessionId: null, position: 'NONE', motion: loon.stats.motion,
displayType: null, placeable: false, owners: [],
displayType: null, placeable: false, ownerRange: false, owners: [],
stats: enemyAsTokenStats(loon), rangeGrid: null, dmgType: loon.stats.dmgType, attackKind: 'ranged',
projectile: 'bolt', canHitFly: true, skill: loon.skills?.[0] ? { skillId: loon.skills[0].prefabKey, bb: loon.skills[0].bb } : null,
skills: loon.skills, talents: loon.talents, deployLimit: 2, count: 1,
@@ -1152,7 +1156,7 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
out[charId] = {
tokenId: charId, kind: 'mapChar', name: char.name, appellation: char.appellation || null,
desc: v.trait.desc, descRaw: v.trait.descRaw, profession: char.profession, subProfessionId: char.subProfessionId,
position: char.position, displayType: null, placeable: false, owners: [],
position: char.position, displayType: null, placeable: false, ownerRange: false, owners: [],
stats: v.stats, rangeGrid: v.rangeGrid, dmgType: v.dmgType, attackKind: v.attackKind, projectile: v.projectile,
canHitFly: v.canHitFly, skill: v.skill, talents: v.talents, trait: v.trait, phase: v.phase, level: v.level,
deployLimit: 1, count: 1, abnormal: [], positions: positions.sort((a, b) => naturalCmp(a.alias, b.alias)), variants: {},
@@ -1986,7 +1990,7 @@ const TILE_LEGEND = {
O: { tileKey: 'tile_telout', desc: '传送出口/领袖区出生点' },
m: { tileKey: 'tile_mire', special: 'mire', desc: '沼泽:停留叠加减速减攻速' },
g: { tileKey: 'tile_smog', special: 'smog', desc: '排气格栅:其上干员不会成为敌方远程攻击目标' },
d: { tileKey: 'tile_deepsea', special: 'deepsea', desc: '深水:敌人持续受伤、减速、减攻速' },
d: { tileKey: 'tile_deepsea', special: 'deepsea', desc: '深水区:不可部署(拒绝部署),敌人持续受伤、减速、减攻速' },
i: { tileKey: 'tile_infection', special: 'infection', desc: '活性源石:单位受持续真实伤害,攻击力与攻速提升' },
};
@@ -2039,6 +2043,14 @@ const DEVICE_ROLES = {
/** Roles that block ground movement while active ([ASSUMED] for platform/mound). */
const BLOCKING_ROLES = new Set(['crate', 'platform', 'mound']);
/**
* The deploy type the game applies to a level tile: its buildableType, except tiles whose mechanism refuses deployment
* (server/sim/grid.js DEPLOY_REFUSED_TILES — 深水区 tile_deepsea: PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)";
* player report after 0.1.0: operators could be placed in 战场#08's pool), which are NONE. The stages.json legend's
* `buildable` is this value; `buildableType` keeps the level's own where they differ.
*/
const effectiveBuildable = (t) => (DEPLOY_REFUSED_TILES.has(t.tileKey) ? 'NONE' : t.buildableType);
/**
* Is a predefined device active at match start? Exactly the non-hidden ones: the 下半 act1 m02 has all its crates
* and platforms hidden and starts with none (research 08 §3.3: level file + the PRTS 下半 screenshot); hidden devices
@@ -2131,8 +2143,10 @@ function buildStages(ctx, modesById) {
line += g;
if (t && !glyphTiles[g]) {
const { bb } = flattenBB(t.blackboard);
const buildable = effectiveBuildable(t);
glyphTiles[g] = {
tileKey: t.tileKey, height: t.heightType === 'HIGHLAND' ? 'HIGH' : 'LOW', buildable: t.buildableType,
tileKey: t.tileKey, height: t.heightType === 'HIGHLAND' ? 'HIGH' : 'LOW', buildable,
...(buildable !== t.buildableType ? { buildableType: t.buildableType } : {}),
passable: t.passableMask, groundPassable: t.passableMask === 'ALL', flyPassable: t.passableMask !== 'NONE',
special: TILE_LEGEND[g]?.special || null, bb,
};
@@ -2163,21 +2177,25 @@ function buildStages(ctx, modesById) {
const runes = (lv.runes || []).map((r) => ({ key: r.key, ...flattenBB(r.blackboard) }));
const globalBuffs = (lv.globalBuffs || []).map((g) => ({ key: g.key, ...flattenBB(g.blackboard) }));
// Deployable tiles at match start (active crates/mounds remove a tile, platforms make it ranged-only).
// Deployable tiles at match start (active crates/mounds remove a tile, platforms make it ranged-only, a 特制水上平台
// makes its 深水区 tile deployable for any unit: "在水上建立可以部署任意单位的平台", sktok_canoe_1).
const activeBlocking = devices.filter((d) => d.active && BLOCKING_ROLES.has(d.role));
const blocked = new Set(activeBlocking.map((d) => d.pos.join(',')));
const platformAt = new Set(activeBlocking.filter((d) => d.role === 'platform').map((d) => d.pos.join(',')));
const waterPlatformAt = new Set(devices.filter((d) => d.active && d.role === 'waterPlatform').map((d) => d.pos.join(',')));
const deployIn = (r0, r1, c0, c1) => {
const melee = [], rangedOnly = [], byDevice = [];
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
const t = tileAt(r, c);
if (!t) continue;
const k = `${r},${c}`;
const buildable = t.buildableType !== 'NONE';
const bt = effectiveBuildable(t);
const buildable = bt !== 'NONE';
if (platformAt.has(k)) { rangedOnly.push([r, c]); if (buildable) byDevice.push([r, c]); continue; }
if (blocked.has(k)) { if (buildable) byDevice.push([r, c]); continue; }
if (t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE')) melee.push([r, c]);
else if (t.buildableType === 'RANGED' || (t.heightType === 'HIGHLAND' && t.buildableType === 'ALL' && t.tileKey !== 'tile_achand')) rangedOnly.push([r, c]);
if (waterPlatformAt.has(k)) { melee.push([r, c]); if (!buildable) byDevice.push([r, c]); continue; }
if (t.heightType === 'LOWLAND' && (bt === 'ALL' || bt === 'MELEE')) melee.push([r, c]);
else if (bt === 'RANGED' || (t.heightType === 'HIGHLAND' && bt === 'ALL' && t.tileKey !== 'tile_achand')) rangedOnly.push([r, c]);
}
return { melee, rangedOnly, changedByDevices: byDevice };
};